@sequent-org/moodboard 1.2.119 → 1.3.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 (122) hide show
  1. package/package.json +11 -1
  2. package/src/assets/icons/rotate-icon.svg +1 -1
  3. package/src/core/HistoryManager.js +16 -16
  4. package/src/core/KeyboardManager.js +48 -539
  5. package/src/core/PixiEngine.js +9 -9
  6. package/src/core/SaveManager.js +56 -31
  7. package/src/core/bootstrap/CoreInitializer.js +65 -0
  8. package/src/core/commands/DeleteObjectCommand.js +8 -0
  9. package/src/core/commands/GroupDeleteCommand.js +75 -0
  10. package/src/core/commands/GroupRotateCommand.js +6 -0
  11. package/src/core/commands/UpdateContentCommand.js +52 -0
  12. package/src/core/commands/UpdateFramePropertiesCommand.js +98 -0
  13. package/src/core/commands/UpdateFrameTypeCommand.js +85 -0
  14. package/src/core/commands/UpdateNoteStyleCommand.js +88 -0
  15. package/src/core/commands/UpdateTextStyleCommand.js +90 -0
  16. package/src/core/commands/index.js +6 -0
  17. package/src/core/events/Events.js +6 -0
  18. package/src/core/flows/ClipboardFlow.js +553 -0
  19. package/src/core/flows/LayerAndViewportFlow.js +283 -0
  20. package/src/core/flows/ObjectLifecycleFlow.js +336 -0
  21. package/src/core/flows/SaveFlow.js +34 -0
  22. package/src/core/flows/TransformFlow.js +277 -0
  23. package/src/core/flows/TransformFlowResizeHelpers.js +83 -0
  24. package/src/core/index.js +41 -1773
  25. package/src/core/keyboard/KeyboardClipboardImagePaste.js +190 -0
  26. package/src/core/keyboard/KeyboardContextGuards.js +35 -0
  27. package/src/core/keyboard/KeyboardEventRouter.js +92 -0
  28. package/src/core/keyboard/KeyboardSelectionActions.js +103 -0
  29. package/src/core/keyboard/KeyboardShortcutMap.js +31 -0
  30. package/src/core/keyboard/KeyboardToolSwitching.js +26 -0
  31. package/src/core/rendering/ObjectRenderer.js +3 -7
  32. package/src/grid/BaseGrid.js +26 -0
  33. package/src/grid/CrossGrid.js +7 -6
  34. package/src/grid/DotGrid.js +89 -33
  35. package/src/grid/DotGridZoomPhases.js +42 -0
  36. package/src/grid/LineGrid.js +22 -21
  37. package/src/moodboard/MoodBoard.js +31 -532
  38. package/src/moodboard/bootstrap/MoodBoardInitializer.js +47 -0
  39. package/src/moodboard/bootstrap/MoodBoardManagersFactory.js +38 -0
  40. package/src/moodboard/bootstrap/MoodBoardUiFactory.js +109 -0
  41. package/src/moodboard/integration/MoodBoardEventBindings.js +65 -0
  42. package/src/moodboard/integration/MoodBoardLoadApi.js +82 -0
  43. package/src/moodboard/integration/MoodBoardScreenshotApi.js +33 -0
  44. package/src/moodboard/integration/MoodBoardScreenshotCanvas.js +98 -0
  45. package/src/moodboard/lifecycle/MoodBoardDestroyer.js +97 -0
  46. package/src/objects/FileObject.js +17 -6
  47. package/src/objects/FrameObject.js +50 -10
  48. package/src/objects/NoteObject.js +5 -4
  49. package/src/services/BoardService.js +42 -2
  50. package/src/services/FrameService.js +83 -42
  51. package/src/services/ResizePolicyService.js +152 -0
  52. package/src/services/SettingsApplier.js +7 -2
  53. package/src/services/ZoomPanController.js +35 -9
  54. package/src/tools/ToolManager.js +30 -537
  55. package/src/tools/board-tools/PanTool.js +5 -11
  56. package/src/tools/manager/ToolActivationController.js +49 -0
  57. package/src/tools/manager/ToolEventRouter.js +396 -0
  58. package/src/tools/manager/ToolManagerGuards.js +33 -0
  59. package/src/tools/manager/ToolManagerLifecycle.js +110 -0
  60. package/src/tools/manager/ToolRegistry.js +33 -0
  61. package/src/tools/object-tools/DrawingTool.js +48 -14
  62. package/src/tools/object-tools/PlacementTool.js +50 -1049
  63. package/src/tools/object-tools/PlacementToolV2.js +88 -0
  64. package/src/tools/object-tools/SelectTool.js +174 -2681
  65. package/src/tools/object-tools/placement/GhostController.js +504 -0
  66. package/src/tools/object-tools/placement/PlacementCoordinateResolver.js +20 -0
  67. package/src/tools/object-tools/placement/PlacementEventsBridge.js +91 -0
  68. package/src/tools/object-tools/placement/PlacementInputRouter.js +267 -0
  69. package/src/tools/object-tools/placement/PlacementPayloadFactory.js +111 -0
  70. package/src/tools/object-tools/placement/PlacementSessionStore.js +18 -0
  71. package/src/tools/object-tools/selection/BoxSelectController.js +0 -5
  72. package/src/tools/object-tools/selection/CloneFlowController.js +71 -0
  73. package/src/tools/object-tools/selection/CoordinateMapper.js +10 -0
  74. package/src/tools/object-tools/selection/CursorController.js +78 -0
  75. package/src/tools/object-tools/selection/FileNameInlineEditorController.js +184 -0
  76. package/src/tools/object-tools/selection/HitTestService.js +102 -0
  77. package/src/tools/object-tools/selection/InlineEditorController.js +24 -0
  78. package/src/tools/object-tools/selection/InlineEditorDomFactory.js +50 -0
  79. package/src/tools/object-tools/selection/InlineEditorListenersRegistry.js +14 -0
  80. package/src/tools/object-tools/selection/InlineEditorPositioningService.js +25 -0
  81. package/src/tools/object-tools/selection/NoteInlineEditorController.js +113 -0
  82. package/src/tools/object-tools/selection/SelectInputRouter.js +267 -0
  83. package/src/tools/object-tools/selection/SelectToolLifecycleController.js +128 -0
  84. package/src/tools/object-tools/selection/SelectToolSetup.js +134 -0
  85. package/src/tools/object-tools/selection/SelectionOverlayService.js +81 -0
  86. package/src/tools/object-tools/selection/SelectionStateController.js +91 -0
  87. package/src/tools/object-tools/selection/TextEditorDomFactory.js +65 -0
  88. package/src/tools/object-tools/selection/TextEditorInteractionController.js +266 -0
  89. package/src/tools/object-tools/selection/TextEditorLifecycleRegistry.js +90 -0
  90. package/src/tools/object-tools/selection/TextEditorPositioningService.js +158 -0
  91. package/src/tools/object-tools/selection/TextEditorSyncService.js +110 -0
  92. package/src/tools/object-tools/selection/TextInlineEditorController.js +457 -0
  93. package/src/tools/object-tools/selection/TransformInteractionController.js +466 -0
  94. package/src/ui/FilePropertiesPanel.js +61 -32
  95. package/src/ui/FramePropertiesPanel.js +176 -101
  96. package/src/ui/HtmlHandlesLayer.js +121 -999
  97. package/src/ui/MapPanel.js +12 -7
  98. package/src/ui/NotePropertiesPanel.js +17 -2
  99. package/src/ui/TextPropertiesPanel.js +124 -738
  100. package/src/ui/Toolbar.js +71 -1180
  101. package/src/ui/Topbar.js +23 -25
  102. package/src/ui/ZoomPanel.js +16 -5
  103. package/src/ui/handles/GroupSelectionHandlesController.js +29 -0
  104. package/src/ui/handles/HandlesDomRenderer.js +278 -0
  105. package/src/ui/handles/HandlesEventBridge.js +102 -0
  106. package/src/ui/handles/HandlesInteractionController.js +772 -0
  107. package/src/ui/handles/HandlesPositioningService.js +206 -0
  108. package/src/ui/handles/SingleSelectionHandlesController.js +22 -0
  109. package/src/ui/styles/toolbar.css +2 -0
  110. package/src/ui/styles/workspace.css +13 -6
  111. package/src/ui/text-properties/TextPropertiesPanelBindings.js +92 -0
  112. package/src/ui/text-properties/TextPropertiesPanelEventBridge.js +77 -0
  113. package/src/ui/text-properties/TextPropertiesPanelMapper.js +173 -0
  114. package/src/ui/text-properties/TextPropertiesPanelRenderer.js +434 -0
  115. package/src/ui/text-properties/TextPropertiesPanelState.js +39 -0
  116. package/src/ui/toolbar/ToolbarActionRouter.js +193 -0
  117. package/src/ui/toolbar/ToolbarDialogsController.js +186 -0
  118. package/src/ui/toolbar/ToolbarPopupsController.js +662 -0
  119. package/src/ui/toolbar/ToolbarRenderer.js +97 -0
  120. package/src/ui/toolbar/ToolbarStateController.js +79 -0
  121. package/src/ui/toolbar/ToolbarTooltipController.js +52 -0
  122. package/src/utils/emojiLoaderNoBundler.js +1 -1
