@sequent-org/moodboard 1.2.119 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/package.json +11 -1
  2. package/src/assets/icons/rotate-icon.svg +1 -1
  3. package/src/core/HistoryManager.js +16 -16
  4. package/src/core/KeyboardManager.js +48 -539
  5. package/src/core/PixiEngine.js +9 -9
  6. package/src/core/SaveManager.js +56 -31
  7. package/src/core/bootstrap/CoreInitializer.js +65 -0
  8. package/src/core/commands/DeleteObjectCommand.js +8 -0
  9. package/src/core/commands/GroupDeleteCommand.js +75 -0
  10. package/src/core/commands/GroupRotateCommand.js +6 -0
  11. package/src/core/commands/UpdateContentCommand.js +52 -0
  12. package/src/core/commands/UpdateFramePropertiesCommand.js +98 -0
  13. package/src/core/commands/UpdateFrameTypeCommand.js +85 -0
  14. package/src/core/commands/UpdateNoteStyleCommand.js +88 -0
  15. package/src/core/commands/UpdateTextStyleCommand.js +90 -0
  16. package/src/core/commands/index.js +6 -0
  17. package/src/core/events/Events.js +6 -0
  18. package/src/core/flows/ClipboardFlow.js +553 -0
  19. package/src/core/flows/LayerAndViewportFlow.js +283 -0
  20. package/src/core/flows/ObjectLifecycleFlow.js +336 -0
  21. package/src/core/flows/SaveFlow.js +34 -0
  22. package/src/core/flows/TransformFlow.js +277 -0
  23. package/src/core/flows/TransformFlowResizeHelpers.js +83 -0
  24. package/src/core/index.js +41 -1773
  25. package/src/core/keyboard/KeyboardClipboardImagePaste.js +190 -0
  26. package/src/core/keyboard/KeyboardContextGuards.js +35 -0
  27. package/src/core/keyboard/KeyboardEventRouter.js +92 -0
  28. package/src/core/keyboard/KeyboardSelectionActions.js +103 -0
  29. package/src/core/keyboard/KeyboardShortcutMap.js +31 -0
  30. package/src/core/keyboard/KeyboardToolSwitching.js +26 -0
  31. package/src/core/rendering/ObjectRenderer.js +3 -7
  32. package/src/grid/BaseGrid.js +26 -0
  33. package/src/grid/CrossGrid.js +7 -6
  34. package/src/grid/DotGrid.js +89 -33
  35. package/src/grid/DotGridZoomPhases.js +42 -0
  36. package/src/grid/LineGrid.js +22 -21
  37. package/src/moodboard/MoodBoard.js +31 -532
  38. package/src/moodboard/bootstrap/MoodBoardInitializer.js +47 -0
  39. package/src/moodboard/bootstrap/MoodBoardManagersFactory.js +38 -0
  40. package/src/moodboard/bootstrap/MoodBoardUiFactory.js +109 -0
  41. package/src/moodboard/integration/MoodBoardEventBindings.js +65 -0
  42. package/src/moodboard/integration/MoodBoardLoadApi.js +82 -0
  43. package/src/moodboard/integration/MoodBoardScreenshotApi.js +33 -0
  44. package/src/moodboard/integration/MoodBoardScreenshotCanvas.js +98 -0
  45. package/src/moodboard/lifecycle/MoodBoardDestroyer.js +97 -0
  46. package/src/objects/FileObject.js +17 -6
  47. package/src/objects/FrameObject.js +50 -10
  48. package/src/objects/NoteObject.js +5 -4
  49. package/src/services/BoardService.js +42 -2
  50. package/src/services/FrameService.js +83 -42
  51. package/src/services/ResizePolicyService.js +152 -0
  52. package/src/services/SettingsApplier.js +7 -2
  53. package/src/services/ZoomPanController.js +35 -9
  54. package/src/tools/ToolManager.js +30 -537
  55. package/src/tools/board-tools/PanTool.js +5 -11
  56. package/src/tools/manager/ToolActivationController.js +49 -0
  57. package/src/tools/manager/ToolEventRouter.js +396 -0
  58. package/src/tools/manager/ToolManagerGuards.js +33 -0
  59. package/src/tools/manager/ToolManagerLifecycle.js +110 -0
  60. package/src/tools/manager/ToolRegistry.js +33 -0
  61. package/src/tools/object-tools/DrawingTool.js +48 -14
  62. package/src/tools/object-tools/PlacementTool.js +50 -1049
  63. package/src/tools/object-tools/PlacementToolV2.js +88 -0
  64. package/src/tools/object-tools/SelectTool.js +174 -2681
  65. package/src/tools/object-tools/placement/GhostController.js +504 -0
  66. package/src/tools/object-tools/placement/PlacementCoordinateResolver.js +20 -0
  67. package/src/tools/object-tools/placement/PlacementEventsBridge.js +91 -0
  68. package/src/tools/object-tools/placement/PlacementInputRouter.js +267 -0
  69. package/src/tools/object-tools/placement/PlacementPayloadFactory.js +111 -0
  70. package/src/tools/object-tools/placement/PlacementSessionStore.js +18 -0
  71. package/src/tools/object-tools/selection/BoxSelectController.js +0 -5
  72. package/src/tools/object-tools/selection/CloneFlowController.js +71 -0
  73. package/src/tools/object-tools/selection/CoordinateMapper.js +10 -0
  74. package/src/tools/object-tools/selection/CursorController.js +78 -0
  75. package/src/tools/object-tools/selection/FileNameInlineEditorController.js +184 -0
  76. package/src/tools/object-tools/selection/HitTestService.js +102 -0
  77. package/src/tools/object-tools/selection/InlineEditorController.js +24 -0
  78. package/src/tools/object-tools/selection/InlineEditorDomFactory.js +50 -0
  79. package/src/tools/object-tools/selection/InlineEditorListenersRegistry.js +14 -0
  80. package/src/tools/object-tools/selection/InlineEditorPositioningService.js +25 -0
  81. package/src/tools/object-tools/selection/NoteInlineEditorController.js +113 -0
  82. package/src/tools/object-tools/selection/SelectInputRouter.js +267 -0
  83. package/src/tools/object-tools/selection/SelectToolLifecycleController.js +128 -0
  84. package/src/tools/object-tools/selection/SelectToolSetup.js +134 -0
  85. package/src/tools/object-tools/selection/SelectionOverlayService.js +81 -0
  86. package/src/tools/object-tools/selection/SelectionStateController.js +91 -0
  87. package/src/tools/object-tools/selection/TextEditorDomFactory.js +65 -0
  88. package/src/tools/object-tools/selection/TextEditorInteractionController.js +266 -0
  89. package/src/tools/object-tools/selection/TextEditorLifecycleRegistry.js +90 -0
  90. package/src/tools/object-tools/selection/TextEditorPositioningService.js +158 -0
  91. package/src/tools/object-tools/selection/TextEditorSyncService.js +110 -0
  92. package/src/tools/object-tools/selection/TextInlineEditorController.js +457 -0
  93. package/src/tools/object-tools/selection/TransformInteractionController.js +466 -0
  94. package/src/ui/FilePropertiesPanel.js +61 -32
  95. package/src/ui/FramePropertiesPanel.js +176 -101
  96. package/src/ui/HtmlHandlesLayer.js +121 -999
  97. package/src/ui/MapPanel.js +12 -7
  98. package/src/ui/NotePropertiesPanel.js +17 -2
  99. package/src/ui/TextPropertiesPanel.js +124 -738
  100. package/src/ui/Toolbar.js +71 -1180
  101. package/src/ui/Topbar.js +23 -25
  102. package/src/ui/ZoomPanel.js +16 -5
  103. package/src/ui/handles/GroupSelectionHandlesController.js +29 -0
  104. package/src/ui/handles/HandlesDomRenderer.js +278 -0
  105. package/src/ui/handles/HandlesEventBridge.js +102 -0
  106. package/src/ui/handles/HandlesInteractionController.js +772 -0
  107. package/src/ui/handles/HandlesPositioningService.js +206 -0
  108. package/src/ui/handles/SingleSelectionHandlesController.js +22 -0
  109. package/src/ui/styles/toolbar.css +2 -0
  110. package/src/ui/styles/workspace.css +13 -6
  111. package/src/ui/text-properties/TextPropertiesPanelBindings.js +92 -0
  112. package/src/ui/text-properties/TextPropertiesPanelEventBridge.js +77 -0
  113. package/src/ui/text-properties/TextPropertiesPanelMapper.js +173 -0
  114. package/src/ui/text-properties/TextPropertiesPanelRenderer.js +434 -0
  115. package/src/ui/text-properties/TextPropertiesPanelState.js +39 -0
  116. package/src/ui/toolbar/ToolbarActionRouter.js +193 -0
  117. package/src/ui/toolbar/ToolbarDialogsController.js +186 -0
  118. package/src/ui/toolbar/ToolbarPopupsController.js +662 -0
  119. package/src/ui/toolbar/ToolbarRenderer.js +97 -0
  120. package/src/ui/toolbar/ToolbarStateController.js +79 -0
  121. package/src/ui/toolbar/ToolbarTooltipController.js +52 -0
  122. package/src/utils/emojiLoaderNoBundler.js +1 -1
