@sequent-org/moodboard 1.3.4 → 1.4.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 (64) hide show
  1. package/package.json +6 -1
  2. package/src/assets/icons/mindmap.svg +3 -0
  3. package/src/core/SaveManager.js +44 -15
  4. package/src/core/commands/MindmapStatePatchCommand.js +85 -0
  5. package/src/core/commands/UpdateContentCommand.js +47 -4
  6. package/src/core/flows/LayerAndViewportFlow.js +87 -14
  7. package/src/core/flows/ObjectLifecycleFlow.js +7 -2
  8. package/src/core/flows/SaveFlow.js +10 -7
  9. package/src/core/flows/TransformFlow.js +2 -2
  10. package/src/core/index.js +81 -11
  11. package/src/core/rendering/ObjectRenderer.js +7 -2
  12. package/src/grid/BaseGrid.js +65 -0
  13. package/src/grid/CrossGrid.js +89 -24
  14. package/src/grid/CrossGridZoomPhases.js +167 -0
  15. package/src/grid/DotGrid.js +117 -34
  16. package/src/grid/DotGridZoomPhases.js +214 -16
  17. package/src/grid/GridDiagnostics.js +80 -0
  18. package/src/grid/GridFactory.js +13 -11
  19. package/src/grid/LineGrid.js +176 -37
  20. package/src/grid/LineGridZoomPhases.js +163 -0
  21. package/src/grid/ScreenGridPhaseMachine.js +51 -0
  22. package/src/mindmap/MindmapCompoundContract.js +235 -0
  23. package/src/moodboard/ActionHandler.js +1 -0
  24. package/src/moodboard/DataManager.js +57 -0
  25. package/src/moodboard/bootstrap/MoodBoardUiFactory.js +21 -0
  26. package/src/moodboard/integration/MoodBoardEventBindings.js +26 -1
  27. package/src/moodboard/lifecycle/MoodBoardDestroyer.js +15 -0
  28. package/src/objects/MindmapObject.js +76 -0
  29. package/src/objects/ObjectFactory.js +3 -1
  30. package/src/services/BoardService.js +127 -31
  31. package/src/services/GridSnapResolver.js +60 -0
  32. package/src/services/MiroZoomLevels.js +39 -0
  33. package/src/services/SettingsApplier.js +0 -4
  34. package/src/services/ZoomPanController.js +51 -32
  35. package/src/tools/object-tools/PlacementTool.js +12 -3
  36. package/src/tools/object-tools/SelectTool.js +11 -1
  37. package/src/tools/object-tools/placement/GhostController.js +100 -1
  38. package/src/tools/object-tools/placement/PlacementEventsBridge.js +2 -0
  39. package/src/tools/object-tools/placement/PlacementInputRouter.js +2 -2
  40. package/src/tools/object-tools/selection/FileNameInlineEditorController.js +2 -2
  41. package/src/tools/object-tools/selection/InlineEditorController.js +15 -0
  42. package/src/tools/object-tools/selection/MindmapInlineEditorController.js +716 -0
  43. package/src/tools/object-tools/selection/SelectInputRouter.js +6 -0
  44. package/src/tools/object-tools/selection/SelectToolSetup.js +2 -0
  45. package/src/tools/object-tools/selection/TextEditorLifecycleRegistry.js +12 -16
  46. package/src/ui/ContextMenu.js +6 -6
  47. package/src/ui/DotGridDebugPanel.js +253 -0
  48. package/src/ui/HtmlTextLayer.js +1 -1
  49. package/src/ui/TextPropertiesPanel.js +2 -2
  50. package/src/ui/handles/GroupSelectionHandlesController.js +4 -1
  51. package/src/ui/handles/HandlesDomRenderer.js +1486 -15
  52. package/src/ui/handles/HandlesEventBridge.js +49 -5
  53. package/src/ui/handles/HandlesInteractionController.js +4 -4
  54. package/src/ui/mindmap/MindmapConnectionLayer.js +239 -0
  55. package/src/ui/mindmap/MindmapHtmlTextLayer.js +285 -0
  56. package/src/ui/mindmap/MindmapLayoutConfig.js +29 -0
  57. package/src/ui/mindmap/MindmapTextOverlayAdapter.js +144 -0
  58. package/src/ui/styles/toolbar.css +1 -0
  59. package/src/ui/styles/workspace.css +100 -0
  60. package/src/ui/toolbar/ToolbarActionRouter.js +35 -0
  61. package/src/ui/toolbar/ToolbarPopupsController.js +6 -6
  62. package/src/ui/toolbar/ToolbarRenderer.js +1 -0
  63. package/src/ui/toolbar/ToolbarStateController.js +1 -0
  64. package/src/utils/iconLoader.js +10 -4