@@ -17,7 +17,12 @@ const _scaledICursorSvg = (() => {
17
17
 
18
18
  const TEXT_CURSOR = `url("data:image/svg+xml;charset=utf-8,${encodeURIComponent(_scaledICursorSvg)}") 0 0, text`;
19
19
  import { Events } from '../../core/events/Events.js';
20
- import * as PIXI from 'pixi.js';
20
+ import { GhostController } from './placement/GhostController.js';
21
+ import { PlacementPayloadFactory } from './placement/PlacementPayloadFactory.js';
22
+ import { PlacementInputRouter } from './placement/PlacementInputRouter.js';
23
+ import { PlacementEventsBridge } from './placement/PlacementEventsBridge.js';
24
+ import { PlacementSessionStore } from './placement/PlacementSessionStore.js';
25
+ import { PlacementCoordinateResolver } from './placement/PlacementCoordinateResolver.js';
21
26
 
22
27
  /**
23
28
  * Инструмент одноразового размещения объекта по клику на холст
@@ -30,116 +35,20 @@ export class PlacementTool extends BaseTool {
30
35
  this.hotkey = null;
31
36
  this.app = null;
32
37
  this.world = null;
33
- this.pending = null; // { type, properties }
34
38
  this.core = core;
35
-
36
- // Состояние выбранного файла
37
- this.selectedFile = null; // { file, fileName, fileSize, mimeType, properties }
38
- // Состояние выбранного изображения
39
- this.selectedImage = null; // { file, fileName, fileSize, mimeType, properties }
40
- this.ghostContainer = null; // Контейнер для "призрака" файла, изображения, текста, записки, эмоджи, фрейма или фигур
39
+ this.ghostController = new GhostController(this);
40
+ this.payloadFactory = new PlacementPayloadFactory(this);
41
+ this.inputRouter = new PlacementInputRouter(this);
42
+ this.eventsBridge = new PlacementEventsBridge(this);
43
+ this.sessionStore = new PlacementSessionStore(this);
44
+ this.coordinateResolver = new PlacementCoordinateResolver(this);
45
+ this.sessionStore.initialize();
41
46
  // Оригинальные стили курсора PIXI, чтобы можно было временно переопределить pointer/default для текстового инструмента
42
47
  this._origCursorStyles = null;
48
+ // Сохраняем bound handler для корректного removeEventListener (избежание утечки памяти)
49
+ this._boundOnMouseMove = null;
43
50
 
44
- if (this.eventBus) {
45
- this.eventBus.on(Events.Place.Set, (cfg) => {
46
- this.pending = cfg ? { ...cfg } : null;
47
- // Обновляем курсор в зависимости от pending
48
- if (this.app && this.app.view) {
49
- const cur = this._getPendingCursor();
50
- this.cursor = cur;
51
- this.app.view.style.cursor = (cur === 'default') ? '' : cur;
52
- }
53
- // При выборе текста заставляем pointer вести себя как текстовый курсор
54
- this._updateCursorOverride();
55
-
56
- // Показываем призрак для записки, эмоджи, фрейма или фигур, если они активны
57
- if (this.pending && this.app && this.world) {
58
- if (this.pending.type === 'note') {
59
- this.showNoteGhost();
60
- } else if (this.pending.type === 'emoji') {
61
- this.showEmojiGhost();
62
- } else if (this.pending.type === 'image') {
63
- this.showImageUrlGhost();
64
- } else if (this.pending.type === 'frame') {
65
- this.showFrameGhost();
66
- } else if (this.pending.type === 'frame-draw') {
67
- this.startFrameDrawMode();
68
- } else if (this.pending.type === 'shape') {
69
- this.showShapeGhost();
70
- }
71
- // Поддержка сценария перетаскивания из панели: отпускание без предварительного mousedown на канвасе
72
- if (this.pending.placeOnMouseUp && this.app && this.app.view) {
73
- const onUp = (ev) => {
74
- this.app.view.removeEventListener('mouseup', onUp);
75
- if (!this.pending) return;
76
- const worldPoint = this._toWorld(ev.x, ev.y);
77
- const position = {
78
- x: Math.round(worldPoint.x - (this.pending.size?.width ?? 100) / 2),
79
- y: Math.round(worldPoint.y - (this.pending.size?.height ?? 100) / 2)
80
- };
81
- const props = { ...(this.pending.properties || {}) };
82
- this.eventBus.emit(Events.UI.ToolbarAction, {
83
- type: this.pending.type,
84
- id: this.pending.type,
85
- position,
86
- properties: props
87
- });
88
- this.pending = null;
89
- this.hideGhost();
90
- this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'select' });
91
- };
92
- this.app.view.addEventListener('mouseup', onUp, { once: true });
93
- }
94
- }
95
- });
96
-
97
- // Сброс pending при явном выборе select-инструмента
98
- this.eventBus.on(Events.Tool.Activated, ({ tool }) => {
99
- if (tool === 'select') {
100
- this.pending = null;
101
- this.selectedFile = null;
102
- this.selectedImage = null;
103
- this.hideGhost();
104
- // Возвращаем стандартное поведение курсора, когда уходим с PlacementTool
105
- this._updateCursorOverride();
106
- }
107
- });
108
-
109
- // Обработка выбора файла
110
- this.eventBus.on(Events.Place.FileSelected, (fileData) => {
111
- this.selectedFile = fileData;
112
- this.selectedImage = null;
113
-
114
- // Если PlacementTool уже активен - показываем призрак сразу
115
- if (this.world) {
116
- this.showFileGhost();
117
- }
118
- });
119
-
120
- // Обработка отмены выбора файла
121
- this.eventBus.on(Events.Place.FileCanceled, () => {
122
- this.selectedFile = null;
123
- this.hideGhost();
124
- // Возвращаемся к инструменту выделения
125
- this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'select' });
126
- });
127
-
128
- // Обработка выбора изображения
129
- this.eventBus.on(Events.Place.ImageSelected, (imageData) => {
130
- this.selectedImage = imageData;
131
- this.selectedFile = null;
132
- this.showImageGhost();
133
- });
134
-
135
- // Обработка отмены выбора изображения
136
- this.eventBus.on(Events.Place.ImageCanceled, () => {
137
- this.selectedImage = null;
138
- this.hideGhost();
139
- // Возвращаемся к инструменту выделения
140
- this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'select' });
141
- });
142
- }
51
+ this.eventsBridge.attach();
143
52
  }
144
53
 
145
54
  activate(app) {
@@ -150,8 +59,8 @@ export class PlacementTool extends BaseTool {
150
59
  if (this.app && this.app.view) {
151
60
  this.cursor = this._getPendingCursor();
152
61
  this.app.view.style.cursor = this.cursor;
153
- // Добавляем обработчик движения мыши для "призрака"
154
- this.app.view.addEventListener('mousemove', this._onMouseMove.bind(this));
62
+ this._boundOnMouseMove = this._boundOnMouseMove || this._onMouseMove.bind(this);
63
+ this.app.view.addEventListener('mousemove', this._boundOnMouseMove);
155
64
  }
156
65
  // При активации синхронизируем переопределение курсора pointer для текста
157
66
  this._updateCursorOverride();
@@ -176,10 +85,9 @@ export class PlacementTool extends BaseTool {
176
85
 
177
86
  deactivate() {
178
87
  super.deactivate();
179
- if (this.app && this.app.view) {
88
+ if (this.app && this.app.view && this._boundOnMouseMove) {
180
89
  this.app.view.style.cursor = '';
181
- // Убираем обработчик движения мыши
182
- this.app.view.removeEventListener('mousemove', this._onMouseMove.bind(this));
90
+ this.app.view.removeEventListener('mousemove', this._boundOnMouseMove);
183
91
  }
184
92
  // Восстанавливаем стандартные стили курсора при выходе из инструмента
185
93
  this._updateCursorOverride(true);
@@ -189,392 +97,38 @@ export class PlacementTool extends BaseTool {
189
97
  }
190
98
 
191
99
  onMouseDown(event) {
192
- super.onMouseDown(event);
193
-
194
- // Если есть выбранный файл, размещаем его
195
- if (this.selectedFile) {
196
- this.placeSelectedFile(event);
197
- return;
198
- }
199
-
200
- // Если есть выбранное изображение, размещаем его
201
- if (this.selectedImage) {
202
- this.placeSelectedImage(event);
203
- return;
204
- }
205
-
206
- if (!this.pending) return;
207
- // Если включен режим "перетянуть и отпустить" из панели (placeOnMouseUp),
208
- // то размещение выполняем на mouseup, а здесь только показываем призрак и запоминаем старт
209
- if (this.pending.placeOnMouseUp) {
210
- const onUp = (ev) => {
211
- this.app.view.removeEventListener('mouseup', onUp);
212
- // Имитация обычного place по текущему положению курсора
213
- const worldPoint = this._toWorld(ev.x, ev.y);
214
- const position = {
215
- x: Math.round(worldPoint.x - (this.pending.size?.width ?? 100) / 2),
216
- y: Math.round(worldPoint.y - (this.pending.size?.height ?? 100) / 2)
217
- };
218
- const props = { ...(this.pending.properties || {}) };
219
- this.eventBus.emit(Events.UI.ToolbarAction, {
220
- type: this.pending.type,
221
- id: this.pending.type,
222
- position,
223
- properties: props
224
- });
225
- this.pending = null;
226
- this.hideGhost();
227
- this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'select' });
228
- };
229
- this.app.view.addEventListener('mouseup', onUp, { once: true });
230
- return;
231
- }
232
- // Если включен режим рисования фрейма — инициируем рамку
233
- if (this.pending.type === 'frame-draw') {
234
- const start = this._toWorld(event.x, event.y);
235
- this._frameDrawState = { startX: start.x, startY: start.y, graphics: null };
236
- if (this.world) {
237
- const g = new PIXI.Graphics();
238
- g.zIndex = 3000;
239
- this.world.addChild(g);
240
- this._frameDrawState.graphics = g;
241
- }
242
- // Вешаем временные обработчики движения/отпускания
243
- this._onFrameDrawMoveBound = (ev) => this._onFrameDrawMove(ev);
244
- this._onFrameDrawUpBound = (ev) => this._onFrameDrawUp(ev);
245
- this.app.view.addEventListener('mousemove', this._onFrameDrawMoveBound);
246
- this.app.view.addEventListener('mouseup', this._onFrameDrawUpBound, { once: true });
247
- return;
248
- }
249
-
250
- const worldPoint = this._toWorld(event.x, event.y);
251
- // Базовая позиция (может быть переопределена для конкретных типов)
252
- let position = {
253
- x: Math.round(worldPoint.x - (this.pending.size?.width ?? 100) / 2),
254
- y: Math.round(worldPoint.y - (this.pending.size?.height ?? 100) / 2)
255
- };
256
-
257
- let props = this.pending.properties || {};
258
- const isTextWithEditing = this.pending.type === 'text' && props.editOnCreate;
259
- const isImage = this.pending.type === 'image';
260
- const isFile = this.pending.type === 'file';
261
- const presetSize = {
262
- width: (this.pending.size && this.pending.size.width) ? this.pending.size.width : (props.width || 200),
263
- height: (this.pending.size && this.pending.size.height) ? this.pending.size.height : (props.height || 150),
264
- };
265
-
266
- if (isTextWithEditing) {
267
- // Для текста позиция должна совпадать с точкой клика без смещений.
268
- // Используем ту же систему координат, что HtmlTextLayer/HtmlHandlesLayer:
269
- // CSS ←→ world через toGlobal/toLocal БЕЗ дополнительных поправок на resolution.
270
- let worldForText = worldPoint;
271
- try {
272
- const app = this.app;
273
- const view = app?.view;
274
- const worldLayer = this.world || this._getWorldLayer();
275
- if (view && view.parentElement && worldLayer && worldLayer.toLocal) {
276
- const containerRect = view.parentElement.getBoundingClientRect();
277
- const viewRect = view.getBoundingClientRect();
278
- const offsetLeft = viewRect.left - containerRect.left;
279
- const offsetTop = viewRect.top - containerRect.top;
280
- // event.x / event.y заданы в координатах контейнера ToolManager,
281
- // поэтому приводим их к экранным координатам относительно view
282
- const screenX = event.x - offsetLeft;
283
- const screenY = event.y - offsetTop;
284
- const globalPoint = new PIXI.Point(screenX, screenY);
285
- const local = worldLayer.toLocal(globalPoint);
286
- worldForText = { x: local.x, y: local.y };
287
- }
288
- console.log('🧭 Text click', {
289
- cursor: { x: event.x, y: event.y },
290
- world: { x: Math.round(worldForText.x), y: Math.round(worldForText.y) }
291
- });
292
- } catch (_) {}
293
- position = {
294
- x: Math.round(worldForText.x),
295
- y: Math.round(worldForText.y)
296
- };
297
- // Слушаем событие создания объекта, чтобы получить его ID
298
- const handleObjectCreated = (objectData) => {
299
- if (objectData.type === 'text') {
300
- // Убираем слушатель, чтобы не реагировать на другие объекты
301
- this.eventBus.off('object:created', handleObjectCreated);
302
-
303
- // Переключаемся на select
304
- this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'select' });
305
-
306
-
307
-
308
- // Даем небольшую задержку, чтобы HTML-элемент успел создаться
309
- setTimeout(() => {
310
- // Открываем редактор с правильным ID и данными объекта
311
- this.eventBus.emit(Events.Tool.ObjectEdit, {
312
- object: {
313
- id: objectData.id,
314
- type: 'text',
315
- position: objectData.position,
316
- properties: { fontSize: props.fontSize || 18, content: '' }
317
- },
318
- create: true // Это создание нового объекта с редактированием
319
- });
320
- }, 50); // 50ms задержка
321
- }
322
- };
323
-
324
- // Подписываемся на событие создания объекта
325
- this.eventBus.on('object:created', handleObjectCreated);
326
-
327
- // Создаем объект через обычный канал
328
- this.eventBus.emit(Events.UI.ToolbarAction, {
329
- type: 'text',
330
- id: 'text',
331
- position,
332
- properties: {
333
- fontSize: props.fontSize || 18,
334
- content: '',
335
- fontFamily: 'Arial, sans-serif', // Дефолтный шрифт
336
- color: '#000000', // Дефолтный цвет (черный)
337
- backgroundColor: 'transparent' // Дефолтный фон (прозрачный)
338
- }
339
- });
340
- } else if (this.pending.type === 'frame') {
341
- // Для фрейма центр привязываем к курсору так же, как у призрака
342
- const width = props.width || presetSize.width || 200;
343
- const height = props.height || presetSize.height || 300;
344
- position = {
345
- x: Math.round(worldPoint.x - width / 2),
346
- y: Math.round(worldPoint.y - height / 2)
347
- };
348
- this.eventBus.emit(Events.UI.ToolbarAction, {
349
- type: 'frame',
350
- id: 'frame',
351
- position,
352
- properties: { ...props, width, height }
353
- });
354
- } else if (isImage && props.selectFileOnPlace) {
355
- const input = document.createElement('input');
356
- input.type = 'file';
357
- input.accept = 'image/*';
358
- input.style.display = 'none';
359
- document.body.appendChild(input);
360
- input.addEventListener('change', async () => {
361
- try {
362
- const file = input.files && input.files[0];
363
- if (!file) return;
364
- // Читаем как DataURL, чтобы не использовать blob: URL (устраняем ERR_FILE_NOT_FOUND)
365
- // Загружаем файл на сервер
366
- try {
367
- const uploadResult = await this.core.imageUploadService.uploadImage(file, file.name);
368
-
369
- // Вычисляем целевой размер
370
- const natW = uploadResult.width || 1;
371
- const natH = uploadResult.height || 1;
372
- const targetW = 300; // дефолтная ширина
373
- const targetH = Math.max(1, Math.round(natH * (targetW / natW)));
374
-
375
- this.eventBus.emit(Events.UI.ToolbarAction, {
376
- type: 'image',
377
- id: 'image',
378
- position,
379
- properties: {
380
- src: uploadResult.url,
381
- name: uploadResult.name,
382
- width: targetW,
383
- height: targetH
384
- },
385
- imageId: uploadResult.imageId || uploadResult.id // Сохраняем ID изображения
386
- });
387
- } catch (error) {
388
- console.error('Ошибка загрузки изображения:', error);
389
- alert('Ошибка загрузки изображения: ' + error.message);
390
- }
391
- } finally {
392
- input.remove();
393
- }
394
- }, { once: true });
395
- input.click();
396
- } else if (isFile && props.selectFileOnPlace) {
397
- // Создаем диалог выбора файла
398
- const input = document.createElement('input');
399
- input.type = 'file';
400
- input.accept = '*/*'; // Принимаем любые файлы
401
- input.style.display = 'none';
402
- document.body.appendChild(input);
403
- input.addEventListener('change', async () => {
404
- try {
405
- const file = input.files && input.files[0];
406
- if (!file) return;
407
-
408
- // Загружаем файл на сервер
409
- try {
410
- const uploadResult = await this.core.fileUploadService.uploadFile(file, file.name);
411
-
412
- // Создаем объект файла с данными с сервера
413
- this.eventBus.emit(Events.UI.ToolbarAction, {
414
- type: 'file',
415
- id: 'file',
416
- position,
417
- properties: {
418
- fileName: uploadResult.name,
419
- fileSize: uploadResult.size,
420
- mimeType: uploadResult.mimeType,
421
- formattedSize: uploadResult.formattedSize,
422
- url: uploadResult.url,
423
- width: props.width || 120,
424
- height: props.height || 140
425
- },
426
- fileId: uploadResult.fileId || uploadResult.id // Сохраняем ID файла
427
- });
428
-
429
- // Возвращаемся к инструменту выделения после создания файла
430
- this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'select' });
431
- } catch (uploadError) {
432
- console.error('Ошибка загрузки файла на сервер:', uploadError);
433
- // Fallback: создаем объект файла с локальными данными
434
- const fileName = file.name;
435
- const fileSize = file.size;
436
- const mimeType = file.type;
437
-
438
- this.eventBus.emit(Events.UI.ToolbarAction, {
439
- type: 'file',
440
- id: 'file',
441
- position,
442
- properties: {
443
- fileName: fileName,
444
- fileSize: fileSize,
445
- mimeType: mimeType,
446
- width: props.width || 120,
447
- height: props.height || 140
448
- }
449
- });
450
-
451
- // Возвращаемся к инструменту выделения
452
- this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'select' });
453
-
454
- // Показываем предупреждение пользователю
455
- alert('Ошибка загрузки файла на сервер. Файл добавлен локально.');
456
- }
457
- } catch (error) {
458
- console.error('Ошибка при выборе файла:', error);
459
- alert('Ошибка при выборе файла: ' + error.message);
460
- } finally {
461
- input.remove();
462
- }
463
- }, { once: true });
464
- input.click();
465
- } else {
466
- // Для записки: выставляем фактические габариты и центрируем по курсору
467
- if (this.pending.type === 'note') {
468
- const base = 250; // квадрат 250x250
469
- const noteW = (typeof props.width === 'number') ? props.width : base;
470
- const noteH = (typeof props.height === 'number') ? props.height : base;
471
- const side = Math.max(noteW, noteH);
472
- props = { ...props, width: side, height: side };
473
- position = {
474
- x: Math.round(worldPoint.x - side / 2),
475
- y: Math.round(worldPoint.y - side / 2)
476
- };
477
- }
478
- // Обычное размещение через общий канал
479
- this.eventBus.emit(Events.UI.ToolbarAction, {
480
- type: this.pending.type,
481
- id: this.pending.type,
482
- position,
483
- properties: props
484
- });
485
- }
100
+ return this.inputRouter.onMouseDown(event);
101
+ }
486
102
 