@@ -26,7 +26,6 @@ export class BoardService {
26
26
  this.grid?.setEnabled(false);
27
27
  this.grid?.updateVisual();
28
28
  this.pixi.setGrid(this.grid);
29
- // Обновляем сохранённые данные
30
29
  try {
31
30
  this.eventBus.emit(Events.Grid.BoardDataChanged, {
32
31
  grid: { type: 'off', options: this.grid?.serialize ? this.grid.serialize() : {} }
@@ -34,6 +33,7 @@ export class BoardService {
34
33
  } catch (_) {}
35
34
  return;
36
35
  }
36
+ this.grid?.destroy?.();
37
37
  const gridOptions = {
38
38
  ...GridFactory.getDefaultOptions(type),
39
39
  enabled: true,
@@ -44,8 +44,8 @@ export class BoardService {
44
44
  };
45
45
  try {
46
46
  this.grid = GridFactory.createGrid(type, gridOptions);
47
- this.grid.updateVisual();
48
47
  this.pixi.setGrid(this.grid);
48
+ this.refreshGridViewport();
49
49
  this.eventBus.emit(Events.UI.GridCurrent, { type });
50
50
  // Сообщаем об обновлении данных сетки для сохранения в boardData
51
51
  try {
@@ -67,6 +67,7 @@ export class BoardService {
67
67
  req.view = { width: viewEl.clientWidth, height: viewEl.clientHeight };
68
68
  // Прокидываем только метаданные объектов через ядро (сам список формирует Core)
69
69
  });
70
+ this.eventBus.on(Events.Viewport.Changed, () => this.refreshGridViewport());
70
71
  this.eventBus.on(Events.UI.MinimapCenterOn, ({ worldX, worldY }) => {
71
72
  const world = this.pixi.worldLayer || this.pixi.app.stage;
72
73
  const viewW = this.pixi.app.view.clientWidth;
@@ -74,6 +75,11 @@ export class BoardService {
74
75
  const s = world.scale?.x || 1;
75
76
  world.x = viewW / 2 - worldX * s;
76
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
+ }
82
+ this.refreshGridViewport();
77
83
  });
78
84
  }
79
85
 
@@ -81,9 +87,43 @@ export class BoardService {
81
87
  if (!this.grid) return;
82
88
  const size = this._getCanvasSize?.() || { width: 800, height: 600 };
83
89
  this.grid.resize(size.width, size.height);
90
+ this.grid.viewportBounds = null;
84
91
  this.grid.updateVisual();
85
92
  this.pixi.setGrid(this.grid);
86
93
  }
94
+
95
+ /**
96
+ * Синхронизирует gridLayer с world (позиция + масштаб) и перерисовывает сетку.
97
+ */
98
+ refreshGridViewport() {
99
+ if (!this.grid?.enabled || !this.pixi?.gridLayer) return;
100
+ const view = this.pixi.app?.view;
101
+ if (!view) return;
102
+ const world = this.pixi.worldLayer || this.pixi.app.stage;
103
+ const gl = this.pixi.gridLayer;
104
+ const scale = world.scale?.x ?? 1;
105
+
106
+ // Синхронизация gridLayer с world — сетка зуммируется вместе с доской
107
+ gl.x = world.x;
108
+ gl.y = world.y;
109
+ if (gl.scale) {
110
+ gl.scale.set(scale);
111
+ }
112
+
113
+ // DotGrid: передаём zoom до setVisibleBounds (чтобы createVisual видел актуальный zoom)
114
+ if (this.grid.type === 'dot' && typeof this.grid.setZoom === 'function') {
115
+ this.grid.setZoom(scale);
116
+ }
117
+
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;
125
+ this.grid.setVisibleBounds(left, top, right, bottom);
126
+ }
87
127
  }
88
128
 
89
129
 
@@ -50,8 +50,10 @@ export class FrameService {
50
50
  }
51
51
 
52
52
  attach() {
53
- // Автоприкрепление объектов при создании произвольного фрейма
54
- this.eventBus.on(Events.Object.Created, ({ objectId, objectData }) => {
53
+ if (this._attached) return;
54
+ this._attached = true;
55
+
56
+ this._onObjectCreated = ({ objectId, objectData }) => {
55
57
  try {
56
58
  if (!objectData || objectData.type !== 'frame') return;
57
59
  const isArbitrary = (objectData.properties && objectData.properties.lockedAspect === false)
@@ -66,14 +68,14 @@ export class FrameService {
66
68
  // И оповестим общий менеджер на всякий случай
67
69
  this.eventBus.emit(Events.Object.Reordered, { reason: 'attach_arbitrary_frame_children' });
68
70
  } catch (_) { /* no-op */ }
69
- });
71
+ };
72
+ this.eventBus.on(Events.Object.Created, this._onObjectCreated);
70
73
 
71
- // Визуал подсветки при drag над фреймом и перенос детей на drag
72
- this.eventBus.on(Events.Tool.DragStart, (data) => {
74
+ this._onDragStart = (data) => {
73
75
  const moved = this.state.state.objects.find(o => o.id === data.object);
74
76
  if (moved && moved.type === 'frame') {
75
77
  // Серый фон
76
- this.pixi.setFrameFill(moved.id, moved.width, moved.height, 0xEEEEEE);
78
+ this.pixi.setFrameFill(moved.id, moved.width, moved.height, 0xFAFAFA);
77
79
  // Cнимок стартовых позиций по центру PIXI
78
80
  const fp = this.pixi.objects.get(moved.id);
79
81
  this._frameDragFrameStart = { x: fp?.x || 0, y: fp?.y || 0 };
@@ -84,9 +86,10 @@ export class FrameService {
84
86
  if (childPixi) this._frameDragChildStart.set(childId, { x: childPixi.x, y: childPixi.y });
85
87
  }
86
88
  }
87
- });
89
+ };
90
+ this.eventBus.on(Events.Tool.DragStart, this._onDragStart);
88
91
 
89
- this.eventBus.on(Events.Tool.DragUpdate, (data) => {
92
+ this._onDragUpdate = (data) => {
90
93
  const moved = this.state.state.objects.find(o => o.id === data.object);
91
94
  if (!moved) return;
92
95
  if (moved.type === 'frame') {
@@ -125,40 +128,14 @@ export class FrameService {
125
128
  });
126
129
  }
127
130
  }
128
- // Во время перетаскивания тоже гарантируем порядок
129
- this._forceFramesBelow();
130
131
  } else {
131
- // Hover-эффект: подсветка фрейма, если центр объекта внутри
132
- const centerX = moved.position.x + (moved.width || 0) / 2;
133
- const centerY = moved.position.y + (moved.height || 0) / 2;
134
- const frames = (this.state.state.objects || []).filter(o => o.type === 'frame');
135
- const ordered = frames.slice().sort((a, b) => {
136
- const pa = this.pixi.objects.get(a.id);
137
- const pb = this.pixi.objects.get(b.id);
138
- return (pb?.zIndex || 0) - (pa?.zIndex || 0);
139
- });
140
- let hoverId = null;
141
- for (const f of ordered) {
142
- const rect = { x: f.position.x, y: f.position.y, w: f.width || 0, h: f.height || 0 };
143
- if (centerX >= rect.x && centerX <= rect.x + rect.w && centerY >= rect.y && centerY <= rect.y + rect.h) {
144
- hoverId = f.id; break;
145
- }
146
- }
147
- if (hoverId !== this._frameHoverId) {
148
- if (this._frameHoverId) {
149
- const prev = frames.find(fr => fr.id === this._frameHoverId);
150
- if (prev) this.pixi.setFrameFill(prev.id, prev.width, prev.height, 0xFFFFFF);
151
- }
152
- if (hoverId) {
153
- const cur = frames.find(fr => fr.id === hoverId);
154
- if (cur) this.pixi.setFrameFill(cur.id, cur.width, cur.height, 0xEEEEEE);
155
- }
156
- this._frameHoverId = hoverId || null;
157
- }
132
+ // Hover-эффект: throttle, чтобы не вызывать setFrameFill на каждый кадр
133
+ this._applyHoverThrottled(data.object);
158
134
  }
159
- });
135
+ };
136
+ this.eventBus.on(Events.Tool.DragUpdate, this._onDragUpdate);
160
137
 
161
- this.eventBus.on(Events.Tool.DragEnd, (data) => {
138
+ this._onDragEnd = (data) => {
162
139
  const movedObj = this.state.state.objects.find(o => o.id === data.object);
163
140
  if (!movedObj) return;
164
141
  if (movedObj.type === 'frame') {
@@ -172,9 +149,32 @@ export class FrameService {
172
149
  const frames = (this.state.state.objects || []).filter(o => o.type === 'frame');
173
150
  const prev = frames.find(fr => fr.id === this._frameHoverId);
174
151
  if (prev) this.pixi.setFrameFill(prev.id, prev.width, prev.height, 0xFFFFFF);
175
- this._frameHoverId = null;
176
- }
177
- });
152
+ this._frameHoverId = null;
153
+ }
154
+ };
155
+ this.eventBus.on(Events.Tool.DragEnd, this._onDragEnd);
156
+ }
157
+
158
+ detach() {
159
+ if (!this._attached) return;
160
+ this._attached = false;
161
+ if (this._hoverRafId != null) {
162
+ cancelAnimationFrame(this._hoverRafId);
163
+ this._hoverRafId = null;
164
+ }
165
+ this._hoverRafScheduled = false;
166
+ this._hoverPendingObjectId = null;
167
+ if (this._onObjectCreated) this.eventBus.off(Events.Object.Created, this._onObjectCreated);
168
+ if (this._onDragStart) this.eventBus.off(Events.Tool.DragStart, this._onDragStart);
169
+ if (this._onDragUpdate) this.eventBus.off(Events.Tool.DragUpdate, this._onDragUpdate);
170
+ if (this._onDragEnd) this.eventBus.off(Events.Tool.DragEnd, this._onDragEnd);
171
+ this._onObjectCreated = null;
172
+ this._onDragStart = null;
173
+ this._onDragUpdate = null;
174
+ this._onDragEnd = null;
175
+ this._frameDragFrameStart = null;
176
+ this._frameDragChildStart = null;
177
+ this._frameHoverId = null;
178
178
  }
179
179
 
180
180
  _getFrameChildren(frameId) {
@@ -186,6 +186,47 @@ export class FrameService {
186
186
  return res;
187
187
  }
188
188
 
189
+ _applyHoverThrottled(movedObjectId) {
190
+ if (this._hoverRafScheduled) return;
191
+ this._hoverRafScheduled = true;
192
+ this._hoverPendingObjectId = movedObjectId;
193
+ this._hoverRafId = requestAnimationFrame(() => {
194
+ this._hoverRafScheduled = false;
195
+ this._hoverRafId = null;
196
+ const oid = this._hoverPendingObjectId;
197
+ this._hoverPendingObjectId = null;
198
+ if (!oid) return;
199
+ const moved = this.state.state.objects.find(o => o.id === oid);
200
+ if (!moved || moved.type === 'frame') return;
201
+ const centerX = moved.position.x + (moved.width || 0) / 2;
202
+ const centerY = moved.position.y + (moved.height || 0) / 2;
203
+ const frames = (this.state.state.objects || []).filter(o => o.type === 'frame');
204
+ const ordered = frames.slice().sort((a, b) => {
205
+ const pa = this.pixi.objects.get(a.id);
206
+ const pb = this.pixi.objects.get(b.id);
207
+ return (pb?.zIndex || 0) - (pa?.zIndex || 0);
208
+ });
209
+ let hoverId = null;
210
+ for (const f of ordered) {
211
+ const rect = { x: f.position.x, y: f.position.y, w: f.width || 0, h: f.height || 0 };
212
+ if (centerX >= rect.x && centerX <= rect.x + rect.w && centerY >= rect.y && centerY <= rect.y + rect.h) {
213
+ hoverId = f.id; break;
214
+ }
215
+ }
216
+ if (hoverId !== this._frameHoverId) {
217
+ if (this._frameHoverId) {
218
+ const prev = frames.find(fr => fr.id === this._frameHoverId);
219
+ if (prev) this.pixi.setFrameFill(prev.id, prev.width, prev.height, 0xFFFFFF);
220
+ }
221
+ if (hoverId) {
222
+ const cur = frames.find(fr => fr.id === hoverId);
223
+ if (cur) this.pixi.setFrameFill(cur.id, cur.width, cur.height, 0xFAFAFA);
224
+ }
225
+ this._frameHoverId = hoverId || null;
226
+ }
227
+ });
228
+ }
229
+
189
230
  _recomputeFrameAttachment(objectId) {
190
231
  const obj = (this.state.state.objects || []).find(o => o.id === objectId);
191
232
  if (!obj) return;
@@ -0,0 +1,152 @@
1
+ const MIN_FRAME_AREA = 1800;
2
+
3
+ function getRoundedSize(width, height) {
4
+ return {
5
+ width: Math.max(1, Math.round(width || 1)),
6
+ height: Math.max(1, Math.round(height || 1)),
7
+ };
8
+ }
9
+
10
+ function isHandleEdge(handle) {
11
+ return ['n', 's', 'e', 'w'].includes(handle);
12
+ }
13
+
14
+ export function getSingleResizePolicy({ objectType = null, properties = {} } = {}) {
15
+ const isEmojiImage = objectType === 'image' && !!properties?.isEmojiIcon;
16
+ const isEmoji = objectType === 'emoji' || isEmojiImage;
17
+ const isNote = objectType === 'note';
18
+ const lockedAspect = objectType === 'frame' && properties?.lockedAspect === true;
19
+ const keepAspect = objectType === 'image' || lockedAspect || isEmoji || isNote;
20
+
21
+ return {
22
+ keepAspect,
23
+ square: isEmoji || isNote,
24
+ minArea: objectType === 'frame' ? MIN_FRAME_AREA : 0,
25
+ };
26
+ }
27
+
28
+ export function resolveAnchoredResizePosition({
29
+ startPosition,
30
+ startSize,
31
+ normalizedSize,
32
+ handle,
33
+ }) {
34
+ if (!startPosition || !startSize || !normalizedSize || !handle) return null;
35
+
36
+ const normalizedHandle = String(handle).toLowerCase();
37
+ const startW = Math.max(1, startSize.width || 1);
38
+ const startH = Math.max(1, startSize.height || 1);
39
+ const nextW = Math.max(1, normalizedSize.width || 1);
40
+ const nextH = Math.max(1, normalizedSize.height || 1);
41
+ let x = startPosition.x;
42
+ let y = startPosition.y;
43
+
44
+ if (normalizedHandle.includes('w')) x = startPosition.x + (startW - nextW);
45
+ if (normalizedHandle.includes('n')) y = startPosition.y + (startH - nextH);
46
+
47
+ if (isHandleEdge(normalizedHandle)) {
48
+ if (normalizedHandle === 'n' || normalizedHandle === 's') {
49
+ x = startPosition.x + ((startW - nextW) / 2);
50
+ } else if (normalizedHandle === 'e' || normalizedHandle === 'w') {
51
+ y = startPosition.y + ((startH - nextH) / 2);
52
+ }
53
+ }
54
+
55
+ return {
56
+ x: Math.round(x),
57
+ y: Math.round(y),
58
+ };
59
+ }
60
+
61
+ export function resolveSingleResizeDominantAxis({
62
+ startSize,
63
+ size,
64
+ objectType = null,
65
+ properties = {},
66
+ preferredDominantAxis = null,
67
+ }) {
68
+ if (!size) return 'none';
69
+
70
+ const policy = getSingleResizePolicy({ objectType, properties });
71
+ if (policy.square) return 'square';
72
+ if (!policy.keepAspect) return 'free';
73
+ if (preferredDominantAxis === 'width' || preferredDominantAxis === 'height') {
74
+ return preferredDominantAxis;
75
+ }
76
+
77
+ const startW = Math.max(1, startSize?.width || size.width || 1);
78
+ const startH = Math.max(1, startSize?.height || size.height || 1);
79
+ const rawWidth = Math.max(1, size.width || 1);
80
+ const rawHeight = Math.max(1, size.height || 1);
81
+ const deltaWidth = Math.abs(rawWidth - startW);
82
+ const deltaHeight = Math.abs(rawHeight - startH);
83
+ return deltaWidth >= deltaHeight ? 'width' : 'height';
84
+ }
85
+
86
+ export function normalizeSingleResizeGeometry({
87
+ startSize,
88
+ startPosition,
89
+ handle,
90
+ size,
91
+ position = null,
92
+ objectType = null,
93
+ properties = {},
94
+ preferredDominantAxis = null,
95
+ }) {
96
+ if (!size) {
97
+ return { size: null, position };
98
+ }
99
+
100
+ const startW = Math.max(1, startSize?.width || size.width || 1);
101
+ const startH = Math.max(1, startSize?.height || size.height || 1);
102
+ const policy = getSingleResizePolicy({ objectType, properties });
103
+ let width = Math.max(1, size.width || 1);
104
+ let height = Math.max(1, size.height || 1);
105
+ const dominantAxis = resolveSingleResizeDominantAxis({
106
+ startSize: { width: startW, height: startH },
107
+ size: { width, height },
108
+ objectType,
109
+ properties,
110
+ preferredDominantAxis,
111
+ });
112
+
113
+ if (policy.square) {
114
+ const squareSize = Math.max(width, height);
115
+ width = squareSize;
116
+ height = squareSize;
117
+ } else if (policy.keepAspect) {
118
+ const aspect = startW / startH;
119
+ if (dominantAxis === 'width') {
120
+ height = width / aspect;
121
+ } else {
122
+ width = height * aspect;
123
+ }
124
+ }
125
+
126
+ if (policy.minArea > 0) {
127
+ const area = Math.max(1, width * height);
128
+ if (area < policy.minArea) {
129
+ const scale = Math.sqrt(policy.minArea / area);
130
+ width *= scale;
131
+ height *= scale;
132
+ }
133
+ }
134
+
135
+ const normalizedSize = getRoundedSize(width, height);
136
+ const shouldResolvePosition = (policy.keepAspect || policy.square || policy.minArea > 0)
137
+ && !!startPosition
138
+ && !!handle;
139
+ const resolvedPosition = shouldResolvePosition
140
+ ? resolveAnchoredResizePosition({
141
+ startPosition,
142
+ startSize: { width: startW, height: startH },
143
+ normalizedSize,
144
+ handle,
145
+ })
146
+ : position;
147
+
148
+ return {
149
+ size: normalizedSize,
150
+ position: resolvedPosition,
151
+ };
152
+ }
@@ -72,9 +72,10 @@ export class SettingsApplier {
72
72
  if (partial.zoom && s.zoom && (typeof s.zoom.current === 'number')) {
73
73
  const world = this.pixi?.worldLayer || this.pixi?.app?.stage;
74
74
  if (world) {
75
- const z = Math.max(0.1, Math.min(5, s.zoom.current));
75
+ const z = Math.max(0.02, Math.min(5, s.zoom.current));
76
76
  world.scale.set(z);
77
77
  try { this.eventBus.emit(Events.UI.ZoomPercent, { percentage: Math.round(z * 100) }); } catch (_) {}
78
+ try { this.eventBus.emit(Events.Viewport.Changed); } catch (_) {}
78
79
  }
79
80
  }
80
81
 
@@ -84,7 +85,11 @@ export class SettingsApplier {
84
85
  if (world) {
85
86
  world.x = s.pan.x;
86
87
  world.y = s.pan.y;
87
- // Обновление зависимых слоёв/панелей выполняется по их событиям; здесь только применяем позицию
88
+ if (this.pixi?.gridLayer) {
89
+ this.pixi.gridLayer.x = s.pan.x;
90
+ this.pixi.gridLayer.y = s.pan.y;
91
+ }
92
+ try { this.eventBus.emit(Events.Viewport.Changed); } catch (_) {}
88
93
  }
89
94
  }
90
95
  }
@@ -1,29 +1,48 @@
1
1
  import { Events } from '../core/events/Events.js';
2
2
 
3
+ /**
4
+ * Уровни зума, выровненные под профиль Miro.
5
+ * Верхняя граница — 500%.
6
+ */
7
+ const ZOOM_LEVELS = [
8
+ 2, 5, 10, 15, 20, 25, 33, 50, 75, 100, 125, 150, 200, 250, 300, 400, 500
9
+ ];
10
+
3
11
  export class ZoomPanController {
4
12
  constructor(eventBus, pixi) {
5
13
  this.eventBus = eventBus;
6
14
  this.pixi = pixi;
7
15
  }
8
16
 
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;
25
+ }
26
+ }
27
+ return best;
28
+ }
29
+
9
30
  attach() {
10
31
  // Масштабирование колесом — глобально отрабатываем Ctrl+Wheel
11
32
  this.eventBus.on(Events.Tool.WheelZoom, ({ x, y, delta }) => {
12
- // Дискретный шаг зума 10%
13
33
  const world = this.pixi.worldLayer || this.pixi.app.stage;
14
34
  const oldScale = world.scale.x || 1;
15
35
  const oldPercent = Math.round(oldScale * 100);
36
+ const idx = this._nearestLevelIndex(oldPercent);
16
37
  let targetPercent;
17
38
  if (delta < 0) {
18
- // Zoom in: к ближайшему следующему кратному 10 плюс шаг
19
- targetPercent = Math.min(500, Math.floor(oldPercent / 10) * 10 + 10);
39
+ targetPercent = ZOOM_LEVELS[Math.min(ZOOM_LEVELS.length - 1, idx + 1)];
20
40
  } else if (delta > 0) {
21
- // Zoom out: к ближайшему предыдущему кратному 10 минус шаг
22
- targetPercent = Math.max(10, Math.ceil(oldPercent / 10) * 10 - 10);
41
+ targetPercent = ZOOM_LEVELS[Math.max(0, idx - 1)];
23
42
  } else {
24
43
  return;
25
44
  }
26
- const newScale = Math.max(0.1, Math.min(5, targetPercent / 100));
45
+ const newScale = Math.max(0.02, Math.min(5, targetPercent / 100));
27
46
  if (Math.abs(newScale - oldScale) < 0.0001) return;
28
47
  // Вычисляем мировые координаты точки под курсором до изменения скейла
29
48
  const worldX = (x - world.x) / oldScale;
@@ -33,15 +52,20 @@ export class ZoomPanController {
33
52
  world.x = x - worldX * newScale;
34
53
  world.y = y - worldY * newScale;
35
54
  this.eventBus.emit(Events.UI.ZoomPercent, { percentage: Math.round(newScale * 100) });
55
+ this.eventBus.emit(Events.Viewport.Changed);
36
56
  });
37
57
 
38
58
  // Кнопки зума из UI
39
59
  this.eventBus.on(Events.UI.ZoomIn, () => {
40
- const center = { x: this.pixi.app.view.clientWidth / 2, y: this.pixi.app.view.clientHeight / 2 };
60
+ const view = this.pixi?.app?.view;
61
+ if (!view) return;
62
+ const center = { x: view.clientWidth / 2, y: view.clientHeight / 2 };
41
63
  this.eventBus.emit(Events.Tool.WheelZoom, { x: center.x, y: center.y, delta: -120 });
42
64
  });
43
65
  this.eventBus.on(Events.UI.ZoomOut, () => {
44
- const center = { x: this.pixi.app.view.clientWidth / 2, y: this.pixi.app.view.clientHeight / 2 };
66
+ const view = this.pixi?.app?.view;
67
+ if (!view) return;
68
+ const center = { x: view.clientWidth / 2, y: view.clientHeight / 2 };
45
69
  this.eventBus.emit(Events.Tool.WheelZoom, { x: center.x, y: center.y, delta: 120 });
46
70
  });
47
71
  this.eventBus.on(Events.UI.ZoomReset, () => {
@@ -55,6 +79,7 @@ export class ZoomPanController {
55
79
  world.x = centerX - worldX * 1;
56
80
  world.y = centerY - worldY * 1;
57
81
  this.eventBus.emit(Events.UI.ZoomPercent, { percentage: 100 });
82
+ this.eventBus.emit(Events.Viewport.Changed);
58
83
  });
59
84
  this.eventBus.on(Events.UI.ZoomFit, () => {
60
85
  const objs = (this.pixi?.objects ? Array.from(this.pixi.objects.values()) : []);
@@ -74,7 +99,7 @@ export class ZoomPanController {
74
99
  const padding = 40;
75
100
  const scaleX = (viewW - padding) / bboxW;
76
101
  const scaleY = (viewH - padding) / bboxH;
77
- const newScale = Math.max(0.1, Math.min(5, Math.min(scaleX, scaleY)));
102
+ const newScale = Math.max(0.02, Math.min(5, Math.min(scaleX, scaleY)));
78
103
  const world = this.pixi.worldLayer || this.pixi.app.stage;
79
104
  const worldCenterX = minX + bboxW / 2;
80
105
  const worldCenterY = minY + bboxH / 2;
@@ -82,6 +107,7 @@ export class ZoomPanController {
82
107
  world.x = viewW / 2 - worldCenterX * newScale;
83
108
  world.y = viewH / 2 - worldCenterY * newScale;
84
109
  this.eventBus.emit(Events.UI.ZoomPercent, { percentage: Math.round(newScale * 100) });
110
+ this.eventBus.emit(Events.Viewport.Changed);
85
111
  });
86
112
  }
87
113
  }