@@ -1,5 +1,9 @@
1
1
  import { GridFactory } from '../grid/GridFactory.js';
2
2
  import { Events } from '../core/events/Events.js';
3
+ import {
4
+ incrementGridDiagnosticCounter,
5
+ logGridDiagnostic,
6
+ } from '../grid/GridDiagnostics.js';
3
7
 
4
8
  export class BoardService {
5
9
  constructor(eventBus, pixi) {
@@ -7,9 +11,17 @@ export class BoardService {
7
11
  this.pixi = pixi;
8
12
  this.grid = null;
9
13
  this._getCanvasSize = null;
14
+ this._eventsAttached = false;
15
+ this._handlers = null;
16
+ this._destroyed = false;
17
+ this._lastZoomCursor = null;
18
+ this._consumeZoomCursorAnchor = false;
10
19
  }
11
20
 
12
21
  async init(getCanvasSize) {
22
+ if (this._destroyed) {
23
+ this._destroyed = false;
24
+ }
13
25
  this._getCanvasSize = getCanvasSize;
14
26
  // Не создаём сетку по умолчанию, чтобы избежать визуального переключения.
15
27
  // Сетка будет установлена из сохранённых настроек через Events.UI.GridChange.
@@ -18,9 +30,9 @@ export class BoardService {
18
30
  this._attachEvents();
19
31
  }
20
32
 
21
- _attachEvents() {
22
- // Смена вида сетки из UI
23
- this.eventBus.on(Events.UI.GridChange, ({ type, options: overrideOptions }) => {
33
+ _buildHandlers() {
34
+ const onGridChange = ({ type, options: overrideOptions }) => {
35
+ incrementGridDiagnosticCounter('boardService.gridChange.calls');
24
36
  const size = this._getCanvasSize?.() || { width: 800, height: 600 };
25
37
  if (type === 'off') {
26
38
  this.grid?.setEnabled(false);
@@ -31,6 +43,7 @@ export class BoardService {
31
43
  grid: { type: 'off', options: this.grid?.serialize ? this.grid.serialize() : {} }
32
44
  });
33
45
  } catch (_) {}
46
+ logGridDiagnostic('BoardService', 'grid switched off', { hasGrid: !!this.grid });
34
47
  return;
35
48
  }
36
49
  this.grid?.destroy?.();
@@ -53,34 +66,75 @@ export class BoardService {
53
66
  grid: { type, options: this.grid.serialize ? this.grid.serialize() : gridOptions }
54
67
  });
55
68
  } catch (_) {}
69
+ logGridDiagnostic('BoardService', 'grid switched on', {
70
+ type,
71
+ options: gridOptions,
72
+ });
56
73
  } catch (e) {
57
74
  console.warn('Unknown grid type:', type);
58
75
  }
59
- });
76
+ };
60
77
 
