@sequent-org/moodboard 1.4.58 → 1.4.60

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 (28) hide show
  1. package/package.json +1 -1
  2. package/src/core/ApiClient.js +41 -2
  3. package/src/core/PixiEngine.js +23 -2
  4. package/src/core/events/Events.js +1 -0
  5. package/src/core/flows/ClipboardFlow.js +48 -0
  6. package/src/objects/FrameObject.js +16 -3
  7. package/src/objects/ObjectFactory.js +2 -0
  8. package/src/objects/VideoObject.js +171 -0
  9. package/src/services/text/TextBoxMetrics.js +86 -0
  10. package/src/tools/manager/ToolManagerLifecycle.js +48 -2
  11. package/src/tools/object-tools/connector/ConnectorDragController.js +30 -1
  12. package/src/tools/object-tools/selection/NoteInlineEditorController.js +7 -1
  13. package/src/tools/object-tools/selection/TextEditorCaretService.js +4 -2
  14. package/src/tools/object-tools/selection/TextEditorInteractionController.js +14 -0
  15. package/src/tools/object-tools/selection/TextEditorLifecycleRegistry.js +7 -0
  16. package/src/tools/object-tools/selection/TextEditorPositioningService.js +4 -3
  17. package/src/tools/object-tools/selection/TextEditorSyncService.js +260 -6
  18. package/src/tools/object-tools/selection/TextInlineEditorController.js +44 -5
  19. package/src/ui/HtmlTextLayer.js +31 -13
  20. package/src/ui/animation/HoverLiftController.js +20 -0
  21. package/src/ui/chat/ChatWindow.js +39 -8
  22. package/src/ui/comments/CommentThreadPopover.js +3 -1
  23. package/src/ui/handles/HandlesDomRenderer.js +30 -3
  24. package/src/ui/handles/HandlesInteractionController.js +26 -28
  25. package/src/ui/styles/chat.css +43 -0
  26. package/src/ui/styles/topbar.css +20 -0
  27. package/src/ui/styles/workspace.css +35 -2
  28. package/src/ui/text-properties/TextPropertiesPanelRenderer.js +19 -19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequent-org/moodboard",
3
- "version": "1.4.58",
3
+ "version": "1.4.60",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -241,7 +241,30 @@ export class ApiClient {
241
241
  }
242
242
  return cleanedObj;
243
243
  }
244
-
244
+
245
+ if (obj.type === 'video') {
246
+ const topSrcRaw = typeof obj.src === 'string' ? obj.src : '';
247
+ const propSrcRaw = typeof obj.properties?.src === 'string' ? obj.properties.src : '';
248
+ const normalizedSrc = topSrcRaw.trim() || propSrcRaw.trim();
249
+
250
+ if (!normalizedSrc) {
251
+ throw new Error(`Video object "${obj.id || 'unknown'}" has no src. Save is blocked.`);
252
+ }
253
+ if (/^data:/i.test(normalizedSrc) || /^blob:/i.test(normalizedSrc)) {
254
+ throw new Error(`Video object "${obj.id || 'unknown'}" contains forbidden data/blob src. Save is blocked.`);
255
+ }
256
+
257
+ const cleanedObj = {
258
+ ...obj,
259
+ src: normalizedSrc
260
+ };
261
+ if (cleanedObj.properties?.src) {
262
+ cleanedObj.properties = { ...cleanedObj.properties };
263
+ delete cleanedObj.properties.src;
264
+ }
265
+ return cleanedObj;
266
+ }
267
+
245
268
  return obj;
246
269
  });
247
270
 
@@ -296,7 +319,23 @@ export class ApiClient {
296
319
  }
297
320
  return restoredObj;
298
321
  }
299
-
322
+
323
+ if (obj.type === 'video') {
324
+ const topSrc = typeof obj.src === 'string' ? obj.src.trim() : '';
325
+ const propSrc = typeof obj.properties?.src === 'string' ? obj.properties.src.trim() : '';
326
+ const normalizedSrc = topSrc || propSrc;
327
+ if (!normalizedSrc) return obj;
328
+ const restoredObj = {
329
+ ...obj,
330
+ src: normalizedSrc
331
+ };
332
+ if (restoredObj.properties?.src) {
333
+ restoredObj.properties = { ...restoredObj.properties };
334
+ delete restoredObj.properties.src;
335
+ }
336
+ return restoredObj;
337
+ }
338
+
300
339
  return obj;