487
- // Сбрасываем pending и возвращаем стандартное поведение
488
- this.pending = null;
489
- this.hideGhost(); // Скрываем призрак после размещения
490
- if (!isTextWithEditing && !(isFile && props.selectFileOnPlace)) {
491
- this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'select' });
492
- }
103
+ __baseOnMouseDown(event) {
104
+ super.onMouseDown(event);
493
105
  }
494
106
 
495
107
  startFrameDrawMode() {
496
- // Курсор при рисовании фрейма
497
- this.cursor = 'crosshair';
498
- if (this.app && this.app.view) this.app.view.style.cursor = this.cursor;
108
+ return this.inputRouter.startFrameDrawMode();
499
109
  }
500
110
 
501
111
  _onFrameDrawMove(event) {
502
- if (!this._frameDrawState || !this._frameDrawState.graphics) return;
503
- const p = this._toWorld(event.offsetX, event.offsetY);
504
- const x = Math.min(this._frameDrawState.startX, p.x);
505
- const y = Math.min(this._frameDrawState.startY, p.y);
506
- const w = Math.abs(p.x - this._frameDrawState.startX);
507
- const h = Math.abs(p.y - this._frameDrawState.startY);
508
- const g = this._frameDrawState.graphics;
509
- g.clear();
510
- // Снапим к полупикселю и используем внутреннее выравнивание линии для чётких 1px краёв
511
- const x0 = Math.floor(x) + 0.5;
512
- const y0 = Math.floor(y) + 0.5;
513
- const w0 = Math.max(1, Math.round(w));
514
- const h0 = Math.max(1, Math.round(h));
515
- g.lineStyle(1, 0x3B82F6, 1, 1 /* alignment: inner */);
516
- g.beginFill(0xFFFFFF, 0.6);
517
- g.drawRect(x0, y0, w0, h0);
518
- g.endFill();
112
+ return this.inputRouter.onFrameDrawMove(event);
519
113
  }
