@sequent-org/moodboard 1.0.24 → 1.1.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.
package/src/core/index.js CHANGED
@@ -213,19 +213,115 @@ export class CoreMoodBoard {
213
213
  if (this.clipboard.type === 'object') {
214
214
  this.pasteObject({ x, y });
215
215
  } else if (this.clipboard.type === 'group') {
216
- // Вставляем группу с сохранением относительных позиций относительно клика
217
216
  const group = this.clipboard;
218
217
  const data = Array.isArray(group.data) ? group.data : [];
219
218
  if (data.length === 0) return;
220
- // Вычисляем топ-левт группы для относительного смещения клик-точки
221
- let minX = Infinity, minY = Infinity;
222
- data.forEach(o => {
223
- if (!o || !o.position) return;
224
- minX = Math.min(minX, o.position.x);
225
- minY = Math.min(minY, o.position.y);
226
- });
227
- if (!isFinite(minX) || !isFinite(minY)) return;
228
- const baseX = minX, baseY = minY;
219
+
220
+ // Особая логика: если это бандл фрейма (фрейм + дети)
221
+ if (group.meta && group.meta.frameBundle) {
222
+ // Вычисляем топ-левт группы для относительного смещения клик-точки
223
+ let minX = Infinity, minY = Infinity;
224
+ data.forEach(o => {
225
+ if (!o || !o.position) return;
226
+ minX = Math.min(minX, o.position.x);
227
+ minY = Math.min(minY, o.position.y);
228
+ });
229
+ if (!isFinite(minX) || !isFinite(minY)) return;
230
+ const baseX = minX, baseY = minY;
231
+
232
+ // Ищем фрейм в бандле
233
+ const frames = data.filter(o => o && o.type === 'frame');
234
+ if (frames.length !== 1) {
235
+ // fallback к обычной вставке группы
236
+ const newIds = [];
237
+ let pending = data.length;
238
+ const onPasted = (payload) => {
239
+ if (!payload || !payload.newId) return;
240
+ newIds.push(payload.newId);
241
+ pending -= 1;
242
+ if (pending === 0) {
243
+ this.eventBus.off(Events.Object.Pasted, onPasted);
244
+ requestAnimationFrame(() => {
245
+ if (this.selectTool && newIds.length > 0) {
246
+ this.selectTool.setSelection(newIds);
247
+ this.selectTool.updateResizeHandles();
248
+ }
249
+ });
250
+ }
251
+ };
252
+ this.eventBus.on(Events.Object.Pasted, onPasted);
253
+ data.forEach(orig => {
254
+ const cloned = JSON.parse(JSON.stringify(orig));
255
+ const targetPos = {
256
+ x: x + (cloned.position.x - baseX),
257
+ y: y + (cloned.position.y - baseY)
258
+ };
259
+ this.clipboard = { type: 'object', data: cloned };
260
+ const cmd = new PasteObjectCommand(this, targetPos);
261
+ cmd.setEventBus(this.eventBus);
262
+ this.history.executeCommand(cmd);
263
+ });
264
+ this.clipboard = group;
265
+ return;
266
+ }
267
+
268
+ const frameOriginal = frames[0];
269
+ const children = data.filter(o => o && o.id !== frameOriginal.id);
270
+ const totalToPaste = 1 + children.length;
271
+ const newIds = [];
272
+ let pastedCount = 0;
273
+ let newFrameId = null;
274
+
275
+ const onPasted = (payload) => {
276
+ if (!payload || !payload.newId) return;
277
+ newIds.push(payload.newId);
278
+ pastedCount += 1;
279
+ // Как только вставили фрейм — вставляем детей с новым frameId
280
+ if (!newFrameId && payload.originalId === frameOriginal.id) {
281
+ newFrameId = payload.newId;
282
+ for (const child of children) {
283
+ const clonedChild = JSON.parse(JSON.stringify(child));
284
+ clonedChild.properties = clonedChild.properties || {};
285
+ clonedChild.properties.frameId = newFrameId;
286
+ const targetPos = {
287
+ x: x + (clonedChild.position.x - baseX),
288
+ y: y + (clonedChild.position.y - baseY)
289
+ };
290
+ this.clipboard = { type: 'object', data: clonedChild };
291
+ const cmdChild = new PasteObjectCommand(this, targetPos);
292
+ cmdChild.setEventBus(this.eventBus);
293
+ this.history.executeCommand(cmdChild);
294
+ }
295
+ }
296
+ if (pastedCount === totalToPaste) {
297
+ this.eventBus.off(Events.Object.Pasted, onPasted);
298
+ requestAnimationFrame(() => {
299
+ if (this.selectTool && newIds.length > 0) {
300
+ this.selectTool.setSelection(newIds);
301
+ this.selectTool.updateResizeHandles();
302
+ }
303
+ });
304
+ }
305
+ };
306
+ this.eventBus.on(Events.Object.Pasted, onPasted);
307
+
308
+ // Вставляем фрейм первым
309
+ const frameClone = JSON.parse(JSON.stringify(frameOriginal));
310
+ this.clipboard = { type: 'object', data: frameClone };
311
+ const targetPosFrame = {
312
+ x: x + (frameClone.position.x - baseX),
313
+ y: y + (frameClone.position.y - baseY)
314
+ };
315
+ const cmdFrame = new PasteObjectCommand(this, targetPosFrame);
316
+ cmdFrame.setEventBus(this.eventBus);
317
+ this.history.executeCommand(cmdFrame);
318
+
319
+ // Возвращаем clipboard к группе для повторных вставок
320
+ this.clipboard = group;
321
+ return;
322
+ }
323
+
324
+ // Обычная вставка группы (не фрейм-бандл)
229
325
  const newIds = [];
230
326
  let pending = data.length;
231
327
  const onPasted = (payload) => {
@@ -246,15 +342,14 @@ export class CoreMoodBoard {
246
342
  data.forEach(orig => {
247
343
  const cloned = JSON.parse(JSON.stringify(orig));
248
344
  const targetPos = {
249
- x: x + (cloned.position.x - baseX),
250
- y: y + (cloned.position.y - baseY)
345
+ x: x + (cloned.position.x - minX),
346
+ y: y + (cloned.position.y - minY)
251
347
  };
252
348
  this.clipboard = { type: 'object', data: cloned };
253
349
  const cmd = new PasteObjectCommand(this, targetPos);
254
350
  cmd.setEventBus(this.eventBus);
255
351
  this.history.executeCommand(cmd);
256
352
  });
257
- // Возвращаем clipboard к группе для повторных вставок
258
353
  this.clipboard = group;
259
354
  }
260
355
  });
@@ -673,13 +768,95 @@ export class CoreMoodBoard {
673
768
  const original = objects.find(obj => obj.id === originalId);
674
769
  if (!original) return;
675
770
 
676
- // Сохраняем копию в буфер обмена, чтобы переиспользовать PasteObjectCommand
771
+ // Если дублируем фрейм копируем вместе с его содержимым
772
+ if (original.type === 'frame') {
773
+ const frame = JSON.parse(JSON.stringify(original));
774
+ const dx = (position?.x ?? frame.position.x) - frame.position.x;
775
+ const dy = (position?.y ?? frame.position.y) - frame.position.y;
776
+
777
+ // Дети фрейма
778
+ const children = (this.state.state.objects || []).filter(o => o && o.properties && o.properties.frameId === originalId);
779
+
780
+ // После вставки фрейма вставим детей, перепривязав к новому frameId
781
+ const onFramePasted = (payload) => {
782
+ if (!payload || payload.originalId !== originalId) return;
783
+ const newFrameId = payload.newId;
784
+ this.eventBus.off(Events.Object.Pasted, onFramePasted);
785
+ for (const child of children) {
786
+ const clonedChild = JSON.parse(JSON.stringify(child));
787
+ clonedChild.properties = clonedChild.properties || {};
788
+ clonedChild.properties.frameId = newFrameId;
789
+ const targetPos = {
790
+ x: (child.position?.x || 0) + dx,
791
+ y: (child.position?.y || 0) + dy
792
+ };
793
+ this.clipboard = { type: 'object', data: clonedChild };
794
+ const cmdChild = new PasteObjectCommand(this, targetPos);
795
+ cmdChild.setEventBus(this.eventBus);
796
+ this.history.executeCommand(cmdChild);
797
+ }
798
+ };
799
+ this.eventBus.on(Events.Object.Pasted, onFramePasted);
800
+
801
+ // Подготовим буфер для фрейма (с новым названием)
802
+ const frameClone = JSON.parse(JSON.stringify(frame));
803
+ try {
804
+ const arr = this.state.state.objects || [];
805
+ let maxNum = 0;
806
+ for (const o of arr) {
807
+ if (!o || o.type !== 'frame') continue;
808
+ const t = o?.properties?.title || '';
809
+ const m = t.match(/^\s*Фрейм\s+(\d+)\s*$/i);
810
+ if (m) {
811
+ const n = parseInt(m[1], 10);
812
+ if (Number.isFinite(n)) maxNum = Math.max(maxNum, n);
813
+ }
814
+ }
815
+ const next = maxNum + 1;
816
+ frameClone.properties = frameClone.properties || {};
817
+ frameClone.properties.title = `Фрейм ${next}`;
818
+ } catch (_) {}
819
+ this.clipboard = { type: 'object', data: frameClone };
820
+ const cmdFrame = new PasteObjectCommand(this, { x: frame.position.x + dx, y: frame.position.y + dy });
821
+ cmdFrame.setEventBus(this.eventBus);
822
+ this.history.executeCommand(cmdFrame);
823
+ return;
824
+ }
825
+
826
+ // Обычная логика для остальных типов
677
827
  this.clipboard = {
678
828
  type: 'object',
679
829
  data: JSON.parse(JSON.stringify(original))
680
830
  };
831
+ // Запоминаем исходное название фрейма, чтобы не менять его
832
+ try {
833
+ if (original.type === 'frame') {
834
+ this._dupTitleMap = this._dupTitleMap || new Map();
835
+ const prevTitle = (original.properties && typeof original.properties.title !== 'undefined') ? original.properties.title : undefined;
836
+ this._dupTitleMap.set(originalId, prevTitle);
837
+ }
838
+ } catch (_) {}
839
+ // Если фрейм — проставим будущий заголовок в буфер
840
+ try {
841
+ if (this.clipboard.data && this.clipboard.data.type === 'frame') {
842
+ const arr = this.state.state.objects || [];
843
+ let maxNum = 0;
844
+ for (const o of arr) {
845
+ if (!o || o.type !== 'frame') continue;
846
+ const t = o?.properties?.title || '';
847
+ const m = t.match(/^\s*Фрейм\s+(\d+)\s*$/i);
848
+ if (m) {
849
+ const n = parseInt(m[1], 10);
850
+ if (Number.isFinite(n)) maxNum = Math.max(maxNum, n);
851
+ }
852
+ }
853
+ const next = maxNum + 1;
854
+ this.clipboard.data.properties = this.clipboard.data.properties || {};
855
+ this.clipboard.data.properties.title = `Фрейм ${next}`;
856
+ }
857
+ } catch (_) {}
681
858
 
682
- // Вызываем вставку с конкретной позицией (там рассчитается ID и пр.)
859
+ // Вызываем вставку по указанной позиции (под курсором)
683
860
  this.pasteObject(position);
684
861
  });