301
340
  })
302
341
  );
@@ -248,7 +248,23 @@ export class PixiEngine {
248
248
  const pixiObject = this.objects.get(objectId);
249
249
  if (pixiObject) {
250
250
  console.log('🗑️ PixiEngine: удаляем объект из сцены:', objectId);
251
-
251
+
252
+ const mbMeta = pixiObject._mb;
253
+ const mbInstance = mbMeta?.instance;
254
+
255
+ // Видео: останавливаем HTMLVideoElement и снимаем listeners до уничтожения спрайта.
256
+ // Фрейм и другие объекты с instance.destroy(): снимаем подписки с EventBus ДО того,
257
+ // как PIXI-контейнер будет уничтожен. FrameObject.destroy() сам уничтожает контейнер,
258
+ // поэтому после него ручной pixiObject.destroy() не нужен — флаг handledByInstance.
259
+ let handledByInstance = false;
260
+ if (typeof mbInstance?.destroy === 'function') {
261
+ try { mbInstance.destroy(); } catch (_) {}
262
+ // Типы, чей instance.destroy() полностью берёт на себя PIXI-cleanup
263
+ if (mbMeta.type === 'frame') {
264
+ handledByInstance = true;
265
+ }
266
+ }
267
+
252
268
  // Удаляем из родительского контейнера
253
269
  if (this.worldLayer) {
254
270
  this.worldLayer.removeChild(pixiObject);
@@ -257,7 +273,12 @@ export class PixiEngine {
257
273
  }
258
274
 
259
275
  // ИСПРАВЛЕНИЕ: Полная очистка для изображений/эмоджи
260
- if (pixiObject instanceof PIXI.Sprite) {
276
+ if (handledByInstance) {
277
+ // instance.destroy() уже уничтожил PIXI-контейнер и снял все подписки
278
+ if (this.hoverLift) {
279
+ try { this.hoverLift.detach(pixiObject); } catch (_) {}
280
+ }
281
+ } else if (pixiObject instanceof PIXI.Sprite) {
261
282
  console.log('🗑️ PixiEngine: очищаем ресурсы изображения/эмоджи');
262
283
 
263
284
  // Очищаем текстуру (data URL, blob URL — освобождаем память)
@@ -63,6 +63,7 @@ export const Events = {
63
63
  PasteAt: 'ui:paste-at',
64
64
  PasteImage: 'ui:paste-image',
65
65
  PasteImageAt: 'ui:paste-image-at',
66
+ PasteVideoAt: 'ui:paste-video-at',
66
67
  ZoomIn: 'ui:zoom:in',
67
68
  ZoomOut: 'ui:zoom:out',
68
69
  ZoomReset: 'ui:zoom:reset',
@@ -380,6 +380,54 @@ export function setupClipboardFlow(core) {
380
380
  }
381
381
  });
382
382
 
383
+ core.eventBus.on(Events.UI.PasteVideoAt, ({ x, y, src, mimeType, poster, aiMessageId, skipUpload = true }) => {
384
+ if (!src) return;
385
+ const world = core.pixi.worldLayer || core.pixi.app.stage;
386
+ const s = world?.scale?.x || 1;
387
+ const worldX = (x - (world?.x || 0)) / s;
388
+ const worldY = (y - (world?.y || 0)) / s;
389
+
390
+ const placeVideo = (natW, natH) => {
391
+ const ar = (natW > 0 && natH > 0) ? natW / natH : (16 / 9);
392
+ const w = 300;
393
+ const h = Math.max(1, Math.round(w / ar));
394
+ const created = core.createObject(
395
+ 'video',
396
+ { x: Math.round(worldX - Math.round(w / 2)), y: Math.round(worldY - Math.round(h / 2)) },
397
+ {
398
+ src,
399
+ width: w,
400
+ height: h,
401
+ mimeType: mimeType || 'video/mp4',
402
+ poster: poster || null,
403
+ loop: true,
404
+ muted: true,
405
+ ...(aiMessageId ? { aiMessageId } : {})
406
+ }
407
+ );
408
+ // Видео не проходит через SaveManager до подтверждения — раскрываем сразу,
409
+ // как для AI-картинок и 3D-скринов.
410
+ if (skipUpload && created?.id) {
411
+ core._pendingPersistAckVisibilityIds?.delete(created.id);
412
+ core._setObjectVisibility?.(created.id, true);
413
+ }
414
+ };
415
+
416
+ // Узнаём натуральные пропорции из метаданных видео (для aspect-ratio).
417
+ try {
418
+ const probe = document.createElement('video');
419
+ probe.preload = 'metadata';
420
+ probe.muted = true;
421
+ probe.addEventListener('loadedmetadata', () => {
422
+ placeVideo(probe.videoWidth || 0, probe.videoHeight || 0);
423
+ }, { once: true });
424
+ probe.addEventListener('error', () => placeVideo(0, 0), { once: true });
425
+ probe.src = src;
426
+ } catch (_) {
427
+ placeVideo(0, 0);
428
+ }
429
+ });
430
+
383
431
  core.eventBus.on(Events.Tool.DuplicateRequest, (data) => {
384
432
  const { originalId, position } = data || {};
385
433
  if (!originalId) return;
@@ -138,6 +138,7 @@ export class FrameObject {
138
138
 
139
139
  /** Скрыть/показать серую рамку (при выделении скрываем, чтобы не накладывалась на синюю) */
140
140
  setBorderVisible(visible) {
141
+ if (this._destroyed || !this.container) return;
141
142
  if (this._borderVisible === visible) return;
142
143
  this._borderVisible = visible;
143
144
  this._redrawPreserveTransform(this.width, this.height, this.fillColor);
@@ -332,6 +333,7 @@ export class FrameObject {
332
333
  */
333
334
  _onZoomChange(data) {
334
335
  if (!data || typeof data.percentage !== 'number') return;
336
+ if (!this.titleLayer || this.container?.destroyed) return;
335
337
 
336
338
  const worldScale = data.percentage / 100;
337
339
  this.currentWorldScale = worldScale;
@@ -342,7 +344,7 @@ export class FrameObject {
342
344
  * Масштаб и позиция слоя заголовка — компенсируем зум, держим постоянный экранный размер
343
345
  */
344
346
  _updateTitleScale() {
345
- if (!this.titleLayer) return;
347
+ if (!this.titleLayer || this.titleLayer.destroyed || this.container?.destroyed) return;
346
348
 
347
349
  const compensationScale = 1 / this.currentWorldScale;
348
350
 
@@ -590,9 +592,13 @@ export class FrameObject {
590
592
  }
591
593
 
592
594
  /**
593
- * Метод для отписки от событий при уничтожении объекта
595
+ * Метод для отписки от событий при уничтожении объекта.
596
+ * Идемпотентен: повторный вызов безопасен.
594
597
  */
595
598
  destroy() {
599
+ if (this._destroyed) return;
600
+ this._destroyed = true;
601
+
596
602
  if (this.eventBus) {
597
603
  if (this._boundOnZoomChange) {
598
604
  this.eventBus.off(Events.UI.ZoomPercent, this._boundOnZoomChange);
@@ -605,9 +611,16 @@ export class FrameObject {
605
611
  this._boundOnSelectionAdd = this._boundOnSelectionRemove = this._boundOnSelectionClear = null;
606
612
  }
607
613
  }
608
- if (this.container) {
614
+
615
+ if (this.container && !this.container.destroyed) {
609
616
  this.container.destroy({ children: true });
610
617
  }
618
+
619
+ this.container = null;
620
+ this.titleLayer = null;
621
+ this.titleText = null;
622
+ this.titleBg = null;
623
+ this.graphics = null;
611
624
  }
612
625
  }
613
626
 
@@ -4,6 +4,7 @@ import { DrawingObject } from './DrawingObject.js';
4
4
  import { TextObject } from './TextObject.js';
5
5
  import { EmojiObject } from './EmojiObject.js';
6
6
  import { ImageObject } from './ImageObject.js';
7
+ import { VideoObject } from './VideoObject.js';
7
8
  import { RevitScreenshotImageObject } from './RevitScreenshotImageObject.js';
8
9
  import { Model3dScreenshotImageObject } from './Model3dScreenshotImageObject.js';
9
10
  import { CommentObject } from './CommentObject.js';
@@ -25,6 +26,7 @@ export class ObjectFactory {
25
26
  ['simple-text', TextObject],
26
27
  ['emoji', EmojiObject],
27
28
  ['image', ImageObject],
29
+ ['video', VideoObject],
28
30
  ['revit-screenshot-img', RevitScreenshotImageObject],
29
31
  ['model3d-screenshot-img', Model3dScreenshotImageObject],
30
32
  ['comment', CommentObject],
@@ -0,0 +1,171 @@
1
+ import * as PIXI from 'pixi.js';
2
+
3
+ /**
4
+ * VideoObject — отображение видео как спрайт с видео-текстурой.
5
+ *
6
+ * MVP: спрайт с muted-видео, клик по телу переключает play/pause.
7
+ * Трансформации (resize/rotate/move/z-order) переиспользуют конвейер ImageObject:
8
+ * единственный display-object — Sprite с anchor(0.5).
9
+ */
10
+ export class VideoObject {
11
+ constructor(objectData = {}) {
12
+ this.objectData = objectData;
13
+ let src = objectData.src || objectData.properties?.src;
14
+ // blob: недолговечны — не используем (ERR_FILE_NOT_FOUND после перезагрузки)
15
+ if (typeof src === 'string' && src.startsWith('blob:')) {
16
+ src = null;
17
+ }
18
+ this.src = src;
19
+ this.width = Math.max(1, Math.round(objectData.width || objectData.properties?.width || 300));
20
+ this.height = Math.max(1, Math.round(objectData.height || objectData.properties?.height || 169));
21
+
22
+ this.videoEl = null;
23
+ let texture = PIXI.Texture.WHITE;
24
+ if (src) {
25
+ const video = document.createElement('video');
26
+ video.src = src;
27
+ video.muted = true;
28
+ video.loop = objectData.properties?.loop ?? true;
29
+ video.playsInline = true;
30
+ video.crossOrigin = 'anonymous';
31
+ video.preload = 'auto';
32
+ this.videoEl = video;
33
+ // autoPlay:false — стартуем на паузе, первый кадр показываем после loadeddata
34
+ texture = PIXI.Texture.from(video, { resourceOptions: { autoPlay: false } });
35
+ }
36
+
37
+ this.sprite = new PIXI.Sprite(texture);
38
+ this.sprite.anchor.set(0.5, 0.5);
39
+ if (!src) {
40
+ this.sprite.tint = 0xcccccc;
41
+ }
42
+
43
+ this._fitToSize = () => {
44
+ const texW = this.sprite.texture.width || 1;
45
+ const texH = this.sprite.texture.height || 1;
46
+ this.sprite._mb = {
47
+ ...(this.sprite._mb || {}),
48
+ type: this.objectData?.type || 'video',
49
+ properties: {
50
+ ...((this.sprite._mb || {}).properties || {}),
51
+ src,
52
+ baseW: texW,
53
+ baseH: texH
54
+ }
55
+ };
56
+ this.sprite.scale.set(this.width / texW, this.height / texH);
57
+ };
58
+
59
+ const onError = () => {
60
+ this.sprite.texture = PIXI.Texture.WHITE;
61
+ this.sprite.tint = 0xcccccc;
62
+ this._fitToSize();
63
+ };
64
+
65
+ if (this.videoEl) {
66
+ // Показать первый кадр на паузе: после loadeddata толкнуть кадр в GPU
67
+ this.videoEl.addEventListener('loadeddata', () => {
68
+ try { this.sprite.texture.update(); } catch (_) {}
69
+ this._fitToSize();
70
+ }, { once: true });
71
+ this.videoEl.addEventListener('error', onError, { once: true });
72
+ }
73
+
74
+ if (this.sprite.texture.baseTexture?.valid) {
75
+ this._fitToSize();
76
+ } else if (this.sprite.texture.baseTexture) {
77
+ this.sprite.texture.baseTexture.once('loaded', this._fitToSize);
78
+ }
79
+
80
+ this._setupPlaybackToggle();
81
+ }
82
+
83
+ /**
84
+ * Клик по телу видео (без перетаскивания) переключает play/pause.
85
+ * Порог движения отсекает drag, чтобы перемещение не триггерило тоггл.
86
+ */
87
+ _setupPlaybackToggle() {
88
+ this.sprite.eventMode = 'static';
89
+ let down = null;
90
+ this._onDown = (e) => { down = { x: e.global.x, y: e.global.y }; };
91
+ this._onUp = (e) => {
92
+ if (!down) return;
93
+ const dx = e.global.x - down.x;
94
+ const dy = e.global.y - down.y;
95
+ down = null;
96
+ if (Math.hypot(dx, dy) < 5) {
97
+ this.toggle();
98
+ }
99
+ };
100
+ this.sprite.on('pointerdown', this._onDown);
101
+ this.sprite.on('pointerup', this._onUp);
102
+ this.sprite.on('pointerupoutside', () => { down = null; });
103
+ }
104
+
105
+ play() {
106
+ if (!this.videoEl) return;
107
+ const p = this.videoEl.play();
108
+ if (p && typeof p.catch === 'function') {
109
+ p.catch(() => {});
110
+ }
111
+ }
112
+
113
+ pause() {
114
+ if (this.videoEl) {
115
+ this.videoEl.pause();
116
+ }
117
+ }
118
+
119
+ toggle() {
120
+ if (!this.videoEl) return;
121
+ if (this.videoEl.paused) {
122
+ this.play();
123
+ } else {
124
+ this.pause();
125
+ }
126
+ }
127
+
128
+ getPixi() {
129
+ return this.sprite;
130
+ }
131
+
132
+ updateSize(size) {
133
+ if (!size) return;
134
+ this.width = Math.max(1, Math.round(size.width || 1));
135
+ this.height = Math.max(1, Math.round(size.height || 1));
136
+ const apply = () => {
137
+ const texW = this.sprite.texture.width || 1;
138
+ const texH = this.sprite.texture.height || 1;
139
+ this.sprite.scale.set(this.width / texW, this.height / texH);
140
+ };
141
+ if (this.sprite.texture.baseTexture?.valid) {
142
+ apply();
143
+ } else {
144
+ this.sprite.texture.baseTexture?.once('loaded', apply);
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Остановить видео и освободить ресурсы (вызывается при удалении объекта).
150
+ * Без этого HTMLVideoElement продолжает жить и тикать текстуру — утечка.
151
+ */
152
+ destroy() {
153
+ if (this.videoEl) {
154
+ try {
155
+ this.videoEl.pause();
156
+ this.videoEl.removeAttribute('src');
157
+ this.videoEl.load();
158
+ } catch (_) {}
159
+ this.videoEl = null;
160
+ }
161
+ try {
162
+ this.sprite?.off('pointerdown', this._onDown);
163
+ this.sprite?.off('pointerup', this._onUp);
164
+ } catch (_) {}
165
+ try {
166
+ if (this.sprite?.texture && this.sprite.texture !== PIXI.Texture.WHITE) {
167
+ this.sprite.texture.destroy(true);
168
+ }
169
+ } catch (_) {}
170
+ }
171
+ }
@@ -98,6 +98,92 @@ export function applyTextStyles(el, { fontSizePx, baseFontSizePx, fontFamily, pr
98
98
  el.style.textRendering = 'optimizeLegibility';
99
99
  }
100
100
 
101
+ // Канвас и кеш для измерения видимых границ глифов (см. computeSingleLineCenterDelta).
102
+ // getContext оборачиваем в try/catch: в jsdom (тесты) он не реализован и иначе шумит в stderr.
103
+ let _vCenterCtx = null;
104
+ try {
105
+ if (typeof document !== 'undefined') {
106
+ _vCenterCtx = document.createElement('canvas').getContext('2d');
107
+ }
108
+ } catch (_) {
109
+ _vCenterCtx = null;
110
+ }
111
+ const _vCenterCache = new Map();
112
+ const _VCENTER_CACHE_LIMIT = 512;
113
+
114
+ /**
115
+ * Дельта высоты (px) для вертикального центрирования видимых глифов в однострочном
116
+ * текстовом боксе.
117
+ *
118
+ * Проблема: line-box шрифта резервирует сверху место под прописные и верхние выносные,
119
+ * которого строчный текст не занимает. Из-за этого буквы визуально прижаты к низу рамки
120
+ * (сверху пустого места больше, снизу меньше), и на отдельных стадиях зума перекос
121
+ * усиливается округлением. Возвращаем «верхний резерв − нижний резерв»: добавив это к
122
+ * высоте бокса, уравниваем зазоры сверху и снизу вокруг реальных букв.
123
+ *
124
+ * Измеряем по фактически отрисованному элементу (его computed-стили, baseline и
125
+ * округление на текущем зуме), поэтому остаточная асимметрия не превышает 1px на любой
126
+ * стадии. Результат кешируется по сигнатуре «шрифт+размер+контент», чтобы не запускать
127
+ * измерение на каждый кадр пана/зума.
128
+ *
129
+ * @param {HTMLElement} el — элемент .mb-text с уже выставленной height:auto
130
+ * @returns {number|null} целочисленная дельта (может быть отрицательной), либо null,
131
+ * если центрирование неприменимо (пустой/многострочный текст, окружение без layout/canvas)
132
+ */
133
+ export function computeSingleLineCenterDelta(el) {
134
+ if (!el || !_vCenterCtx || typeof el.getBoundingClientRect !== 'function' || typeof window === 'undefined') {
135
+ return null;
136
+ }
137
+ const text = el.textContent || '';
138
+ if (!text.trim() || text.includes('\n')) {
139
+ return null;
140
+ }
141
+ let cs;
142
+ try {
143
+ cs = window.getComputedStyle(el);
144
+ } catch (_) {
145
+ return null;
146
+ }
147
+ const key = `${cs.fontFamily}|${cs.fontWeight}|${cs.fontStyle}|${cs.lineHeight}|${cs.fontSize}|${text}`;
148
+ const cached = _vCenterCache.get(key);
149
+ if (cached !== undefined) {
150
+ return cached;
151
+ }
152
+
153
+ let delta = null;
154
+ try {
155
+ const elRect = el.getBoundingClientRect();
156
+ const lineBoxHeight = el.scrollHeight;
157
+ // Базовая линия строки: inline-block нулевой высоты, выровненный по baseline,
158
+ // верхней гранью садится ровно на базовую линию текста.
159
+ const marker = document.createElement('span');
160
+ marker.style.cssText = 'display:inline-block;width:0;height:0;vertical-align:baseline;';
161
+ el.appendChild(marker);
162
+ const baselineRel = marker.getBoundingClientRect().top - elRect.top;
163
+ el.removeChild(marker);
164
+
165
+ _vCenterCtx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
166
+ const m = _vCenterCtx.measureText(text);
167
+ const ascent = m.actualBoundingBoxAscent;
168
+ const descent = m.actualBoundingBoxDescent;
169
+ if (Number.isFinite(ascent) && Number.isFinite(descent) && lineBoxHeight > 0) {
170
+ const inkTop = baselineRel - ascent;
171
+ const inkBottom = baselineRel + descent;
172
+ const topReserve = inkTop;
173
+ const bottomReserve = lineBoxHeight - inkBottom;
174
+ delta = Math.round(topReserve - bottomReserve);
175
+ }
176
+ } catch (_) {
177
+ delta = null;
178
+ }
179
+
180
+ if (_vCenterCache.size >= _VCENTER_CACHE_LIMIT) {
181
+ _vCenterCache.clear();
182
+ }
183
+ _vCenterCache.set(key, delta);
184
+ return delta;
185
+ }
186
+
101
187
  /**
102
188
  * Применяет параметры размера поля для inline-редактора (textarea/backdrop).
103
189
  * Отдельно от applyTextStyles, чтобы не дублировать логику в статическом слое.
@@ -1,3 +1,5 @@
1
+ import { Events } from '../../core/events/Events.js';
2
+
1
3
  const PASSIVE_FALSE = { passive: false };
2
4
 
3
5
  export class ToolManagerLifecycle {
@@ -70,12 +72,49 @@ export class ToolManagerLifecycle {
70
72
 
71
73
  manager._onWindowWheel = (event) => {
72
74
  try {
73
- if (event && event.ctrlKey && manager.isMouseOverContainer) {
74
- event.preventDefault();
75
+ if (!event || !(event.ctrlKey || event.metaKey)) return;
76
+ // Гасим браузерный зум страницы при Ctrl/Cmd+колесо в пределах мудборда,
77
+ // независимо от того, над холстом курсор или над HTML-панелью
78
+ event.preventDefault();
79
+
80
+ // _onWheel (на canvas) ловит зум, когда цель события — сам canvas.
81
+ // HTML-оверлеи (textarea редактора текста, ручки и т.п.) — соседи canvas,
82
+ // а не его потомки, поэтому их wheel-события не всплывают через canvas.
83
+ // Ловим их здесь: эмитируем WheelZoom, если событие пришло изнутри
84
+ // контейнера мудборда, но НЕ с самого canvas (чтобы не дублировать).
85
+ if (event.target !== manager.container) {
86
+ const parent = manager.container && manager.container.parentElement;
87
+ if (parent && parent.contains(event.target)) {
88
+ const canvasRect = manager.container.getBoundingClientRect();
89
+ const x = event.clientX - canvasRect.left;
90
+ const y = event.clientY - canvasRect.top;
91
+ manager.eventBus.emit(Events.Tool.WheelZoom, { x, y, delta: event.deltaY });
92
+ }
75
93
  }
76
94
  } catch (_) {}
77
95
  };
78
96
  window.addEventListener('wheel', manager._onWindowWheel, PASSIVE_FALSE);
97
+
98
+ // Перехватываем браузерный зум страницы (Ctrl +/-/0) и масштабируем только холст
99
+ manager._onWindowZoomKeys = (event) => {
100
+ try {
101
+ if (!event || !(event.ctrlKey || event.metaKey) || event.altKey) return;
102
+ const key = event.key;
103
+ let zoomEvent = null;
104
+ if (key === '=' || key === '+') {
105
+ zoomEvent = Events.UI.ZoomIn;
106
+ } else if (key === '-' || key === '_') {
107
+ zoomEvent = Events.UI.ZoomOut;
108
+ } else if (key === '0') {
109
+ zoomEvent = Events.UI.ZoomReset;
110
+ }
111
+ if (!zoomEvent) return;
112
+ event.preventDefault();
113
+ event.stopPropagation();
114
+ manager.eventBus.emit(zoomEvent);
115
+ } catch (_) {}
116
+ };
117
+ window.addEventListener('keydown', manager._onWindowZoomKeys, true);
79
118
  }
80
119
 
81
120
  static destroy(manager) {
@@ -112,6 +151,13 @@ export class ToolManagerLifecycle {
112
151
  manager._onWindowWheel = null;
113
152
  }
114
153
 
154
+ if (manager._onWindowZoomKeys) {
155
+ try {
156
+ window.removeEventListener('keydown', manager._onWindowZoomKeys, true);
157
+ } catch (_) {}
158
+ manager._onWindowZoomKeys = null;
159
+ }
160
+
115
161
  if (manager.gestures) {
116
162
  manager.gestures.destroy();
117
163
  }
@@ -101,6 +101,34 @@ export class ConnectorDragController {
101
101
  return { x: posData.position.x, y: posData.position.y, ...sizeData.size };
102
102
  }
103
103
 
104
+ /**
105
+ * Визуальные габариты объекта с учётом hover-трансформации (scale + подъём).
106
+ * Подсветка коннектора должна совпадать с тем, что видит пользователь, а не
107
+ * с логическими bounds из состояния. Привязка коннектора при этом остаётся
108
+ * на логических bounds — трансформируется только прямоугольник подсветки.
109
+ */
110
+ _visualBounds(objectId, bounds) {
111
+ const hoverLift = this.core?.pixi?.hoverLift;
112
+ if (!hoverLift) return bounds;
113
+
114
+ const pixiReq = { objectId, pixiObject: null };
115
+ this.eventBus.emit(Events.Tool.GetObjectPixi, pixiReq);
116
+ const pixiObject = pixiReq.pixiObject;
117
+ if (!pixiObject) return bounds;
118
+
119
+ const t = hoverLift.getVisualTransform(pixiObject);
120
+ if (!t) return bounds;
121
+
122
+ const width = bounds.width * t.scaleMulX;
123
+ const height = bounds.height * t.scaleMulY;
124
+ return {
125
+ x: t.centerX - width / 2,
126
+ y: t.centerY - height / 2,
127
+ width,
128
+ height,
129
+ };
130
+ }
131
+
104
132
  /**
105
133
  * Возвращает ближайший якорь из TARGET_ANCHORS в пределах ANCHOR_SNAP_CSS от worldPt,
106
134
  * иначе null. Приоритет выше грани — вызывать в _resolveEnd первым.
@@ -192,8 +220,9 @@ export class ConnectorDragController {
192
220
  if (objectId && objectId !== this._sourceTerminal?.boundId) {
193
221
  const bounds = this._objectBounds(objectId);
194
222
  if (bounds) {
223
+ const visual = this._visualBounds(objectId, bounds);
195
224
  this._highlightGraphics.lineStyle({ width: 2, color: 0x2563EB, alpha: 0.85 });
196
- this._highlightGraphics.drawRect(bounds.x, bounds.y, bounds.width, bounds.height);
225
+ this._highlightGraphics.drawRect(visual.x, visual.y, visual.width, visual.height);
197
226
  // Подводим превью к коннектору, если курсор в зоне магнита
198
227
  const snapAnchor = this._snapToAnchor(bounds, worldPt);
199
228
  if (snapAnchor) {
@@ -58,8 +58,14 @@ export function setupNoteInlineEditor(controller, params) {
58
58
  wrapper.style.width = `${editorWidthPx}px`;
59
59
  wrapper.style.height = `${editorHeightPx}px`;
60
60
 
61
- // Для записок: авто-ресайз редактора под содержимое с сохранением центрирования
61
+ // Для записок: авто-ресайз редактора под содержимое с сохранением центрирования.
62
+ // backdrop (видимые глифы) обязан повторять выравнивание textarea, иначе каретка,
63
+ // позиционируемая по center-align textarea, уходит вправо от прижатого влево текста.
62
64
  textarea.style.textAlign = 'center';
65
+ const backdrop = wrapper.querySelector('.moodboard-text-backdrop');
66
+ if (backdrop) {
67
+ backdrop.style.textAlign = 'center';
68
+ }
63
69
  const maxEditorWidthPx = Math.max(1, Math.round((noteWidth - (horizontalPadding * 2)) * sCssLocal));
64
70
  const maxEditorHeightPx = Math.max(1, Math.round((noteHeight - (horizontalPadding * 2)) * sCssLocal));
65
71
  const MIN_NOTE_EDITOR_W = 20;
@@ -84,8 +84,10 @@ export function updateCustomCaret(textarea, caretEl) {
84
84
  // Calculate width based on font size to match stroke thickness
85
85
  const computed = window.getComputedStyle(textarea);
86
86
  const fontSize = parseFloat(computed.fontSize) || 16;
87
- // Caveat has thicker strokes, Arial is standard. A good heuristic is ~0.06 to 0.08 of font size, min 2px.
88
- const caretWidth = Math.max(2, Math.round(fontSize * 0.07));
87
+ // Толщина каретки пропорциональна шрифту (≈7% размера). Минимум 1px при сильном
88
+ // отдалении каретка остаётся видимой и не нарушает пропорцию. Жёсткий пол 2px убран,
89
+ // чтобы при уменьшении масштаба каретка масштабировалась вместе с текстом.
90
+ const caretWidth = Math.max(1, Math.round(fontSize * 0.07));
89
91
 
90
92
  // Цвет текста в textarea теперь прозрачный (видимый текст рисует backdrop-слой),
91
93
  // поэтому цвет каретки берём из backdrop, иначе она станет невидимой.