@sequent-org/moodboard 1.4.55 → 1.4.57
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/commands/PasteObjectCommand.js +7 -1
- package/src/core/commands/UpdateContentCommand.js +49 -0
- package/src/core/flows/ObjectLifecycleFlow.js +10 -1
- package/src/initNoBundler.js +29 -5
- package/src/tools/object-tools/TextTool.js +9 -3
- package/src/tools/object-tools/selection/SelectInputRouter.js +1 -1
- package/src/tools/object-tools/selection/TextEditorInteractionController.js +86 -11
- package/src/tools/object-tools/selection/TextInlineEditorController.js +2 -2
- package/src/ui/FramePropertiesPanel.js +7 -0
- package/src/ui/HtmlTextLayer.js +79 -6
- package/src/ui/ImagePropertiesPanel.js +7 -0
- package/src/ui/TextPropertiesPanel.js +163 -3
- package/src/ui/styles/panels.css +471 -31
- package/src/ui/styles/workspace.css +31 -7
- package/src/ui/text-properties/TextFormatControls.js +351 -41
- package/src/ui/text-properties/TextLinkControl.js +255 -0
- package/src/ui/text-properties/TextLockMoreControls.js +173 -0
- package/src/ui/text-properties/TextPropertiesPanelBindings.js +105 -2
- package/src/ui/text-properties/TextPropertiesPanelMapper.js +23 -0
- package/src/ui/text-properties/TextPropertiesPanelRenderer.js +348 -56
- package/src/ui/text-properties/TextPropertiesPanelState.js +6 -0
- package/src/utils/styleLoader.js +1 -1
package/package.json
CHANGED
|
@@ -87,7 +87,13 @@ export class PasteObjectCommand extends BaseCommand {
|
|
|
87
87
|
|
|
88
88
|
// Создаем PIXI объект
|
|
89
89
|
this.coreMoodboard.pixi.createObject(this.newObjectData);
|
|
90
|
-
|
|
90
|
+
|
|
91
|
+
// Уведомляем слои (HtmlTextLayer и др.), которые ждут { objectId, objectData }
|
|
92
|
+
this.coreMoodboard.eventBus.emit(Events.Object.Created, {
|
|
93
|
+
objectId: this.newObjectId,
|
|
94
|
+
objectData: this.newObjectData,
|
|
95
|
+
});
|
|
96
|
+
|
|
91
97
|
this.emit(Events.Object.Pasted, {
|
|
92
98
|
originalId: originalData.id,
|
|
93
99
|
newId: this.newObjectId,
|
|
@@ -47,6 +47,14 @@ export class UpdateContentCommand extends BaseCommand {
|
|
|
47
47
|
if (!object.properties) {
|
|
48
48
|
object.properties = {};
|
|
49
49
|
}
|
|
50
|
+
// Пересчитываем диапазоны ссылок при изменении контента
|
|
51
|
+
if (Array.isArray(object.properties.links) && object.properties.links.length > 0) {
|
|
52
|
+
object.properties.links = _adjustLinks(
|
|
53
|
+
object.properties.links,
|
|
54
|
+
object.properties.content ?? '',
|
|
55
|
+
content
|
|
56
|
+
);
|
|
57
|
+
}
|
|
50
58
|
object.properties.content = content;
|
|
51
59
|
|
|
52
60
|
const isMindmap = object.type === 'mindmap';
|
|
@@ -93,3 +101,44 @@ export class UpdateContentCommand extends BaseCommand {
|
|
|
93
101
|
});
|
|
94
102
|
}
|
|
95
103
|
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Пересчитывает диапазоны ссылок после изменения контента.
|
|
107
|
+
* Алгоритм: находим наибольший общий префикс и суффикс, определяем
|
|
108
|
+
* изменённый диапазон; ссылки внутри него удаляем, за ним — сдвигаем.
|
|
109
|
+
* @param {Array<{start:number,end:number,url:string}>} links
|
|
110
|
+
* @param {string} oldText
|
|
111
|
+
* @param {string} newText
|
|
112
|
+
* @returns {Array<{start:number,end:number,url:string}>}
|
|
113
|
+
*/
|
|
114
|
+
function _adjustLinks(links, oldText, newText) {
|
|
115
|
+
if (!links || links.length === 0) return links;
|
|
116
|
+
|
|
117
|
+
let prefixLen = 0;
|
|
118
|
+
const minLen = Math.min(oldText.length, newText.length);
|
|
119
|
+
while (prefixLen < minLen && oldText[prefixLen] === newText[prefixLen]) prefixLen++;
|
|
120
|
+
|
|
121
|
+
let oldSuffix = 0;
|
|
122
|
+
while (
|
|
123
|
+
oldSuffix < oldText.length - prefixLen &&
|
|
124
|
+
oldSuffix < newText.length - prefixLen &&
|
|
125
|
+
oldText[oldText.length - 1 - oldSuffix] === newText[newText.length - 1 - oldSuffix]
|
|
126
|
+
) oldSuffix++;
|
|
127
|
+
|
|
128
|
+
const oldChangeEnd = oldText.length - oldSuffix; // конец изменённого диапазона в старом тексте
|
|
129
|
+
const newChangeEnd = newText.length - oldSuffix; // конец изменённого диапазона в новом тексте
|
|
130
|
+
const delta = newChangeEnd - oldChangeEnd; // сдвиг для символов после изменения
|
|
131
|
+
|
|
132
|
+
return links.reduce((acc, link) => {
|
|
133
|
+
const { start, end, url } = link;
|
|
134
|
+
if (end <= prefixLen) {
|
|
135
|
+
// Ссылка полностью в неизменённом префиксе — оставляем
|
|
136
|
+
acc.push({ start, end, url });
|
|
137
|
+
} else if (start >= oldChangeEnd) {
|
|
138
|
+
// Ссылка полностью после изменения — сдвигаем
|
|
139
|
+
acc.push({ start: start + delta, end: end + delta, url });
|
|
140
|
+
}
|
|
141
|
+
// Ссылка перекрывает изменённый диапазон — удаляем (стала некорректной)
|
|
142
|
+
return acc;
|
|
143
|
+
}, []);
|
|
144
|
+
}
|
|
@@ -309,7 +309,16 @@ export function setupObjectLifecycleFlow(core) {
|
|
|
309
309
|
const objects = core.state.getObjects();
|
|
310
310
|
const object = objects.find(obj => obj.id === data.objectId);
|
|
311
311
|
if (object) {
|
|
312
|
-
|
|
312
|
+
let w = object.width;
|
|
313
|
+
let h = object.height;
|
|
314
|
+
if (typeof w !== 'number' || typeof h !== 'number') {
|
|
315
|
+
const pixiObj = core.pixi.objects.get(data.objectId);
|
|
316
|
+
if (pixiObj) {
|
|
317
|
+
if (typeof w !== 'number') w = pixiObj.width;
|
|
318
|
+
if (typeof h !== 'number') h = pixiObj.height;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
data.size = { width: w, height: h };
|
|
313
322
|
}
|
|
314
323
|
});
|
|
315
324
|
|
package/src/initNoBundler.js
CHANGED
|
@@ -203,9 +203,9 @@ export function injectCriticalStyles() {
|
|
|
203
203
|
min-height: 20px;
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
-
.current-color-button, .
|
|
207
|
-
width:
|
|
208
|
-
height:
|
|
206
|
+
.current-color-button, .fpp-color-button {
|
|
207
|
+
width: 24px !important;
|
|
208
|
+
height: 24px !important;
|
|
209
209
|
border: 1px solid #ddd;
|
|
210
210
|
border-radius: 50%;
|
|
211
211
|
cursor: pointer;
|
|
@@ -214,6 +214,18 @@ export function injectCriticalStyles() {
|
|
|
214
214
|
display: block;
|
|
215
215
|
box-sizing: border-box;
|
|
216
216
|
}
|
|
217
|
+
|
|
218
|
+
.current-bgcolor-button {
|
|
219
|
+
width: 20px !important;
|
|
220
|
+
height: 20px !important;
|
|
221
|
+
border: none;
|
|
222
|
+
border-radius: 50%;
|
|
223
|
+
cursor: pointer;
|
|
224
|
+
margin: 0;
|
|
225
|
+
padding: 0;
|
|
226
|
+
display: block;
|
|
227
|
+
box-sizing: border-box;
|
|
228
|
+
}
|
|
217
229
|
|
|
218
230
|
/* Скрыть до полной загрузки стилей */
|
|
219
231
|
.moodboard-toolbar__popup {
|
|
@@ -356,9 +368,9 @@ export function forceInjectPanelStyles() {
|
|
|
356
368
|
}
|
|
357
369
|
|
|
358
370
|
.font-select { min-width: 110px !important; }
|
|
359
|
-
.font-size-select { min-width:
|
|
371
|
+
.font-size-select { min-width: 32px !important; }
|
|
360
372
|
|
|
361
|
-
.current-color-button, .
|
|
373
|
+
.current-color-button, .fpp-color-button {
|
|
362
374
|
width: 28px !important;
|
|
363
375
|
height: 28px !important;
|
|
364
376
|
border: 1px solid #ddd !important;
|
|
@@ -369,6 +381,18 @@ export function forceInjectPanelStyles() {
|
|
|
369
381
|
display: block !important;
|
|
370
382
|
box-sizing: border-box !important;
|
|
371
383
|
}
|
|
384
|
+
|
|
385
|
+
.current-bgcolor-button {
|
|
386
|
+
width: 20px !important;
|
|
387
|
+
height: 20px !important;
|
|
388
|
+
border: none !important;
|
|
389
|
+
border-radius: 50% !important;
|
|
390
|
+
cursor: pointer !important;
|
|
391
|
+
margin: 0 !important;
|
|
392
|
+
padding: 0 !important;
|
|
393
|
+
display: block !important;
|
|
394
|
+
box-sizing: border-box !important;
|
|
395
|
+
}
|
|
372
396
|
`;
|
|
373
397
|
|
|
374
398
|
const style = document.createElement('style');
|
|
@@ -108,7 +108,6 @@ export class TextTool extends BaseTool {
|
|
|
108
108
|
startEditingNew(textData, x, y) {
|
|
109
109
|
this.isEditing = true;
|
|
110
110
|
this.editingObject = textData;
|
|
111
|
-
|
|
112
111
|
this.createTextInput(x, y, '');
|
|
113
112
|
this.emit('text:edit:start', { object: textData });
|
|
114
113
|
}
|
|
@@ -166,7 +165,7 @@ export class TextTool extends BaseTool {
|
|
|
166
165
|
});
|
|
167
166
|
|
|
168
167
|
// Обработчики событий input
|
|
169
|
-
this.textInput.addEventListener('blur', () => this.
|
|
168
|
+
this.textInput.addEventListener('blur', (e) => this.handleBlur(e));
|
|
170
169
|
this.textInput.addEventListener('keydown', (e) => this.handleInputKeys(e));
|
|
171
170
|
this.textInput.addEventListener('input', (e) => this.handleTextChange(e));
|
|
172
171
|
|
|
@@ -183,6 +182,13 @@ export class TextTool extends BaseTool {
|
|
|
183
182
|
this.adjustInputSize();
|
|
184
183
|
}
|
|
185
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Обработка blur
|
|
187
|
+
*/
|
|
188
|
+
handleBlur(event) {
|
|
189
|
+
this.finishEditing();
|
|
190
|
+
}
|
|
191
|
+
|
|
186
192
|
/**
|
|
187
193
|
* Обработка клавиш в input
|
|
188
194
|
*/
|
|
@@ -328,7 +334,7 @@ export class TextTool extends BaseTool {
|
|
|
328
334
|
// TODO: Реализовать поиск текстового объекта по координатам
|
|
329
335
|
return null; // Временная заглушка
|
|
330
336
|
}
|
|
331
|
-
|
|
337
|
+
|
|
332
338
|
/**
|
|
333
339
|
* Обработка клавиш во время редактирования (глобальные)
|
|
334
340
|
*/
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Events } from '../../../core/events/Events.js';
|
|
2
2
|
|
|
3
3
|
const DRAG_START_THRESHOLD_PX = 4;
|
|
4
|
-
const TEXT_EDITOR_STYLE_KEYS = ['fontFamily', 'fontSize', 'color', 'backgroundColor', 'markdown', 'bold', 'italic', 'underline', 'strikethrough', 'textAlign', 'lineHeight', 'listType', 'listChecked'];
|
|
4
|
+
const TEXT_EDITOR_STYLE_KEYS = ['fontFamily', 'fontSize', 'color', 'highlightColor', 'backgroundColor', 'markdown', 'bold', 'italic', 'underline', 'strikethrough', 'textAlign', 'lineHeight', 'listType', 'listChecked'];
|
|
5
5
|
|
|
6
6
|
function pickTextEditorProperties(properties = {}) {
|
|
7
7
|
const picked = {};
|
|
@@ -92,7 +92,9 @@ export function createTextEditorFinalize(controller, {
|
|
|
92
92
|
const scaleX = worldLayerRef?.scale?.x || 1;
|
|
93
93
|
const viewResLocal = (controller.app?.renderer?.resolution) || (view.width && view.clientWidth ? (view.width / view.clientWidth) : 1);
|
|
94
94
|
const wPx = Math.max(1, wrapper.offsetWidth);
|
|
95
|
-
const
|
|
95
|
+
const _taStyle = typeof window !== 'undefined' ? window.getComputedStyle(textarea) : null;
|
|
96
|
+
const _taPaddingV = _taStyle ? (parseFloat(_taStyle.paddingTop) || 0) + (parseFloat(_taStyle.paddingBottom) || 0) : 0;
|
|
97
|
+
const hPx = Math.max(1, wrapper.offsetHeight - _taPaddingV);
|
|
96
98
|
const newW = Math.max(1, Math.round(wPx * viewResLocal / scaleX));
|
|
97
99
|
const newH = Math.max(1, Math.round(hPx * viewResLocal / scaleX));
|
|
98
100
|
const sizeReq = { objectId, size: null };
|
|
@@ -135,14 +137,22 @@ export function createTextEditorFinalize(controller, {
|
|
|
135
137
|
const worldLayerRef = controller.textEditor.world || (controller.app?.stage);
|
|
136
138
|
const scaleX = worldLayerRef?.scale?.x || 1;
|
|
137
139
|
const wPx = Math.max(1, wrapper.offsetWidth);
|
|
138
|
-
const
|
|
140
|
+
const _taStyle2 = typeof window !== 'undefined' ? window.getComputedStyle(textarea) : null;
|
|
141
|
+
const _taPaddingV2 = _taStyle2 ? (parseFloat(_taStyle2.paddingTop) || 0) + (parseFloat(_taStyle2.paddingBottom) || 0) : 0;
|
|
142
|
+
const hPx = Math.max(1, wrapper.offsetHeight - _taPaddingV2);
|
|
139
143
|
const wWorld = Math.max(1, Math.round(wPx * viewRes / scaleX));
|
|
140
144
|
const hWorld = Math.max(1, Math.round(hPx * viewRes / scaleX));
|
|
141
145
|
controller.eventBus.emit(Events.UI.ToolbarAction, {
|
|
142
146
|
type: objectType,
|
|
143
147
|
id: objectType,
|
|
144
148
|
position: { x: position.x, y: position.y },
|
|
145
|
-
properties: {
|
|
149
|
+
properties: {
|
|
150
|
+
content: value,
|
|
151
|
+
fontSize,
|
|
152
|
+
width: wWorld,
|
|
153
|
+
height: hWorld,
|
|
154
|
+
highlightColor: properties.highlightColor
|
|
155
|
+
},
|
|
146
156
|
});
|
|
147
157
|
} else {
|
|
148
158
|
if (isNewCreation) {
|
|
@@ -164,6 +174,22 @@ export function createTextEditorFinalize(controller, {
|
|
|
164
174
|
};
|
|
165
175
|
}
|
|
166
176
|
|
|
177
|
+
// Классы DOM-элементов, клики по которым не должны закрывать пустой текстовый редактор.
|
|
178
|
+
const UI_BLOCK_SELECTOR = [
|
|
179
|
+
'.moodboard-toolbar',
|
|
180
|
+
'.text-properties-layer',
|
|
181
|
+
'.moodboard-topbar',
|
|
182
|
+
'.moodboard-zoom-panel',
|
|
183
|
+
'.moodboard-ui-layer',
|
|
184
|
+
].join(', ');
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Возвращает true, если элемент находится внутри UI-панелей (тулбар, панель свойств текста и т.п.).
|
|
188
|
+
*/
|
|
189
|
+
function _isInsideToolbarUI(target) {
|
|
190
|
+
return !!(target && typeof target.closest === 'function' && target.closest(UI_BLOCK_SELECTOR));
|
|
191
|
+
}
|
|
192
|
+
|
|
167
193
|
export function bindTextEditorInteractions(controller, {
|
|
168
194
|
textarea,
|
|
169
195
|
isNewCreation,
|
|
@@ -192,6 +218,46 @@ export function bindTextEditorInteractions(controller, {
|
|
|
192
218
|
}, 0);
|
|
193
219
|
};
|
|
194
220
|
|
|
221
|
+
// Перехватываем mousedown на тулбаре/панелях в capture-фазе, чтобы:
|
|
222
|
+
// 1. Предотвратить потерю фокуса textarea (e.preventDefault()).
|
|
223
|
+
// 2. Заблокировать последующий click до ToolbarActionRouter (одноразовый capture-обработчик).
|
|
224
|
+
// Работает только пока поле ввода пусто и объект только что создан — если текст уже есть,
|
|
225
|
+
// клик по другому инструменту отрабатывает штатно (commit + переключение).
|
|
226
|
+
let _pendingClickBlocker = null;
|
|
227
|
+
const mousedownCaptureHandler = (e) => {
|
|
228
|
+
if (!controller?.textEditor?.active) return;
|
|
229
|
+
if (e.target === textarea) return;
|
|
230
|
+
if (!_isInsideToolbarUI(e.target)) return;
|
|
231
|
+
|
|
232
|
+
const value = (textarea.value || '').trim();
|
|
233
|
+
if (!isNewCreation || value.length > 0) return;
|
|
234
|
+
|
|
235
|
+
// Пустое новое поле + клик по UI: не даём сместить фокус с textarea.
|
|
236
|
+
e.preventDefault();
|
|
237
|
+
|
|
238
|
+
// Блокируем следующий click-события, чтобы ToolbarActionRouter не переключил инструмент.
|
|
239
|
+
if (_pendingClickBlocker) {
|
|
240
|
+
document.removeEventListener('click', _pendingClickBlocker, true);
|
|
241
|
+
}
|
|
242
|
+
_pendingClickBlocker = (ce) => {
|
|
243
|
+
if (_isInsideToolbarUI(ce.target)) {
|
|
244
|
+
ce.stopPropagation();
|
|
245
|
+
ce.preventDefault();
|
|
246
|
+
}
|
|
247
|
+
document.removeEventListener('click', _pendingClickBlocker, true);
|
|
248
|
+
_pendingClickBlocker = null;
|
|
249
|
+
};
|
|
250
|
+
document.addEventListener('click', _pendingClickBlocker, true);
|
|
251
|
+
|
|
252
|
+
// Страховочный возврат фокуса на случай, если браузер всё-таки убрал фокус.
|
|
253
|
+
setTimeout(() => {
|
|
254
|
+
if (controller?.textEditor?.active && textarea) {
|
|
255
|
+
try { textarea.focus(); } catch (_) {}
|
|
256
|
+
}
|
|
257
|
+
}, 0);
|
|
258
|
+
};
|
|
259
|
+
document.addEventListener('mousedown', mousedownCaptureHandler, true);
|
|
260
|
+
|
|
195
261
|
const keydownHandler = (e) => {
|
|
196
262
|
const isList = listType && listType !== 'none';
|
|
197
263
|
if (e.key === 'Enter') {
|
|
@@ -244,6 +310,11 @@ export function bindTextEditorInteractions(controller, {
|
|
|
244
310
|
textarea.removeEventListener('blur', blurHandler);
|
|
245
311
|
textarea.removeEventListener('keydown', keydownHandler);
|
|
246
312
|
textarea.removeEventListener('input', inputHandler);
|
|
313
|
+
document.removeEventListener('mousedown', mousedownCaptureHandler, true);
|
|
314
|
+
if (_pendingClickBlocker) {
|
|
315
|
+
document.removeEventListener('click', _pendingClickBlocker, true);
|
|
316
|
+
_pendingClickBlocker = null;
|
|
317
|
+
}
|
|
247
318
|
};
|
|
248
319
|
|
|
249
320
|
if (controller.textEditor) {
|
|
@@ -316,14 +387,18 @@ export function closeTextEditorFromState(controller, commit) {
|
|
|
316
387
|
}
|
|
317
388
|
return;
|
|
318
389
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
390
|
+
if (objectId == null) {
|
|
391
|
+
controller.eventBus.emit(Events.UI.ToolbarAction, {
|
|
392
|
+
type: objectType,
|
|
393
|
+
id: objectType,
|
|
394
|
+
position: { x: position.x, y: position.y },
|
|
395
|
+
properties: {
|
|
396
|
+
content: value,
|
|
397
|
+
fontSize: properties.fontSize,
|
|
398
|
+
highlightColor: properties.highlightColor
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
} else {
|
|
327
402
|
if (isNewCreation) {
|
|
328
403
|
const oldContent = typeof initialContent === 'string' ? initialContent : '';
|
|
329
404
|
controller.eventBus.emit(Events.Object.ContentChange, {
|
|
@@ -437,7 +437,7 @@ export function openTextEditor(object, create = false) {
|
|
|
437
437
|
// +25% — запас на Caveat vs Arial: при незагруженном Caveat span рендерится в Arial,
|
|
438
438
|
// а Caveat (рукописный шрифт) заметно шире для кириллицы.
|
|
439
439
|
const startWidth = Math.max(1, Math.ceil(measureTextEditorPlaceholderWidth(textarea, 'Напишите что-нибудь') * 1.25));
|
|
440
|
-
const startHeight = Math.max(1, lhInitial - BASELINE_FIX +
|
|
440
|
+
const startHeight = Math.max(1, lhInitial - BASELINE_FIX + 2); // +1px сверху и +1px снизу (паддинги textarea)
|
|
441
441
|
textarea.style.width = `${startWidth}px`;
|
|
442
442
|
textarea.style.height = `${startHeight}px`;
|
|
443
443
|
wrapper.style.width = `${startWidth}px`;
|
|
@@ -502,7 +502,7 @@ export function openTextEditor(object, create = false) {
|
|
|
502
502
|
wrapper,
|
|
503
503
|
world: this.textEditor.world,
|
|
504
504
|
position,
|
|
505
|
-
properties: { fontSize },
|
|
505
|
+
properties: { fontSize, highlightColor: properties.highlightColor },
|
|
506
506
|
objectType,
|
|
507
507
|
listType: properties.listType || 'none',
|
|
508
508
|
_phStyle: styleEl,
|
|
@@ -1043,7 +1043,14 @@ export class FramePropertiesPanel {
|
|
|
1043
1043
|
const isOpen = dropdown.classList.contains('is-open');
|
|
1044
1044
|
this._closeMenus();
|
|
1045
1045
|
if (!isOpen) {
|
|
1046
|
+
const rect = mainBtn.getBoundingClientRect();
|
|
1047
|
+
dropdown.style.top = (rect.bottom + 6) + 'px';
|
|
1048
|
+
dropdown.style.left = (rect.right - 220) + 'px';
|
|
1046
1049
|
dropdown.classList.add('is-open');
|
|
1050
|
+
requestAnimationFrame(() => {
|
|
1051
|
+
const left = Math.max(4, rect.right - dropdown.offsetWidth);
|
|
1052
|
+
dropdown.style.left = left + 'px';
|
|
1053
|
+
});
|
|
1047
1054
|
mainBtn.classList.add('is-active');
|
|
1048
1055
|
this._attachOutside();
|
|
1049
1056
|
}
|
package/src/ui/HtmlTextLayer.js
CHANGED
|
@@ -67,6 +67,44 @@ function resolveLineHeightRatio(baseFontSizePx, properties) {
|
|
|
67
67
|
return 1.18;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Строит безопасный HTML-фрагмент с кликабельными ссылками по массиву диапазонов.
|
|
72
|
+
* Символы вне ссылок экранируются через textContent. Диапазоны не должны пересекаться.
|
|
73
|
+
* @param {string} content
|
|
74
|
+
* @param {Array<{start:number, end:number, url:string}>} links
|
|
75
|
+
* @returns {string} готовый innerHTML
|
|
76
|
+
*/
|
|
77
|
+
function _buildHtmlWithLinks(content, links) {
|
|
78
|
+
if (!links || links.length === 0) return _escapeHtml(content);
|
|
79
|
+
|
|
80
|
+
const sorted = [...links]
|
|
81
|
+
.filter(l => typeof l.start === 'number' && typeof l.end === 'number' && l.end > l.start && l.url)
|
|
82
|
+
.sort((a, b) => a.start - b.start);
|
|
83
|
+
|
|
84
|
+
let result = '';
|
|
85
|
+
let pos = 0;
|
|
86
|
+
for (const link of sorted) {
|
|
87
|
+
const s = Math.max(0, link.start);
|
|
88
|
+
const e = Math.min(content.length, link.end);
|
|
89
|
+
if (s < pos) continue; // пропускаем пересечения
|
|
90
|
+
if (s > pos) result += _escapeHtml(content.slice(pos, s));
|
|
91
|
+
const linkText = _escapeHtml(content.slice(s, e));
|
|
92
|
+
const href = _escapeAttr(link.url);
|
|
93
|
+
result += `<a href="${href}" target="_blank" rel="noopener noreferrer" class="mb-text-link">${linkText}</a>`;
|
|
94
|
+
pos = e;
|
|
95
|
+
}
|
|
96
|
+
if (pos < content.length) result += _escapeHtml(content.slice(pos));
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function _escapeHtml(str) {
|
|
101
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function _escapeAttr(str) {
|
|
105
|
+
return str.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
106
|
+
}
|
|
107
|
+
|
|
70
108
|
/**
|
|
71
109
|
* HtmlTextLayer — рисует текст как HTML-элементы поверх PIXI для максимальной чёткости
|
|
72
110
|
* Синхронизирует позицию/размер/масштаб с миром (worldLayer) и состоянием объектов
|
|
@@ -108,6 +146,10 @@ export class HtmlTextLayer {
|
|
|
108
146
|
if (objectData.type === 'text' || objectData.type === 'simple-text' || objectData.type === 'shape') {
|
|
109
147
|
this._ensureTextEl(objectId, objectData);
|
|
110
148
|
this.updateOne(objectId);
|
|
149
|
+
// Нормализуем высоту по реальному scrollHeight .mb-text,
|
|
150
|
+
// чтобы начальная рамка совпадала с той, что получается после
|
|
151
|
+
// любого изменения свойства (там тоже вызывается _autoFitTextHeight).
|
|
152
|
+
this._autoFitTextHeight(objectId);
|
|
111
153
|
}
|
|
112
154
|
});
|
|
113
155
|
this.eventBus.on(Events.Object.Deleted, ({ objectId }) => {
|
|
@@ -166,13 +208,20 @@ export class HtmlTextLayer {
|
|
|
166
208
|
if (el && typeof content === 'string') {
|
|
167
209
|
const _obj = this.core?.state?.state?.objects?.find(o => o.id === objectId);
|
|
168
210
|
const isMarkdown = resolveMarkdown(_obj?.properties, content);
|
|
169
|
-
|
|
211
|
+
const _links = !isMarkdown ? (_obj?.properties?.links || null) : null;
|
|
212
|
+
this._syncElementContent(el, content, isMarkdown, _links);
|
|
170
213
|
if (el.classList.contains('mb-text--md') !== isMarkdown) {
|
|
171
214
|
el.classList.toggle('mb-text--md', isMarkdown);
|
|
172
215
|
el.style.whiteSpace = isMarkdown ? 'normal' : 'pre';
|
|
173
216
|
el.style.overflowWrap = isMarkdown ? 'break-word' : '';
|
|
174
217
|
if (!isMarkdown) el.style.padding = '0';
|
|
175
218
|
}
|
|
219
|
+
// После коммита текста высота .mb-text осталась от пустого редактора (схлопнута),
|
|
220
|
+
// а ResizeUpdate при завершении редактирования отрабатывает раньше синхронизации
|
|
221
|
+
// контента. Без пере-подгонки DOM-бокс не оборачивает глифы, и рамка выделения,
|
|
222
|
+
// строящаяся по getBoundingClientRect этого блока, оказывается выше текста.
|
|
223
|
+
this._autoFitTextHeight(objectId);
|
|
224
|
+
this.updateOne(objectId);
|
|
176
225
|
console.log(`🔍 HtmlTextLayer: содержимое обновлено для ${objectId}:`, content);
|
|
177
226
|
} else {
|
|
178
227
|
console.warn(`❌ HtmlTextLayer: не удалось обновить содержимое для ${objectId}:`, { el: !!el, content });
|
|
@@ -205,6 +254,20 @@ export class HtmlTextLayer {
|
|
|
205
254
|
el.style.backgroundColor = updates.backgroundColor === 'transparent' ? '' : updates.backgroundColor;
|
|
206
255
|
console.log(`🔍 HtmlTextLayer: обновлен фон для ${objectId}:`, updates.backgroundColor);
|
|
207
256
|
}
|
|
257
|
+
if (updates.highlightColor !== undefined) {
|
|
258
|
+
if (updates.highlightColor === 'transparent') {
|
|
259
|
+
el.style.removeProperty('--highlight-color');
|
|
260
|
+
// Не сбрасываем backgroundColor, так как он может быть установлен отдельно
|
|
261
|
+
} else {
|
|
262
|
+
el.style.setProperty('--highlight-color', updates.highlightColor);
|
|
263
|
+
// Для обычного текста без Quill мы можем просто установить backgroundColor
|
|
264
|
+
// если нет выделения
|
|
265
|
+
if (!el.querySelector('span[style*="background-color"]')) {
|
|
266
|
+
el.style.backgroundColor = updates.highlightColor;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
console.log(`🔍 HtmlTextLayer: обновлен цвет фона текста для ${objectId}:`, updates.highlightColor);
|
|
270
|
+
}
|
|
208
271
|
// После изменения свойств текста — автоподгон высоты рамки под контент и принудительное обновление
|
|
209
272
|
this._autoFitTextHeight(objectId);
|
|
210
273
|
this.updateOne(objectId);
|
|
@@ -416,7 +479,8 @@ export class HtmlTextLayer {
|
|
|
416
479
|
const initDec = [props.underline && 'underline', props.strikethrough && 'line-through'].filter(Boolean).join(' ');
|
|
417
480
|
el.style.textDecoration = initDec || '';
|
|
418
481
|
el.style.textAlign = props.textAlign || '';
|
|
419
|
-
|
|
482
|
+
const initLinks = !isMarkdown ? (props.links || null) : null;
|
|
483
|
+
this._syncElementContent(el, content, isMarkdown, initLinks);
|
|
420
484
|
// Базовые размеры сохраняем в dataset
|
|
421
485
|
const fs = objectData.fontSize || objectData.properties?.fontSize || 32;
|
|
422
486
|
const bw = Math.max(1, objectData.width || objectData.properties?.baseW || 160);
|
|
@@ -623,7 +687,8 @@ export class HtmlTextLayer {
|
|
|
623
687
|
el.style.padding = '';
|
|
624
688
|
} else {
|
|
625
689
|
el.dataset.renderedList = '';
|
|
626
|
-
const
|
|
690
|
+
const plainLinks = !isMarkdown ? (props.links || null) : null;
|
|
691
|
+
const contentChanged = this._syncElementContent(el, content, isMarkdown, plainLinks);
|
|
627
692
|
if (contentChanged) {
|
|
628
693
|
console.log(`🔍 HtmlTextLayer: содержимое обновлено в updateOne для ${objectId}:`, content);
|
|
629
694
|
}
|
|
@@ -680,18 +745,26 @@ export class HtmlTextLayer {
|
|
|
680
745
|
});
|
|
681
746
|
}
|
|
682
747
|
|
|
683
|
-
/** Обновляет innerHTML/textContent только при реальной смене content
|
|
684
|
-
_syncElementContent(el, content, isMarkdown) {
|
|
748
|
+
/** Обновляет innerHTML/textContent только при реальной смене content, флага markdown или ссылок */
|
|
749
|
+
_syncElementContent(el, content, isMarkdown, links) {
|
|
685
750
|
if (typeof content !== 'string') return false;
|
|
686
751
|
const mdFlag = isMarkdown ? '1' : '0';
|
|
687
|
-
|
|
752
|
+
const linksKey = (Array.isArray(links) && links.length > 0) ? JSON.stringify(links) : '';
|
|
753
|
+
if (
|
|
754
|
+
el.dataset.renderedContent === content &&
|
|
755
|
+
el.dataset.renderedMd === mdFlag &&
|
|
756
|
+
(el.dataset.renderedLinks || '') === linksKey
|
|
757
|
+
) return false;
|
|
688
758
|
if (isMarkdown) {
|
|
689
759
|
el.innerHTML = renderRichText(content);
|
|
760
|
+
} else if (linksKey) {
|
|
761
|
+
el.innerHTML = _buildHtmlWithLinks(content, links);
|
|
690
762
|
} else {
|
|
691
763
|
el.textContent = content;
|
|
692
764
|
}
|
|
693
765
|
el.dataset.renderedContent = content;
|
|
694
766
|
el.dataset.renderedMd = mdFlag;
|
|
767
|
+
el.dataset.renderedLinks = linksKey;
|
|
695
768
|
return true;
|
|
696
769
|
}
|
|
697
770
|
|
|
@@ -1105,7 +1105,14 @@ export class ImagePropertiesPanel {
|
|
|
1105
1105
|
});
|
|
1106
1106
|
|
|
1107
1107
|
if (!isOpen) {
|
|
1108
|
+
const rect = mainBtn.getBoundingClientRect();
|
|
1109
|
+
dropdown.style.top = (rect.bottom + 6) + 'px';
|
|
1110
|
+
dropdown.style.left = (rect.right - 220) + 'px';
|
|
1108
1111
|
dropdown.classList.add('is-open');
|
|
1112
|
+
requestAnimationFrame(() => {
|
|
1113
|
+
const left = Math.max(4, rect.right - dropdown.offsetWidth);
|
|
1114
|
+
dropdown.style.left = left + 'px';
|
|
1115
|
+
});
|
|
1109
1116
|
mainBtn.classList.add('is-active');
|
|
1110
1117
|
} else {
|
|
1111
1118
|
infoPopover.classList.remove('is-open');
|