685
862
 
@@ -715,14 +892,77 @@ export class CoreMoodBoard {
715
892
  this.eventBus.on(Events.Object.Pasted, handler);
716
893
  // Кладем в clipboard объект, затем вызываем PasteObjectCommand с текущей позицией
717
894
  this.clipboard = { type: 'object', data: JSON.parse(JSON.stringify(obj)) };
895
+ // Запомним оригинальные названия фреймов
896
+ try {
897
+ if (obj.type === 'frame') {
898
+ this._dupTitleMap = this._dupTitleMap || new Map();
899
+ const prevTitle = (obj.properties && typeof obj.properties.title !== 'undefined') ? obj.properties.title : undefined;
900
+ this._dupTitleMap.set(obj.id, prevTitle);
901
+ }
902
+ } catch (_) { /* no-op */ }
903
+ // Если фрейм — сразу проставим новый заголовок в буфер
904
+ try {
905
+ if (this.clipboard.data && this.clipboard.data.type === 'frame') {
906
+ const arr = this.state.state.objects || [];
907
+ let maxNum = 0;
908
+ for (const o2 of arr) {
909
+ if (!o2 || o2.type !== 'frame') continue;
910
+ const t2 = o2?.properties?.title || '';
911
+ const m2 = t2.match(/^\s*Фрейм\s+(\d+)\s*$/i);
912
+ if (m2) {
913
+ const n2 = parseInt(m2[1], 10);
914
+ if (Number.isFinite(n2)) maxNum = Math.max(maxNum, n2);
915
+ }
916
+ }
917
+ const next2 = maxNum + 1;
918
+ this.clipboard.data.properties = this.clipboard.data.properties || {};
919
+ this.clipboard.data.properties.title = `Фрейм ${next2}`;
920
+ }
921
+ } catch (_) { /* no-op */ }
718
922
  const cmd = new PasteObjectCommand(this, { x: obj.position.x, y: obj.position.y });
719
923
  cmd.setEventBus(this.eventBus);
720
924
  this.history.executeCommand(cmd);
721
925
  }
