@sequent-org/moodboard 1.2.119 → 1.3.1

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 +82 -1181
  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 +665 -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
package/src/ui/Toolbar.js CHANGED
@@ -3,7 +3,12 @@
3
3
  */
4
4
  import { Events } from '../core/events/Events.js';
5
5
  import { IconLoader } from '../utils/iconLoader.js';
6
- import { getInlinePngEmojiUrl, hasInlinePngEmoji } from '../utils/inlinePngEmojis.js';
6
+ import { ToolbarDialogsController } from './toolbar/ToolbarDialogsController.js';
7
+ import { ToolbarPopupsController } from './toolbar/ToolbarPopupsController.js';
8
+ import { ToolbarActionRouter } from './toolbar/ToolbarActionRouter.js';
9
+ import { ToolbarTooltipController } from './toolbar/ToolbarTooltipController.js';
10
+ import { ToolbarStateController } from './toolbar/ToolbarStateController.js';
11
+ import { ToolbarRenderer } from './toolbar/ToolbarRenderer.js';
7
12
 
8
13
  export class Toolbar {
9
14
  constructor(container, eventBus, theme = 'light', options = {}) {
@@ -19,6 +24,13 @@ export class Toolbar {
19
24
 
20
25
  // Кэш для SVG иконок
21
26
  this.icons = {};
27
+
28
+ this.dialogsController = new ToolbarDialogsController(this);
29
+ this.popupsController = new ToolbarPopupsController(this);
30
+ this.actionRouter = new ToolbarActionRouter(this);
31
+ this.tooltipController = new ToolbarTooltipController(this);
32
+ this.stateController = new ToolbarStateController(this);
33
+ this.renderer = new ToolbarRenderer(this);
22
34
 
23
35
  this.init();
24
36
  }
@@ -28,13 +40,19 @@ export class Toolbar {
28
40
  */
29
41
  async init() {
30
42
  try {
31
- // Инициализируем IconLoader и загружаем все иконки
32
43
  await this.iconLoader.init();
33
44
  this.icons = await this.iconLoader.loadAllIcons();
34
45
  } catch (error) {
35
46
  console.error('❌ Ошибка загрузки иконок:', error);
36
47
  }
37
-
48
+
49
+ this._toolActivatedHandler = ({ tool }) => {
50
+ this.setActiveToolbarButton(tool);
51
+ // Draw palette must stay open only while draw tool is active.
52
+ if (tool !== 'draw') {
53
+ this.closeDrawPopup();
54
+ }
55
+ };
38
56
  this.createToolbar();
39
57
  this.attachEvents();
40
58
  this.setupHistoryEvents();
@@ -44,297 +62,54 @@ export class Toolbar {
44
62
  * Создает HTML структуру тулбара
45
63
  */
46
64
  createToolbar() {
47
- this.element = document.createElement('div');
48
- this.element.className = `moodboard-toolbar moodboard-toolbar--${this.theme}`;
49
-
50
- // Новые элементы интерфейса (без функционала)
51
- const newTools = [
52
- { id: 'select', iconName: 'select', title: 'Инструмент выделения (V)', type: 'activate-select' },
53
- { id: 'pan', iconName: 'pan', title: 'Панорамирование (Пробел)', type: 'activate-pan' },
54
- { id: 'divider', type: 'divider' },
55
- { id: 'text-add', iconName: 'text-add', title: 'Добавить текст', type: 'text-add' },
56
- { id: 'note', iconName: 'note', title: 'Добавить записку', type: 'note-add' },
57
- { id: 'image', iconName: 'image', title: 'Добавить картинку', type: 'image-add' },
58
- { id: 'shapes', iconName: 'shapes', title: 'Фигуры', type: 'custom-shapes' },
59
- { id: 'pencil', iconName: 'pencil', title: 'Рисование', type: 'custom-draw' },
60
- // { id: 'comments', iconName: 'comments', title: 'Комментарии', type: 'custom-comments' }, // Временно скрыто
61
- { id: 'attachments', iconName: 'attachments', title: 'Файлы', type: 'custom-attachments' },
62
- { id: 'emoji', iconName: 'emoji', title: 'Эмоджи', type: 'custom-emoji' }
63
- ];
64
-
65
- // Существующие элементы ниже новых
66
- // убрал { id: 'clear', iconName: 'clear', title: 'Очистить холст', type: 'clear' },
67
- const existingTools = [
68
- { id: 'frame', iconName: 'frame', title: 'Добавить фрейм', type: 'frame' },
69
- { id: 'divider', type: 'divider' },
70
- { id: 'undo', iconName: 'undo', title: 'Отменить (Ctrl+Z)', type: 'undo', disabled: true },
71
- { id: 'redo', iconName: 'redo', title: 'Повторить (Ctrl+Y)', type: 'redo', disabled: true }
72
- ];
73
-
74
- [...newTools, ...existingTools].forEach(tool => {
75
- if (tool.type === 'divider') {
76
- const divider = document.createElement('div');
77
- divider.className = 'moodboard-toolbar__divider';
78
- this.element.appendChild(divider);
79
- } else {
80
- const button = this.createButton(tool);
81
- this.element.appendChild(button);
82
- }
83
- });
84
-
85
- this.container.appendChild(this.element);
86
-
87
- // Создаем всплывающие панели (фигуры, рисование, эмоджи)
88
- this.createShapesPopup();
89
- this.createDrawPopup();
90
- this.createEmojiPopup();
91
- this.createFramePopup();
92
-
93
- // Подсветка активной кнопки на тулбаре по активному инструменту
94
- this.eventBus.on(Events.Tool.Activated, ({ tool }) => {
95
- this.setActiveToolbarButton(tool);
96
- });
97
-
98
- // Текущее состояние попапа рисования
99
- this.currentDrawTool = 'pencil';
65
+ return this.renderer.createToolbar();
100
66
  }
101
67
 
102
68
  createFramePopup() {
103
- this.framePopupEl = document.createElement('div');
104
- this.framePopupEl.className = 'moodboard-toolbar__popup frame-popup';
105
- this.framePopupEl.style.display = 'none';
106
-
107
- const makeBtn = (label, id, enabled, aspect, options = {}) => {
108
- const btn = document.createElement('button');
109
- btn.className = 'frame-popup__btn' + (enabled ? '' : ' is-disabled') + (options.header ? ' frame-popup__btn--header' : '');
110
- if (options.header) {
111
- // handled by CSS class
112
- }
113
- btn.dataset.id = id;
114
- // Внутри кнопки — превью (слева) и подпись (справа/ниже)
115
- const holder = document.createElement('div');
116
- holder.className = 'frame-popup__holder';
117
- let preview = document.createElement('div');
118
- if (options.header) {
119
- // Для «Произвольный» — горизонтальный пунктирный прямоугольник
120
- preview.className = 'frame-popup__preview frame-popup__preview--custom';
121
- } else {
122
- // Для пресетов — мини-превью с нужными пропорциями, слева от текста
123
- preview.className = 'frame-popup__preview';
124
- preview.style.aspectRatio = aspect || '1 / 1';
125
- }
126
- const caption = document.createElement('div');
127
- caption.textContent = label;
128
- caption.className = 'frame-popup__caption';
129
- holder.appendChild(preview);
130
- holder.appendChild(caption);
131
- btn.appendChild(holder);
132
- if (enabled) {
133
- btn.addEventListener('click', (e) => {
134
- e.stopPropagation();
135
- // Активируем place, устанавливаем pending для frame (А4)
136
- this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'place' });
137
- this.placeSelectedButtonId = 'frame';
138
- this.setActiveToolbarButton('place');
139
- if (id === 'custom') {
140
- // Рисовать фрейм вручную прямоугольником
141
- this.eventBus.emit(Events.Place.Set, { type: 'frame-draw', properties: {} });
142
- } else {
143
- // Подбираем размеры по пресету и увеличиваем площадь в 2 раза (масштаб по корню из 2)
144
- let width = 210, height = 297, titleText = 'A4';
145
- if (id === '1x1') { width = 300; height = 300; titleText = '1:1'; }
146
- else if (id === '4x3') { width = 320; height = 240; titleText = '4:3'; }
147
- else if (id === '16x9') { width = 320; height = 180; titleText = '16:9'; }
148
- const scale = 2; // х2 по сторонам = х4 по площади
149
- width = Math.round(width * scale);
150
- height = Math.round(height * scale);
151
- // Устанавливаем pending для размещения фрейма указанного размера
152
- this.eventBus.emit(Events.Place.Set, {
153
- type: 'frame',
154
- properties: {
155
- width,
156
- height,
157
- borderColor: 0x333333,
158
- fillColor: 0xFFFFFF,
159
- title: titleText,
160
- lockedAspect: true,
161
- type: id
162
- }
163
- });
164
- }
165
- this.closeFramePopup();
166
- });
167
- }
168
- this.framePopupEl.appendChild(btn);
169
- };
170
-
171
- // Верхний ряд: одна кнопка «Произвольный» (включаем рисование фрейма)
172
- makeBtn('Произвольный', 'custom', true, 'none', { header: true });
173
-
174
- makeBtn('A4', 'a4', true, '210 / 297');
175
- makeBtn('1:1', '1x1', true, '1 / 1');
176
- makeBtn('4:3', '4x3', true, '4 / 3');
177
- makeBtn('16:9', '16x9', true, '16 / 9');
178
-
179
- this.container.appendChild(this.framePopupEl);
69
+ return this.popupsController.createFramePopup();
180
70
  }
181
71
 
182
72
  toggleFramePopup(anchorBtn) {
183
- if (!this.framePopupEl) return;
184
- const visible = this.framePopupEl.style.display !== 'none';
185
- if (visible) {
186
- this.closeFramePopup();
187
- return;
188
- }
189
- const buttonRect = anchorBtn.getBoundingClientRect();
190
- const toolbarRect = this.container.getBoundingClientRect();
191
- // Сначала показываем невидимо, чтобы измерить размеры
192
- this.framePopupEl.style.display = 'grid';
193
- this.framePopupEl.style.visibility = 'hidden';
194
- const panelW = this.framePopupEl.offsetWidth || 120;
195
- const panelH = this.framePopupEl.offsetHeight || 120;
196
- // Горизонтально: как у панели фигур — от правого края тулбара + 8px
197
- const targetLeft = this.element.offsetWidth + 8;
198
- // Вертикально: центр панели на уровне центра кнопки, с тем же лёгким смещением -4px как у фигур
199
- const btnCenterY = buttonRect.top + buttonRect.height / 2;
200
- const targetTop = Math.max(0, Math.round(btnCenterY - toolbarRect.top - panelH / 2 - 4));
201
- this.framePopupEl.style.left = `${Math.round(targetLeft)}px`;
202
- this.framePopupEl.style.top = `${targetTop}px`;
203
- // Делаем видимой
204
- this.framePopupEl.style.visibility = '';
73
+ return this.popupsController.toggleFramePopup(anchorBtn);
205
74
  }
206
75
 
207
76
  closeFramePopup() {
208
- if (this.framePopupEl) this.framePopupEl.style.display = 'none';
77
+ return this.popupsController.closeFramePopup();
209
78
  }
210
79
 
211
80
  /**
212
81
  * Создает кнопку инструмента
213
82
  */
214
83
  createButton(tool) {
215
- const button = document.createElement('button');
216
- button.className = `moodboard-toolbar__button moodboard-toolbar__button--${tool.id}`;
217
- button.dataset.tool = tool.type;
218
- button.dataset.toolId = tool.id;
219
-
220
- // Устанавливаем disabled состояние если указано
221
- if (tool.disabled) {
222
- button.disabled = true;
223
- button.classList.add('moodboard-toolbar__button--disabled');
224
- }
225
-
226
- // Создаем tooltip если есть title
227
- if (tool.title) {
228
- this.createTooltip(button, tool.title);
229
- }
230
-
231
- // Создаем SVG иконку
232
- if (tool.iconName) {
233
- this.createSvgIcon(button, tool.iconName);
234
- }
235
-
236
- return button;
84
+ return this.renderer.createButton(tool);
237
85
  }
238
86
 
239
87
  /**
240
88
  * Создает SVG иконку для кнопки
241
89
  */
242
90
  createSvgIcon(button, iconName) {
243
- if (this.icons[iconName]) {
244
- // Создаем SVG элемент из загруженного содержимого
245
- const tempDiv = document.createElement('div');
246
- tempDiv.innerHTML = this.icons[iconName];
247
- const svg = tempDiv.querySelector('svg');
248
-
249
- if (svg) {
250
- // Убираем inline размеры, чтобы CSS мог их контролировать
251
- svg.removeAttribute('width');
252
- svg.removeAttribute('height');
253
- svg.style.display = 'block';
254
-
255
- // Добавляем SVG в кнопку
256
- button.appendChild(svg);
257
- }
258
- } else {
259
- // Fallback: создаем простую текстовую иконку
260
- const fallbackIcon = document.createElement('span');
261
- fallbackIcon.textContent = iconName.charAt(0).toUpperCase();
262
- fallbackIcon.style.fontSize = '14px';
263
- fallbackIcon.style.fontWeight = 'bold';
264
- button.appendChild(fallbackIcon);
265
- }
91
+ return this.renderer.createSvgIcon(button, iconName);
266
92
  }
267
93
 
268
94
  /**
269
95
  * Создает tooltip для кнопки
270
96
  */
271
97
  createTooltip(button, text) {
272
- // Создаем элемент tooltip
273
- const tooltip = document.createElement('div');
274
- tooltip.className = 'moodboard-tooltip';
275
- tooltip.textContent = text;
276
-
277
- // Добавляем tooltip в DOM
278
- document.body.appendChild(tooltip);
279
-
280
- // Переменные для управления tooltip
281
- let showTimeout;
282
- let hideTimeout;
283
-
284
- // Показываем tooltip при наведении
285
- button.addEventListener('mouseenter', () => {
286
- clearTimeout(hideTimeout);
287
- showTimeout = setTimeout(() => {
288
- this.showTooltip(tooltip, button);
289
- }, 300); // Задержка 300ms перед показом
290
- });
291
-
292
- // Скрываем tooltip при уходе мыши
293
- button.addEventListener('mouseleave', () => {
294
- clearTimeout(showTimeout);
295
- hideTimeout = setTimeout(() => {
296
- this.hideTooltip(tooltip);
297
- }, 100); // Задержка 100ms перед скрытием
298
- });
299
-
300
- // Скрываем tooltip при клике
301
- button.addEventListener('click', () => {
302
- clearTimeout(showTimeout);
303
- this.hideTooltip(tooltip);
304
- });
305
-
306
- // Сохраняем ссылку на tooltip в кнопке для очистки
307
- button._tooltip = tooltip;
98
+ return this.tooltipController.createTooltip(button, text);
308
99
  }
309
100
 
310
101
  /**
311
102
  * Показывает tooltip
312
103
  */
313
104
  showTooltip(tooltip, button) {
314
- // Получаем позицию кнопки
315
- const buttonRect = button.getBoundingClientRect();
316
- const toolbarRect = this.element.getBoundingClientRect();
317
-
318
- // Позиционируем tooltip справа от кнопки
319
- const left = buttonRect.right + 8; // 8px отступ справа от кнопки
320
- const top = buttonRect.top + (buttonRect.height / 2) - (tooltip.offsetHeight / 2); // центрируем по вертикали
321
-
322
- // Проверяем, чтобы tooltip не выходил за правую границу экрана
323
- const maxLeft = window.innerWidth - tooltip.offsetWidth - 8;
324
- const adjustedLeft = Math.min(left, maxLeft);
325
-
326
- tooltip.style.left = `${adjustedLeft}px`;
327
- tooltip.style.top = `${top}px`;
328
-
329
- // Показываем tooltip
330
- tooltip.classList.add('moodboard-tooltip--show');
105
+ return this.tooltipController.showTooltip(tooltip, button);
331
106
  }
332
107
 
333
108
  /**
334
109
  * Скрывает tooltip
335
110
  */
336
111
  hideTooltip(tooltip) {
337
- tooltip.classList.remove('moodboard-tooltip--show');
112
+ return this.tooltipController.hideTooltip(tooltip);
338
113
  }
339
114
 
340
115
  /**
@@ -344,219 +119,17 @@ export class Toolbar {
344
119
  this.element.addEventListener('click', (e) => {
345
120
  const button = e.target.closest('.moodboard-toolbar__button');
346
121
  if (!button || button.disabled) return;
347
-
122
+
348
123
  const toolType = button.dataset.tool;
349
124
  const toolId = button.dataset.toolId;
350
-
351
- // Обрабатываем undo/redo отдельно
352
- if (toolType === 'undo') {
353
- this.eventBus.emit(Events.Keyboard.Undo);
354
- this.animateButton(button);
355
- return;
356
- }
357
-
358
- if (toolType === 'redo') {
359
- this.eventBus.emit(Events.Keyboard.Redo);
360
- this.animateButton(button);
361
- return;
362
- }
363
-
364
- // Выбор инструмента выделения — отменяем режимы размещения и возвращаемся к select
365
- if (toolType === 'activate-select') {
366
- this.animateButton(button);
367
- this.closeShapesPopup();
368
- this.closeDrawPopup();
369
- this.closeEmojiPopup();
370
- // Сбрасываем отложенное размещение, активируем select
371
- this.eventBus.emit(Events.Place.Set, null);
372
- this.placeSelectedButtonId = null;
373
- this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'select' });
374
- this.setActiveToolbarButton('select');
375
- return;
376
- }
377
125
 
378
- // Временная активация панорамирования с панели
379
- if (toolType === 'activate-pan') {
380
- this.animateButton(button);
381
- this.closeShapesPopup();
382
- this.closeDrawPopup();
383
- this.closeEmojiPopup();
384
- this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'pan' });
385
- this.setActiveToolbarButton('pan');
386
- return;
387
- }
388
-
389
-
390
-
391
- // Добавление текста: включаем placement и ждём клика для выбора позиции
392
- if (toolType === 'text-add') {
393
- this.animateButton(button);
394
- this.closeShapesPopup();
395
- this.closeDrawPopup();
396
- this.closeEmojiPopup();
397
- // Переходим в универсальный placement tool и задаем pending конфигурацию
398
- this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'place' });
399
- this.placeSelectedButtonId = 'text';
400
- this.setActiveToolbarButton('place');
401
- this.eventBus.emit(Events.Place.Set, {
402
- type: 'text',
403
- // Специальный флаг: не создавать сразу объект, а открыть форму ввода на холсте
404
- properties: { editOnCreate: true, fontSize: 18 }
405
- });
406
- return;
407
- }
408
-
409
- // Добавление записки: включаем placement и ждём клика для выбора позиции
410
- if (toolType === 'note-add') {
411
- this.animateButton(button);
412
- this.closeShapesPopup();
413
- this.closeDrawPopup();
414
- this.closeEmojiPopup();
415
- // Активируем place, устанавливаем pending для note
416
- this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'place' });
417
- this.placeSelectedButtonId = 'note';
418
- this.setActiveToolbarButton('place');
419
- // Устанавливаем свойства записки по умолчанию
420
- this.eventBus.emit(Events.Place.Set, {
421
- type: 'note',
422
- properties: {
423
- content: 'Новая записка',
424
- fontFamily: 'Caveat, Arial, cursive',
425
- fontSize: 32,
426
- width: 250,
427
- height: 250
428
- }
429
- });
430
- return;
431
- }
432
-
433
- // Фрейм: показываем всплывающую панель с пресетами
434
- if (toolType === 'frame') {
435
- this.animateButton(button);
436
- this.toggleFramePopup(button);
437
- this.closeShapesPopup();
438
- this.closeDrawPopup();
439
- this.closeEmojiPopup();
440
- // Активируем place и подсвечиваем кнопку Frame
441
- this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'place' });
442
- this.placeSelectedButtonId = 'frame';
443
- this.setActiveToolbarButton('place');
444
- return;
445
- }
446
-
447
- // Добавление картинки — сразу открываем диалог выбора изображения
448
- if (toolType === 'image-add') {
449
- this.animateButton(button);
450
- this.closeShapesPopup();
451
- this.closeDrawPopup();
452
- this.closeEmojiPopup();
453
- // Открываем диалог выбора изображения
454
- this.openImageDialog();
455
- return;
456
- }
457
-
458
- // Комментарии — включаем режим размещения comment
459
- if (toolType === 'custom-comments') {
460
- this.animateButton(button);
461
- this.closeShapesPopup();
462
- this.closeDrawPopup();
463
- this.closeEmojiPopup();
464
- this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'place' });
465
- this.placeSelectedButtonId = 'comments';
466
- this.setActiveToolbarButton('place');
467
- // Увеличенный размер по умолчанию
468
- this.eventBus.emit(Events.Place.Set, { type: 'comment', properties: { width: 72, height: 72 } });
469
- return;
470
- }
471
-
472
- // Файлы — сразу открываем диалог выбора файла
473
- if (toolType === 'custom-attachments') {
474
- this.animateButton(button);
475
- this.closeShapesPopup();
476
- this.closeDrawPopup();
477
- this.closeEmojiPopup();
478
- // Открываем диалог выбора файла
479
- this.openFileDialog();
480
- return;
481
- }
482
-
483
- // Инструмент «Фрейм» — создаём через универсальный place-поток с размерами 200x300
484
- if (toolType === 'custom-frame') {
485
- this.animateButton(button);
486
- this.closeShapesPopup();
487
- this.closeDrawPopup();
488
- this.closeEmojiPopup();
489
- // Активируем режим размещения и устанавливаем pending
490
- this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'place' });
491
- this.placeSelectedButtonId = 'frame-tool';
492
- this.setActiveToolbarButton('place');
493
- this.eventBus.emit(Events.Place.Set, {
494
- type: 'frame',
495
- properties: { width: 200, height: 300 }
496
- });
497
- return;
498
- }
499
-
500
- // Тоггл всплывающей панели фигур
501
- if (toolType === 'custom-shapes') {
502
- this.animateButton(button);
503
- this.toggleShapesPopup(button);
504
- this.closeDrawPopup();
505
- this.closeEmojiPopup();
506
- // Активируем универсальный place tool для дальнейшего размещения
507
- this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'place' });
508
- this.placeSelectedButtonId = 'shapes';
509
- this.setActiveToolbarButton('place');
510
- return;
511
- }
512
-
513
- // Тоггл всплывающей панели рисования
514
- if (toolType === 'custom-draw') {
515
- this.animateButton(button);
516
- this.toggleDrawPopup(button);
517
- this.closeShapesPopup();
518
- this.closeEmojiPopup();
519
- // Выбираем инструмент рисования (последующее действие — на холсте)
520
- this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'draw' });
521
- this.setActiveToolbarButton('draw');
522
- return;
523
- }
524
-
525
- // Тоггл всплывающей панели эмоджи
526
- if (toolType === 'custom-emoji') {
527
- this.animateButton(button);
528
- this.toggleEmojiPopup(button);
529
- this.closeShapesPopup();
530
- this.closeDrawPopup();
531
- this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'place' });
532
- this.placeSelectedButtonId = 'emoji';
533
- this.setActiveToolbarButton('place'); // ← Исправление: подсвечиваем кнопку эмоджи
534
- return;
535
- }
536
-
537
- // Очистка холста - требует подтверждения
538
- if (toolType === 'clear') {
539
- this.animateButton(button);
540
- this.showClearConfirmation();
541
- return;
542
- }
543
-
544
- // Эмитим событие для других инструментов
545
- this.eventBus.emit(Events.UI.ToolbarAction, {
546
- type: toolType,
547
- id: toolId,
548
- position: this.getRandomPosition()
549
- });
550
-
551
- // Визуальная обратная связь
552
- this.animateButton(button);
126
+ this.actionRouter.routeToolbarAction(button, toolType, toolId);
553
127
  });
554
128
 
555
- // Клик вне попапов — закрыть
556
- document.addEventListener('click', (e) => {
557
- // ИСПРАВЛЕНИЕ: Защита от null элементов
129
+ // Клик вне попапов — закрыть (сохраняем handler для корректного removeEventListener)
130
+ this._documentClickHandler = (e) => {
558
131
  if (!e.target) return;
559
-
132
+
560
133
  const isInsideToolbar = this.element && this.element.contains(e.target);
561
134
  const isInsideShapesPopup = this.shapesPopupEl && this.shapesPopupEl.contains(e.target);
562
135
  const isInsideDrawPopup = this.drawPopupEl && this.drawPopupEl.contains(e.target);
@@ -566,63 +139,25 @@ export class Toolbar {
566
139
  const isDrawButton = e.target.closest && e.target.closest('.moodboard-toolbar__button--pencil');
567
140
  const isEmojiButton = e.target.closest && e.target.closest('.moodboard-toolbar__button--emoji');
568
141
  const isFrameButton = e.target.closest && e.target.closest('.moodboard-toolbar__button--frame');
142
+ const isDrawActive = !!(this.element && this.element.querySelector('.moodboard-toolbar__button--pencil.moodboard-toolbar__button--active'));
143
+
569
144
  if (!isInsideToolbar && !isInsideShapesPopup && !isShapesButton && !isInsideDrawPopup && !isDrawButton && !isInsideEmojiPopup && !isEmojiButton && !isInsideFramePopup && !isFrameButton) {
570
145
  this.closeShapesPopup();
571
- this.closeDrawPopup();
146
+ if (!isDrawActive) {
147
+ this.closeDrawPopup();
148
+ }
572
149
  this.closeEmojiPopup();
573
150
  this.closeFramePopup();
574
151
  }
575
- });
152
+ };
153
+ document.addEventListener('click', this._documentClickHandler);
576
154
  }
577
155
 
578
156
  /**
579
157
  * Подсвечивает активную кнопку на тулбаре в зависимости от активного инструмента
580
158
  */
581
159
  setActiveToolbarButton(toolName) {
582
- if (!this.element) return;
583
-
584
-
585
- // Сбрасываем активные классы
586
- this.element.querySelectorAll('.moodboard-toolbar__button--active').forEach(el => {
587
- el.classList.remove('moodboard-toolbar__button--active');
588
- });
589
-
590
- // Соответствие инструмент → кнопка
591
- const map = {
592
- select: 'select',
593
- pan: 'pan',
594
- draw: 'pencil',
595
- text: 'text-add' // Добавляем маппинг для text инструмента
596
- };
597
-
598
- let btnId = map[toolName];
599
-
600
- if (!btnId && toolName === 'place') {
601
- // Подсвечиваем тот источник place, который активен
602
- const placeButtonMap = {
603
- 'text': 'text-add',
604
- 'note': 'note',
605
- 'frame': 'frame',
606
- 'frame-tool': 'frame',
607
- 'comments': 'comments',
608
- 'attachments': 'attachments',
609
- 'shapes': 'shapes',
610
- 'emoji': 'emoji',
611
- null: 'image' // для изображений placeSelectedButtonId = null
612
- };
613
-
614
- btnId = placeButtonMap[this.placeSelectedButtonId] || 'shapes';
615
- }
616
-
617
- if (!btnId) {
618
- return;
619
- }
620
-
621
- const btn = this.element.querySelector(`.moodboard-toolbar__button--${btnId}`);
622
- if (btn) {
623
- btn.classList.add('moodboard-toolbar__button--active');
624
- } else {
625
- }
160
+ return this.stateController.setActiveToolbarButton(toolName);
626
161
  }
627
162
 
628
163
  /**
@@ -649,592 +184,71 @@ export class Toolbar {
649
184
  * Всплывающая панель с фигурами (UI)
650
185
  */
651
186
  createShapesPopup() {
652
- this.shapesPopupEl = document.createElement('div');
653
- this.shapesPopupEl.className = 'moodboard-toolbar__popup moodboard-toolbar__popup--shapes';
654
- this.shapesPopupEl.style.display = 'none';
655
-
656
- const grid = document.createElement('div');
657
- grid.className = 'moodboard-shapes__grid';
658
-
659
- const shapes = [
660
- // Перенесли кнопку "Добавить фигуру" сюда как первый элемент
661
- { id: 'shape', title: 'Добавить фигуру', isToolbarAction: true },
662
- { id: 'rounded-square', title: 'Скругленный квадрат' },
663
- { id: 'circle', title: 'Круг' },
664
- { id: 'triangle', title: 'Треугольник' },
665
- { id: 'diamond', title: 'Ромб' },
666
- { id: 'parallelogram', title: 'Параллелограмм' },
667
- { id: 'arrow', title: 'Стрелка' }
668
- ];
669
-
670
- shapes.forEach(s => {
671
- const btn = document.createElement('button');
672
- btn.className = `moodboard-shapes__btn moodboard-shapes__btn--${s.id}`;
673
- btn.title = s.title;
674
- const icon = document.createElement('span');
675
- if (s.isToolbarAction) {
676
- // Визуально как квадрат, действие — как старая кнопка "Добавить фигуру"
677
- icon.className = 'moodboard-shapes__icon shape-square';
678
- } else {
679
- icon.className = `moodboard-shapes__icon shape-${s.id}`;
680
- if (s.id === 'arrow') {
681
- // Залитая стрелка в стиле U+21E8 (прямоугольник + треугольник)
682
- icon.innerHTML = '<svg width="18" height="12" viewBox="0 0 18 12" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect x="0" y="5" width="12" height="2" rx="1" fill="#1d4ed8"/><path d="M12 0 L18 6 L12 12 Z" fill="#1d4ed8"/></svg>';
683
- }
684
- }
685
- btn.appendChild(icon);
686
- btn.addEventListener('click', () => {
687
- this.animateButton(btn);
688
- if (s.isToolbarAction) {
689
- // Режим: добавить дефолтную фигуру по клику на холсте
690
- this.eventBus.emit(Events.Place.Set, { type: 'shape', properties: { kind: 'square' } });
691
- this.closeShapesPopup();
692
- return;
693
- }
694
- // Для остальных фигур — запоминаем выбранную форму и ждём клика по холсту
695
- const propsMap = {
696
- 'rounded-square': { kind: 'rounded', cornerRadius: 10 },
697
- 'circle': { kind: 'circle' },
698
- 'triangle': { kind: 'triangle' },
699
- 'diamond': { kind: 'diamond' },
700
- 'parallelogram': { kind: 'parallelogram' },
701
- 'arrow': { kind: 'arrow' }
702
- };
703
- const props = propsMap[s.id] || { kind: 'square' };
704
- this.eventBus.emit(Events.Place.Set, { type: 'shape', properties: props });
705
- this.closeShapesPopup();
706
- });
707
- grid.appendChild(btn);
708
- });
709
-
710
- this.shapesPopupEl.appendChild(grid);
711
- // Добавляем попап внутрь контейнера тулбара
712
- this.container.appendChild(this.shapesPopupEl);
187
+ return this.popupsController.createShapesPopup();
713
188
  }
714
189
 
715
190
  toggleShapesPopup(anchorButton) {
716
- if (!this.shapesPopupEl) return;
717
- if (this.shapesPopupEl.style.display === 'none') {
718
- this.openShapesPopup(anchorButton);
719
- } else {
720
- this.closeShapesPopup();
721
- }
191
+ return this.popupsController.toggleShapesPopup(anchorButton);
722
192
  }
723
193
 
724
194
  openShapesPopup(anchorButton) {
725
- if (!this.shapesPopupEl) return;
726
- // Позиционируем справа от тулбара, по вертикали — напротив кнопки
727
- const toolbarRect = this.container.getBoundingClientRect();
728
- const buttonRect = anchorButton.getBoundingClientRect();
729
- const top = buttonRect.top - toolbarRect.top - 4; // легкое выравнивание
730
- const left = this.element.offsetWidth + 8; // отступ от тулбара
731
- this.shapesPopupEl.style.top = `${top}px`;
732
- this.shapesPopupEl.style.left = `${left}px`;
733
- this.shapesPopupEl.style.display = 'block';
195
+ return this.popupsController.openShapesPopup(anchorButton);
734
196
  }
735
197
 
736
198
  closeShapesPopup() {
737
- if (this.shapesPopupEl) {
738
- this.shapesPopupEl.style.display = 'none';
739
- }
199
+ return this.popupsController.closeShapesPopup();
740
200
  }
741
201
 
742
202
  /**
743
203
  * Всплывающая панель рисования (UI)
744
204
  */
745
205
  createDrawPopup() {
746
- this.drawPopupEl = document.createElement('div');
747
- this.drawPopupEl.className = 'moodboard-toolbar__popup moodboard-toolbar__popup--draw';
748
- this.drawPopupEl.style.display = 'none';
749
-
750
- const grid = document.createElement('div');
751
- grid.className = 'moodboard-draw__grid';
752
-
753
- // Первый ряд: карандаш, маркер, ластик (иконки SVG)
754
- const tools = [
755
- { id: 'pencil-tool', tool: 'pencil', title: 'Карандаш', svg: '<svg width="20" height="20" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M14.492 3.414 8.921 8.985a4.312 4.312 0 0 0 6.105 6.09l5.564-5.562 1.414 1.414-5.664 5.664a6.002 6.002 0 0 1-2.182 1.392L3.344 21.94 2.06 20.656 6.02 9.845c.3-.82.774-1.563 1.391-2.18l.093-.092.01-.01L13.077 2l1.415 1.414ZM4.68 19.32l4.486-1.64a6.305 6.305 0 0 1-1.651-1.19 6.306 6.306 0 0 1-1.192-1.655L4.68 19.32Z" clip-rule="evenodd"/></svg>' },
756
- { id: 'marker-tool', tool: 'marker', title: 'Маркер', svg: '<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" width="20" height="20" class="c-bxOhME c-bxOhME-dvzWZT-size-medium"><path fill="currentColor" fill-rule="evenodd" d="M12.737 2.676 8.531 7.264a1 1 0 0 0 .03 1.382l7.674 7.675a1 1 0 0 0 1.442-.029l4.589-4.97 1.468 1.357-4.588 4.97a3 3 0 0 1-3.46.689l-1.917 2.303-1.454.087-.63-.593-.828 1.38L10 22v-1l-.001-.001L10 22H1v-3l.18-.573 3.452-4.93-.817-.77.045-1.496 2.621-2.184a2.999 2.999 0 0 1 .577-3.134l4.205-4.589 1.474 1.352ZM3 19.315v.684h6.434l.76-1.268-4.09-3.85L3 19.314Zm3.007-7.27 6.904 6.498 1.217-1.46-6.667-6.25-1.454 1.212Z" clip-rule="evenodd"></path></svg>' },
757
- { id: 'eraser-tool', tool: 'eraser', title: 'Ластик', svg: '<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" width="20" height="20" class="c-bxOhME c-bxOhME-dvzWZT-size-medium"><path fill="currentColor" fill-rule="evenodd" d="M12.63 3.957 4.319 12.27a3 3 0 0 0 0 4.242L7.905 20.1 8.612 20.394H21v-2h-5.6l6.629-6.63a3 3 0 0 0 0-4.242L17.858 3.42a3 3 0 0 0-4.242 0ZM5.12 14.293a1 1 0 0 0 0 1.414L8.414 19h3.172l3-3L9 10.414l-3.879 3.88Zm10.336-8.922a1 1 0 0 0-1.414 0l-3.629 3.63L16 14.585l3.63-3.629a1 1 0 0 0 0-1.414L15.457 5.37Z" clip-rule="evenodd"></path></svg>' }
758
- ];
759
- const row1 = document.createElement('div');
760
- row1.className = 'moodboard-draw__row';
761
- this.drawRow1 = row1;
762
- tools.forEach(t => {
763
- const btn = document.createElement('button');
764
- btn.className = `moodboard-draw__btn moodboard-draw__btn--${t.id}`;
765
- btn.title = t.title;
766
- const icon = document.createElement('span');
767
- icon.className = 'draw-icon';
768
- icon.innerHTML = t.svg;
769
- btn.appendChild(icon);
770
- btn.addEventListener('click', () => {
771
- this.animateButton(btn);
772
- // Активируем инструмент рисования
773
- row1.querySelectorAll('.moodboard-draw__btn--active').forEach(el => el.classList.remove('moodboard-draw__btn--active'));
774
- btn.classList.add('moodboard-draw__btn--active');
775
- this.currentDrawTool = t.tool;
776
- // Сообщаем текущий мод
777
- this.eventBus.emit(Events.Draw.BrushSet, { mode: t.tool });
778
- // Перестраиваем нижний ряд пресетов
779
- this.buildDrawPresets(row2);
780
- });
781
- row1.appendChild(btn);
782
- });
783
-
784
- // Второй ряд: толщина/цвет — круг + центральная точка
785
- const row2 = document.createElement('div');
786
- row2.className = 'moodboard-draw__row';
787
- this.drawRow2 = row2;
788
- this.buildDrawPresets = (container) => {
789
- container.innerHTML = '';
790
- if (this.currentDrawTool === 'pencil') {
791
- const sizes = [
792
- { id: 'size-thin-black', title: 'Тонкий черный', color: '#111827', dot: 4, width: 2 },
793
- { id: 'size-medium-red', title: 'Средний красный', color: '#ef4444', dot: 8, width: 4 },
794
- { id: 'size-thick-green', title: 'Толстый зеленый', color: '#16a34a', dot: 10, width: 6 }
795
- ];
796
- sizes.forEach(s => {
797
- const btn = document.createElement('button');
798
- btn.className = `moodboard-draw__btn moodboard-draw__btn--${s.id}`;
799
- btn.title = s.title;
800
- btn.dataset.brushWidth = String(s.width);
801
- btn.dataset.brushColor = s.color;
802
- const holder = document.createElement('span');
803
- holder.className = 'draw-size';
804
- const dot = document.createElement('span');
805
- dot.className = 'draw-dot';
806
- dot.style.background = s.color;
807
- dot.style.width = `${s.dot}px`;
808
- dot.style.height = `${s.dot}px`;
809
- holder.appendChild(dot);
810
- btn.appendChild(holder);
811
- btn.addEventListener('click', () => {
812
- this.animateButton(btn);
813
- container.querySelectorAll('.moodboard-draw__btn--active').forEach(el => el.classList.remove('moodboard-draw__btn--active'));
814
- btn.classList.add('moodboard-draw__btn--active');
815
- const width = s.width;
816
- const color = parseInt(s.color.replace('#',''), 16);
817
- this.eventBus.emit(Events.Draw.BrushSet, { mode: 'pencil', width, color });
818
- });
819
- container.appendChild(btn);
820
- });
821
- // Выставляем дефолт
822
- const first = container.querySelector('.moodboard-draw__btn');
823
- if (first) {
824
- first.classList.add('moodboard-draw__btn--active');
825
- const width = parseInt(first.dataset.brushWidth, 10) || 2;
826
- const color = parseInt((first.dataset.brushColor || '#111827').replace('#',''), 16);
827
- this.eventBus.emit(Events.Draw.BrushSet, { mode: 'pencil', width, color });
828
- }
829
- } else if (this.currentDrawTool === 'marker') {
830
- const swatches = [
831
- { id: 'marker-yellow', title: 'Жёлтый', color: '#facc15' },
832
- { id: 'marker-green', title: 'Светло-зелёный', color: '#22c55e' },
833
- { id: 'marker-pink', title: 'Розовый', color: '#ec4899' }
834
- ];
835
- swatches.forEach(s => {
836
- const btn = document.createElement('button');
837
- btn.className = `moodboard-draw__btn moodboard-draw__btn--${s.id}`;
838
- btn.title = s.title;
839
- const sw = document.createElement('span');
840
- sw.className = 'draw-swatch';
841
- sw.style.background = s.color;
842
- btn.appendChild(sw);
843
- btn.addEventListener('click', () => {
844
- this.animateButton(btn);
845
- container.querySelectorAll('.moodboard-draw__btn--active').forEach(el => el.classList.remove('moodboard-draw__btn--active'));
846
- btn.classList.add('moodboard-draw__btn--active');
847
- const color = parseInt(s.color.replace('#',''), 16);
848
- this.eventBus.emit(Events.Draw.BrushSet, { mode: 'marker', color, width: 8 });
849
- });
850
- container.appendChild(btn);
851
- });
852
- // Дефолт — первый цвет
853
- const first = container.querySelector('.moodboard-draw__btn');
854
- if (first) {
855
- first.classList.add('moodboard-draw__btn--active');
856
- const color = parseInt(swatches[0].color.replace('#',''), 16);
857
- this.eventBus.emit(Events.Draw.BrushSet, { mode: 'marker', color, width: 8 });
858
- }
859
- } else if (this.currentDrawTool === 'eraser') {
860
- // Ластик — без пресетов
861
- this.eventBus.emit(Events.Draw.BrushSet, { mode: 'eraser' });
862
- }
863
- };
864
-
865
- grid.appendChild(row1);
866
- grid.appendChild(row2);
867
- this.drawPopupEl.appendChild(grid);
868
- this.container.appendChild(this.drawPopupEl);
869
- // Инициализируем верх/низ по умолчанию: активен карандаш и первый пресет
870
- const pencilBtn = row1.querySelector('.moodboard-draw__btn--pencil-tool');
871
- if (pencilBtn) pencilBtn.classList.add('moodboard-draw__btn--active');
872
- this.currentDrawTool = 'pencil';
873
- this.eventBus.emit(Events.Draw.BrushSet, { mode: 'pencil' });
874
- this.buildDrawPresets(row2);
206
+ return this.popupsController.createDrawPopup();
875
207
  }
876
208
 
877
209
  toggleDrawPopup(anchorButton) {
878
- if (!this.drawPopupEl) return;
879
- if (this.drawPopupEl.style.display === 'none') {
880
- this.openDrawPopup(anchorButton);
881
- } else {
882
- this.closeDrawPopup();
883
- }
210
+ return this.popupsController.toggleDrawPopup(anchorButton);
884
211
  }
885
212
 
886
213
  openDrawPopup(anchorButton) {
887
- if (!this.drawPopupEl) return;
888
- const toolbarRect = this.container.getBoundingClientRect();
889
- const buttonRect = anchorButton.getBoundingClientRect();
890
- const top = buttonRect.top - toolbarRect.top - 4;
891
- const left = this.element.offsetWidth + 8;
892
- this.drawPopupEl.style.top = `${top}px`;
893
- this.drawPopupEl.style.left = `${left}px`;
894
- this.drawPopupEl.style.display = 'block';
214
+ return this.popupsController.openDrawPopup(anchorButton);
895
215
  }
896
216
 
897
217
  closeDrawPopup() {
898
- if (this.drawPopupEl) {
899
- this.drawPopupEl.style.display = 'none';
900
- }
218
+ return this.popupsController.closeDrawPopup();
901
219
  }
902
220
 
903
221
  /**
904
222
  * Всплывающая панель эмоджи (UI)
905
223
  */
906
224
  createEmojiPopup() {
907
- this.emojiPopupEl = document.createElement('div');
908
- this.emojiPopupEl.className = 'moodboard-toolbar__popup moodboard-toolbar__popup--emoji';
909
- this.emojiPopupEl.style.display = 'none';
910
-
911
- // Загружаем файловые эмоджи и заменяем их на встроенные PNG data URL
912
- let groups = new Map();
913
- let convertedCount = 0;
914
-
915
- console.log('🎯 Создание EmojiPopup: заменяем файловые эмоджи на встроенные PNG...');
916
- if (typeof import.meta !== 'undefined' && import.meta.glob) {
917
- // Режим с bundler (Vite) - используем import.meta.glob
918
- const modules = import.meta.glob('../assets/emodji/**/*.{png,PNG,svg,SVG}', { eager: true, as: 'url' });
919
-
920
- // Группируем по подпапкам внутри emodji (категории)
921
- const entries = Object.entries(modules).sort(([a], [b]) => a.localeCompare(b));
922
- entries.forEach(([path, url]) => {
923
- const marker = '/emodji/';
924
- const idx = path.indexOf(marker);
925
- let category = 'Разное';
926
- if (idx >= 0) {
927
- const after = path.slice(idx + marker.length);
928
- const parts = after.split('/');
929
- category = parts.length > 1 ? parts[0] : 'Разное';
930
- }
931
-
932
- // Извлекаем код эмоджи из имени файла (например, "1f600.png" -> "1f600")
933
- const fileName = path.split('/').pop();
934
- const emojiCode = fileName.split('.')[0];
935
-
936
- // Заменяем на встроенный PNG data URL
937
- const inlineUrl = getInlinePngEmojiUrl(emojiCode);
938
-
939
- if (inlineUrl) {
940
- // Используем встроенный PNG
941
- if (!groups.has(category)) groups.set(category, []);
942
- groups.get(category).push({
943
- path: `inline:${emojiCode}`,
944
- url: inlineUrl,
945
- isInline: true,
946
- emojiCode: emojiCode
947
- });
948
- convertedCount++;
949
- } else {
950
- // Fallback на файловый URL (если встроенного нет)
951
- if (!groups.has(category)) groups.set(category, []);
952
- groups.get(category).push({ path, url, isInline: false });
953
- console.warn(`⚠️ Нет встроенного PNG для ${emojiCode}, используем файл`);
954
- }
955
- });
956
- } else {
957
- // Режим без bundler - используем статичный список
958
- const fallbackGroups = this.getFallbackEmojiGroups();
959
- fallbackGroups.forEach((items, category) => {
960
- if (!groups.has(category)) groups.set(category, []);
961
- groups.get(category).push(...items); // items уже содержат правильные isInline флаги
962
- convertedCount += items.filter(item => item.isInline).length;
963
- });
964
- }
965
-
966
- // Задаем желаемый порядок категорий (используем ваши оригинальные разделы)
967
- const ORDER = ['Смайлики', 'Жесты', 'Женские эмоции', 'Котики', 'Обезьянка', 'Разное'];
968
-
969
- console.log(`✅ Заменено ${convertedCount} файловых эмоджи на встроенные PNG`);
970
- const present = [...groups.keys()];
971
- const orderedFirst = ORDER.filter(name => groups.has(name));
972
- const theRest = present.filter(name => !ORDER.includes(name)).sort((a, b) => a.localeCompare(b));
973
- const orderedCategories = [...orderedFirst, ...theRest];
974
-
975
- // Рендерим секции по категориям в нужном порядке
976
- orderedCategories.forEach((cat) => {
977
- const section = document.createElement('div');
978
- section.className = 'moodboard-emoji__section';
979
-
980
- const title = document.createElement('div');
981
- title.className = 'moodboard-emoji__title';
982
- title.textContent = cat;
983
- section.appendChild(title);
984
-
985
- const grid = document.createElement('div');
986
- grid.className = 'moodboard-emoji__grid';
987
-
988
- groups.get(cat).forEach(({ url, isInline, emojiCode }) => {
989
- const btn = document.createElement('button');
990
- btn.className = 'moodboard-emoji__btn';
991
- btn.title = isInline ? `Встроенный PNG: ${emojiCode}` : 'Добавить изображение';
992
- const img = document.createElement('img');
993
- img.className = 'moodboard-emoji__img';
994
- img.src = url;
995
- img.alt = emojiCode || '';
996
- btn.appendChild(img);
997
-
998
- // Перетаскивание: начинаем только если был реальный drag (движение > 4px)
999
- btn.addEventListener('mousedown', (e) => {
1000
- // Блокируем одновременную обработку
1001
- if (btn.__clickProcessing || btn.__dragActive) return;
1002
-
1003
- const startX = e.clientX;
1004
- const startY = e.clientY;
1005
- let startedDrag = false;
1006
-
1007
- const onMove = (ev) => {
1008
- if (startedDrag) return;
1009
- const dx = Math.abs(ev.clientX - startX);
1010
- const dy = Math.abs(ev.clientY - startY);
1011
- if (dx > 4 || dy > 4) {
1012
- startedDrag = true;
1013
- btn.__dragActive = true;
1014
-
1015
- // Блокируем click handler
1016
- btn.__clickProcessing = true;
1017
-
1018
- const target = 64;
1019
- const targetW = target;
1020
- const targetH = target;
1021
- // Активируем инструмент размещения и включаем режим placeOnMouseUp
1022
- this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'place' });
1023
- this.eventBus.emit(Events.Place.Set, {
1024
- type: 'image',
1025
- properties: { src: url, width: targetW, height: targetH, isEmojiIcon: true },
1026
- size: { width: targetW, height: targetH },
1027
- placeOnMouseUp: true
1028
- });
1029
- // Закрываем поповер, чтобы не мешал курсору над холстом
1030
- this.closeEmojiPopup();
1031
- cleanup();
1032
- }
1033
- };
1034
- const onUp = () => {
1035
- cleanup();
1036
- // Снимаем флаги с задержкой
1037
- setTimeout(() => {
1038
- btn.__dragActive = false;
1039
- btn.__clickProcessing = false;
1040
- }, 50);
1041
- };
1042
- const cleanup = () => {
1043
- document.removeEventListener('mousemove', onMove);
1044
- document.removeEventListener('mouseup', onUp);
1045
- };
1046
- document.addEventListener('mousemove', onMove);
1047
- document.addEventListener('mouseup', onUp, { once: true });
1048
- });
1049
-
1050
- btn.addEventListener('click', (e) => {
1051
- // Блокируем обработку клика если был drag или если уже обрабатывается
1052
- if (btn.__dragActive || btn.__clickProcessing) return;
1053
-
1054
- btn.__clickProcessing = true;
1055
- setTimeout(() => { btn.__clickProcessing = false; }, 100);
1056
-
1057
- this.animateButton(btn);
1058
- const target = 64; // кратно 128 для лучшей четкости при даунскейле
1059
- const targetW = target;
1060
- const targetH = target;
1061
-
1062
- console.log(`🎯 Создаем эмоджи: ${isInline ? 'встроенный PNG' : 'файл'} (${emojiCode})`);
1063
-
1064
- this.eventBus.emit(Events.Place.Set, {
1065
- type: 'image',
1066
- properties: {
1067
- src: url,
1068
- width: targetW,
1069
- height: targetH,
1070
- isEmojiIcon: true,
1071
- isInlinePng: isInline || false,
1072
- emojiCode: emojiCode || null
1073
- },
1074
- size: { width: targetW, height: targetH }
1075
- });
1076
- this.closeEmojiPopup();
1077
- });
1078
-
1079
- grid.appendChild(btn);
1080
- });
1081
-
1082
- section.appendChild(grid);
1083
- this.emojiPopupEl.appendChild(section);
1084
- });
1085
- this.container.appendChild(this.emojiPopupEl);
225
+ return this.popupsController.createEmojiPopup();
1086
226
  }
1087
227
 
1088
228
  /**
1089
229
  * Возвращает fallback группы эмоджи для работы без bundler
1090
230
  */
1091
231
  getFallbackEmojiGroups() {
1092
- const groups = new Map();
1093
- let convertedCount = 0;
1094
-
1095
- console.log('🎯 Fallback режим: заменяем файловые эмоджи на встроенные PNG...');
1096
-
1097
- // Статичный список эмоджи с реальными именами файлов
1098
- const fallbackEmojis = {
1099
- 'Смайлики': [
1100
- '1f600', '1f601', '1f602', '1f603', '1f604', '1f605', '1f606', '1f607',
1101
- '1f609', '1f60a', '1f60b', '1f60c', '1f60d', '1f60e', '1f60f', '1f610',
1102
- '1f611', '1f612', '1f613', '1f614', '1f615', '1f616', '1f617', '1f618',
1103
- '1f619', '1f61a', '1f61b', '1f61c', '1f61d', '1f61e', '1f61f', '1f620',
1104
- '1f621', '1f622', '1f623', '1f624', '1f625', '1f626', '1f627', '1f628',
1105
- '1f629', '1f62a', '1f62b', '1f62c', '1f62d', '1f62e', '1f62f', '1f630',
1106
- '1f631', '1f632', '1f633', '1f635', '1f636', '1f641', '1f642', '2639', '263a'
1107
- ],
1108
- 'Жесты': [
1109
- '1f446', '1f447', '1f448', '1f449', '1f44a', '1f44b', '1f44c', '1f450',
1110
- '1f4aa', '1f590', '1f596', '1f64c', '1f64f', '261d', '270a', '270b', '270c', '270d'
1111
- ],
1112
- 'Женские эмоции': [
1113
- '1f645', '1f646', '1f64b', '1f64d', '1f64e'
1114
- ],
1115
- 'Котики': [
1116
- '1f638', '1f639', '1f63a', '1f63b', '1f63c', '1f63d', '1f63e', '1f63f', '1f640'
1117
- ],
1118
- 'Обезьянка': [
1119
- '1f435', '1f648', '1f649', '1f64a'
1120
- ],
1121
- 'Разное': [
1122
- '1f440', '1f441', '1f499', '1f4a1', '1f4a3', '1f4a9', '1f4ac', '1f4af', '203c', '26d4', '2764'
1123
- ]
1124
- };
1125
-
1126
- Object.entries(fallbackEmojis).forEach(([category, emojis]) => {
1127
- const emojiList = [];
1128
-
1129
- emojis.forEach(emojiCode => {
1130
- // Заменяем на встроенный PNG data URL
1131
- const inlineUrl = getInlinePngEmojiUrl(emojiCode);
1132
-
1133
- if (inlineUrl) {
1134
- emojiList.push({
1135
- path: `inline:${emojiCode}`,
1136
- url: inlineUrl,
1137
- isInline: true,
1138
- emojiCode: emojiCode
1139
- });
1140
- convertedCount++;
1141
- } else {
1142
- // Fallback на файловый URL (если встроенного нет)
1143
- const basePath = this.getEmojiBasePath();
1144
- emojiList.push({
1145
- path: `${basePath}${category}/${emojiCode}.png`,
1146
- url: `${basePath}${category}/${emojiCode}.png`,
1147
- isInline: false
1148
- });
1149
- console.warn(`⚠️ Нет встроенного PNG для ${emojiCode}, используем файл`);
1150
- }
1151
- });
1152
-
1153
- if (emojiList.length > 0) {
1154
- groups.set(category, emojiList);
1155
- }
1156
- });
1157
-
1158
- console.log(`✅ Fallback: заменено ${convertedCount} файловых эмоджи на встроенные PNG`);
1159
- return groups;
232
+ return this.popupsController.getFallbackEmojiGroups();
1160
233
  }
1161
234
 
1162
235
  /**
1163
236
  * Определяет базовый путь для эмоджи в зависимости от режима
1164
237
  */
1165
238
  getEmojiBasePath() {
1166
- // 1. Приоритет: опция basePath из конструктора
1167
- if (this.emojiBasePath) {
1168
- return this.emojiBasePath.endsWith('/') ? this.emojiBasePath : this.emojiBasePath + '/';
1169
- }
1170
-
1171
- // 2. Глобальная настройка (абсолютный URL)
1172
- if (window.MOODBOARD_BASE_PATH) {
1173
- const basePath = window.MOODBOARD_BASE_PATH.endsWith('/') ? window.MOODBOARD_BASE_PATH : window.MOODBOARD_BASE_PATH + '/';
1174
- return `${basePath}src/assets/emodji/`;
1175
- }
1176
-
1177
- // 3. Вычисление от URL текущего модуля (import.meta.url)
1178
- try {
1179
- // Используем import.meta.url для получения абсолютного пути к ассетам
1180
- const currentModuleUrl = import.meta.url;
1181
- // От текущего модуля (ui/Toolbar.js) поднимаемся к корню пакета и идем к assets
1182
- const emojiUrl = new URL('../assets/emodji/', currentModuleUrl).href;
1183
- return emojiUrl;
1184
- } catch (error) {
1185
- console.warn('⚠️ Не удалось определить путь через import.meta.url:', error);
1186
- }
1187
-
1188
- // 4. Fallback: поиск script тега для определения базового URL
1189
- try {
1190
- const currentScript = document.currentScript;
1191
- if (currentScript && currentScript.src) {
1192
- // Пытаемся определить от текущего скрипта
1193
- const scriptUrl = new URL(currentScript.src);
1194
- const baseUrl = new URL('../assets/emodji/', scriptUrl).href;
1195
- return baseUrl;
1196
- }
1197
- } catch (error) {
1198
- console.warn('⚠️ Не удалось определить путь через currentScript:', error);
1199
- }
1200
-
1201
- // 5. Последний fallback: абсолютный путь от корня домена
1202
- return '/src/assets/emodji/';
239
+ return this.popupsController.getEmojiBasePath();
1203
240
  }
1204
241
 
1205
242
  toggleEmojiPopup(anchorButton) {
1206
- if (!this.emojiPopupEl) return;
1207
- if (this.emojiPopupEl.style.display === 'none') {
1208
- this.openEmojiPopup(anchorButton);
1209
- } else {
1210
- this.closeEmojiPopup();
1211
- }
243
+ return this.popupsController.toggleEmojiPopup(anchorButton);
1212
244
  }
1213
245
 
1214
246
  openEmojiPopup(anchorButton) {
1215
- if (!this.emojiPopupEl) return;
1216
- const toolbarRect = this.container.getBoundingClientRect();
1217
- const buttonRect = anchorButton.getBoundingClientRect();
1218
- const left = this.element.offsetWidth + 8;
1219
- // Показать невидимо для вычисления размеров
1220
- this.emojiPopupEl.style.visibility = 'hidden';
1221
- this.emojiPopupEl.style.display = 'block';
1222
- // Рассчитать top так, чтобы попап не уходил за нижнюю границу
1223
- const desiredTop = buttonRect.top - toolbarRect.top - 4;
1224
- const popupHeight = this.emojiPopupEl.offsetHeight;
1225
- const containerHeight = this.container.clientHeight || toolbarRect.height;
1226
- const minTop = 8;
1227
- const maxTop = Math.max(minTop, containerHeight - popupHeight - 8);
1228
- const top = Math.min(Math.max(minTop, desiredTop), maxTop);
1229
- this.emojiPopupEl.style.top = `${top}px`;
1230
- this.emojiPopupEl.style.left = `${left}px`;
1231
- this.emojiPopupEl.style.visibility = 'visible';
247
+ return this.popupsController.openEmojiPopup(anchorButton);
1232
248
  }
1233
249
 
1234
250
  closeEmojiPopup() {
1235
- if (this.emojiPopupEl) {
1236
- this.emojiPopupEl.style.display = 'none';
1237
- }
251
+ return this.popupsController.closeEmojiPopup();
1238
252
  }
1239
253
 
1240
254
  /**
@@ -1281,179 +295,66 @@ export class Toolbar {
1281
295
  * Настройка обработчиков событий истории
1282
296
  */
1283
297
  setupHistoryEvents() {
1284
- // Слушаем изменения истории для обновления кнопок undo/redo
1285
- this.eventBus.on(Events.UI.UpdateHistoryButtons, (data) => {
1286
- this.updateHistoryButtons(data.canUndo, data.canRedo);
1287
- });
298
+ return this.stateController.setupHistoryEvents();
1288
299
  }
1289
300
 
1290
301
  /**
1291
302
  * Открывает диалог выбора файла и запускает режим "призрака"
1292
303
  */
1293
304
  async openFileDialog() {
1294
- const input = document.createElement('input');
1295
- input.type = 'file';
1296
- input.accept = '*/*'; // Принимаем любые файлы
1297
- input.style.display = 'none';
1298
- document.body.appendChild(input);
1299
-
1300
- input.addEventListener('change', async () => {
1301
- try {
1302
- const file = input.files && input.files[0];
1303
- if (!file) {
1304
- // Пользователь отменил выбор файла
1305
- this.eventBus.emit(Events.Place.FileCanceled);
1306
- return;
1307
- }
1308
-
1309
- // Файл выбран - запускаем режим "призрака"
1310
- this.eventBus.emit(Events.Place.FileSelected, {
1311
- file: file,
1312
- fileName: file.name,
1313
- fileSize: file.size,
1314
- mimeType: file.type,
1315
- properties: {
1316
- width: 120,
1317
- height: 140
1318
- }
1319
- });
1320
-
1321
- // Активируем инструмент размещения
1322
- this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'place' });
1323
- this.placeSelectedButtonId = 'attachments';
1324
- this.setActiveToolbarButton('place');
1325
-
1326
- } catch (error) {
1327
- console.error('Ошибка при выборе файла:', error);
1328
- alert('Ошибка при выборе файла: ' + error.message);
1329
- } finally {
1330
- input.remove();
1331
- }
1332
- }, { once: true });
1333
-
1334
- // Обработка отмены диалога (клик вне диалога или ESC)
1335
- const handleCancel = () => {
1336
- setTimeout(() => {
1337
- if (input.files.length === 0) {
1338
- this.eventBus.emit(Events.Place.FileCanceled);
1339
- input.remove();
1340
- }
1341
- window.removeEventListener('focus', handleCancel);
1342
- }, 100);
1343
- };
1344
-
1345
- window.addEventListener('focus', handleCancel, { once: true });
1346
- input.click();
305
+ return this.dialogsController.openFileDialog();
1347
306
  }
