@sequent-org/moodboard 1.4.59 → 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/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 +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/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/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
|
}
|
|
@@ -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, иначе она станет невидимой.
|
|
@@ -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 {
|
|
@@ -286,6 +287,12 @@ export function bindTextEditorInteractions(controller, {
|
|
|
286
287
|
const keydownHandler = (e) => {
|
|
287
288
|
const isList = listType && listType !== 'none';
|
|
288
289
|
if (e.key === 'Enter') {
|
|
290
|
+
// В записке Enter переносит каретку на новую строку (нативное поведение
|
|
291
|
+
// textarea), а не завершает ввод. Завершение — по клику вне записки (blur)
|
|
292
|
+
// или по Escape. Высота поля пересчитывается через 'input' → updateNoteEditor.
|
|
293
|
+
if (isNote) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
289
296
|
if (isList && !e.shiftKey) {
|
|
290
297
|
e.preventDefault();
|
|
291
298
|
const start = textarea.selectionStart;
|
|
@@ -359,6 +366,10 @@ export function bindTextEditorInteractions(controller, {
|
|
|
359
366
|
};
|
|
360
367
|
|
|
361
368
|
const caretUpdateHandler = () => {
|
|
369
|
+
// _caretSuppressed выставляется registerRegularTextEditorSync на время зума:
|
|
370
|
+
// не перерисовываем каретку, пока она должна быть скрыта (избегаем мигания
|
|
371
|
+
// каретки старого размера в промежуточных кадрах при Ctrl±).
|
|
372
|
+
if (controller.textEditor && controller.textEditor._caretSuppressed) return;
|
|
362
373
|
if (controller.textEditor && controller.textEditor.caret) {
|
|
363
374
|
updateCustomCaret(textarea, controller.textEditor.caret);
|
|
364
375
|
}
|
|
@@ -444,6 +455,9 @@ export function closeTextEditorFromState(controller, commit) {
|
|
|
444
455
|
}
|
|
445
456
|
}
|
|
446
457
|
|
|
458
|
+
if (Array.isArray(controller.textEditor._listeners)) {
|
|
459
|
+
try { unregisterEditorListeners(controller.eventBus, controller.textEditor._listeners); } catch (_) {}
|
|
460
|
+
}
|
|
447
461
|
if (typeof removeDomListeners === 'function') {
|
|
448
462
|
try { removeDomListeners(); } catch (_) {}
|
|
449
463
|
}
|
|
@@ -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`;
|