722
926
  });
723
927
 
724
- // Когда объект вставлен (из PasteObjectCommand) — сообщаем SelectTool
928
+ // Когда объект вставлен (из PasteObjectCommand)
725
929
  this.eventBus.on(Events.Object.Pasted, ({ originalId, newId }) => {
930
+ try {
931
+ const arr = this.state.state.objects || [];
932
+ const newObj = arr.find(o => o.id === newId);
933
+ const origObj = arr.find(o => o.id === originalId);
934
+ if (newObj && newObj.type === 'frame') {
935
+ // Рассчитываем следующий номер среди уже существующих (кроме только что вставленного)
936
+ let maxNum = 0;
937
+ for (const o of arr) {
938
+ if (!o || o.id === newId || o.type !== 'frame') continue;
939
+ const t = o?.properties?.title || '';
940
+ const m = t.match(/^\s*Фрейм\s+(\d+)\s*$/i);
941
+ if (m) {
942
+ const n = parseInt(m[1], 10);
943
+ if (Number.isFinite(n)) maxNum = Math.max(maxNum, n);
944
+ }
945
+ }
946
+ const next = maxNum + 1;
947
+ // Присваиваем новое имя только НОВОМУ
948
+ newObj.properties = newObj.properties || {};
949
+ newObj.properties.title = `Фрейм ${next}`;
950
+ const pixNew = this.pixi.objects.get(newId);
951
+ if (pixNew && pixNew._mb?.instance?.setTitle) pixNew._mb.instance.setTitle(newObj.properties.title);
952
+ // Восстанавливаем исходное имя оригинала, если оно было записано
953
+ if (this._dupTitleMap && this._dupTitleMap.has(originalId) && origObj && origObj.type === 'frame') {
954
+ const prev = this._dupTitleMap.get(originalId);
955
+ origObj.properties = origObj.properties || {};
956
+ // Если prev undefined, очистим title
957
+ origObj.properties.title = prev;
958
+ const pixOrig = this.pixi.objects.get(originalId);
959
+ if (pixOrig && pixOrig._mb?.instance?.setTitle) pixOrig._mb.instance.setTitle(prev);
960
+ this._dupTitleMap.delete(originalId);
961
+ }
962
+ this.state.markDirty();
963
+ }
964
+ } catch (_) { /* no-op */ }
965
+ // Сообщаем SelectTool id нового объекта для переключения drag
726
966
  this.eventBus.emit(Events.Tool.DuplicateReady, { originalId, newId });
727
967
  });