1348
307
 
1349
308
  /**
1350
309
  * Открывает диалог выбора изображения и запускает режим "призрака"
1351
310
  */
1352
311
  async openImageDialog() {
1353
- const input = document.createElement('input');
1354
- input.type = 'file';
1355
- input.accept = 'image/*'; // Принимаем только изображения
1356
- input.style.display = 'none';
1357
- document.body.appendChild(input);
1358
-
1359
- input.addEventListener('change', async () => {
1360
- try {
1361
- const file = input.files && input.files[0];
1362
- if (!file) {
1363
- // Пользователь отменил выбор изображения
1364
- this.eventBus.emit(Events.Place.ImageCanceled);
1365
- return;
1366
- }
1367
-
1368
- // Изображение выбрано - запускаем режим "призрака"
1369
- this.eventBus.emit(Events.Place.ImageSelected, {
1370
- file: file,
1371
- fileName: file.name,
1372
- fileSize: file.size,
1373
- mimeType: file.type,
1374
- properties: {
1375
- width: 300, // Дефолтная ширина для изображения
1376
- height: 200 // Дефолтная высота для изображения (будет пересчитана по пропорциям)
1377
- }
1378
- });
1379
-
1380
- // Активируем инструмент размещения
1381
- this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'place' });
1382
- this.placeSelectedButtonId = 'image';
1383
- this.setActiveToolbarButton('place');
1384
-
1385
- } catch (error) {
1386
- console.error('Ошибка при выборе изображения:', error);
1387
- alert('Ошибка при выборе изображения: ' + error.message);
1388
- } finally {
1389
- input.remove();
1390
- }
1391
- }, { once: true });
312
+ return this.dialogsController.openImageDialog();
313
+ }
1392
314
 