520
114
 
521
115
  _onFrameDrawUp(event) {
522
- const g = this._frameDrawState?.graphics;
523
- if (!this._frameDrawState || !g) return;
524
- const p = this._toWorld(event.offsetX, event.offsetY);
525
- const x = Math.min(this._frameDrawState.startX, p.x);
526
- const y = Math.min(this._frameDrawState.startY, p.y);
527
- const w = Math.abs(p.x - this._frameDrawState.startX);
528
- const h = Math.abs(p.y - this._frameDrawState.startY);
529
- // Удаляем временную графику
530
- if (g.parent) g.parent.removeChild(g);
531
- g.destroy();
532
- this._frameDrawState = null;
533
- // Создаем фрейм, если размер достаточный
534
- if (w >= 2 && h >= 2) {
535
- this.eventBus.emit(Events.UI.ToolbarAction, {
536
- type: 'frame',
537
- id: 'frame',
538
- position: { x, y },
539
- properties: { width: Math.round(w), height: Math.round(h), title: 'Произвольный', lockedAspect: false, isArbitrary: true }
540
- });
541
- }
542
- // Сбрасываем pending и выходим из режима place → select
543
- this.pending = null;
544
- this.hideGhost();
545
- if (this.app && this.app.view) {
546
- this.app.view.removeEventListener('mousemove', this._onFrameDrawMoveBound);
547
- this.app.view.style.cursor = '';
548
- }
549
- this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'select' });
116
+ return this.inputRouter.onFrameDrawUp(event);
550
117
  }
