@sequent-org/moodboard 1.4.59 → 1.4.61
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/ban.svg +1 -0
- package/src/core/PixiEngine.js +19 -7
- package/src/objects/FrameObject.js +16 -3
- package/src/services/text/TextBoxMetrics.js +86 -0
- package/src/tools/manager/ToolManagerLifecycle.js +48 -2
- package/src/tools/object-tools/connector/ConnectorDragController.js +9 -0
- package/src/tools/object-tools/selection/NoteInlineEditorController.js +58 -47
- package/src/tools/object-tools/selection/TextEditorCaretService.js +55 -3
- package/src/tools/object-tools/selection/TextEditorInteractionController.js +40 -1
- 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 +391 -29
- package/src/tools/object-tools/selection/TextInlineEditorController.js +62 -8
- package/src/ui/HtmlTextLayer.js +31 -13
- package/src/ui/animation/HoverLiftController.js +33 -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 +57 -2
- package/src/ui/text-properties/TextPropertiesPanelRenderer.js +19 -19
package/package.json
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ban-icon lucide-ban"><circle cx="12" cy="12" r="10"/><path d="M4.929 4.929 19.07 19.071"/></svg>
|
package/src/core/PixiEngine.js
CHANGED
|
@@ -249,13 +249,20 @@ export class PixiEngine {
|
|
|
249
249
|
if (pixiObject) {
|
|
250
250
|
console.log('🗑️ PixiEngine: удаляем объект из сцены:', objectId);
|
|
251
251
|
|
|
252
|
-
// Видео: останавливаем HTMLVideoElement и снимаем listeners до уничтожения
|
|
253
|
-
// спрайта. Без этого видео продолжает играть и тикать текстуру — утечка.
|
|
254
|
-
// Хук сужен до 'video', чтобы не задеть destroy() фреймов/коннекторов,
|
|
255
|
-
// у которых контейнер уничтожается общим путём ниже (иначе двойной destroy).
|
|
256
252
|
const mbMeta = pixiObject._mb;
|
|
257
|
-
|
|
258
|
-
|
|
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
|
+
}
|
|
259
266
|
}
|
|
260
267
|
|
|
261
268
|
// Удаляем из родительского контейнера
|
|
@@ -266,7 +273,12 @@ export class PixiEngine {
|
|
|
266
273
|
}
|
|
267
274
|
|
|
268
275
|
// ИСПРАВЛЕНИЕ: Полная очистка для изображений/эмоджи
|
|
269
|
-
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) {
|
|
270
282
|
console.log('🗑️ PixiEngine: очищаем ресурсы изображения/эмоджи');
|
|
271
283
|
|
|
272
284
|
// Очищаем текстуру (data URL, blob URL — освобождаем память)
|
|
@@ -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
|
|
|
@@ -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
|
}
|
|
@@ -172,6 +172,9 @@ export class ConnectorDragController {
|
|
|
172
172
|
if (Math.abs(e.clientX - this._startX) < DRAG_THRESHOLD
|
|
173
173
|
&& Math.abs(e.clientY - this._startY) < DRAG_THRESHOLD) return;
|
|
174
174
|
this._dragging = true;
|
|
175
|
+
// Гасим hover-lift на время протягивания: цель не должна масштабироваться
|
|
176
|
+
// и подниматься, иначе её границы вылезают за рамку подсветки.
|
|
177
|
+
this.core?.pixi?.hoverLift?.setConnecting(true);
|
|
175
178
|
const world = this._world();
|
|
176
179
|
if (world) {
|
|
177
180
|
this._previewGraphics = new PIXI.Graphics();
|
|
@@ -192,6 +195,10 @@ export class ConnectorDragController {
|
|
|
192
195
|
if (objectId && objectId !== this._sourceTerminal?.boundId) {
|
|
193
196
|
const bounds = this._objectBounds(objectId);
|
|
194
197
|
if (bounds) {
|
|
198
|
+
// Рамку строим строго по логическим bounds — коннектор привязывается
|
|
199
|
+
// к ним же. Hover-трансформацию (scale/lift) не учитываем: иначе рамка
|
|
200
|
+
// выпирает за периметр объекта (раздув ×scale + подъём центра вверх) и
|
|
201
|
+
// отстаёт от анимации, т.к. перерисовывается только на pointermove.
|
|
195
202
|
this._highlightGraphics.lineStyle({ width: 2, color: 0x2563EB, alpha: 0.85 });
|
|
196
203
|
this._highlightGraphics.drawRect(bounds.x, bounds.y, bounds.width, bounds.height);
|
|
197
204
|
// Подводим превью к коннектору, если курсор в зоне магнита
|
|
@@ -220,6 +227,7 @@ export class ConnectorDragController {
|
|
|
220
227
|
const source = this._sourceTerminal;
|
|
221
228
|
this._dragging = false;
|
|
222
229
|
this._sourceTerminal = null;
|
|
230
|
+
this.core?.pixi?.hoverLift?.setConnecting(false);
|
|
223
231
|
this._clearGraphics();
|
|
224
232
|
|
|
225
233
|
if (!source) return;
|
|
@@ -331,6 +339,7 @@ export class ConnectorDragController {
|
|
|
331
339
|
destroy() {
|
|
332
340
|
document.removeEventListener('pointermove', this._boundMove);
|
|
333
341
|
document.removeEventListener('pointerup', this._boundUp);
|
|
342
|
+
this.core?.pixi?.hoverLift?.setConnecting(false);
|
|
334
343
|
if (this._pendingDupListener) {
|
|
335
344
|
this.eventBus?.off(Events.Tool.DuplicateReady, this._pendingDupListener);
|
|
336
345
|
this._pendingDupListener = null;
|
|
@@ -1,9 +1,33 @@
|
|
|
1
1
|
import { Events } from '../../../core/events/Events.js';
|
|
2
2
|
import {
|
|
3
|
+
applyNoteEditorBox,
|
|
3
4
|
createNoteEditorUpdater,
|
|
4
5
|
registerNoteEditorSync,
|
|
5
6
|
} from './TextEditorSyncService.js';
|
|
6
7
|
|
|
8
|
+
// Разметка иконки запрета (assets/icons/ban.svg). stroke=currentColor — цвет задаётся CSS.
|
|
9
|
+
const NOTE_BAN_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M4.929 4.929 19.07 19.071"/></svg>';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Создаёт (или переиспользует) оверлей запрета ввода внутри wrapper редактора записки
|
|
13
|
+
* и возвращает функцию переключения его видимости.
|
|
14
|
+
* @param {HTMLElement} wrapper
|
|
15
|
+
* @returns {(visible: boolean) => void}
|
|
16
|
+
*/
|
|
17
|
+
function ensureNoteLimitBan(wrapper) {
|
|
18
|
+
let banEl = wrapper.querySelector('.moodboard-note-limit-ban');
|
|
19
|
+
if (!banEl) {
|
|
20
|
+
banEl = document.createElement('div');
|
|
21
|
+
banEl.className = 'moodboard-note-limit-ban';
|
|
22
|
+
banEl.innerHTML = NOTE_BAN_SVG;
|
|
23
|
+
banEl.style.display = 'none';
|
|
24
|
+
wrapper.appendChild(banEl);
|
|
25
|
+
}
|
|
26
|
+
return (visible) => {
|
|
27
|
+
banEl.style.display = visible ? 'flex' : 'none';
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
7
31
|
export function setupNoteInlineEditor(controller, params) {
|
|
8
32
|
const {
|
|
9
33
|
objectId,
|
|
@@ -35,63 +59,50 @@ export function setupNoteInlineEditor(controller, params) {
|
|
|
35
59
|
}
|
|
36
60
|
}
|
|
37
61
|
|
|
38
|
-
// Текст у записки центрирован по обеим осям;
|
|
39
|
-
const horizontalPadding = 16;
|
|
40
|
-
//
|
|
41
|
-
|
|
62
|
+
// Текст у записки центрирован по обеим осям; редактор повторяет блок текста.
|
|
63
|
+
const horizontalPadding = 16;
|
|
64
|
+
// Мировые размеры/отступы → CSS-пиксели. Множитель = worldScale, БЕЗ деления на
|
|
65
|
+
// renderer.resolution: screenPos берётся из toGlobal (см. InlineEditorPositioningService),
|
|
66
|
+
// который уже возвращает CSS-px независимо от res, а сама записка рисуется в PIXI, где
|
|
67
|
+
// 1 мировая единица = worldScale CSS-px. Лишнее /res занижало ширину блока и слагаемое
|
|
68
|
+
// центрирования (noteWidth*scale/2) → при зуме браузера ≠100% (res≠1) редактор уезжал влево.
|
|
42
69
|
const worldLayerRefForCss = controller.textEditor.world || (controller.app?.stage);
|
|
43
70
|
const sForCss = worldLayerRefForCss?.scale?.x || 1;
|
|
44
|
-
const sCssLocal = sForCss
|
|
45
|
-
const editorWidthWorld = Math.min(360, Math.max(1, noteWidth - (horizontalPadding * 2)));
|
|
46
|
-
const editorHeightWorld = Math.min(180, Math.max(1, noteHeight - (horizontalPadding * 2)));
|
|
47
|
-
const editorWidthPx = Math.max(1, Math.round(editorWidthWorld * sCssLocal));
|
|
48
|
-
const editorHeightPx = Math.max(1, Math.round(editorHeightWorld * sCssLocal));
|
|
49
|
-
const textCenterXWorld = noteWidth / 2;
|
|
50
|
-
const textCenterYWorld = noteHeight / 2;
|
|
51
|
-
const editorLeftWorld = textCenterXWorld - (editorWidthWorld / 2);
|
|
52
|
-
const editorTopWorld = textCenterYWorld - (editorHeightWorld / 2);
|
|
53
|
-
wrapper.style.left = `${Math.round(screenPos.x + editorLeftWorld * sCssLocal)}px`;
|
|
54
|
-
wrapper.style.top = `${Math.round(screenPos.y + editorTopWorld * sCssLocal)}px`;
|
|
55
|
-
// Устанавливаем размеры редактора (центрируем по контенту) в CSS-пикселях
|
|
56
|
-
textarea.style.width = `${editorWidthPx}px`;
|
|
57
|
-
textarea.style.height = `${editorHeightPx}px`;
|
|
58
|
-
wrapper.style.width = `${editorWidthPx}px`;
|
|
59
|
-
wrapper.style.height = `${editorHeightPx}px`;
|
|
71
|
+
const sCssLocal = sForCss;
|
|
60
72
|
|
|
61
|
-
|
|
62
|
-
textarea.style.textAlign = 'center';
|
|
63
|
-
const maxEditorWidthPx = Math.max(1, Math.round((noteWidth - (horizontalPadding * 2)) * sCssLocal));
|
|
64
|
-
const maxEditorHeightPx = Math.max(1, Math.round((noteHeight - (horizontalPadding * 2)) * sCssLocal));
|
|
65
|
-
const MIN_NOTE_EDITOR_W = 20;
|
|
66
|
-
const MIN_NOTE_EDITOR_H = Math.max(1, computeLineHeightPx(effectiveFontPx));
|
|
73
|
+
const backdrop = wrapper.querySelector('.moodboard-text-backdrop');
|
|
67
74
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
textarea.style.height = 'auto';
|
|
75
|
+
// Оверлей запрета ввода: показывается по центру записки, когда достигнут лимит
|
|
76
|
+
// строк (NOTE_MAX_LINES). Иконка — assets/icons/ban.svg (stroke=currentColor).
|
|
77
|
+
const setBanVisible = ensureNoteLimitBan(wrapper);
|
|
72
78
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
79
|
+
// Внутренний блок текста = границы записки минус отступы, ограничение ширины 360 —
|
|
80
|
+
// как в NoteObject._getVisibleTextWidth.
|
|
81
|
+
const MIN_NOTE_EDITOR_H = Math.max(1, computeLineHeightPx(effectiveFontPx));
|
|
82
|
+
const innerWorldW = Math.max(1, Math.min(360, noteWidth - (horizontalPadding * 2)));
|
|
83
|
+
const innerWorldH = Math.max(1, noteHeight - (horizontalPadding * 2));
|
|
84
|
+
const boxW = Math.max(1, Math.round(innerWorldW * sCssLocal));
|
|
85
|
+
const innerH = Math.max(MIN_NOTE_EDITOR_H, Math.round(innerWorldH * sCssLocal));
|
|
78
86
|
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
wrapper.style.
|
|
87
|
+
const autoSizeNote = () => {
|
|
88
|
+
const result = applyNoteEditorBox(textarea, backdrop, {
|
|
89
|
+
boxW,
|
|
90
|
+
innerH,
|
|
91
|
+
effectiveFontPx,
|
|
92
|
+
});
|
|
93
|
+
wrapper.style.width = `${boxW}px`;
|
|
94
|
+
wrapper.style.height = `${result.contentH}px`;
|
|
86
95
|
|
|
87
|
-
// Центрируем
|
|
88
|
-
const left = Math.round(screenPos.x + (noteWidth * sCssLocal) / 2 - (
|
|
89
|
-
const top = Math.round(screenPos.y + (noteHeight * sCssLocal) / 2 - (
|
|
96
|
+
// Центрируем блок контента внутри записки по обеим осям (как PIXI-текст).
|
|
97
|
+
const left = Math.round(screenPos.x + (noteWidth * sCssLocal) / 2 - (boxW / 2));
|
|
98
|
+
const top = Math.round(screenPos.y + (noteHeight * sCssLocal) / 2 - (result.contentH / 2));
|
|
90
99
|
wrapper.style.left = `${left}px`;
|
|
91
100
|
wrapper.style.top = `${top}px`;
|
|
101
|
+
return result;
|
|
92
102
|
};
|
|
93
103
|
// Первый вызов — синхронизировать с текущим содержимым
|
|
94
|
-
autoSizeNote();
|
|
104
|
+
const initial = autoSizeNote();
|
|
105
|
+
setBanVisible(!!(initial && initial.full));
|
|
95
106
|
|
|
96
107
|
// Динамическое обновление позиции/размера редактора при зуме/панорамировании/трансформациях
|
|
97
108
|
const updateNoteEditor = createNoteEditorUpdater(controller, {
|
|
@@ -109,5 +120,5 @@ export function setupNoteInlineEditor(controller, params) {
|
|
|
109
120
|
});
|
|
110
121
|
registerNoteEditorSync(controller, { objectId, updateNoteEditor });
|
|
111
122
|
|
|
112
|
-
return { updateNoteEditor };
|
|
123
|
+
return { updateNoteEditor, setBanVisible };
|
|
113
124
|
}
|
|
@@ -8,6 +8,45 @@ const propertiesToCopy = [
|
|
|
8
8
|
];
|
|
9
9
|
|
|
10
10
|
let mirrorDiv = null;
|
|
11
|
+
let glyphMeasureCanvas = null;
|
|
12
|
+
|
|
13
|
+
// Возвращает выступ чернил (ink) глифа за его advance width в CSS-px. У рукописных и
|
|
14
|
+
// наклонных шрифтов (например Caveat) правый край буквы выходит за ширину продвижения,
|
|
15
|
+
// по которой считается позиция каретки, поэтому она визуально наезжает на символ.
|
|
16
|
+
// actualBoundingBoxRight — расстояние от точки отрисовки до правого края чернил;
|
|
17
|
+
// разница с width даёт величину выступа. <=0 для обычных прямых шрифтов.
|
|
18
|
+
function measureGlyphRightOverflow(computed, ch) {
|
|
19
|
+
if (!ch || ch === '\n') {
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
if (typeof document === 'undefined') {
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
if (!glyphMeasureCanvas) {
|
|
27
|
+
glyphMeasureCanvas = document.createElement('canvas');
|
|
28
|
+
}
|
|
29
|
+
const ctx = glyphMeasureCanvas.getContext('2d');
|
|
30
|
+
if (!ctx) {
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
const fontStyle = computed.fontStyle || 'normal';
|
|
34
|
+
const fontWeight = computed.fontWeight || 'normal';
|
|
35
|
+
const fontSize = computed.fontSize || '16px';
|
|
36
|
+
const fontFamily = computed.fontFamily || 'sans-serif';
|
|
37
|
+
ctx.font = `${fontStyle} ${fontWeight} ${fontSize} ${fontFamily}`;
|
|
38
|
+
const metrics = ctx.measureText(ch);
|
|
39
|
+
const advance = metrics.width || 0;
|
|
40
|
+
const inkRight = metrics.actualBoundingBoxRight;
|
|
41
|
+
if (!Number.isFinite(inkRight)) {
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
const overflow = inkRight - advance;
|
|
45
|
+
return overflow > 0 ? overflow : 0;
|
|
46
|
+
} catch (_) {
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
11
50
|
|
|
12
51
|
export function getCaretCoordinates(element, position) {
|
|
13
52
|
if (!mirrorDiv) {
|
|
@@ -84,8 +123,21 @@ export function updateCustomCaret(textarea, caretEl) {
|
|
|
84
123
|
// Calculate width based on font size to match stroke thickness
|
|
85
124
|
const computed = window.getComputedStyle(textarea);
|
|
86
125
|
const fontSize = parseFloat(computed.fontSize) || 16;
|
|
87
|
-
|
|
88
|
-
|
|
126
|
+
|
|
127
|
+
// Сдвиг каретки вправо складывается из выступа чернил последней буквы (у рукописных/
|
|
128
|
+
// наклонных шрифтов глиф вылазит за advance width) и межбуквенного интервала текста.
|
|
129
|
+
// Применяем только в конце строки — в середине следующий символ и так перекрывает выступ.
|
|
130
|
+
const value = textarea.value || '';
|
|
131
|
+
const atLineEnd = pos >= value.length || value[pos] === '\n';
|
|
132
|
+
const lastChar = pos > 0 ? value[pos - 1] : '';
|
|
133
|
+
const inkOverflow = atLineEnd ? measureGlyphRightOverflow(computed, lastChar) : 0;
|
|
134
|
+
const letterSpacing = parseFloat(computed.letterSpacing);
|
|
135
|
+
const spacingGap = Number.isFinite(letterSpacing) ? Math.max(0, letterSpacing) : 0;
|
|
136
|
+
const caretGap = inkOverflow + spacingGap;
|
|
137
|
+
// Толщина каретки пропорциональна шрифту (≈7% размера). Минимум 1px — при сильном
|
|
138
|
+
// отдалении каретка остаётся видимой и не нарушает пропорцию. Жёсткий пол 2px убран,
|
|
139
|
+
// чтобы при уменьшении масштаба каретка масштабировалась вместе с текстом.
|
|
140
|
+
const caretWidth = Math.max(1, Math.round(fontSize * 0.07));
|
|
89
141
|
|
|
90
142
|
// Цвет текста в textarea теперь прозрачный (видимый текст рисует backdrop-слой),
|
|
91
143
|
// поэтому цвет каретки берём из backdrop, иначе она станет невидимой.
|
|
@@ -101,7 +153,7 @@ export function updateCustomCaret(textarea, caretEl) {
|
|
|
101
153
|
}
|
|
102
154
|
|
|
103
155
|
caretEl.style.top = `${top - 2}px`;
|
|
104
|
-
caretEl.style.left = `${left - Math.floor(caretWidth / 2)}px`;
|
|
156
|
+
caretEl.style.left = `${left - Math.floor(caretWidth / 2) + caretGap}px`;
|
|
105
157
|
caretEl.style.height = `${coords.height}px`;
|
|
106
158
|
caretEl.style.width = `${caretWidth}px`;
|
|
107
159
|
caretEl.style.backgroundColor = caretColor;
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
showStaticTextAfterEditing,
|
|
9
9
|
updateGlobalTextEditorHandlesLayer,
|
|
10
10
|
} from './TextEditorLifecycleRegistry.js';
|
|
11
|
+
import { unregisterEditorListeners } from './InlineEditorListenersRegistry.js';
|
|
11
12
|
|
|
12
13
|
export function applyTextEditorCaretFromClick({ create, objectId, object, textarea }) {
|
|
13
14
|
try {
|
|
@@ -213,6 +214,7 @@ export function bindTextEditorInteractions(controller, {
|
|
|
213
214
|
isShape,
|
|
214
215
|
autoSize,
|
|
215
216
|
updateNoteEditor,
|
|
217
|
+
setNoteBanVisible,
|
|
216
218
|
finalize,
|
|
217
219
|
listType,
|
|
218
220
|
}) {
|
|
@@ -286,6 +288,12 @@ export function bindTextEditorInteractions(controller, {
|
|
|
286
288
|
const keydownHandler = (e) => {
|
|
287
289
|
const isList = listType && listType !== 'none';
|
|
288
290
|
if (e.key === 'Enter') {
|
|
291
|
+
// В записке Enter переносит каретку на новую строку (нативное поведение
|
|
292
|
+
// textarea), а не завершает ввод. Завершение — по клику вне записки (blur)
|
|
293
|
+
// или по Escape. Высота поля пересчитывается через 'input' → updateNoteEditor.
|
|
294
|
+
if (isNote) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
289
297
|
if (isList && !e.shiftKey) {
|
|
290
298
|
e.preventDefault();
|
|
291
299
|
const start = textarea.selectionStart;
|
|
@@ -320,7 +328,31 @@ export function bindTextEditorInteractions(controller, {
|
|
|
320
328
|
// уносил текст вправо от фигуры. Поэтому ввод в фигуре границы не меняет.
|
|
321
329
|
let inputHandler;
|
|
322
330
|
if (isNote) {
|
|
323
|
-
|
|
331
|
+
// Лимит строк записки: если очередной ввод превышает вместимость (>25 строк или
|
|
332
|
+
// не влезает по высоте при минимальном шрифте), откатываем значение и показываем
|
|
333
|
+
// иконку запрета. Иначе пересчитываем подгонку и держим иконку, пока записка полна.
|
|
334
|
+
let lastGoodValue = textarea.value;
|
|
335
|
+
let lastGoodSel = textarea.selectionStart || 0;
|
|
336
|
+
inputHandler = () => {
|
|
337
|
+
try {
|
|
338
|
+
const res = updateNoteEditor ? updateNoteEditor() : null;
|
|
339
|
+
// Откатываем только рост текста, превысивший вместимость. Удаление/замену
|
|
340
|
+
// на более короткое значение всегда принимаем, иначе из переполненного
|
|
341
|
+
// состояния нельзя выйти.
|
|
342
|
+
const growing = textarea.value.length > lastGoodValue.length;
|
|
343
|
+
if (res && res.fits === false && growing) {
|
|
344
|
+
const restorePos = Math.min(lastGoodSel, lastGoodValue.length);
|
|
345
|
+
textarea.value = lastGoodValue;
|
|
346
|
+
textarea.selectionStart = textarea.selectionEnd = restorePos;
|
|
347
|
+
if (updateNoteEditor) updateNoteEditor();
|
|
348
|
+
if (setNoteBanVisible) setNoteBanVisible(true);
|
|
349
|
+
} else {
|
|
350
|
+
lastGoodValue = textarea.value;
|
|
351
|
+
lastGoodSel = textarea.selectionStart || 0;
|
|
352
|
+
if (setNoteBanVisible) setNoteBanVisible(!!(res && res.full));
|
|
353
|
+
}
|
|
354
|
+
} catch (_) {}
|
|
355
|
+
};
|
|
324
356
|
} else if (isShape) {
|
|
325
357
|
inputHandler = () => {};
|
|
326
358
|
} else {
|
|
@@ -359,6 +391,10 @@ export function bindTextEditorInteractions(controller, {
|
|
|
359
391
|
};
|
|
360
392
|
|
|
361
393
|
const caretUpdateHandler = () => {
|
|
394
|
+
// _caretSuppressed выставляется registerRegularTextEditorSync на время зума:
|
|
395
|
+
// не перерисовываем каретку, пока она должна быть скрыта (избегаем мигания
|
|
396
|
+
// каретки старого размера в промежуточных кадрах при Ctrl±).
|
|
397
|
+
if (controller.textEditor && controller.textEditor._caretSuppressed) return;
|
|
362
398
|
if (controller.textEditor && controller.textEditor.caret) {
|
|
363
399
|
updateCustomCaret(textarea, controller.textEditor.caret);
|
|
364
400
|
}
|
|
@@ -444,6 +480,9 @@ export function closeTextEditorFromState(controller, commit) {
|
|
|
444
480
|
}
|
|
445
481
|
}
|
|
446
482
|
|
|
483
|
+
if (Array.isArray(controller.textEditor._listeners)) {
|
|
484
|
+
try { unregisterEditorListeners(controller.eventBus, controller.textEditor._listeners); } catch (_) {}
|
|
485
|
+
}
|
|
447
486
|
if (typeof removeDomListeners === 'function') {
|
|
448
487
|
try { removeDomListeners(); } catch (_) {}
|
|
449
488
|
}
|
|
@@ -71,6 +71,13 @@ export function cleanupActiveTextEditor(controller, wrapper) {
|
|
|
71
71
|
unregisterEditorListeners(controller.eventBus, controller.textEditor._listeners);
|
|
72
72
|
}
|
|
73
73
|
} catch (_) {}
|
|
74
|
+
// Снимаем таймер дебаунса и флаг подавления каретки (редактор мог закрыться в процессе зума)
|
|
75
|
+
try {
|
|
76
|
+
if (typeof controller.textEditor?._caretHideTimer === 'function') {
|
|
77
|
+
controller.textEditor._caretHideTimer();
|
|
78
|
+
}
|
|
79
|
+
if (controller.textEditor) controller.textEditor._caretSuppressed = false;
|
|
80
|
+
} catch (_) {}
|
|
74
81
|
|
|
75
82
|
wrapper.remove();
|
|
76
83
|
controller.textEditor = {
|
|
@@ -71,9 +71,10 @@ export function positionRegularTextEditor({
|
|
|
71
71
|
} catch (_) {}
|
|
72
72
|
|
|
73
73
|
const leftPx = Math.round(baseLeftPx - padLeft);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
// Верх текста редактора привязываем к точке клика (= верх рамки ручек и будущего
|
|
75
|
+
// статического текста). Прежний create-якорь `- lineHeightPx/2` (центрирование каретки
|
|
76
|
+
// по клику) поднимал текст на пол-строки выше рамки mb-handles-box-obj_.
|
|
77
|
+
const topPx = Math.round(baseTopPx + staticPadTop - padTop + 1);
|
|
77
78
|
|
|
78
79
|
wrapper.style.left = `${leftPx}px`;
|
|
79
80
|
wrapper.style.top = `${topPx}px`;
|