1393
- // Обработка отмены диалога (клик вне диалога или ESC)
1394
- const handleCancel = () => {
1395
- setTimeout(() => {
1396
- if (input.files.length === 0) {
1397
- this.eventBus.emit(Events.Place.ImageCanceled);
1398
- input.remove();
1399
- }
1400
- window.removeEventListener('focus', handleCancel);
1401
- }, 100);
1402
- };
1403
-
1404
- window.addEventListener('focus', handleCancel, { once: true });
1405
- input.click();
315
+ /**
316
+ * Открывает диалог выбора изображения для ImageObject2 (новая изолированная цепочка)
317
+ */
318
+ async openImageObject2Dialog() {
319
+ return this.dialogsController.openImageObject2Dialog();
1406
320
  }
1407
321
 
1408
322
  /**
1409
323
  * Обновление состояния кнопок undo/redo
1410
324
  */
1411
325
  updateHistoryButtons(canUndo, canRedo) {
1412
- const undoButton = this.element.querySelector('[data-tool="undo"]');
1413
- const redoButton = this.element.querySelector('[data-tool="redo"]');
1414
-
1415
- if (undoButton) {
1416
- undoButton.disabled = !canUndo;
1417
- if (canUndo) {
1418
- undoButton.classList.remove('moodboard-toolbar__button--disabled');
1419
- undoButton.title = 'Отменить последнее действие (Ctrl+Z)';
1420
- } else {
1421
- undoButton.classList.add('moodboard-toolbar__button--disabled');
1422
- undoButton.title = 'Нет действий для отмены';
1423
- }
1424
- }
1425
-
1426
- if (redoButton) {
1427
- redoButton.disabled = !canRedo;
1428
- if (canRedo) {
1429
- redoButton.classList.remove('moodboard-toolbar__button--disabled');
1430
- redoButton.title = 'Повторить отмененное действие (Ctrl+Y)';
1431
- } else {
1432
- redoButton.classList.add('moodboard-toolbar__button--disabled');
1433
- redoButton.title = 'Нет действий для повтора';
1434
- }
1435
- }
326
+ return this.stateController.updateHistoryButtons(canUndo, canRedo);
1436
327
  }