728
968
 
@@ -733,6 +973,13 @@ export class CoreMoodBoard {
733
973
  const object = objects.find(obj => obj.id === data.object);
734
974
  if (object) {
735
975
  this.resizeStartSize = { width: object.width, height: object.height };
976
+ // Сохраняем контекст активного ресайза для расчёта позиции, если она не будет передана
977
+ this._activeResize = {
978
+ objectId: data.object,
979
+ handle: data.handle,
980
+ startSize: { width: object.width, height: object.height },
981
+ startPosition: { x: object.position.x, y: object.position.y }
982
+ };
736
983
  }
737
984
  });
738
985
 
@@ -814,13 +1061,166 @@ export class CoreMoodBoard {
814
1061
  const objects = this.state.getObjects();
815
1062
  const object = objects.find(obj => obj.id === data.object);
816
1063
  const objectType = object ? object.type : null;
817
-
818
- this.updateObjectSizeAndPositionDirect(data.object, data.size, data.position, objectType);
1064
+
1065
+ // Сохраняем пропорции для фреймов, кроме произвольных (lockedAspect=false)
1066
+ if (objectType === 'frame' && data.size) {
1067
+ const lockedAspect = !!(object?.properties && (object.properties.lockedAspect === true));
1068
+ if (!lockedAspect) {
1069
+ // произвольные фреймы — без ограничений
1070
+ } else {
1071
+ const start = this._activeResize?.startSize || { width: object.width, height: object.height };
1072
+ const aspect = (start.width > 0 && start.height > 0) ? (start.width / start.height) : (object.width / Math.max(1, object.height));
1073
+ let w = Math.max(1, data.size.width);
1074
+ let h = Math.max(1, data.size.height);
1075
+ // Приводим к ближайшему по изменяемой стороне
1076
+ // Определим, какая сторона изменилась сильнее
1077
+ const dw = Math.abs(w - start.width);
1078
+ const dh = Math.abs(h - start.height);
1079
+ if (dw >= dh) {
1080
+ h = Math.round(w / aspect);
1081
+ } else {
1082
+ w = Math.round(h * aspect);
1083
+ }
1084
+ // Минимальная площадь фрейма ~1800px²
1085
+ const minArea = 1800;
1086
+ const area = Math.max(1, w * h);
1087
+ if (area < minArea) {
1088
+ const scale = Math.sqrt(minArea / area);
1089
+ w = Math.round(w * scale);
1090
+ h = Math.round(h * scale);
1091
+ }
1092
+ data.size = { width: w, height: h };
1093
+
1094
+ // Если позиция известна (фиксированная противоположная сторона) — откорректируем её
1095
+ if (!data.position && this._activeResize && this._activeResize.objectId === data.object) {
1096
+ const hndl = (this._activeResize.handle || '').toLowerCase();
1097
+ const startPos = this._activeResize.startPosition;
1098
+ const sw = this._activeResize.startSize.width;
1099
+ const sh = this._activeResize.startSize.height;
1100
+ let x = startPos.x;
1101
+ let y = startPos.y;
1102
+ // Базовая привязка противоположной стороны
1103
+ if (hndl.includes('w')) { x = startPos.x + (sw - w); }
1104
+ if (hndl.includes('n')) { y = startPos.y + (sh - h); }
1105
+ // Симметрическая компенсация по перпендикулярной оси для edge-хэндлов
1106
+ const isEdge = ['n','s','e','w'].includes(hndl);
1107
+ if (isEdge) {
1108
+ if (hndl === 'n' || hndl === 's') {
1109
+ // Вверх/вниз: верх или низ фиксируется, ширина меняется симметрично относительно центра
1110
+ x = startPos.x + Math.round((sw - w) / 2);
1111
+ } else if (hndl === 'e' || hndl === 'w') {
1112
+ // Вправо/влево: левая или правая фиксируется, высота меняется симметрично относительно центра
1113
+ y = startPos.y + Math.round((sh - h) / 2);
1114
+ }
1115
+ }
1116
+ data.position = { x: Math.round(x), y: Math.round(y) };
1117
+ }
1118
+ }
1119
+ }
1120
+
1121
+ // Если позиция не пришла из UI, вычислим её из контекста активной ручки
1122
+ let position = data.position;
1123
+ if (!position && this._activeResize && this._activeResize.objectId === data.object) {
1124
+ const h = (this._activeResize.handle || '').toLowerCase();
1125
+ const start = this._activeResize.startPosition;
1126
+ const startSize = this._activeResize.startSize;
1127
+ const dw = (data.size?.width || startSize.width) - startSize.width;
1128
+ const dh = (data.size?.height || startSize.height) - startSize.height;
1129
+ let nx = start.x;
1130
+ let ny = start.y;
1131
+ // Для левых/верхних ручек смещаем топ-лев на полную величину изменения
1132
+ if (h.includes('w')) nx = start.x + dw;
1133
+ if (h.includes('n')) ny = start.y + dh;
1134
+ // Для правых/нижних ручек топ-лев остаётся стартовым (nx, ny уже равны start)
1135
+ position = { x: nx, y: ny };
1136
+ }
1137
+
1138
+ // Для фреймов с произвольным аспектом также обеспечим минимальную площадь
1139
+ if (objectType === 'frame' && data.size) {
1140
+ const minArea = 1800;
1141
+ const w0 = Math.max(1, data.size.width);
1142
+ const h0 = Math.max(1, data.size.height);
1143
+ const area0 = w0 * h0;
1144
+ if (area0 < minArea) {
1145
+ const scale = Math.sqrt(minArea / Math.max(1, area0));
1146
+ const w = Math.round(w0 * scale);
1147
+ const h = Math.round(h0 * scale);
1148
+ data.size = { width: w, height: h };
1149
+ // позиция будет скорректирована ниже общей логикой (уже рассчитана выше при необходимости)
1150
+ }
1151
+ }
1152
+
1153
+ this.updateObjectSizeAndPositionDirect(data.object, data.size, position, objectType);
819
1154
  });