61
- // Миникарта: данные и управление
62
- this.eventBus.on(Events.UI.MinimapGetData, (req) => {
78
+ const onMinimapGetData = (req) => {
63
79
  const world = this.pixi.worldLayer || this.pixi.app.stage;
64
80
  const viewEl = this.pixi.app.view;
65
- const objects = (this.pixi?.objects ? Array.from(this.pixi.objects.keys()) : []).map((id) => id);
66
81
  req.world = { x: world.x, y: world.y, scale: world.scale?.x || 1 };
67
82
  req.view = { width: viewEl.clientWidth, height: viewEl.clientHeight };
68
83
  // Прокидываем только метаданные объектов через ядро (сам список формирует Core)
69
- });
70
- this.eventBus.on(Events.Viewport.Changed, () => this.refreshGridViewport());
71
- this.eventBus.on(Events.UI.MinimapCenterOn, ({ worldX, worldY }) => {
84
+ };
85
+
86
+ const onViewportChanged = () => this.refreshGridViewport();
87
+ const onWheelZoom = ({ x, y } = {}) => {
88
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return;
89
+ this._lastZoomCursor = { x: Math.round(x), y: Math.round(y) };
90
+ this._consumeZoomCursorAnchor = true;
91
+ };
92
+ const onMinimapCenterOn = ({ worldX, worldY }) => {
72
93
  const world = this.pixi.worldLayer || this.pixi.app.stage;
73
94
  const viewW = this.pixi.app.view.clientWidth;
74
95
  const viewH = this.pixi.app.view.clientHeight;
75
96
  const s = world.scale?.x || 1;
76
- world.x = viewW / 2 - worldX * s;
77
- world.y = viewH / 2 - worldY * s;
78
- if (this.pixi.gridLayer) {
79
- this.pixi.gridLayer.x = world.x;
80
- this.pixi.gridLayer.y = world.y;
81
- }
97
+ world.x = Math.round(viewW / 2 - worldX * s);
98
+ world.y = Math.round(viewH / 2 - worldY * s);
82
99
  this.refreshGridViewport();
83
- });
100
+ };
101
+
102
+ return {
103
+ onGridChange,
104
+ onMinimapGetData,
105
+ onViewportChanged,
106
+ onWheelZoom,
107
+ onMinimapCenterOn,
108
+ };
109
+ }
110
+
111
+ _attachEvents() {
112
+ if (this._eventsAttached) return;
113
+ this._handlers = this._handlers || this._buildHandlers();
114
+
115
+ // Смена вида сетки из UI
116
+ this.eventBus.on(Events.UI.GridChange, this._handlers.onGridChange);
117
+
118
+ // Миникарта: данные и управление
119
+ this.eventBus.on(Events.UI.MinimapGetData, this._handlers.onMinimapGetData);
120
+ this.eventBus.on(Events.Viewport.Changed, this._handlers.onViewportChanged);
121
+ this.eventBus.on(Events.Tool.WheelZoom, this._handlers.onWheelZoom);
122
+ this.eventBus.on(Events.UI.MinimapCenterOn, this._handlers.onMinimapCenterOn);
123
+ this._eventsAttached = true;
124
+ incrementGridDiagnosticCounter('boardService.lifecycle.attach');
125
+ logGridDiagnostic('BoardService', 'events attached');
126
+ }
127
+
128
+ _detachEvents() {
129
+ if (!this._eventsAttached || !this._handlers) return;
130
+ this.eventBus.off(Events.UI.GridChange, this._handlers.onGridChange);
131
+ this.eventBus.off(Events.UI.MinimapGetData, this._handlers.onMinimapGetData);
132
+ this.eventBus.off(Events.Viewport.Changed, this._handlers.onViewportChanged);
133
+ this.eventBus.off(Events.Tool.WheelZoom, this._handlers.onWheelZoom);
134
+ this.eventBus.off(Events.UI.MinimapCenterOn, this._handlers.onMinimapCenterOn);
135
+ this._eventsAttached = false;
136
+ incrementGridDiagnosticCounter('boardService.lifecycle.detach');
137
+ logGridDiagnostic('BoardService', 'events detached');
84
138
  }
85
139
 
