@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.
@@ -4,7 +4,7 @@ import { ResizeHandles } from '../ResizeHandles.js';
4
4
  import * as PIXI from 'pixi.js';
5
5
  import { Events } from '../../core/events/Events.js';
6
6
  import { SelectionModel } from './selection/SelectionModel.js';
7
- import { HandlesSync } from './selection/HandlesSync.js';
7
+ // import { HandlesSync } from './selection/HandlesSync.js';
8
8
  import { SimpleDragController } from './selection/SimpleDragController.js';
9
9
  import { ResizeController } from './selection/ResizeController.js';
10
10
  import { RotateController } from './selection/RotateController.js';
@@ -149,12 +149,10 @@ export class SelectTool extends BaseTool {
149
149
  // Инициализируем систему ручек изменения размера
150
150
  if (!this.resizeHandles && app) {
151
151
  this.resizeHandles = new ResizeHandles(app);
152
- this._handlesSync = new HandlesSync({
153
- app,
154
- resizeHandles: this.resizeHandles,
155
- selection: this.selection,
156
- emit: (event, payload) => this.emit(event, payload)
157
- });
152
+ // Полностью отключаем синхронизацию старых PIXI-ручек
153
+ if (this.resizeHandles && typeof this.resizeHandles.hideHandles === 'function') {
154
+ this.resizeHandles.hideHandles();
155
+ }
158
156
  this._dragCtrl = new SimpleDragController({
159
157
  emit: (event, payload) => this.emit(event, payload)
160
158
  });
@@ -283,17 +281,77 @@ export class SelectTool extends BaseTool {
283
281
  this.startBoxSelect(event);
284
282
  }
285
283
  } else if (hitResult.type === 'object') {
286
- // Начинаем обычный drag исходника; Alt-режим включим на лету при движении
284
+ // Особая логика для фреймов: если у фрейма есть дети и клик внутри внутренней области (без 20px рамки),
285
+ // то не начинаем drag фрейма, а запускаем box-select для выбора объектов внутри
286
+ const req = { objectId: hitResult.object, pixiObject: null };
287
+ this.emit(Events.Tool.GetObjectPixi, req);
288
+ const mbType = req.pixiObject && req.pixiObject._mb && req.pixiObject._mb.type;
289
+ if (mbType === 'frame') {
290
+ // Получаем данные фрейма и его экранные границы
291
+ const objects = this.core?.state?.getObjects ? this.core.state.getObjects() : [];
292
+ const frameObj = objects.find(o => o.id === hitResult.object);
293
+ const hasChildren = !!(objects && objects.some(o => o.properties && o.properties.frameId === hitResult.object));
294
+ if (req.pixiObject && hasChildren && frameObj) {
295
+ const bounds = req.pixiObject.getBounds(); // экранные координаты
296
+ const inner = { x: bounds.x + 20, y: bounds.y + 20, width: Math.max(0, bounds.width - 40), height: Math.max(0, bounds.height - 40) };
297
+ const insideInner = this.isPointInBounds({ x: event.x, y: event.y }, inner);
298
+ // Если клик внутри внутренней области — запускаем box-select и выходим
299
+ if (insideInner) {
300
+ // Запускаем рамку выделения вместо drag фрейма
301
+ this.startBoxSelect(event);
302
+ return;
303
+ }
304
+ // Если клик на 20px рамке — позволяем перетягивать фрейм. Но запрещаем box-select от рамки.
305
+ // Здесь ничего не делаем: ниже пойдёт обычная логика handleObjectSelect
306
+ }
307
+ }
308
+ // Обычная логика: начинаем drag выбранного объекта
287
309
  this.handleObjectSelect(hitResult.object, event);
288
310
  } else {
289
311
  // Клик по пустому месту — если есть одиночное выделение, разрешаем drag за пределами объекта в пределах рамки
290
312
  if (this.selection.size() === 1) {
291
313
  const selId = this.selection.toArray()[0];
314
+ // Если выбран фрейм с детьми и клик внутри внутренней области — не начинаем drag, а box-select
315
+ const req = { objectId: selId, pixiObject: null };
316
+ this.emit(Events.Tool.GetObjectPixi, req);
317
+ const isFrame = !!(req.pixiObject && req.pixiObject._mb && req.pixiObject._mb.type === 'frame');
318
+ if (isFrame) {
319
+ const objects = this.core?.state?.getObjects ? this.core.state.getObjects() : [];
320
+ const frameObj = objects.find(o => o.id === selId);
321
+ const hasChildren = !!(objects && objects.some(o => o.properties && o.properties.frameId === selId));
322
+ if (frameObj && hasChildren) {
323
+ const b = { x: frameObj.position.x, y: frameObj.position.y, width: frameObj.width || 0, height: frameObj.height || 0 };
324
+ const inner = { x: b.x + 20, y: b.y + 20, width: Math.max(0, b.width - 40), height: Math.max(0, b.height - 40) };
325
+ const insideInner = this.isPointInBounds({ x: event.x, y: event.y }, inner);
326
+ if (insideInner) {
327
+ this.startBoxSelect(event);
328
+ return;
329
+ }
330
+ }
331
+ }
332
+ // Обычная логика: если клик внутри рамки выбранного — начинаем drag
292
333
  const boundsReq = { objects: [] };
293
334
  this.emit(Events.Tool.GetAllObjects, boundsReq);
294
335
  const map = new Map(boundsReq.objects.map(o => [o.id, o.bounds]));
295
336
  const b = map.get(selId);
296
337
  if (b && this.isPointInBounds({ x: event.x, y: event.y }, b)) {
338
+ // Для фрейма c детьми: отфильтруем клики внутри внутренней области (box-select)
339
+ const req2 = { objectId: selId, pixiObject: null };
340
+ this.emit(Events.Tool.GetObjectPixi, req2);
341
+ const isFrame2 = !!(req2.pixiObject && req2.pixiObject._mb && req2.pixiObject._mb.type === 'frame');
342
+ if (isFrame2) {
343
+ const os = this.core?.state?.getObjects ? this.core.state.getObjects() : [];
344
+ const fr = os.find(o => o.id === selId);
345
+ const hasChildren2 = !!(os && os.some(o => o.properties && o.properties.frameId === selId));
346
+ if (req2.pixiObject && fr && hasChildren2) {
347
+ const bounds2 = req2.pixiObject.getBounds();
348
+ const inner2 = { x: bounds2.x + 20, y: bounds2.y + 20, width: Math.max(0, bounds2.width - 40), height: Math.max(0, bounds2.height - 40) };
349
+ if (this.isPointInBounds({ x: event.x, y: event.y }, inner2)) {
350
+ this.startBoxSelect(event);
351
+ return;
352
+ }
353
+ }
354
+ }
297
355
  // Старт перетаскивания как если бы кликнули по объекту
298
356
  this.startDrag(selId, event);
299
357
  return;
@@ -614,12 +672,23 @@ export class SelectTool extends BaseTool {
614
672
  startDrag(objectId, event) {
615
673
  this.isDragging = true;
616
674
  this.dragTarget = objectId;
675
+ // Сообщаем HtmlHandlesLayer о начале перетаскивания одиночного объекта
676
+ this.emit(Events.Tool.DragStart, { object: objectId });
617
677
 
618
678
  // Получаем текущую позицию объекта
619
679
  const objectData = { objectId, position: null };
620
680
  this.emit(Events.Tool.GetObjectPosition, objectData);
621
681
  // Нормализуем координаты в мировые (worldLayer), чтобы убрать влияние зума
622
682
  const w = this._toWorld(event.x, event.y);
683
+ // Запоминаем смещение точки захвата курсора относительно левого-верхнего угла объекта (в мировых координатах)
684
+ if (objectData.position) {
685
+ this._dragGrabOffset = {
686
+ x: w.x - objectData.position.x,
687
+ y: w.y - objectData.position.y
688
+ };
689
+ } else {
690
+ this._dragGrabOffset = null;
691
+ }
623
692
  const worldEvent = { ...event, x: w.x, y: w.y };
624
693
  if (this._dragCtrl) this._dragCtrl.start(objectId, worldEvent);
625
694
  }
@@ -639,13 +708,14 @@ export class SelectTool extends BaseTool {
639
708
  this.isAltCloneMode = true;
640
709
  this.cloneSourceId = this.dragTarget;
641
710
  this.clonePending = true;
642
- // Запрашиваем текущую позицию исходного объекта
643
- const positionData = { objectId: this.cloneSourceId, position: null };
644
- this.emit(Events.Tool.GetObjectPosition, positionData);
645
- // Сообщаем ядру о необходимости создать дубликат у позиции исходного объекта
711
+ // Создаём дубликат так, чтобы курсор захватывал ту же точку объекта
712
+ const wpos = this._toWorld(event.x, event.y);
713
+ const targetTopLeft = this._dragGrabOffset
714
+ ? { x: wpos.x - this._dragGrabOffset.x, y: wpos.y - this._dragGrabOffset.y }
715
+ : { x: wpos.x, y: wpos.y };
646
716
  this.emit(Events.Tool.DuplicateRequest, {
647
717
  originalId: this.cloneSourceId,
648
- position: positionData.position || { x: event.x, y: event.y }
718
+ position: targetTopLeft
649
719
  });
650
720
  // Не сбрасываем dragTarget, чтобы исходник продолжал двигаться до появления копии
651
721
  // Визуально это ок: копия появится и захватит drag в onDuplicateReady
@@ -657,6 +727,13 @@ export class SelectTool extends BaseTool {
657
727
  const w = this._toWorld(event.x, event.y);
658
728
  this._dragCtrl.update({ ...event, x: w.x, y: w.y });
659
729
  }
730
+ // Сообщаем о процессе перетаскивания (с текущей позицией объекта)
731
+ if (this.isDragging && this.dragTarget) {
732
+ // Передаём текущий центр PIXI для точного расчёта dx/dy службами
733
+ const pix = this.getPixiObject(this.dragTarget);
734
+ const center = pix ? { x: pix.x, y: pix.y } : null;
735
+ this.emit(Events.Tool.DragUpdate, { object: this.dragTarget, pixiCenter: center });
736
+ }
660
737
 
661
738
  // Обновляем ручки во время перетаскивания
662
739
  if (this.resizeHandles && this.selection.has(this.dragTarget)) {
@@ -678,6 +755,8 @@ export class SelectTool extends BaseTool {
678
755
  this.groupCloneMap = null;
679
756
  } else if (this.dragTarget) {
680
757
  if (this._dragCtrl) this._dragCtrl.end();
758
+ // Сообщаем о завершении перетаскивания одиночного объекта
759
+ this.emit(Events.Tool.DragEnd, { object: this.dragTarget });
681
760
  }
682
761
 
683
762
  this.isDragging = false;
@@ -763,7 +842,8 @@ export class SelectTool extends BaseTool {
763
842
  this.groupBoundsGraphics.position.set(gb.x, gb.y);
764
843
  }
765
844
  if (this.resizeHandles) {
766
- this.resizeHandles.showHandles(this.groupBoundsGraphics, this.groupId);
845
+ // Отключаем старые PIXI-ручки
846
+ this.resizeHandles.hideHandles();
767
847
  }
768
848
  return;
769
849
  }
@@ -868,7 +948,7 @@ export class SelectTool extends BaseTool {
868
948
  this.groupBoundsGraphics.pivot.set(0, 0);
869
949
  this.groupBoundsGraphics.position.set(gb.x, gb.y);
870
950
  }
871
- if (this.resizeHandles) this.resizeHandles.showHandles(this.groupBoundsGraphics, this.groupId);
951
+ if (this.resizeHandles) this.resizeHandles.hideHandles();
872
952
  return;
873
953
  }
874
954
  if (this._rotateCtrl) this._rotateCtrl.end();
@@ -1044,7 +1124,7 @@ export class SelectTool extends BaseTool {
1044
1124
  this.isDragging = false; // отключаем одиночный drag, если был
1045
1125
  this.ensureGroupBoundsGraphics(gb);
1046
1126
  if (this.groupBoundsGraphics && this.resizeHandles) {
1047
- this.resizeHandles.showHandles(this.groupBoundsGraphics, this.groupId);
1127
+ this.resizeHandles.hideHandles();
1048
1128
  }
1049
1129
  if (this._groupDragCtrl) {
1050
1130
  const w = this._toWorld(event.x, event.y);
@@ -1562,22 +1642,27 @@ export class SelectTool extends BaseTool {
1562
1642
  left: '0px',
1563
1643
  top: '0px',
1564
1644
  border: 'none',
1565
- padding: '6px 8px', // Увеличиваем отступы для лучшего отображения
1645
+ padding: '0',
1566
1646
  fontSize: `${fontSize}px`,
1567
1647
  fontFamily: 'Arial, sans-serif',
1568
- lineHeight: '1.2',
1648
+ lineHeight: `${fontSize}px`,
1569
1649
  color: '#111', // Для записок делаем текст черным для лучшей видимости
1570
1650
  background: 'white',
1571
1651
  outline: 'none',
1572
1652
  resize: 'none',
1573
1653
  minWidth: '240px', // Для заметок уменьшаем минимальную ширину
1574
- minHeight: '28px', // Для заметок уменьшаем минимальную высоту
1654
+ minHeight: `${fontSize}px`,
1575
1655
  width: '280px', // Для заметок уменьшаем начальную ширину
1576
- height: '36px', // Для заметок уменьшаем начальную высоту
1577
- boxSizing: 'border-box',
1656
+ height: `${fontSize}px`,
1657
+ boxSizing: 'content-box',
1658
+ overflow: 'hidden',
1578
1659
  // Повыше чёткость текста в CSS
1579
1660
  WebkitFontSmoothing: 'antialiased',
1580
1661
  MozOsxFontSmoothing: 'grayscale',
1662
+ // Улучшенное перенесение слов, как в Miro
1663
+ overflowWrap: 'anywhere',
1664
+ wordBreak: 'break-word',
1665
+ margin: '0',
1581
1666
  });
1582
1667
 
1583
1668
  wrapper.appendChild(textarea);
@@ -1727,8 +1812,22 @@ export class SelectTool extends BaseTool {
1727
1812
  const initialHpx = initialSize ? Math.max(1, (initialSize.height || 0) * s / viewRes) : null;
1728
1813
 
1729
1814
  // Определяем минимальные границы для всех типов объектов
1730
- let minWBound = initialWpx || 240;
1731
- let minHBound = 28;
1815
+ let minWBound = initialWpx || 120; // базово близко к призраку
1816
+ let minHBound = fontSize; // высота по шрифту, чтобы не было пустого пространства
1817
+
1818
+ // Если создаём новый текст — выставляем стартовый размер, как у призрака, но чуть шире
1819
+ if (create && !isNote) {
1820
+ const ghostWidth = 120;
1821
+ const startWidth = Math.round(ghostWidth * 1.33); // чуть длиннее призрака (~160px)
1822
+ const startHeight = fontSize; // высота равна высоте шрифта
1823
+ textarea.style.width = `${startWidth}px`;
1824
+ textarea.style.height = `${startHeight}px`;
1825
+ wrapper.style.width = `${startWidth}px`;
1826
+ wrapper.style.height = `${startHeight}px`;
1827
+ // Зафиксируем минимальные границы, чтобы авторазмер не схлопывал пустое поле
1828
+ minWBound = startWidth;
1829
+ minHBound = startHeight;
1830
+ }
1732
1831
 
1733
1832
  // Для записок размеры уже установлены выше, пропускаем эту логику
1734
1833
  if (!isNote) {
@@ -1742,24 +1841,38 @@ export class SelectTool extends BaseTool {
1742
1841
  }
1743
1842
  }
1744
1843
  // Автоподгон
1844
+ const MAX_AUTO_WIDTH = 360; // Поведение как в Miro: авто-ширина до порога, далее перенос строк
1745
1845
  const autoSize = () => {
1746
1846
  if (isNote) {
1747
1847
  // Для заметок используем фиксированные размеры, вычисленные выше
1748
- // Не вызываем autoSize, чтобы сохранить точное позиционирование
1749
1848
  return;
1750
1849
  }
1751
-
1752
- // Для обычного текста восстанавливаем автоподгон
1753
- textarea.style.height = '1px';
1754
- textarea.style.width = '1px';
1755
- const w = Math.max(minWBound, textarea.scrollWidth + 8);
1756
- const h = Math.max(minHBound, textarea.scrollHeight + 4);
1757
- textarea.style.width = `${w}px`;
1758
- textarea.style.height = `${h}px`;
1759
- wrapper.style.width = `${w}px`;
1760
- wrapper.style.height = `${h}px`;
1761
- // Обновляем ручки только для обычного текста
1762
- // placeHandles();
1850
+ // Сначала измеряем естественную ширину без ограничений
1851
+ const prevWidth = textarea.style.width;
1852
+ const prevHeight = textarea.style.height;
1853
+ textarea.style.width = 'auto';
1854
+ textarea.style.height = 'auto';
1855
+
1856
+ // Желаемая ширина: не уже минимальной и не шире максимальной авто-ширины
1857
+ const naturalW = textarea.scrollWidth + 1;
1858
+ const targetW = Math.min(MAX_AUTO_WIDTH, Math.max(minWBound, naturalW));
1859
+ textarea.style.width = `${targetW}px`;
1860
+ wrapper.style.width = `${targetW}px`;
1861
+
1862
+ // Высота по содержимому при установленной ширине
1863
+ textarea.style.height = 'auto';
1864
+ // Коррекция высоты: для одной строки принудительно равна line-height,
1865
+ // для нескольких строк используем scrollHeight с небольшим вычетом браузерного запаса
1866
+ const adjust = 2;
1867
+ const computed = (typeof window !== 'undefined') ? window.getComputedStyle(textarea) : null;
1868
+ const lineH = computed ? parseFloat(computed.lineHeight) : (fontSize || 18);
1869
+ const rawH = textarea.scrollHeight;
1870
+ const lines = lineH > 0 ? Math.max(1, Math.round(rawH / lineH)) : 1;
1871
+ const targetH = lines <= 1
1872
+ ? lineH
1873
+ : Math.max(minHBound, Math.max(1, rawH - adjust));
1874
+ textarea.style.height = `${targetH}px`;
1875
+ wrapper.style.height = `${targetH}px`;
1763
1876
  };
1764
1877
 
1765
1878
  // Вызываем autoSize только для обычного текста
@@ -1828,6 +1941,7 @@ export class SelectTool extends BaseTool {
1828
1941
  // handles.forEach(h => h.addEventListener('mousedown', onHandleDown));
1829
1942
  }
1830
1943
  // Завершение
1944
+ const isNewCreation = !!create;
1831
1945
  const finalize = (commit) => {
1832
1946
  console.log('🔧 SelectTool: finalize called with commit:', commit, 'objectId:', objectId, 'objectType:', this.textEditor.objectType);
1833
1947
  const value = textarea.value.trim();
@@ -1837,8 +1951,8 @@ export class SelectTool extends BaseTool {
1837
1951
  const currentObjectType = this.textEditor.objectType;
1838
1952
  console.log('🔧 SelectTool: finalize - saved objectType:', currentObjectType);
1839
1953
 
1840
- // Показываем статичный текст после завершения редактирования для всех типов объектов
1841
- if (objectId) {
1954
+ // Показываем статичный текст только если не отменяем создание нового пустого
1955
+ if (objectId && (commitValue || !isNewCreation)) {
1842
1956
  // Проверяем, что HTML-элемент существует перед попыткой показать текст
1843
1957
  if (typeof window !== 'undefined' && window.moodboardHtmlTextLayer) {
1844
1958
  const el = window.moodboardHtmlTextLayer.idToEl.get(objectId);
@@ -1852,10 +1966,42 @@ export class SelectTool extends BaseTool {
1852
1966
  }
1853
1967
  }
1854
1968
 
1969
+ // Перед скрытием — если редактировался существующий текст, обновим его размер под текущий редактор
1970
+ if (objectId && (currentObjectType === 'text' || currentObjectType === 'simple-text')) {
1971
+ try {
1972
+ const worldLayerRef = this.textEditor.world || (this.app?.stage);
1973
+ const s = worldLayerRef?.scale?.x || 1;
1974
+ const viewResLocal = (this.app?.renderer?.resolution) || (view.width && view.clientWidth ? (view.width / view.clientWidth) : 1);
1975
+ const wPx = Math.max(1, wrapper.offsetWidth);
1976
+ const hPx = Math.max(1, wrapper.offsetHeight);
1977
+ const newW = Math.max(1, Math.round(wPx * viewResLocal / s));
1978
+ const newH = Math.max(1, Math.round(hPx * viewResLocal / s));
1979
+ // Получим старые размеры для команды
1980
+ const sizeReq = { objectId, size: null };
1981
+ this.eventBus.emit(Events.Tool.GetObjectSize, sizeReq);
1982
+ const oldSize = sizeReq.size || { width: newW, height: newH };
1983
+ // Позиция в state хранится как левый-верх
1984
+ const posReq = { objectId, position: null };
1985
+ this.eventBus.emit(Events.Tool.GetObjectPosition, posReq);
1986
+ const oldPos = posReq.position || { x: position.x, y: position.y };
1987
+ const newSize = { width: newW, height: newH };
1988
+ // Во время ResizeUpdate ядро обновит и PIXI, и state
1989
+ this.eventBus.emit(Events.Tool.ResizeUpdate, { object: objectId, size: newSize, position: oldPos });
1990
+ // Зафиксируем изменение одной командой
1991
+ this.eventBus.emit(Events.Tool.ResizeEnd, { object: objectId, oldSize: oldSize, newSize: newSize, oldPosition: oldPos, newPosition: oldPos });
1992
+ } catch (err) {
1993
+ console.warn('⚠️ Не удалось применить размеры после редактирования текста:', err);
1994
+ }
1995
+ }
1996
+
1855
1997
  wrapper.remove();
1856
1998
  this.textEditor = { active: false, objectId: null, textarea: null, wrapper: null, world: null, position: null, properties: null, objectType: 'text' };
1857
1999
  this.eventBus.emit(Events.UI.TextEditEnd, { objectId: objectId || null });
1858
2000
  if (!commitValue) {
2001
+ // Если это было создание нового текста и оно отменено — удаляем пустой объект
2002
+ if (isNewCreation && objectId) {
2003
+ this.eventBus.emit(Events.Tool.ObjectsDelete, { objects: [objectId] });
2004
+ }
1859
2005
  console.log('🔧 SelectTool: finalize - no commit, returning');
1860
2006
  return;
1861
2007
  }
@@ -1863,11 +2009,18 @@ export class SelectTool extends BaseTool {
1863
2009
  console.log('🔧 SelectTool: finalize - creating new object');
1864
2010
  // Создаем объект с правильным типом
1865
2011
  const objectType = currentObjectType || 'text';
2012
+ // Конвертируем размеры редактора (px) в мировые единицы
2013
+ const worldLayerRef = this.textEditor.world || (this.app?.stage);
2014
+ const s = worldLayerRef?.scale?.x || 1;
2015
+ const wPx = Math.max(1, wrapper.offsetWidth);
2016
+ const hPx = Math.max(1, wrapper.offsetHeight);
2017
+ const wWorld = Math.max(1, Math.round(wPx * viewRes / s));
2018
+ const hWorld = Math.max(1, Math.round(hPx * viewRes / s));
1866
2019
  this.eventBus.emit(Events.UI.ToolbarAction, {
1867
2020
  type: objectType,
1868
2021
  id: objectType,
1869
2022
  position: { x: position.x, y: position.y },
1870
- properties: { content: value, fontSize }
2023
+ properties: { content: value, fontSize, width: wWorld, height: hWorld }
1871
2024
  });
1872
2025
  } else {
1873
2026
  // Обновление существующего: используем команду обновления содержимого
@@ -1905,12 +2058,10 @@ export class SelectTool extends BaseTool {
1905
2058
  }
1906
2059
  };
1907
2060
  textarea.addEventListener('blur', (e) => {
1908
- // Не закрываем новый пустой текст по потере фокуса — чтобы поле не исчезало сразу
1909
- const isNew = objectId == null;
1910
2061
  const value = (textarea.value || '').trim();
1911
- if (isNew && value.length === 0) {
1912
- // Вернём фокус обратно, чтобы пользователь мог ввести текст
1913
- setTimeout(() => textarea.focus(), 0);
2062
+ if (isNewCreation && value.length === 0) {
2063
+ // Клик вне поля при пустом значении отменяем и удаляем созданный объект
2064
+ finalize(false);
1914
2065
  return;
1915
2066
  }
1916
2067
  finalize(true);
@@ -2267,4 +2418,24 @@ export class SelectTool extends BaseTool {
2267
2418
  super.destroy();
2268
2419
  }
2269
2420
 
2421
+ onDuplicateReady(newObjectId) {
2422
+ this.clonePending = false;
2423
+
2424
+ // Переключаем выделение на новый объект
2425
+ this.clearSelection();
2426
+ this.addToSelection(newObjectId);
2427
+
2428
+ // Завершаем drag исходного объекта и переключаем контроллер на новый объект
2429
+ if (this._dragCtrl) this._dragCtrl.end();
2430
+ this.dragTarget = newObjectId;
2431
+ this.isDragging = true;
2432
+ // Стартуем drag нового объекта под текущим курсором (в мировых координатах)
2433
+ const w = this._toWorld(this.currentX, this.currentY);
2434
+ if (this._dragCtrl) this._dragCtrl.start(newObjectId, { x: w.x, y: w.y });
2435
+ // Мгновенно обновляем позицию под курсор
2436
+ this.updateDrag({ x: this.currentX, y: this.currentY });
2437
+ // Обновляем ручки
2438
+ this.updateResizeHandles();
2439
+ }
2440
+
2270
2441
  }
@@ -55,6 +55,9 @@ export class BoxSelectController {
55
55
  this.emit('get:all:objects', request);
56
56
  const matched = [];
57
57
  for (const item of request.objects) {
58
+ const meta = item.pixi && item.pixi._mb;
59
+ // Исключаем фреймы из выделения рамкой — их можно выбрать только кликом по захватной рамке
60
+ if (meta && meta.type === 'frame') continue;
58
61
  if (this.rectIntersectsRect(box, item.bounds)) matched.push(item.id);
59
62
  }
60
63
  let newSelection;
@@ -84,6 +87,8 @@ export class BoxSelectController {
84
87
  this.emit('get:all:objects', request);
85
88
  const matched = [];
86
89
  for (const item of request.objects) {
90
+ const meta = item.pixi && item.pixi._mb;
91
+ if (meta && meta.type === 'frame') continue;
87
92
  if (this.rectIntersectsRect(box, item.bounds)) matched.push(item.id);
88
93
  }
89
94
  if (matched.length > 0) {
@@ -28,9 +28,13 @@ export class FilePropertiesPanel {
28
28
  if (this.currentId && objectId === this.currentId) this.hide();
29
29
  });
30
30
 
31
- // Обновляем позицию при любых изменениях
31
+ // Обновляем позицию / скрываем во время перетаскивания
32
+ this.eventBus.on(Events.Tool.DragStart, () => this.hide());
32
33
  this.eventBus.on(Events.Tool.DragUpdate, () => this.reposition());
34
+ this.eventBus.on(Events.Tool.DragEnd, () => this.updateFromSelection());
33
35
  this.eventBus.on(Events.Tool.GroupDragUpdate, () => this.reposition());
36
+ this.eventBus.on(Events.Tool.GroupDragStart, () => this.hide());
37
+ this.eventBus.on(Events.Tool.GroupDragEnd, () => this.updateFromSelection());
34
38
  this.eventBus.on(Events.Tool.ResizeUpdate, () => this.reposition());
35
39
  this.eventBus.on(Events.Tool.RotateUpdate, () => this.reposition());
36
40
 
@@ -256,7 +260,10 @@ export class FilePropertiesPanel {
256
260
 
257
261
  // Показываем/скрываем кнопку скачивания в зависимости от наличия fileId
258
262
  if (this.downloadButton) {
259
- this.downloadButton.style.display = hasFileId ? 'flex' : 'none';
263
+ // Всегда показываем кнопку, даже без fileId
264
+ this.downloadButton.style.display = 'flex';
265
+ this.downloadButton.disabled = !hasFileId;
266
+ this.downloadButton.title = hasFileId ? 'Скачать файл' : 'Файл недоступен для скачивания';
260
267
  }
261
268
  }
262
269
  }