820
1155
 
821
1156
  this.eventBus.on(Events.Tool.ResizeEnd, (data) => {
822
1157
  // В конце создаем одну команду изменения размера
823
1158
  if (this.resizeStartSize && data.oldSize && data.newSize) {
1159
+ // Принудительно сохраняем пропорции для фреймов (если lockedAspect=true)
1160
+ const objects = this.state.getObjects();
1161
+ const object = objects.find(obj => obj.id === data.object);
1162
+ const objectType = object ? object.type : null;
1163
+ if (objectType === 'frame' && !!(object?.properties && object.properties.lockedAspect === true)) {
1164
+ const start = this._activeResize?.startSize || { width: object.width, height: object.height };
1165
+ const aspect = (start.width > 0 && start.height > 0) ? (start.width / start.height) : (object.width / Math.max(1, object.height));
1166
+ let w = Math.max(1, data.newSize.width);
1167
+ let h = Math.max(1, data.newSize.height);
1168
+ const dw = Math.abs(w - start.width);
1169
+ const dh = Math.abs(h - start.height);
1170
+ if (dw >= dh) { h = Math.round(w / aspect); } else { w = Math.round(h * aspect); }
1171
+ // Минимальная площадь фрейма ~1800px²
1172
+ const minArea = 1800;
1173
+ const area = Math.max(1, w * h);
1174
+ if (area < minArea) {
1175
+ const scale = Math.sqrt(minArea / area);
1176
+ w = Math.round(w * scale);
1177
+ h = Math.round(h * scale);
1178
+ }
1179
+ data.newSize = { width: w, height: h };
1180
+ if (!data.newPosition && this._activeResize && this._activeResize.objectId === data.object) {
1181
+ const hndl = (this._activeResize.handle || '').toLowerCase();
1182
+ const startPos = this._activeResize.startPosition;
1183
+ const sw = this._activeResize.startSize.width;
1184
+ const sh = this._activeResize.startSize.height;
1185
+ let x = startPos.x;
1186
+ let y = startPos.y;
1187
+ if (hndl.includes('w')) { x = startPos.x + (sw - w); }
1188
+ if (hndl.includes('n')) { y = startPos.y + (sh - h); }
1189
+ const isEdge = ['n','s','e','w'].includes(hnl = hndl);
1190
+ if (isEdge) {
1191
+ if (hnl === 'n' || hnl === 's') {
1192
+ x = startPos.x + Math.round((sw - w) / 2);
1193
+ } else if (hnl === 'e' || hnl === 'w') {
1194
+ y = startPos.y + Math.round((sh - h) / 2);
1195
+ }
1196
+ }
1197
+ data.newPosition = { x: Math.round(x), y: Math.round(y) };
1198
+ }
1199
+ }
1200
+ // Для произвольных фреймов также обеспечим минимальную площадь
1201
+ if (objectType === 'frame' && data.newSize && !(object?.properties && object.properties.lockedAspect === true)) {
1202
+ const minArea = 1800;
1203
+ const w0 = Math.max(1, data.newSize.width);
1204
+ const h0 = Math.max(1, data.newSize.height);
1205
+ const area0 = w0 * h0;
1206
+ if (area0 < minArea) {
1207
+ const scale = Math.sqrt(minArea / Math.max(1, area0));
1208
+ const w = Math.round(w0 * scale);
1209
+ const h = Math.round(h0 * scale);
1210
+ data.newSize = { width: w, height: h };
1211
+ if (!data.newPosition && this._activeResize && this._activeResize.objectId === data.object) {
1212
+ const hndl2 = (this._activeResize.handle || '').toLowerCase();
1213
+ const startPos2 = this._activeResize.startPosition;
1214
+ const sw2 = this._activeResize.startSize.width;
1215
+ const sh2 = this._activeResize.startSize.height;
1216
+ let x2 = startPos2.x;
1217
+ let y2 = startPos2.y;
1218
+ if (hndl2.includes('w')) { x2 = startPos2.x + (sw2 - w); }
1219
+ if (hndl2.includes('n')) { y2 = startPos2.y + (sh2 - h); }
1220
+ data.newPosition = { x: Math.round(x2), y: Math.round(y2) };
1221
+ }
1222
+ }
1223
+ }
824
1224
  // Создаем команду только если размер действительно изменился
825
1225
  if (data.oldSize.width !== data.newSize.width ||
826
1226
  data.oldSize.height !== data.newSize.height) {
@@ -833,19 +1233,33 @@ export class CoreMoodBoard {
833
1233
  newPosition: data.newPosition
834
1234
  });
835
1235
 
1236
+ // Гарантируем согласованность позиции: если UI не передал, вычислим
1237
+ let oldPos = data.oldPosition;
1238
+ let newPos = data.newPosition;
1239
+ if ((!oldPos || !newPos) && this._activeResize && this._activeResize.objectId === data.object) {
1240
+ const h = (this._activeResize.handle || '').toLowerCase();
1241
+ const start = this._activeResize.startPosition;
1242
+ const startSize = this._activeResize.startSize;
1243
+ const dw = (data.newSize?.width || startSize.width) - startSize.width;
1244
+ const dh = (data.newSize?.height || startSize.height) - startSize.height;
1245
+ const calcNew = { x: start.x + (h.includes('w') ? dw : 0), y: start.y + (h.includes('n') ? dh : 0) };
1246
+ if (!oldPos) oldPos = { x: start.x, y: start.y };
1247
+ if (!newPos) newPos = calcNew;
1248
+ }
836
1249
  const command = new ResizeObjectCommand(
837
1250
  this,
838
1251
  data.object,
839
1252
  data.oldSize,
840
1253
  data.newSize,
841
- data.oldPosition,
842
- data.newPosition
1254
+ oldPos,
1255
+ newPos
843
1256
  );
844
1257
  command.setEventBus(this.eventBus);
845
1258
  this.history.executeCommand(command);
846
1259
  }
847
1260
  }
848
1261
  this.resizeStartSize = null;
1262
+ this._activeResize = null;
849
1263
  });