1437
328
 
1438
329
  /**
1439
330
  * Очистка ресурсов
1440
331
  */
1441
332
  destroy() {
333
+ // Удаляем document-level listener (предотвращение утечки памяти)
334
+ if (this._documentClickHandler) {
335
+ document.removeEventListener('click', this._documentClickHandler);
336
+ this._documentClickHandler = null;
337
+ }
338
+
339
+ // Отписываемся от Events.Tool.Activated (подписка в ToolbarRenderer)
340
+ if (this._toolActivatedHandler) {
341
+ this.eventBus.off(Events.Tool.Activated, this._toolActivatedHandler);
342
+ this._toolActivatedHandler = null;
343
+ }
344
+
1442
345
  if (this.element) {
1443
- // Очищаем все tooltips перед удалением элемента
1444
346
  const buttons = this.element.querySelectorAll('.moodboard-toolbar__button');
1445
- buttons.forEach(button => {
347
+ buttons.forEach((button) => {
1446
348
  if (button._tooltip) {
1447
349
  button._tooltip.remove();
1448
350
  button._tooltip = null;
1449
351
  }
1450
352
  });
1451
-
353
+
1452
354
  this.element.remove();
1453
355
  this.element = null;
1454
356
  }
1455
-
1456
- // Отписываемся от событий
357
+
1457
358
  this.eventBus.removeAllListeners(Events.UI.UpdateHistoryButtons);
1458
359
  }
1459
360