551
118
 
552
119
  _toWorld(x, y) {
553
- if (!this.world) return { x, y };
554
- const global = new PIXI.Point(x, y);
555
- const local = this.world.toLocal(global);
556
- return { x: local.x, y: local.y };
120
+ return this.coordinateResolver.toWorld(x, y);
557
121
  }
558
122
 
559
123
  _getWorldLayer() {
560
- if (!this.app || !this.app.stage) return null;
561
- const world = this.app.stage.getChildByName && this.app.stage.getChildByName('worldLayer');
562
- return world || this.app.stage;
124
+ return this.coordinateResolver.getWorldLayer();
563
125
  }
564
126
 
565
127
  /**
566
128
  * Обработчик движения мыши для обновления позиции "призрака"
567
129
  */
568
130
  _onMouseMove(event) {
569
- if ((this.selectedFile || this.selectedImage || this.pending) && this.ghostContainer) {
570
- // Сохраним последние координаты мыши (в экранных координатах) — пригодится для первичной позиции призрака
571
- if (this.app && this.app.view) {
572
- this.app.view._lastMouseX = event.x;
573
- this.app.view._lastMouseY = event.y;
574
- }
575
- const worldPoint = this._toWorld(event.offsetX, event.offsetY);
576
- this.updateGhostPosition(worldPoint.x, worldPoint.y);
577
- }
131
+ return this.inputRouter.onMouseMove(event);
578
132
  }
579
133
 
580
134
  /**
@@ -623,106 +177,21 @@ export class PlacementTool extends BaseTool {
623
177
  * Показать "призрак" файла
624
178
  */