86
140
  resize() {
@@ -93,9 +147,10 @@ export class BoardService {
93
147
  }
94
148
 
95
149
  /**
96
- * Синхронизирует gridLayer с world (позиция + масштаб) и перерисовывает сетку.
150
+ * Обновляет screen-grid слой по viewport состоянию.
97
151
  */
98
152
  refreshGridViewport() {
153
+ incrementGridDiagnosticCounter('boardService.refreshGridViewport.calls');
99
154
  if (!this.grid?.enabled || !this.pixi?.gridLayer) return;
100
155
  const view = this.pixi.app?.view;
101
156
  if (!view) return;
@@ -103,26 +158,67 @@ export class BoardService {
103
158
  const gl = this.pixi.gridLayer;
104
159
  const scale = world.scale?.x ?? 1;
105
160
 
106
- // Синхронизация gridLayer с world сетка зуммируется вместе с доской
107
- gl.x = world.x;
108
- gl.y = world.y;
161
+ // Screen-grid всегда рендерится в координатах экрана.
162
+ gl.x = 0;
163
+ gl.y = 0;
109
164
  if (gl.scale) {
110
- gl.scale.set(scale);
165
+ gl.scale.set(1);
166
+ }
167
+ if ((gl.x || 0) !== 0 || (gl.y || 0) !== 0 || (gl.scale?.x || 1) !== 1 || (gl.scale?.y || 1) !== 1) {
168
+ logGridDiagnostic('BoardService', 'gridLayer normalization mismatch', {
169
+ x: gl.x || 0,
170
+ y: gl.y || 0,
171
+ scaleX: gl.scale?.x || 1,
172
+ scaleY: gl.scale?.y || 1,
173
+ });
111
174
  }
112
175
 
113
- // DotGrid: передаём zoom до setVisibleBounds (чтобы createVisual видел актуальный zoom)
114
- if (this.grid.type === 'dot' && typeof this.grid.setZoom === 'function') {
176
+ if (typeof this.grid.setZoom === 'function') {
115
177
  this.grid.setZoom(scale);
116
178
  }
179
+ if (typeof this.grid.setViewportTransform === 'function') {
180
+ const zoomCursor = this._lastZoomCursor;
181
+ const useCursorAnchor = this._consumeZoomCursorAnchor && !!zoomCursor;
182
+ this.grid.setViewportTransform({
183
+ worldX: world.x || 0,
184
+ worldY: world.y || 0,
185
+ scale,
186
+ viewWidth: view.clientWidth,
187
+ viewHeight: view.clientHeight,
188
+ zoomCursorX: zoomCursor?.x ?? null,
189
+ zoomCursorY: zoomCursor?.y ?? null,
190
+ useCursorAnchor,
191
+ });
192
+ this._consumeZoomCursorAnchor = false;
193
+ }
117
194
 
118
- // Видимая область в мировых координатах (с учётом scale)
119
- const gridSize = this.grid._getEffectiveSize?.() ?? this.grid.size;
120
- const pad = Math.max(100, (gridSize || 20) * 4);
121
- const left = (-gl.x - pad) / scale;
122
- const top = (-gl.y - pad) / scale;
123
- const right = (view.clientWidth - gl.x + pad) / scale;
124
- const bottom = (view.clientHeight - gl.y + pad) / scale;
195
+ // Видимая область в screen-координатах.
196
+ const pad = 32;
197
+ const left = -pad;
198
+ const top = -pad;
199
+ const right = view.clientWidth + pad;
200
+ const bottom = view.clientHeight + pad;
125
201
  this.grid.setVisibleBounds(left, top, right, bottom);
202
+ logGridDiagnostic('BoardService', 'viewport refreshed', {
203
+ gridType: this.grid?.type || 'unknown',
204
+ scale,
205
+ worldX: world.x || 0,
206
+ worldY: world.y || 0,
207
+ bounds: { left, top, right, bottom },
208
+ });
209
+ }
210
+
211
+ destroy() {
212
+ if (this._destroyed) return;
213
+ this._destroyed = true;
214
+ this._detachEvents();
215
+ this.grid?.destroy?.();
216
+ this.grid = null;
217
+ if (this.pixi?.setGrid) {
218
+ this.pixi.setGrid(null);
219
+ }
220
+ incrementGridDiagnosticCounter('boardService.lifecycle.destroy');
221
+ logGridDiagnostic('BoardService', 'destroy');
126
222
  }
127
223
  }
128
224
 
@@ -0,0 +1,60 @@
1
+ import { incrementGridDiagnosticCounter, logGridDiagnostic } from '../grid/GridDiagnostics.js';
2
+
3
+ export class GridSnapResolver {
4
+ constructor(core) {
5
+ this.core = core;
6
+ }
7
+
8
+ _getGrid() {
9
+ return this.core?.boardService?.grid || null;
10
+ }
11
+
12
+ _isEnabled() {
13
+ const grid = this._getGrid();
14
+ return !!(grid && grid.enabled && grid.snapEnabled !== false);
15
+ }
16
+
17
+ snapWorldTopLeft(position, size = null) {
18
+ incrementGridDiagnosticCounter('gridSnapResolver.snapWorldTopLeft.calls');
19
+ if (!position || typeof position.x !== 'number' || typeof position.y !== 'number') {
20
+ return position;
21
+ }
22
+ if (!this._isEnabled()) {
23
+ logGridDiagnostic('GridSnapResolver', 'snap skipped: grid disabled', { position, size });
24
+ return position;
25
+ }
26
+ const grid = this._getGrid();
27
+ const halfW = size ? (size.width || 0) / 2 : 0;
28
+ const halfH = size ? (size.height || 0) / 2 : 0;
29
+ const center = {
30
+ x: position.x + halfW,
31
+ y: position.y + halfH,
32
+ };
33
+ if (typeof grid.snapWorldPoint === 'function') {
34
+ const snappedCenter = grid.snapWorldPoint(center.x, center.y);
35
+ const snapped = {
36
+ x: snappedCenter.x - halfW,
37
+ y: snappedCenter.y - halfH,
38
+ };
39
+ logGridDiagnostic('GridSnapResolver', 'snap via snapWorldPoint', {
40
+ type: grid.type || 'unknown',
41
+ position,
42
+ size,
43
+ snapped,
44
+ });
45
+ return snapped;
46
+ }
47
+ const fallback = grid.snapToGrid(center.x, center.y);
48
+ const snapped = {
49
+ x: fallback.x - halfW,
50
+ y: fallback.y - halfH,
51
+ };
52
+ logGridDiagnostic('GridSnapResolver', 'snap via snapToGrid fallback', {
53
+ type: grid.type || 'unknown',
54
+ position,
55
+ size,
56
+ snapped,
57
+ });
58
+ return snapped;
59
+ }
60
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Наблюдаемые шаги Miro при zoom-out от 400%.
3
+ * Оригинальная последовательность содержит дубликаты на низком zoom.
4
+ */
5
+ export const MIRO_ZOOM_DOWN_FROM_400 = [
6
+ 400, 357, 319, 285, 254, 227, 203, 181, 162, 144, 129, 115, 103, 92, 82,
7
+ 73, 65, 58, 52, 46, 41, 37,
8
+ ];
9
+
10
+ /**
11
+ * Наблюдаемые шаги Miro при zoom-in от 100%.
12
+ */
13
+ export const MIRO_ZOOM_UP_FROM_100 = [
14
+ 100, 112, 125, 140, 157, 176, 197, 221, 248, 277, 311, 348, 390, 400,
15
+ ];
16
+
17
+ /**
18
+ * Наблюдаемые шаги Miro при zoom-out от 100%.
19
+ */
20
+ export const MIRO_ZOOM_DOWN_FROM_100 = [
21
+ // Дубли (8, 5) представлены разными zoom-value, но одинаковой label после округления.
22
+ 100, 92, 82, 73, 65, 58, 52, 46, 41, 37, 33, 30, 26, 24, 21, 19, 17, 15, 13,
23
+ 12, 11, 10, 8.4, 7.6, 7, 6, 5.4, 4.6, 4.0, 3.6, 3.4, 3.0, 2.6, 2.4, 2.2, 2.0,
24
+ 1.8, 1.6, 1.4,
25
+ ];
26
+
27
+ const uniqueSorted = new Set([
28
+ ...MIRO_ZOOM_DOWN_FROM_400,
29
+ ...MIRO_ZOOM_UP_FROM_100,
30
+ ...MIRO_ZOOM_DOWN_FROM_100,
31
+ ]);
32
+
33
+ /**
34
+ * Общий справочник checkpoint'ов для аналитики/тестов.
35
+ */
36
+ export const MIRO_ZOOM_LEVELS = Array.from(uniqueSorted).sort((a, b) => a - b);
37
+
38
+ export const MIRO_ZOOM_UP_TO_100 = Array.from(new Set(MIRO_ZOOM_DOWN_FROM_100)).sort((a, b) => a - b);
39
+
@@ -85,10 +85,6 @@ export class SettingsApplier {
85
85
  if (world) {
86
86
  world.x = s.pan.x;
87
87
  world.y = s.pan.y;
88
- if (this.pixi?.gridLayer) {
89
- this.pixi.gridLayer.x = s.pan.x;
90
- this.pixi.gridLayer.y = s.pan.y;
91
- }
92
88
  try { this.eventBus.emit(Events.Viewport.Changed); } catch (_) {}
93
89
  }
94
90
  }
@@ -1,12 +1,19 @@
1
1
  import { Events } from '../core/events/Events.js';
2
+ import {
3
+ MIRO_ZOOM_DOWN_FROM_100,
4
+ MIRO_ZOOM_DOWN_FROM_400,
5
+ MIRO_ZOOM_UP_FROM_100,
6
+ MIRO_ZOOM_UP_TO_100,
7
+ } from './MiroZoomLevels.js';
2
8
 
3
9
  /**
4
- * Уровни зума, выровненные под профиль Miro.
5
- * Верхняя граница — 500%.
10
+ * Направленные zoom-последовательности, выровненные под наблюдения Miro.
6
11
  */
7
- const ZOOM_LEVELS = [
8
- 2, 5, 10, 15, 20, 25, 33, 50, 75, 100, 125, 150, 200, 250, 300, 400, 500
9
- ];
12
+ const ZOOM_UP_TO_100 = MIRO_ZOOM_UP_TO_100;
13
+ const ZOOM_UP_FROM_100 = MIRO_ZOOM_UP_FROM_100;
14
+ const ZOOM_DOWN_FROM_100 = MIRO_ZOOM_DOWN_FROM_100;
15
+ const ZOOM_DOWN_FROM_400 = MIRO_ZOOM_DOWN_FROM_400;
16
+ const EPSILON = 1e-6;
10
17
 
11
18
  export class ZoomPanController {
12
19
  constructor(eventBus, pixi) {
@@ -14,17 +21,34 @@ export class ZoomPanController {
14
21
  this.pixi = pixi;
15
22
  }
16
23
 
17
- _nearestLevelIndex(percent) {
18
- let best = 0;
19
- let bestDist = Math.abs(ZOOM_LEVELS[0] - percent);
20
- for (let i = 1; i < ZOOM_LEVELS.length; i++) {
21
- const d = Math.abs(ZOOM_LEVELS[i] - percent);
22
- if (d < bestDist) {
23
- bestDist = d;
24
- best = i;
24
+ _nextAscendingLevel(percent, levels) {
25
+ for (let i = 0; i < levels.length; i += 1) {
26
+ if (levels[i] > percent + EPSILON) return levels[i];
27
+ }
28
+ return levels[levels.length - 1];
29
+ }
30
+
31
+ _nextDescendingLevel(percent, levels) {
32
+ for (let i = 0; i < levels.length; i += 1) {
33
+ if (levels[i] < percent - EPSILON) return levels[i];
34
+ }
35
+ return levels[levels.length - 1];
36
+ }
37
+
38
+ _pickTargetPercent(oldPercent, delta) {
39
+ if (delta < 0) {
40
+ if (oldPercent >= 100) {
41
+ return this._nextAscendingLevel(oldPercent, ZOOM_UP_FROM_100);
25
42
  }
43
+ return this._nextAscendingLevel(oldPercent, ZOOM_UP_TO_100);
26
44
  }
27
- return best;
45
+ if (delta > 0) {
46
+ if (oldPercent > 100) {
47
+ return this._nextDescendingLevel(oldPercent, ZOOM_DOWN_FROM_400);
48
+ }
49
+ return this._nextDescendingLevel(oldPercent, ZOOM_DOWN_FROM_100);
50
+ }
51
+ return oldPercent;
28
52
  }
29
53
 
30
54
  attach() {
@@ -32,25 +56,20 @@ export class ZoomPanController {
32
56
  this.eventBus.on(Events.Tool.WheelZoom, ({ x, y, delta }) => {
33
57
  const world = this.pixi.worldLayer || this.pixi.app.stage;
34
58
  const oldScale = world.scale.x || 1;
35
- const oldPercent = Math.round(oldScale * 100);
36
- const idx = this._nearestLevelIndex(oldPercent);
37
- let targetPercent;
38
- if (delta < 0) {
39
- targetPercent = ZOOM_LEVELS[Math.min(ZOOM_LEVELS.length - 1, idx + 1)];
40
- } else if (delta > 0) {
41
- targetPercent = ZOOM_LEVELS[Math.max(0, idx - 1)];
42
- } else {
43
- return;
44
- }
45
- const newScale = Math.max(0.02, Math.min(5, targetPercent / 100));
59
+ const oldPercent = oldScale * 100;
60
+ if (!(delta < 0 || delta > 0)) return;
61
+
62
+ const targetPercent = this._pickTargetPercent(oldPercent, delta);
63
+ const newScale = Math.max(0.01, Math.min(5, targetPercent / 100));
46
64
  if (Math.abs(newScale - oldScale) < 0.0001) return;
65
+
47
66
  // Вычисляем мировые координаты точки под курсором до изменения скейла
48
67
  const worldX = (x - world.x) / oldScale;
49
68
  const worldY = (y - world.y) / oldScale;
50
69
  // Применяем новый скейл и пересчитываем позицию, чтобы точка под курсором осталась на месте
51
70
  world.scale.set(newScale);
52
- world.x = x - worldX * newScale;
53
- world.y = y - worldY * newScale;
71
+ world.x = Math.round(x - worldX * newScale);
72
+ world.y = Math.round(y - worldY * newScale);
54
73
  this.eventBus.emit(Events.UI.ZoomPercent, { percentage: Math.round(newScale * 100) });
55
74
  this.eventBus.emit(Events.Viewport.Changed);
56
75
  });
@@ -76,8 +95,8 @@ export class ZoomPanController {
76
95
  const worldX = (centerX - world.x) / oldScale;
77
96
  const worldY = (centerY - world.y) / oldScale;
78
97
  world.scale.set(1);
79
- world.x = centerX - worldX * 1;
80
- world.y = centerY - worldY * 1;
98
+ world.x = Math.round(centerX - worldX);
99
+ world.y = Math.round(centerY - worldY);
81
100
  this.eventBus.emit(Events.UI.ZoomPercent, { percentage: 100 });
82
101
  this.eventBus.emit(Events.Viewport.Changed);
83
102
  });
@@ -99,13 +118,13 @@ export class ZoomPanController {
99
118
  const padding = 40;
100
119
  const scaleX = (viewW - padding) / bboxW;
101
120
  const scaleY = (viewH - padding) / bboxH;
102
- const newScale = Math.max(0.02, Math.min(5, Math.min(scaleX, scaleY)));
121
+ const newScale = Math.max(0.01, Math.min(5, Math.min(scaleX, scaleY)));
103
122
  const world = this.pixi.worldLayer || this.pixi.app.stage;
104
123
  const worldCenterX = minX + bboxW / 2;
105
124
  const worldCenterY = minY + bboxH / 2;
106
125
  world.scale.set(newScale);
107
- world.x = viewW / 2 - worldCenterX * newScale;
108
- world.y = viewH / 2 - worldCenterY * newScale;
126
+ world.x = Math.round(viewW / 2 - worldCenterX * newScale);
127
+ world.y = Math.round(viewH / 2 - worldCenterY * newScale);
109
128
  this.eventBus.emit(Events.UI.ZoomPercent, { percentage: Math.round(newScale * 100) });
110
129
  this.eventBus.emit(Events.Viewport.Changed);
111
130
  });
@@ -81,6 +81,8 @@ export class PlacementTool extends BaseTool {
81
81
  this.showFrameGhost();
82
82
  } else if (this.pending.type === 'shape') {
83
83
  this.showShapeGhost();
84
+ } else if (this.pending.type === 'mindmap') {
85
+ this.showMindmapGhost();
84
86
  }
85
87
  }
86
88
  }
@@ -292,6 +294,13 @@ export class PlacementTool extends BaseTool {
292
294
  return this.ghostController.showShapeGhost();
293
295
  }
294
296
 
297
+ /**
298
+ * Показать "призрак" mindmap-объекта
299
+ */
300
+ showMindmapGhost() {
301
+ return this.ghostController.showMindmapGhost();
302
+ }
303
+
295
304
  /**
296
305
  * Разместить выбранное изображение на холсте
297
306
  */
@@ -360,10 +369,10 @@ export class PlacementTool extends BaseTool {
360
369
 
361
370
  // Возвращает подходящий курсор для текущего pending состояния
362
371
  PlacementTool.prototype._getPendingCursor = function() {
363
- if (!this.pending) return 'crosshair';
372
+ if (!this.pending) return 'default';
364
373
  if (this.pending.type === 'text') return 'text';
365
- if (this.pending.type === 'frame-draw') return 'crosshair';
366
- return 'crosshair';
374
+ if (this.pending.type === 'frame-draw') return 'default';
375
+ return 'default';
367
376
  };
368
377
 
369
378
 
@@ -24,9 +24,11 @@ import {
24
24
  } from './selection/CloneFlowController.js';
25
25
  import {
26
26
  openTextEditor as openTextEditorViaController,
27
+ openMindmapEditor as openMindmapEditorViaController,
27
28
  openFileNameEditor as openFileNameEditorViaController,
28
29
  closeFileNameEditor as closeFileNameEditorViaController,
29
- closeTextEditor as closeTextEditorViaController
30
+ closeTextEditor as closeTextEditorViaController,
31
+ closeMindmapEditor as closeMindmapEditorViaController
30
32
  } from './selection/InlineEditorController.js';
31
33
  import {
32
34
  hitTest as hitTestViaService,
@@ -325,6 +327,10 @@ export class SelectTool extends BaseTool {
325
327
  return openTextEditorViaController.call(this, object, create);
326
328
  }
327
329
 
330
+ _openMindmapEditor(object, create = false) {
331
+ return openMindmapEditorViaController.call(this, object, create);
332
+ }
333
+
328
334
  _openFileNameEditor(object, create = false) {
329
335
  return openFileNameEditorViaController.call(this, object, create);
330
336
  }
@@ -336,6 +342,10 @@ export class SelectTool extends BaseTool {
336
342
  return closeTextEditorViaController.call(this, commit);
337
343
  }
338
344
 
345
+ _closeMindmapEditor(commit) {
346
+ return closeMindmapEditorViaController.call(this, commit);
347
+ }
348
+
339
349
  destroy() {
340
350
  return destroySelectTool.call(this, () => super.destroy());
341
351
  }
@@ -1,4 +1,5 @@
1
1
  import * as PIXI from 'pixi.js';
2
+ import { MINDMAP_LAYOUT } from '../../../ui/mindmap/MindmapLayoutConfig.js';
2
3
 
3
4
  export class GhostController {
4
5
  constructor(host) {
@@ -233,7 +234,7 @@ export class GhostController {
233
234
  }
234
235
  } catch (_) {}
235
236
  } else if (host.app && host.app.view) {
236
- host.cursor = 'crosshair';
237
+ host.cursor = 'default';
237
238
  host.app.view.style.cursor = host.cursor;
238
239
  }
239
240
  }
@@ -501,4 +502,102 @@ export class GhostController {
501
502
 
502
503
  host.world.addChild(host.ghostContainer);
503
504
  }
505
+
506
+ showMindmapGhost() {
507
+ const host = this.host;
508
+ if (!host.pending || host.pending.type !== 'mindmap' || !host.world) return;
509
+
510
+ this.hideGhost();
511
+
512
+ host.ghostContainer = new PIXI.Container();
513
+ host.ghostContainer.alpha = 0.75;
514
+
515
+ const width = Math.max(1, Math.round(host.pending.properties?.width || MINDMAP_LAYOUT.width));
516
+ const height = Math.max(1, Math.round(host.pending.properties?.height || MINDMAP_LAYOUT.height));
517
+ const strokeColor = (typeof host.pending.properties?.strokeColor === 'number')
518
+ ? host.pending.properties.strokeColor
519
+ : 0x2563EB;
520
+ const fillColor = (typeof host.pending.properties?.fillColor === 'number')
521
+ ? host.pending.properties.fillColor
522
+ : 0x3B82F6;
523
+ const fillAlpha = (typeof host.pending.properties?.fillAlpha === 'number')
524
+ ? host.pending.properties.fillAlpha
525
+ : 0.25;
526
+ const strokeWidth = (typeof host.pending.properties?.strokeWidth === 'number')
527
+ ? host.pending.properties.strokeWidth
528
+ : 1;
529
+ const fontSize = Math.max(1, Math.round(host.pending.properties?.fontSize || MINDMAP_LAYOUT.fontSize));
530
+ const fontFamily = host.pending.properties?.fontFamily || 'Roboto, Arial, sans-serif';
531
+ const textColor = host.pending.properties?.textColor || 0x212121;
532
+ const paddingX = Math.max(0, Math.round(host.pending.properties?.paddingX ?? MINDMAP_LAYOUT.paddingX));
533
+ const placeholderText = 'Напишите что-нибудь';
534
+ const dynamicRadius = Math.max(0, Math.floor(Math.min(width, height) / 2));
535
+ const baseHeight = Math.max(
536
+ 1,
537
+ Math.round(host.pending.properties?.capsuleBaseHeight || host.pending.properties?.height || MINDMAP_LAYOUT.height)
538
+ );
539
+ const baseRadius = Math.max(0, Math.floor(baseHeight / 2));
540
+ const cornerRadius = Math.min(dynamicRadius, baseRadius);
541
+
542
+ const graphics = new PIXI.Graphics();
543
+ const drawGhostCapsule = (lineWidth, alpha = 1) => {
544
+ try {
545
+ graphics.lineStyle({
546
+ width: lineWidth,
547
+ color: strokeColor,
548
+ alpha,
549
+ alignment: 0,
550
+ cap: 'round',
551
+ join: 'round',
552
+ miterLimit: 2,
553
+ });
554
+ } catch (_) {
555
+ graphics.lineStyle(lineWidth, strokeColor, alpha, 0);
556
+ }
557
+ graphics.drawRoundedRect(0, 0, width, height, cornerRadius);
558
+ };
559
+
560
+ try {
561
+ graphics.beginFill(fillColor, fillAlpha);
562
+ graphics.drawRoundedRect(0, 0, width, height, cornerRadius);
563
+ graphics.endFill();
564
+ } catch (_) {
565
+ graphics.beginFill(fillColor, fillAlpha);
566
+ graphics.drawRoundedRect(0, 0, width, height, cornerRadius);
567
+ graphics.endFill();
568
+ }
569
+
570
+ drawGhostCapsule(strokeWidth, 1);
571
+
572
+ host.ghostContainer.addChild(graphics);
573
+
574
+ try {
575
+ const lineHeight = Math.max(1, Math.round(fontSize * 1.24));
576
+ const rendererRes = Math.max(1, host.app?.renderer?.resolution || 1);
577
+ const textScale = 1 / rendererRes;
578
+ const placeholder = new PIXI.Text(placeholderText, {
579
+ fontFamily,
580
+ fontSize,
581
+ fontWeight: '400',
582
+ fill: textColor,
583
+ align: 'left',
584
+ lineHeight,
585
+ wordWrap: false,
586
+ breakWords: false,
587
+ });
588
+ placeholder.alpha = 0.45;
589
+ placeholder.scale.set(textScale);
590
+ placeholder.x = paddingX;
591
+ const localBounds = placeholder.getLocalBounds();
592
+ const measuredHeight = Math.max(1, Number.isFinite(localBounds?.height) ? localBounds.height : lineHeight);
593
+ const scaledMeasuredHeight = measuredHeight * textScale;
594
+ const targetY = (height - scaledMeasuredHeight) / 2;
595
+ placeholder.y = (Math.round(targetY * 2) / 2) - 2;
596
+ host.ghostContainer.addChild(placeholder);
597
+ } catch (_) {}
598
+
599
+ host.ghostContainer.pivot.x = width / 2;
600
+ host.ghostContainer.pivot.y = height / 2;
601
+ host.world.addChild(host.ghostContainer);
602
+ }
504
603
  }
@@ -32,6 +32,8 @@ export class PlacementEventsBridge {
32
32
  this.host.startFrameDrawMode();
33
33
  } else if (this.host.pending.type === 'shape') {
34
34
  this.host.showShapeGhost();
35
+ } else if (this.host.pending.type === 'mindmap') {
36
+ this.host.showMindmapGhost();
35
37
  }
36
38
  if (this.host.pending.placeOnMouseUp && this.host.app && this.host.app.view) {
37
39
  const onUp = (ev) => {