850
1264
 
851
1265
  // === ОБРАБОТЧИКИ СОБЫТИЙ ВРАЩЕНИЯ ===
@@ -1293,7 +1707,61 @@ export class CoreMoodBoard {
1293
1707
  group.meta.pasteCount = (group.meta.pasteCount || 0) + 1;
1294
1708
  const dx = offsetStep * group.meta.pasteCount;
1295
1709
  const dy = offsetStep * group.meta.pasteCount;
1296
- // Подготовим сбор новых id через единый временный слушатель
1710
+
1711
+ // Особая логика: фрейм-бандл (фрейм + дети)
1712
+ if (group.meta && group.meta.frameBundle) {
1713
+ const frames = data.filter(o => o && o.type === 'frame');
1714
+ if (frames.length === 1) {
1715
+ const frameOriginal = frames[0];
1716
+ const children = data.filter(o => o && o.id !== frameOriginal.id);
1717
+ const totalToPaste = 1 + children.length;
1718
+ let pastedCount = 0;
1719
+ const newIds = [];
1720
+ let newFrameId = null;
1721
+
1722
+ const onPasted = (payload) => {
1723
+ if (!payload || !payload.newId) return;
1724
+ newIds.push(payload.newId);
1725
+ pastedCount += 1;
1726
+ if (!newFrameId && payload.originalId === frameOriginal.id) {
1727
+ newFrameId = payload.newId;
1728
+ for (const child of children) {
1729
+ const clonedChild = JSON.parse(JSON.stringify(child));
1730
+ clonedChild.properties = clonedChild.properties || {};
1731
+ clonedChild.properties.frameId = newFrameId;
1732
+ const targetPos = {
1733
+ x: (clonedChild.position?.x || 0) + dx,
1734
+ y: (clonedChild.position?.y || 0) + dy
1735
+ };
1736
+ this.clipboard = { type: 'object', data: clonedChild };
1737
+ const cmdChild = new PasteObjectCommand(this, targetPos);
1738
+ cmdChild.setEventBus(this.eventBus);
1739
+ this.history.executeCommand(cmdChild);
1740
+ }
1741
+ }
1742
+ if (pastedCount === totalToPaste) {
1743
+ this.eventBus.off(Events.Object.Pasted, onPasted);
1744
+ if (this.selectTool && newIds.length > 0) {
1745
+ requestAnimationFrame(() => {
1746
+ this.selectTool.setSelection(newIds);
1747
+ this.selectTool.updateResizeHandles();
1748
+ });
1749
+ }
1750
+ }
1751
+ };
1752
+ this.eventBus.on(Events.Object.Pasted, onPasted);
1753
+
1754
+ const frameClone = JSON.parse(JSON.stringify(frameOriginal));
1755
+ this.clipboard = { type: 'object', data: frameClone };
1756
+ const cmdFrame = new PasteObjectCommand(this, { x: (frameClone.position?.x || 0) + dx, y: (frameClone.position?.y || 0) + dy });
1757
+ cmdFrame.setEventBus(this.eventBus);
1758
+ this.history.executeCommand(cmdFrame);
1759
+ this.clipboard = group;
1760
+ return;
1761
+ }
1762
+ }
1763
+
1764
+ // Обычная вставка группы
1297
1765
  let pending = data.length;
1298
1766
  const newIds = [];
1299
1767
  const onPasted = (payload) => {
@@ -1302,7 +1770,6 @@ export class CoreMoodBoard {
1302
1770
  pending -= 1;
1303
1771
  if (pending === 0) {
1304
1772
  this.eventBus.off(Events.Object.Pasted, onPasted);
1305
- // Выделяем новую группу и показываем рамку с ручками
1306
1773
  if (this.selectTool && newIds.length > 0) {
1307
1774
  requestAnimationFrame(() => {
1308
1775
  this.selectTool.setSelection(newIds);
@@ -1313,22 +1780,18 @@ export class CoreMoodBoard {
1313
1780
  };
1314
1781
  this.eventBus.on(Events.Object.Pasted, onPasted);
1315
1782
 
1316
- // Вставляем каждый объект группы, сохраняя относительное расположение + общее смещение
1317
1783
  for (const original of data) {
1318
1784
  const cloned = JSON.parse(JSON.stringify(original));
1319
1785
  const targetPos = {
1320
1786
  x: (cloned.position?.x || 0) + dx,
1321
1787
  y: (cloned.position?.y || 0) + dy
1322
1788
  };
1323
- // Используем существующую логику PasteObjectCommand поверх clipboard типа object
1324
1789
  this.clipboard = { type: 'object', data: cloned };
1325
1790
  const cmd = new PasteObjectCommand(this, targetPos);
1326
1791
  cmd.setEventBus(this.eventBus);
1327
1792
  this.history.executeCommand(cmd);
1328
1793
  }
1329
- // После вставки возвращаем clipboard к группе, чтобы можно было ещё раз вставлять с новым смещением
1330
1794
  this.clipboard = group;
1331
- // Рамка появится по завершении обработки всех событий object:pasted
1332
1795
  }
1333
1796
  });
1334
1797
 
@@ -1494,6 +1957,39 @@ export class CoreMoodBoard {
1494
1957
  };
1495
1958
  const initialWidth = (properties && typeof properties.width === 'number') ? properties.width : 100;
1496
1959
  const initialHeight = (properties && typeof properties.height === 'number') ? properties.height : 100;
1960
+
1961
+ // Если создаём НЕ фрейм — проверим, попадает ли центр нового объекта внутрь какого-либо фрейма.
1962
+ // Если да, сразу прикрепляем объект к этому фрейму (properties.frameId)
1963
+ if (type !== 'frame' && position && this.pixi && typeof this.pixi.findObjectByPosition === 'function') {
1964
+ const center = {
1965
+ x: position.x + initialWidth / 2,
1966
+ y: position.y + initialHeight / 2
1967
+ };
1968
+ try {
1969
+ const hostFrame = this.pixi.findObjectByPosition(center, 'frame');
1970
+ if (hostFrame && hostFrame.id) {
1971
+ properties = { ...(properties || {}), frameId: hostFrame.id };
1972
+ }
1973
+ } catch (e) {
1974
+ // fail-safe: не мешаем созданию при ошибке поиска
1975
+ }
1976
+ }
1977
+
1978
+ // Именование фреймов: "Фрейм N", где N = количество уже пронумерованных фреймов + 1
1979
+ if (type === 'frame') {
1980
+ try {
1981
+ const objects = this.state?.state?.objects || [];
1982
+ const numberedCount = objects.filter(o => o && o.type === 'frame').reduce((acc, o) => {
1983
+ const t = o?.properties?.title || '';
1984
+ // Считаем только пронумерованные: "Фрейм <число>"
1985
+ return (/^\s*Фрейм\s+\d+\s*$/i.test(t)) ? acc + 1 : acc;
1986
+ }, 0);
1987
+ const nextIndex = numberedCount + 1;
1988
+ properties = { ...(properties || {}), title: `Фрейм ${nextIndex}` };
1989
+ } catch (_) {
1990
+ properties = { ...(properties || {}), title: 'Фрейм 1' };
1991
+ }
1992
+ }
1497
1993
  const objectData = {
1498
1994
  id: generateObjectId(exists),
1499
1995
  type,