@sequent-org/moodboard 1.0.23 → 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/package.json +1 -1
- package/src/assets/icons/rotate-icon.svg +3 -0
- package/src/core/PixiEngine.js +32 -0
- package/src/core/commands/CopyObjectCommand.js +20 -9
- package/src/core/commands/PasteObjectCommand.js +26 -15
- package/src/core/index.js +522 -26
- package/src/objects/DrawingObject.js +16 -7
- package/src/objects/FileObject.js +25 -11
- package/src/objects/FrameObject.js +37 -9
- package/src/objects/NoteObject.js +32 -17
- package/src/objects/ShapeObject.js +9 -8
- package/src/objects/TextObject.js +2 -20
- package/src/services/FrameService.js +95 -17
- package/src/tools/object-tools/PlacementTool.js +192 -51
- package/src/tools/object-tools/SelectTool.js +215 -44
- package/src/tools/object-tools/selection/BoxSelectController.js +5 -0
- package/src/ui/FilePropertiesPanel.js +9 -2
- package/src/ui/FramePropertiesPanel.js +177 -34
- package/src/ui/HtmlHandlesLayer.js +145 -89
- package/src/ui/HtmlTextLayer.js +9 -1
- package/src/ui/NotePropertiesPanel.js +13 -6
- package/src/ui/Toolbar.js +118 -15
- package/src/ui/styles/workspace.css +74 -4
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
minX =
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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 -
|
|
250
|
-
y: y + (cloned.position.y -
|
|
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
|
-
//
|
|
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
|
-
// Вызываем вставку
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
842
|
-
|
|
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
|
-
|
|
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,
|