@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.
- package/package.json +1 -1
- package/src/core/ApiClient.js +41 -2
- package/src/core/PixiEngine.js +23 -2
- package/src/core/events/Events.js +1 -0
- package/src/core/flows/ClipboardFlow.js +48 -0
- package/src/objects/FrameObject.js +16 -3
- package/src/objects/ObjectFactory.js +2 -0
- package/src/objects/VideoObject.js +171 -0
- package/src/services/text/TextBoxMetrics.js +86 -0
- package/src/tools/manager/ToolManagerLifecycle.js +48 -2
- package/src/tools/object-tools/connector/ConnectorDragController.js +30 -1
- package/src/tools/object-tools/selection/NoteInlineEditorController.js +7 -1
- package/src/tools/object-tools/selection/TextEditorCaretService.js +4 -2
- package/src/tools/object-tools/selection/TextEditorInteractionController.js +14 -0
- package/src/tools/object-tools/selection/TextEditorLifecycleRegistry.js +7 -0
- package/src/tools/object-tools/selection/TextEditorPositioningService.js +4 -3
- package/src/tools/object-tools/selection/TextEditorSyncService.js +260 -6
- package/src/tools/object-tools/selection/TextInlineEditorController.js +44 -5
- package/src/ui/HtmlTextLayer.js +31 -13
- package/src/ui/animation/HoverLiftController.js +20 -0
- package/src/ui/chat/ChatWindow.js +39 -8
- package/src/ui/comments/CommentThreadPopover.js +3 -1
- package/src/ui/handles/HandlesDomRenderer.js +30 -3
- package/src/ui/handles/HandlesInteractionController.js +26 -28
- package/src/ui/styles/chat.css +43 -0
- package/src/ui/styles/topbar.css +20 -0
- package/src/ui/styles/workspace.css +35 -2
- package/src/ui/text-properties/TextPropertiesPanelRenderer.js +19 -19
package/package.json
CHANGED
package/src/core/ApiClient.js
CHANGED
|
@@ -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
|
);
|
package/src/core/PixiEngine.js
CHANGED
|
@@ -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 (
|
|
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 — освобождаем память)
|
|
@@ -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
|
-
|
|
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
|
|
74
|
-
|
|
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(
|
|
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
|
-
//
|
|
88
|
-
|
|
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, иначе она станет невидимой.
|