625
179
  showFileGhost() {
626
- if (!this.selectedFile || !this.world) return;
627
-
628
- this.hideGhost(); // Сначала убираем старый призрак
629
-
630
- // Создаем контейнер для призрака
631
- this.ghostContainer = new PIXI.Container();
632
- this.ghostContainer.alpha = 0.6; // Полупрозрачность
633
- // Сразу ставим контейнер в позицию курсора, чтобы он не мигал в левом верхнем углу
634
- if (this.app && this.app.view) {
635
- const rect = this.app.view.getBoundingClientRect();
636
- const cursorX = (typeof this.app.view._lastMouseX === 'number') ? this.app.view._lastMouseX : (rect.left + rect.width / 2);
637
- const cursorY = (typeof this.app.view._lastMouseY === 'number') ? this.app.view._lastMouseY : (rect.top + rect.height / 2);
638
- const worldPoint = this._toWorld(cursorX, cursorY);
639
- this.updateGhostPosition(worldPoint.x, worldPoint.y);
640
- }
641
- // Попробуем дождаться загрузки веб-шрифта Caveat до отрисовки
642
- // Для файлов используем selectedFile, а не pending
643
- const fileFont = (this.selectedFile.properties?.fontFamily) || 'Caveat, Arial, cursive';
644
- const primaryFont = String(fileFont).split(',')[0].trim().replace(/^['"]|['"]$/g, '') || 'Caveat';
645
-
646
- // Размеры
647
- const width = this.selectedFile.properties.width || 120;
648
- const height = this.selectedFile.properties.height || 140;
649
-
650
- // Размытая тень (как у FileObject)
651
- const shadow = new PIXI.Graphics();
652
- try {
653
- shadow.filters = [new PIXI.filters.BlurFilter(6)];
654
- } catch (e) {}
655
- shadow.beginFill(0x000000, 1);
656
- shadow.drawRect(0, 0, width, height);
657
- shadow.endFill();
658
- shadow.x = 2;
659
- shadow.y = 3;
660
- shadow.alpha = 0.18;
661
-
662
- // Белый прямоугольник без рамки
663
- const background = new PIXI.Graphics();
664
- background.beginFill(0xFFFFFF, 1);
665
- background.drawRect(0, 0, width, height);
666
- background.endFill();
667
-
668
- // Иконка-заглушка файла наверху (центрируем фактическую ширину)
669
- const icon = new PIXI.Graphics();
670
- const iconSize = Math.min(48, width * 0.4);
671
- const iconWidthDrawn = iconSize * 0.8;
672
- const iconX = (width - iconWidthDrawn) / 2;
673
- const iconY = 16;
674
- icon.beginFill(0x6B7280, 1);
675
- icon.drawRect(iconX, iconY, iconWidthDrawn, iconSize);
676
- icon.endFill();
677
-
678
- // Текст названия файла
679
- const fileName = this.selectedFile.fileName || 'File';
680
- const displayName = fileName;
681
- const nameText = new PIXI.Text(displayName, {
682
- fontFamily: 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
683
- fontSize: 12,
684
- fill: 0x333333,
685
- align: 'center',
686
- wordWrap: true,
687
- breakWords: true,
688
- wordWrapWidth: Math.max(1, width - 24) // padding 12px по бокам
689
- });
690
- nameText.anchor.set(0.5, 0);
691
- nameText.x = width / 2;
692
- nameText.y = iconY + iconSize + 8;
693
-
694
- // Добавляем в контейнер в правильном порядке
695
- this.ghostContainer.addChild(shadow);
696
- this.ghostContainer.addChild(background);
697
- this.ghostContainer.addChild(icon);
698
- this.ghostContainer.addChild(nameText);
699
-
700
- // Центрируем контейнер относительно курсора
701
- this.ghostContainer.pivot.x = width / 2;
702
- this.ghostContainer.pivot.y = height / 2;
703
-
704
- this.world.addChild(this.ghostContainer);
180
+ return this.ghostController.showFileGhost();
705
181
  }
706
182
 
707
183
  /**
708
184
  * Скрыть "призрак" файла
709
185
  */
710
186
  hideGhost() {
711
- if (this.ghostContainer && this.world) {
712
- this.world.removeChild(this.ghostContainer);
713
- this.ghostContainer.destroy();
714
- this.ghostContainer = null;
715
- }
187
+ return this.ghostController.hideGhost();
716
188
  }
717
189
 
718
190
  /**
719
191
  * Обновить позицию "призрака" файла
720
192
  */
721
193
  updateGhostPosition(x, y) {
722
- if (this.ghostContainer) {
723
- this.ghostContainer.x = x;
724
- this.ghostContainer.y = y;
725
- }
194
+ return this.ghostController.updateGhostPosition(x, y);
726
195
  }
727
196
 
728
197
  /**
@@ -748,37 +217,19 @@ export class PlacementTool extends BaseTool {
748
217
  );
749
218
 
750
219
  // Создаем объект файла с данными с сервера
751
- this.eventBus.emit(Events.UI.ToolbarAction, {
752
- type: 'file',
753
- id: 'file',
754
- position,
755
- properties: {
756
- fileName: uploadResult.name,
757
- fileSize: uploadResult.size,
758
- mimeType: uploadResult.mimeType,
759
- formattedSize: uploadResult.formattedSize,
760
- url: uploadResult.url,
761
- width: props.width || 120,
762
- height: props.height || 140
763
- },
764
- fileId: uploadResult.fileId || uploadResult.id // Сохраняем ID файла
765
- });
220
+ this.payloadFactory.emitFileUploaded(position, uploadResult, props.width || 120, props.height || 140);
766
221
 
767
222
  } catch (uploadError) {
768
223
  console.error('Ошибка загрузки файла на сервер:', uploadError);
769
224
  // Fallback: создаем объект файла с локальными данными
770
- this.eventBus.emit(Events.UI.ToolbarAction, {
771
- type: 'file',
772
- id: 'file',
225
+ this.payloadFactory.emitFileFallback(
773
226
  position,
774
- properties: {
775
- fileName: this.selectedFile.fileName,
776
- fileSize: this.selectedFile.fileSize,
777
- mimeType: this.selectedFile.mimeType,
778
- width: props.width || 120,
779
- height: props.height || 140
780
- }
781
- });
227
+ this.selectedFile.fileName,
228
+ this.selectedFile.fileSize,
229
+ this.selectedFile.mimeType,
230
+ props.width || 120,
231
+ props.height || 140
232
+ );
782
233
 
783
234
  // Показываем предупреждение пользователю
784
235
  alert('Ошибка загрузки файла на сервер. Файл добавлен локально.');
@@ -794,478 +245,49 @@ export class PlacementTool extends BaseTool {
794
245
  * Показать "призрак" изображения
795
246
  */
796
247
  async showImageGhost() {
797
- if (!this.selectedImage || !this.world) return;
798
-
799
- this.hideGhost(); // Сначала убираем старый призрак
800
-
801
- // Создаем контейнер для призрака
802
- this.ghostContainer = new PIXI.Container();
803
- this.ghostContainer.alpha = 0.6; // Полупрозрачность
804
-
805
- // Размеры призрака - используем размеры из pending/selected, если есть
806
- const isEmojiIcon = this.selectedImage.properties?.isEmojiIcon;
807
- const maxWidth = this.selectedImage.properties.width || (isEmojiIcon ? 64 : 300);
808
- const maxHeight = this.selectedImage.properties.height || (isEmojiIcon ? 64 : 200);
809
-
810
- try {
811
- // Создаем превью изображения
812
- const imageUrl = URL.createObjectURL(this.selectedImage.file);
813
- const texture = await PIXI.Texture.fromURL(imageUrl);
814
-
815
- // Вычисляем пропорциональные размеры
816
- const imageAspect = texture.width / texture.height;
817
- let width = maxWidth;
818
- let height = maxWidth / imageAspect;
819
-
820
- if (height > maxHeight) {
821
- height = maxHeight;
822
- width = maxHeight * imageAspect;
823
- }
824
-
825
- // Создаем спрайт изображения
826
- const sprite = new PIXI.Sprite(texture);
827
- sprite.width = width;
828
- sprite.height = height;
829
-
830
- // Рамка вокруг изображения
831
- const border = new PIXI.Graphics();
832
- border.lineStyle(2, 0xDEE2E6, 0.8);
833
- border.drawRoundedRect(-2, -2, width + 4, height + 4, 4);
834
-
835
- this.ghostContainer.addChild(border);
836
- this.ghostContainer.addChild(sprite);
837
-
838
- // Центрируем контейнер относительно курсора
839
- this.ghostContainer.pivot.x = width / 2;
840
- this.ghostContainer.pivot.y = height / 2;
841
-
842
- // Освобождаем URL
843
- URL.revokeObjectURL(imageUrl);
844
-
845
- } catch (error) {
846
- console.warn('Не удалось загрузить превью изображения, показываем заглушку:', error);
847
-
848
- // Fallback: простой прямоугольник-заглушка
849
- const graphics = new PIXI.Graphics();
850
- graphics.beginFill(0xF8F9FA, 0.8);
851
- graphics.lineStyle(2, 0xDEE2E6, 0.8);
852
- graphics.drawRoundedRect(0, 0, maxWidth, maxHeight, 8);
853
- graphics.endFill();
854
-
855
- // Иконка изображения
856
- graphics.beginFill(0x6C757D, 0.6);
857
- graphics.drawRoundedRect(maxWidth * 0.2, maxHeight * 0.15, maxWidth * 0.6, maxHeight * 0.3, 4);
858
- graphics.endFill();
859
-
860
- // Текст названия файла
861
- const fileName = this.selectedImage.fileName || 'Image';
862
- const displayName = fileName.length > 20 ? fileName.substring(0, 17) + '...' : fileName;
863
-
864
- const nameText = new PIXI.Text(displayName, {
865
- fontFamily: 'Arial, sans-serif',
866
- fontSize: 12,
867
- fill: 0x495057,
868
- align: 'center',
869
- wordWrap: true,
870
- wordWrapWidth: maxWidth - 10
871
- });
872
-
873
- nameText.x = (maxWidth - nameText.width) / 2;
874
- nameText.y = maxHeight * 0.55;
875
-
876
- this.ghostContainer.addChild(graphics);
877
- this.ghostContainer.addChild(nameText);
878
-
879
- // Центрируем контейнер относительно курсора
880
- this.ghostContainer.pivot.x = maxWidth / 2;
881
- this.ghostContainer.pivot.y = maxHeight / 2;
882
- }
883
-
884
- this.world.addChild(this.ghostContainer);
248
+ return this.ghostController.showImageGhost();
885
249
  }
886
250
 
887
251
  /**
888
252
  * Показать "призрак" изображения по URL (для выбора из панели эмоджи)
889
253
  */
890
254
  async showImageUrlGhost() {
891
- if (!this.pending || this.pending.type !== 'image' || !this.world) return;
892
- const src = this.pending.properties?.src;
893
- if (!src) return;
894
-
895
- this.hideGhost();
896
-
897
- this.ghostContainer = new PIXI.Container();
898
- this.ghostContainer.alpha = 0.6;
899
-
900
- // Для эмоджи используем точные размеры из pending для согласованности
901
- const isEmojiIcon = this.pending.properties?.isEmojiIcon;
902
- const maxWidth = this.pending.size?.width || this.pending.properties?.width || (isEmojiIcon ? 64 : 56);
903
- const maxHeight = this.pending.size?.height || this.pending.properties?.height || (isEmojiIcon ? 64 : 56);
904
-
905
- try {
906
- const texture = await PIXI.Texture.fromURL(src);
907
- const imageAspect = (texture.width || 1) / (texture.height || 1);
908
- let width = maxWidth;
909
- let height = maxWidth / imageAspect;
910
- if (height > maxHeight) {
911
- height = maxHeight;
912
- width = maxHeight * imageAspect;
913
- }
914
-
915
- const sprite = new PIXI.Sprite(texture);
916
- sprite.width = Math.max(1, Math.round(width));
917
- sprite.height = Math.max(1, Math.round(height));
918
-
919
- const border = new PIXI.Graphics();
920
- try { border.lineStyle({ width: 2, color: 0xDEE2E6, alpha: 0.8 }); }
921
- catch (_) { border.lineStyle(2, 0xDEE2E6, 0.8); }
922
- border.drawRoundedRect(-2, -2, sprite.width + 4, sprite.height + 4, 4);
923
-
924
- this.ghostContainer.addChild(border);
925
- this.ghostContainer.addChild(sprite);
926
- this.ghostContainer.pivot.set(sprite.width / 2, sprite.height / 2);
927
- } catch (e) {
928
- const g = new PIXI.Graphics();
929
- g.beginFill(0xF0F0F0, 0.8);
930
- g.lineStyle(2, 0xDEE2E6, 0.8);
931
- g.drawRoundedRect(0, 0, maxWidth, maxHeight, 8);
932
- g.endFill();
933
- this.ghostContainer.addChild(g);
934
- this.ghostContainer.pivot.set(maxWidth / 2, maxHeight / 2);
935
- }
936
-
937
- this.world.addChild(this.ghostContainer);
938
-
939
- // Для эмоджи не используем кастомный курсор, чтобы избежать дублирования призраков
940
- if (!isEmojiIcon) {
941
- // Кастомный курсор только для обычных изображений
942
- try {
943
- if (this.app && this.app.view && src) {
944
- const cursorSize = 24;
945
- const url = encodeURI(src);
946
- // Используем CSS cursor с изображением, если поддерживается
947
- this.cursor = `url(${url}) ${Math.floor(cursorSize/2)} ${Math.floor(cursorSize/2)}, default`;
948
- this.app.view.style.cursor = this.cursor;
949
- }
950
- } catch (_) {}
951
- } else {
952
- // Для эмоджи используем стандартный курсор
953
- if (this.app && this.app.view) {
954
- this.cursor = 'crosshair';
955
- this.app.view.style.cursor = this.cursor;
956
- }
957
- }
255
+ return this.ghostController.showImageUrlGhost();
958
256
  }
959
257
 
960
258
  /**
961
259
  * Показать "призрак" текста
962
260
  */
963
261
  showTextGhost() {
964
- if (!this.pending || this.pending.type !== 'text' || !this.world) return;
965
-
966
- this.hideGhost(); // Сначала убираем старый призрак
967
-
968
- // Создаем контейнер для призрака
969
- this.ghostContainer = new PIXI.Container();
970
- this.ghostContainer.alpha = 0.6; // Полупрозрачность
971
-
972
- // Размеры призрака текста
973
- const fontSize = this.pending.properties?.fontSize || 18;
974
- const width = 120;
975
- const height = fontSize + 20; // Высота зависит от размера шрифта
976
-
977
- // Фон для текста (полупрозрачный прямоугольник)
978
- const background = new PIXI.Graphics();
979
- background.beginFill(0xFFFFFF, 0.8);
980
- background.lineStyle(1, 0x007BFF, 0.8);
981
- background.drawRoundedRect(0, 0, width, height, 4);
982
- background.endFill();
983
-
984
- // Текст-заглушка
985
- const placeholderText = new PIXI.Text('Текст', {
986
- fontFamily: 'Arial, sans-serif',
987
- fontSize: fontSize,
988
- fill: 0x6C757D,
989
- align: 'left'
990
- });
991
-
992
- placeholderText.x = 8;
993
- placeholderText.y = (height - placeholderText.height) / 2;
994
-
995
- // Иконка курсора (маленькая вертикальная линия)
996
- const cursor = new PIXI.Graphics();
997
- cursor.lineStyle(2, 0x007BFF, 0.8);
998
- cursor.moveTo(placeholderText.x + placeholderText.width + 4, placeholderText.y);
999
- cursor.lineTo(placeholderText.x + placeholderText.width + 4, placeholderText.y + placeholderText.height);
1000
-
1001
- this.ghostContainer.addChild(background);
1002
- this.ghostContainer.addChild(placeholderText);
1003
- this.ghostContainer.addChild(cursor);
1004
-
1005
- // Центрируем контейнер относительно курсора
1006
- this.ghostContainer.pivot.x = width / 2;
1007
- this.ghostContainer.pivot.y = height / 2;
1008
-
1009
- this.world.addChild(this.ghostContainer);
262
+ return this.ghostController.showTextGhost();
1010
263
  }
1011
264
 
1012
265
  /**
1013
266
  * Показать "призрак" записки
1014
267
  */
1015
268
  showNoteGhost() {
1016
- if (!this.pending || this.pending.type !== 'note' || !this.world) return;
1017
-
1018
- this.hideGhost(); // Сначала убираем старый призрак
1019
-
1020
- // Создаем контейнер для призрака
1021
- this.ghostContainer = new PIXI.Container();
1022
- this.ghostContainer.alpha = 0.6; // Полупрозрачность
1023
-
1024
- // Размеры и стили (без текста у призрака)
1025
- const width = this.pending.properties?.width || 250;
1026
- const height = this.pending.properties?.height || 250;
1027
- const backgroundColor = (typeof this.pending.properties?.backgroundColor === 'number')
1028
- ? this.pending.properties.backgroundColor
1029
- : 0xFFF9C4; // желтый как у записки
1030
- const textColor = (typeof this.pending.properties?.textColor === 'number')
1031
- ? this.pending.properties.textColor
1032
- : 0x1A1A1A;
1033
-
1034
- // Тени для призрака отключены по требованию (без тени)
1035
-
1036
- // Основной фон записки (желтый как у оригинала)
1037
- const background = new PIXI.Graphics();
1038
- background.beginFill(backgroundColor, 1);
1039
- background.drawRoundedRect(0, 0, width, height, 2);
1040
- background.endFill();
1041
-
1042
- // У призрака текста нет — только фон записки
1043
-
1044
- // Порядок добавления: тень → фон → шапка → текст
1045
- // Без тени
1046
- this.ghostContainer.addChild(background);
1047
-
1048
- // Центрируем контейнер относительно курсора
1049
- this.ghostContainer.pivot.x = width / 2;
1050
- this.ghostContainer.pivot.y = height / 2;
1051
-
1052
- this.world.addChild(this.ghostContainer);
1053
- // Текст убран — дополнительная загрузка шрифтов для призрака не требуется
269
+ return this.ghostController.showNoteGhost();
1054
270
  }
1055
271
 
1056
272
  /**
1057
273
  * Показать "призрак" эмоджи
1058
274
  */
1059
275
  showEmojiGhost() {
1060
- if (!this.pending || this.pending.type !== 'emoji' || !this.world) return;
1061
-
1062
- this.hideGhost(); // Сначала убираем старый призрак
1063
-
1064
- // Создаем контейнер для призрака
1065
- this.ghostContainer = new PIXI.Container();
1066
- this.ghostContainer.alpha = 0.7; // Немного менее прозрачный для эмоджи
1067
-
1068
- // Получаем параметры эмоджи из pending
1069
- const content = this.pending.properties?.content || '🙂';
1070
- const fontSize = this.pending.properties?.fontSize || 48;
1071
- const width = this.pending.properties?.width || fontSize;
1072
- const height = this.pending.properties?.height || fontSize;
1073
-
1074
- // Создаем эмоджи текст (как в EmojiObject)
1075
- const emojiText = new PIXI.Text(content, {
1076
- fontFamily: 'Segoe UI Emoji, Apple Color Emoji, Noto Color Emoji, Arial',
1077
- fontSize: fontSize
1078
- });
1079
-
1080
- // Устанавливаем якорь в левом верхнем углу (как в EmojiObject)
1081
- if (typeof emojiText.anchor?.set === 'function') {
1082
- emojiText.anchor.set(0, 0);
1083
- }
1084
-
1085
- // Получаем базовые размеры для масштабирования
1086
- const bounds = emojiText.getLocalBounds();
1087
- const baseW = Math.max(1, bounds.width || 1);
1088
- const baseH = Math.max(1, bounds.height || 1);
1089
-
1090
- // Применяем равномерное масштабирование для подгонки под целевые размеры
1091
- const scaleX = width / baseW;
1092
- const scaleY = height / baseH;
1093
- const scale = Math.min(scaleX, scaleY); // Равномерное масштабирование
1094
-
1095
- emojiText.scale.set(scale, scale);
1096
-
1097
- // Добавляем лёгкий фон для лучшей видимости призрака
1098
- const background = new PIXI.Graphics();
1099
- background.beginFill(0xFFFFFF, 0.3); // Полупрозрачный белый фон
1100
- background.lineStyle(1, 0xDDDDDD, 0.5); // Тонкая граница
1101
- background.drawRoundedRect(-4, -4, width + 8, height + 8, 4);
1102
- background.endFill();
1103
-
1104
- this.ghostContainer.addChild(background);
1105
- this.ghostContainer.addChild(emojiText);
1106
-
1107
- // Центрируем контейнер относительно курсора
1108
- this.ghostContainer.pivot.x = width / 2;
1109
- this.ghostContainer.pivot.y = height / 2;
1110
-
1111
- this.world.addChild(this.ghostContainer);
276
+ return this.ghostController.showEmojiGhost();
1112
277
  }
1113
278
 
1114
279
  /**
1115
280
  * Показать "призрак" фрейма
1116
281
  */
1117
282
  showFrameGhost() {
1118
- if (!this.pending || this.pending.type !== 'frame' || !this.world) return;
1119
-
1120
- this.hideGhost(); // Сначала убираем старый призрак
1121
-
1122
- // Создаем контейнер для призрака
1123
- this.ghostContainer = new PIXI.Container();
1124
- this.ghostContainer.alpha = 0.6; // Полупрозрачность
1125
-
1126
- // Получаем параметры фрейма из pending
1127
- const width = this.pending.properties?.width || 200;
1128
- const height = this.pending.properties?.height || 300;
1129
- const fillColor = (this.pending.properties?.backgroundColor ?? this.pending.properties?.fillColor) ?? 0xFFFFFF;
1130
- const title = this.pending.properties?.title || 'Новый';
1131
-
1132
- // Читаем стили рамки как у реального фрейма (FrameObject)
1133
- const rootStyles = (typeof window !== 'undefined') ? getComputedStyle(document.documentElement) : null;
1134
- const cssBorderWidth = rootStyles ? parseFloat(rootStyles.getPropertyValue('--frame-border-width') || '4') : 4;
1135
- const cssCornerRadius = rootStyles ? parseFloat(rootStyles.getPropertyValue('--frame-corner-radius') || '6') : 6;
1136
- const cssBorderColor = rootStyles ? rootStyles.getPropertyValue('--frame-border-color').trim() : '';
1137
- const borderWidth = Number.isFinite(cssBorderWidth) ? cssBorderWidth : 4;
1138
- const cornerRadius = Number.isFinite(cssCornerRadius) ? cssCornerRadius : 6;
1139
- let strokeColor;
1140
- if (cssBorderColor && cssBorderColor.startsWith('#')) {
1141
- strokeColor = parseInt(cssBorderColor.slice(1), 16);
1142
- } else {
1143
- strokeColor = (typeof this.pending.properties?.borderColor === 'number') ? this.pending.properties.borderColor : 0xE0E0E0;
1144
- }
1145
-
1146
- // Создаем фон фрейма (как в FrameObject) — повторяем стили рамки
1147
- const frameGraphics = new PIXI.Graphics();
1148
- try {
1149
- frameGraphics.lineStyle({ width: borderWidth, color: strokeColor, alpha: 1, alignment: 1 });
1150
- } catch (e) {
1151
- frameGraphics.lineStyle(borderWidth, strokeColor, 1);
1152
- }
1153
- // Заливка как у фрейма, прозрачность задаётся через контейнер (alpha)
1154
- frameGraphics.beginFill(fillColor, 1);
1155
- frameGraphics.drawRoundedRect(0, 0, width, height, cornerRadius);
1156
- frameGraphics.endFill();
1157
-
1158
- // Создаем заголовок фрейма (как в FrameObject)
1159
- const titleText = new PIXI.Text(title, {
1160
- fontFamily: 'Arial, sans-serif',
1161
- fontSize: 14,
1162
- fill: 0x333333,
1163
- fontWeight: 'bold'
1164
- });
1165
- // Размещаем заголовок внутри верхней части фрейма
1166
- titleText.anchor.set(0, 0);
1167
- titleText.x = 8;
1168
- titleText.y = 4;
1169
-
1170
- this.ghostContainer.addChild(frameGraphics);
1171
- this.ghostContainer.addChild(titleText);
1172
-
1173
- // Центрируем контейнер относительно курсора
1174
- this.ghostContainer.pivot.x = width / 2;
1175
- this.ghostContainer.pivot.y = height / 2;
1176
-
1177
- this.world.addChild(this.ghostContainer);
283
+ return this.ghostController.showFrameGhost();
1178
284
  }
1179
285
 
1180
286
  /**
1181
287
  * Показать "призрак" фигуры
1182
288
  */
1183
289
  showShapeGhost() {
1184
- if (!this.pending || this.pending.type !== 'shape' || !this.world) return;
1185
-
1186
- this.hideGhost(); // Сначала убираем старый призрак
1187
-
1188
- // Создаем контейнер для призрака
1189
- this.ghostContainer = new PIXI.Container();
1190
- this.ghostContainer.alpha = 0.6; // Полупрозрачность
1191
-
1192
- // Получаем параметры фигуры из pending
1193
- const kind = this.pending.properties?.kind || 'square';
1194
- const width = 100; // Стандартный размер по умолчанию
1195
- const height = 100;
1196
- const fillColor = 0x3b82f6; // Синий цвет как в ShapeObject
1197
- const cornerRadius = this.pending.properties?.cornerRadius || 10;
1198
-
1199
- // Создаем графику фигуры (точно как в ShapeObject._draw)
1200
- const shapeGraphics = new PIXI.Graphics();
1201
- shapeGraphics.beginFill(fillColor, 0.8); // Полупрозрачная заливка
1202
-
1203
- switch (kind) {
1204
- case 'circle': {
1205
- const r = Math.min(width, height) / 2;
1206
- shapeGraphics.drawCircle(width / 2, height / 2, r);
1207
- break;
1208
- }
1209
- case 'rounded': {
1210
- const r = cornerRadius || 10;
1211
- shapeGraphics.drawRoundedRect(0, 0, width, height, r);
1212
- break;
1213
- }
1214
- case 'triangle': {
1215
- shapeGraphics.moveTo(width / 2, 0);
1216
- shapeGraphics.lineTo(width, height);
1217
- shapeGraphics.lineTo(0, height);
1218
- shapeGraphics.lineTo(width / 2, 0);
1219
- break;
1220
- }
1221
- case 'diamond': {
1222
- shapeGraphics.moveTo(width / 2, 0);
1223
- shapeGraphics.lineTo(width, height / 2);
1224
- shapeGraphics.lineTo(width / 2, height);
1225
- shapeGraphics.lineTo(0, height / 2);
1226
- shapeGraphics.lineTo(width / 2, 0);
1227
- break;
1228
- }
1229
- case 'parallelogram': {
1230
- const skew = Math.min(width * 0.25, 20);
1231
- shapeGraphics.moveTo(skew, 0);
1232
- shapeGraphics.lineTo(width, 0);
1233
- shapeGraphics.lineTo(width - skew, height);
1234
- shapeGraphics.lineTo(0, height);
1235
- shapeGraphics.lineTo(skew, 0);
1236
- break;
1237
- }
1238
- case 'arrow': {
1239
- const shaftH = Math.max(6, height * 0.3);
1240
- const shaftY = (height - shaftH) / 2;
1241
- shapeGraphics.drawRect(0, shaftY, width * 0.6, shaftH);
1242
- shapeGraphics.moveTo(width * 0.6, 0);
1243
- shapeGraphics.lineTo(width, height / 2);
1244
- shapeGraphics.lineTo(width * 0.6, height);
1245
- shapeGraphics.lineTo(width * 0.6, 0);
1246
- break;
1247
- }
1248
- case 'square':
1249
- default: {
1250
- shapeGraphics.drawRect(0, 0, width, height);
1251
- break;
1252
- }
1253
- }
1254
- shapeGraphics.endFill();
1255
-
1256
- // Добавляем тонкую рамку для лучшей видимости призрака
1257
- const border = new PIXI.Graphics();
1258
- border.lineStyle(2, 0x007BFF, 0.6);
1259
- border.drawRect(-2, -2, width + 4, height + 4);
1260
-
1261
- this.ghostContainer.addChild(border);
1262
- this.ghostContainer.addChild(shapeGraphics);
1263
-
1264
- // Центрируем контейнер относительно курсора
1265
- this.ghostContainer.pivot.x = width / 2;
1266
- this.ghostContainer.pivot.y = height / 2;
1267
-
1268
- this.world.addChild(this.ghostContainer);
290
+ return this.ghostController.showShapeGhost();
1269
291
  }
1270
292
 
1271
293
  /**
@@ -1297,18 +319,7 @@ export class PlacementTool extends BaseTool {
1297
319
  };
1298
320
 
1299
321
  // Создаем объект изображения с данными с сервера
1300
- this.eventBus.emit(Events.UI.ToolbarAction, {
1301
- type: 'image',
1302
- id: 'image',
1303
- position,
1304
- properties: {
1305
- src: uploadResult.url,
1306
- name: uploadResult.name,
1307
- width: targetW,
1308
- height: targetH
1309
- },
1310
- imageId: uploadResult.imageId || uploadResult.id // Сохраняем ID изображения
1311
- });
322
+ this.payloadFactory.emitImageUploaded(position, uploadResult, targetW, targetH);
1312
323
 
1313
324
  } catch (uploadError) {
1314
325
  console.error('Ошибка загрузки изображения на сервер:', uploadError);
@@ -1325,17 +336,7 @@ export class PlacementTool extends BaseTool {
1325
336
  y: Math.round(worldPoint.y - halfH)
1326
337
  };
1327
338
 
1328
- this.eventBus.emit(Events.UI.ToolbarAction, {
1329
- type: 'image',
1330
- id: 'image',
1331
- position,
1332
- properties: {
1333
- src: imageUrl,
1334
- name: this.selectedImage.fileName,
1335
- width: targetW,
1336
- height: targetH
1337
- }
1338
- });
339
+ this.payloadFactory.emitImageFallback(position, imageUrl, this.selectedImage.fileName, targetW, targetH);
1339
340
 
1340
341
  // Показываем предупреждение пользователю
1341
342
  alert('Ошибка загрузки изображения на сервер. Изображение добавлено локально.');