@sequent-org/moodboard 1.4.60 → 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/tools/object-tools/connector/ConnectorDragController.js +10 -30
- package/src/tools/object-tools/selection/NoteInlineEditorController.js +57 -52
- package/src/tools/object-tools/selection/TextEditorCaretService.js +51 -1
- package/src/tools/object-tools/selection/TextEditorInteractionController.js +26 -1
- package/src/tools/object-tools/selection/TextEditorSyncService.js +138 -30
- package/src/tools/object-tools/selection/TextInlineEditorController.js +18 -3
- package/src/ui/animation/HoverLiftController.js +13 -1
- package/src/ui/styles/workspace.css +22 -0
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>
|
|
@@ -101,34 +101,6 @@ 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
|
-
|
|
132
104
|
/**
|
|
133
105
|
* Возвращает ближайший якорь из TARGET_ANCHORS в пределах ANCHOR_SNAP_CSS от worldPt,
|
|
134
106
|
* иначе null. Приоритет выше грани — вызывать в _resolveEnd первым.
|
|
@@ -200,6 +172,9 @@ export class ConnectorDragController {
|
|
|
200
172
|
if (Math.abs(e.clientX - this._startX) < DRAG_THRESHOLD
|
|
201
173
|
&& Math.abs(e.clientY - this._startY) < DRAG_THRESHOLD) return;
|
|
202
174
|
this._dragging = true;
|
|
175
|
+
// Гасим hover-lift на время протягивания: цель не должна масштабироваться
|
|
176
|
+
// и подниматься, иначе её границы вылезают за рамку подсветки.
|
|
177
|
+
this.core?.pixi?.hoverLift?.setConnecting(true);
|
|
203
178
|
const world = this._world();
|
|
204
179
|
if (world) {
|
|
205
180
|
this._previewGraphics = new PIXI.Graphics();
|
|
@@ -220,9 +195,12 @@ export class ConnectorDragController {
|
|
|
220
195
|
if (objectId && objectId !== this._sourceTerminal?.boundId) {
|
|
221
196
|
const bounds = this._objectBounds(objectId);
|
|
222
197
|
if (bounds) {
|
|
223
|
-
|
|
198
|
+
// Рамку строим строго по логическим bounds — коннектор привязывается
|
|
199
|
+
// к ним же. Hover-трансформацию (scale/lift) не учитываем: иначе рамка
|
|
200
|
+
// выпирает за периметр объекта (раздув ×scale + подъём центра вверх) и
|
|
201
|
+
// отстаёт от анимации, т.к. перерисовывается только на pointermove.
|
|
224
202
|
this._highlightGraphics.lineStyle({ width: 2, color: 0x2563EB, alpha: 0.85 });
|
|
225
|
-
this._highlightGraphics.drawRect(
|
|
203
|
+
this._highlightGraphics.drawRect(bounds.x, bounds.y, bounds.width, bounds.height);
|
|
226
204
|
// Подводим превью к коннектору, если курсор в зоне магнита
|
|
227
205
|
const snapAnchor = this._snapToAnchor(bounds, worldPt);
|
|
228
206
|
if (snapAnchor) {
|
|
@@ -249,6 +227,7 @@ export class ConnectorDragController {
|
|
|
249
227
|
const source = this._sourceTerminal;
|
|
250
228
|
this._dragging = false;
|
|
251
229
|
this._sourceTerminal = null;
|
|
230
|
+
this.core?.pixi?.hoverLift?.setConnecting(false);
|
|
252
231
|
this._clearGraphics();
|
|
253
232
|
|
|
254
233
|
if (!source) return;
|
|
@@ -360,6 +339,7 @@ export class ConnectorDragController {
|
|
|
360
339
|
destroy() {
|
|
361
340
|
document.removeEventListener('pointermove', this._boundMove);
|
|
362
341
|
document.removeEventListener('pointerup', this._boundUp);
|
|
342
|
+
this.core?.pixi?.hoverLift?.setConnecting(false);
|
|
363
343
|
if (this._pendingDupListener) {
|
|
364
344
|
this.eventBus?.off(Events.Tool.DuplicateReady, this._pendingDupListener);
|
|
365
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,69 +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
|
-
// backdrop (видимые глифы) обязан повторять выравнивание textarea, иначе каретка,
|
|
63
|
-
// позиционируемая по center-align textarea, уходит вправо от прижатого влево текста.
|
|
64
|
-
textarea.style.textAlign = 'center';
|
|
65
73
|
const backdrop = wrapper.querySelector('.moodboard-text-backdrop');
|
|
66
|
-
if (backdrop) {
|
|
67
|
-
backdrop.style.textAlign = 'center';
|
|
68
|
-
}
|
|
69
|
-
const maxEditorWidthPx = Math.max(1, Math.round((noteWidth - (horizontalPadding * 2)) * sCssLocal));
|
|
70
|
-
const maxEditorHeightPx = Math.max(1, Math.round((noteHeight - (horizontalPadding * 2)) * sCssLocal));
|
|
71
|
-
const MIN_NOTE_EDITOR_W = 20;
|
|
72
|
-
const MIN_NOTE_EDITOR_H = Math.max(1, computeLineHeightPx(effectiveFontPx));
|
|
73
74
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
textarea.style.height = 'auto';
|
|
75
|
+
// Оверлей запрета ввода: показывается по центру записки, когда достигнут лимит
|
|
76
|
+
// строк (NOTE_MAX_LINES). Иконка — assets/icons/ban.svg (stroke=currentColor).
|
|
77
|
+
const setBanVisible = ensureNoteLimitBan(wrapper);
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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));
|
|
84
86
|
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
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`;
|
|
92
95
|
|
|
93
|
-
// Центрируем
|
|
94
|
-
const left = Math.round(screenPos.x + (noteWidth * sCssLocal) / 2 - (
|
|
95
|
-
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));
|
|
96
99
|
wrapper.style.left = `${left}px`;
|
|
97
100
|
wrapper.style.top = `${top}px`;
|
|
101
|
+
return result;
|
|
98
102
|
};
|
|
99
103
|
// Первый вызов — синхронизировать с текущим содержимым
|
|
100
|
-
autoSizeNote();
|
|
104
|
+
const initial = autoSizeNote();
|
|
105
|
+
setBanVisible(!!(initial && initial.full));
|
|
101
106
|
|
|
102
107
|
// Динамическое обновление позиции/размера редактора при зуме/панорамировании/трансформациях
|
|
103
108
|
const updateNoteEditor = createNoteEditorUpdater(controller, {
|
|
@@ -115,5 +120,5 @@ export function setupNoteInlineEditor(controller, params) {
|
|
|
115
120
|
});
|
|
116
121
|
registerNoteEditorSync(controller, { objectId, updateNoteEditor });
|
|
117
122
|
|
|
118
|
-
return { updateNoteEditor };
|
|
123
|
+
return { updateNoteEditor, setBanVisible };
|
|
119
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,6 +123,17 @@ 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;
|
|
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;
|
|
87
137
|
// Толщина каретки пропорциональна шрифту (≈7% размера). Минимум 1px — при сильном
|
|
88
138
|
// отдалении каретка остаётся видимой и не нарушает пропорцию. Жёсткий пол 2px убран,
|
|
89
139
|
// чтобы при уменьшении масштаба каретка масштабировалась вместе с текстом.
|
|
@@ -103,7 +153,7 @@ export function updateCustomCaret(textarea, caretEl) {
|
|
|
103
153
|
}
|
|
104
154
|
|
|
105
155
|
caretEl.style.top = `${top - 2}px`;
|
|
106
|
-
caretEl.style.left = `${left - Math.floor(caretWidth / 2)}px`;
|
|
156
|
+
caretEl.style.left = `${left - Math.floor(caretWidth / 2) + caretGap}px`;
|
|
107
157
|
caretEl.style.height = `${coords.height}px`;
|
|
108
158
|
caretEl.style.width = `${caretWidth}px`;
|
|
109
159
|
caretEl.style.backgroundColor = caretColor;
|
|
@@ -214,6 +214,7 @@ export function bindTextEditorInteractions(controller, {
|
|
|
214
214
|
isShape,
|
|
215
215
|
autoSize,
|
|
216
216
|
updateNoteEditor,
|
|
217
|
+
setNoteBanVisible,
|
|
217
218
|
finalize,
|
|
218
219
|
listType,
|
|
219
220
|
}) {
|
|
@@ -327,7 +328,31 @@ export function bindTextEditorInteractions(controller, {
|
|
|
327
328
|
// уносил текст вправо от фигуры. Поэтому ввод в фигуре границы не меняет.
|
|
328
329
|
let inputHandler;
|
|
329
330
|
if (isNote) {
|
|
330
|
-
|
|
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
|
+
};
|
|
331
356
|
} else if (isShape) {
|
|
332
357
|
inputHandler = () => {};
|
|
333
358
|
} else {
|
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import * as PIXI from 'pixi.js';
|
|
2
2
|
import { Events } from '../../../core/events/Events.js';
|
|
3
3
|
import { registerEditorListeners } from './InlineEditorListenersRegistry.js';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
computeTextRightPadPx,
|
|
6
|
+
resolveLineHeightRatio,
|
|
7
|
+
} from '../../../services/text/TextBoxMetrics.js';
|
|
8
|
+
|
|
9
|
+
// Максимальное число визуальных строк в записке. По достижении лимита ввод
|
|
10
|
+
// блокируется (см. note inputHandler), шрифт до этого сжимается без нижнего предела,
|
|
11
|
+
// чтобы строки помещались по высоте записки.
|
|
12
|
+
export const NOTE_MAX_LINES = 25;
|
|
13
|
+
// Жёсткий пол только чтобы шрифт не стал 0/отрицательным. Ограничения по размеру нет —
|
|
14
|
+
// единственный лимит ввода это NOTE_MAX_LINES.
|
|
15
|
+
const NOTE_MIN_FONT_PX = 1;
|
|
5
16
|
|
|
6
17
|
// Измеряет ширину текста textarea по реальным глифам через скрытый span.
|
|
7
18
|
// Возвращает ширину самой длинной строки в CSS-px (white-space: pre — без переносов),
|
|
@@ -28,6 +39,106 @@ function measureTextareaContentWidth(textarea, value) {
|
|
|
28
39
|
}
|
|
29
40
|
}
|
|
30
41
|
|
|
42
|
+
// Высота блока текста (px) при заданном шрифте и ширине, измеренная скрытым div c
|
|
43
|
+
// тем же переносом, что и редактор. Возвращает 0, если layout недоступен (jsdom).
|
|
44
|
+
function measureNoteBlockHeight(text, boxW, fontPx, fontFamily, ratio) {
|
|
45
|
+
try {
|
|
46
|
+
if (typeof document === 'undefined' || !document.body) return 0;
|
|
47
|
+
const m = document.createElement('div');
|
|
48
|
+
m.style.cssText = 'position:absolute;visibility:hidden;top:-9999px;left:-9999px;padding:0;margin:0;white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere;';
|
|
49
|
+
m.style.width = `${boxW}px`;
|
|
50
|
+
m.style.fontFamily = fontFamily || '';
|
|
51
|
+
m.style.fontSize = `${fontPx}px`;
|
|
52
|
+
m.style.lineHeight = `${ratio}`;
|
|
53
|
+
// \u200b в конце сохраняет высоту хвостового перевода строки при измерении.
|
|
54
|
+
m.textContent = text && text.length ? `${text}\u200b` : 'W';
|
|
55
|
+
document.body.appendChild(m);
|
|
56
|
+
const h = m.getBoundingClientRect().height;
|
|
57
|
+
m.remove();
|
|
58
|
+
return Number.isFinite(h) ? h : 0;
|
|
59
|
+
} catch (_) {
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Считает число визуальных строк текста при ЕСТЕСТВЕННОМ размере шрифта (effectiveFontPx),
|
|
65
|
+
// не зависящем от сжатия для отображения. Сжатие меняет fitFont, но не количество строк —
|
|
66
|
+
// поэтому лимит строк должен меряться по неизменному шрифту, иначе блокировка срабатывает
|
|
67
|
+
// преждевременно (при просевшем fitFont деление давало раздутый счёт).
|
|
68
|
+
function countNoteVisualLines(text, boxW, fontPx, fontFamily) {
|
|
69
|
+
const ratio = resolveLineHeightRatio(fontPx);
|
|
70
|
+
const oneLine = measureNoteBlockHeight('W', boxW, fontPx, fontFamily, ratio);
|
|
71
|
+
if (!(oneLine > 0)) return 1;
|
|
72
|
+
const fullH = measureNoteBlockHeight(text, boxW, fontPx, fontFamily, ratio);
|
|
73
|
+
return Math.max(1, Math.round(fullH / oneLine));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Подгоняет inline-редактор записки под её внутренние границы так же, как
|
|
77
|
+
// NoteObject._fitTextToBounds: текст переносится по фиксированной ширине блока (boxW),
|
|
78
|
+
// размер шрифта уменьшается, пока высота контента не влезет в innerH (без нижнего предела).
|
|
79
|
+
// Число строк (для лимита) меряется отдельно по естественному шрифту effectiveFontPx.
|
|
80
|
+
export function applyNoteEditorBox(textarea, backdrop, { boxW, innerH, effectiveFontPx }) {
|
|
81
|
+
// line-height задаём коэффициентом по текущему размеру (как NoteObject), чтобы
|
|
82
|
+
// высота строки совпадала с отрисованной запиской.
|
|
83
|
+
const setWrapStyles = (el, fontPx) => {
|
|
84
|
+
if (!el) return;
|
|
85
|
+
el.style.whiteSpace = 'pre-wrap';
|
|
86
|
+
el.style.wordBreak = 'break-word';
|
|
87
|
+
el.style.overflowWrap = 'anywhere';
|
|
88
|
+
el.style.textAlign = 'center';
|
|
89
|
+
el.style.boxSizing = 'content-box';
|
|
90
|
+
el.style.padding = '0';
|
|
91
|
+
el.style.overflow = 'hidden';
|
|
92
|
+
el.style.fontSize = `${fontPx}px`;
|
|
93
|
+
el.style.lineHeight = `${resolveLineHeightRatio(fontPx)}`;
|
|
94
|
+
el.style.width = `${boxW}px`;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const fontFamily = (typeof window !== 'undefined' && window.getComputedStyle)
|
|
98
|
+
? window.getComputedStyle(textarea).fontFamily
|
|
99
|
+
: (textarea.style.fontFamily || '');
|
|
100
|
+
const naturalFont = Math.max(NOTE_MIN_FONT_PX, Math.round(effectiveFontPx));
|
|
101
|
+
const value = textarea.value || '';
|
|
102
|
+
const lineCount = countNoteVisualLines(value, boxW, naturalFont, fontFamily);
|
|
103
|
+
|
|
104
|
+
// Подбор шрифта детерминированный — высоту блока меряем скрытым div
|
|
105
|
+
// (measureNoteBlockHeight), а НЕ textarea.scrollHeight. Chromium при быстрых
|
|
106
|
+
// правках возвращает устаревший (завышенный) scrollHeight даже после смены
|
|
107
|
+
// font-size, из-за чего цикл уводил шрифт в минимум (1px), а скачок contentH
|
|
108
|
+
// дёргал каретку. Скрытый div пересоздаётся на каждом замере и стейл-значения
|
|
109
|
+
// не накапливает. Тот же подход уже применён в createRegularTextAutoSize.
|
|
110
|
+
let fitFont = naturalFont;
|
|
111
|
+
let measuredH = measureNoteBlockHeight(value, boxW, fitFont, fontFamily, resolveLineHeightRatio(fitFont));
|
|
112
|
+
// measuredH === 0 → layout недоступен (jsdom): сжимать нечем, оставляем естественный шрифт.
|
|
113
|
+
if (measuredH > 0) {
|
|
114
|
+
for (let safety = 0; safety < 256; safety++) {
|
|
115
|
+
if (measuredH <= innerH || fitFont <= NOTE_MIN_FONT_PX) break;
|
|
116
|
+
fitFont = Math.max(NOTE_MIN_FONT_PX, fitFont - 1);
|
|
117
|
+
measuredH = measureNoteBlockHeight(value, boxW, fitFont, fontFamily, resolveLineHeightRatio(fitFont));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
setWrapStyles(textarea, fitFont);
|
|
122
|
+
textarea.style.minHeight = '0px';
|
|
123
|
+
|
|
124
|
+
const naturalH = measuredH > 0
|
|
125
|
+
? Math.max(1, Math.ceil(measuredH))
|
|
126
|
+
: Math.max(1, Math.ceil(textarea.scrollHeight));
|
|
127
|
+
const contentH = Math.max(1, Math.min(innerH, naturalH));
|
|
128
|
+
|
|
129
|
+
textarea.style.height = `${contentH}px`;
|
|
130
|
+
setWrapStyles(backdrop, fitFont);
|
|
131
|
+
if (backdrop) {
|
|
132
|
+
backdrop.style.height = `${contentH}px`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Единственный лимит — число строк, посчитанное по естественному шрифту.
|
|
136
|
+
const fits = lineCount <= NOTE_MAX_LINES;
|
|
137
|
+
const full = lineCount >= NOTE_MAX_LINES;
|
|
138
|
+
|
|
139
|
+
return { fitFont, contentH, naturalH, lineCount, fits, full };
|
|
140
|
+
}
|
|
141
|
+
|
|
31
142
|
export function createRegularTextAutoSize({
|
|
32
143
|
textarea,
|
|
33
144
|
wrapper,
|
|
@@ -119,10 +230,10 @@ export function createNoteEditorUpdater(controller, {
|
|
|
119
230
|
effectiveFontPx,
|
|
120
231
|
toScreen,
|
|
121
232
|
}) {
|
|
122
|
-
const minNoteEditorWidthPx = 20;
|
|
123
233
|
const minNoteEditorHeightPx = Math.max(1, computeLineHeightPx(effectiveFontPx));
|
|
124
234
|
|
|
125
235
|
return () => {
|
|
236
|
+
let result = null;
|
|
126
237
|
try {
|
|
127
238
|
const posDataNow = { objectId, position: null };
|
|
128
239
|
const sizeDataNow = { objectId, size: null };
|
|
@@ -131,41 +242,38 @@ export function createNoteEditorUpdater(controller, {
|
|
|
131
242
|
const posNow = posDataNow.position || position;
|
|
132
243
|
const sizeNow = sizeDataNow.size || { width: noteWidth, height: noteHeight };
|
|
133
244
|
const screenNow = toScreen(posNow.x, posNow.y);
|
|
134
|
-
const viewRes = (controller.app?.renderer?.resolution) || (view.width && view.clientWidth ? (view.width / view.clientWidth) : 1);
|
|
135
245
|
const worldLayerRef = controller.textEditor.world || (controller.app?.stage);
|
|
136
246
|
const scaleX = worldLayerRef?.scale?.x || 1;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
textarea.style.width = `${widthPx}px`;
|
|
149
|
-
wrapper.style.width = `${widthPx}px`;
|
|
150
|
-
textarea.style.height = `${heightPx}px`;
|
|
151
|
-
wrapper.style.height = `${heightPx}px`;
|
|
152
|
-
|
|
153
|
-
// backdrop рисует видимые глифы и имеет фиксированную высоту в px с момента
|
|
154
|
-
// открытия (applyEditorSizing — одна строка). Без синхронизации с textarea
|
|
155
|
-
// многострочный ввод (Enter) обрезается снизу (overflow: hidden).
|
|
247
|
+
// worldScale без /res: screenNow приходит из toScreen→toGlobal (CSS-px, res-независим),
|
|
248
|
+
// а записка — PIXI (1 мир. ед. = worldScale CSS-px). Деление на res уводило бы редактор
|
|
249
|
+
// влево и сжимало блок при зуме браузера ≠100% (res≠1). См. NoteInlineEditorController.
|
|
250
|
+
const scaleCss = scaleX;
|
|
251
|
+
// Внутренний блок текста = границы записки минус отступы, ограничение
|
|
252
|
+
// ширины 360 — как в NoteObject._getVisibleTextWidth.
|
|
253
|
+
const innerWorldW = Math.max(1, Math.min(360, sizeNow.width - (horizontalPadding * 2)));
|
|
254
|
+
const innerWorldH = Math.max(1, sizeNow.height - (horizontalPadding * 2));
|
|
255
|
+
const boxW = Math.max(1, Math.round(innerWorldW * scaleCss));
|
|
256
|
+
const innerH = Math.max(minNoteEditorHeightPx, Math.round(innerWorldH * scaleCss));
|
|
257
|
+
|
|
156
258
|
const backdrop = wrapper.querySelector('.moodboard-text-backdrop');
|
|
157
|
-
if (backdrop) {
|
|
158
|
-
backdrop.style.width = `${widthPx}px`;
|
|
159
|
-
backdrop.style.height = `${heightPx}px`;
|
|
160
|
-
}
|
|
161
259
|
|
|
162
|
-
|
|
163
|
-
|
|
260
|
+
result = applyNoteEditorBox(textarea, backdrop, {
|
|
261
|
+
boxW,
|
|
262
|
+
innerH,
|
|
263
|
+
effectiveFontPx,
|
|
264
|
+
});
|
|
265
|
+
const contentH = result.contentH;
|
|
266
|
+
|
|
267
|
+
wrapper.style.width = `${boxW}px`;
|
|
268
|
+
wrapper.style.height = `${contentH}px`;
|
|
269
|
+
|
|
270
|
+
// Центрируем блок контента внутри записки по обеим осям (как PIXI-текст).
|
|
271
|
+
const left = Math.round(screenNow.x + (sizeNow.width * scaleCss) / 2 - (boxW / 2));
|
|
272
|
+
const top = Math.round(screenNow.y + (sizeNow.height * scaleCss) / 2 - (contentH / 2));
|
|
164
273
|
wrapper.style.left = `${left}px`;
|
|
165
274
|
wrapper.style.top = `${top}px`;
|
|
166
|
-
textarea.style.width = `${widthPx}px`;
|
|
167
|
-
textarea.style.height = `${heightPx}px`;
|
|
168
275
|
} catch (_) {}
|
|
276
|
+
return result;
|
|
169
277
|
};
|
|
170
278
|
}
|
|
171
279
|
|
|
@@ -140,7 +140,10 @@ export function openTextEditor(object, create = false) {
|
|
|
140
140
|
const wt = inst.textField.worldTransform;
|
|
141
141
|
const scaleY = Math.max(0.0001, Math.hypot(wt.c || 0, wt.d || 0));
|
|
142
142
|
const baseFS = parseFloat(inst.textField.style?.fontSize || fontSize || 14) || (fontSize || 14);
|
|
143
|
-
|
|
143
|
+
// worldTransform.scaleY = worldScale; CSS-размер глифа PIXI-записки = baseFS*worldScale
|
|
144
|
+
// (res в трансформ сцены не входит). Деление на viewResEarly делало шрифт редактора
|
|
145
|
+
// мельче статического при res≠1 и рассогласовывало перенос строк с записанным текстом.
|
|
146
|
+
effectiveFontPx = Math.max(1, Math.round(baseFS * scaleY));
|
|
144
147
|
}
|
|
145
148
|
} catch (_) {}
|
|
146
149
|
} else if (typeof window !== 'undefined' && window.moodboardHtmlTextLayer) {
|
|
@@ -317,6 +320,7 @@ export function openTextEditor(object, create = false) {
|
|
|
317
320
|
|
|
318
321
|
// Для записок позиционируем редактор внутри записки
|
|
319
322
|
let updateNoteEditor = null;
|
|
323
|
+
let setNoteBanVisible = null;
|
|
320
324
|
let regularEditorVisualBox = null;
|
|
321
325
|
if (objectType === 'note') {
|
|
322
326
|
const noteSetup = setupNoteInlineEditor(this, {
|
|
@@ -332,6 +336,7 @@ export function openTextEditor(object, create = false) {
|
|
|
332
336
|
toScreen,
|
|
333
337
|
});
|
|
334
338
|
updateNoteEditor = noteSetup.updateNoteEditor;
|
|
339
|
+
setNoteBanVisible = noteSetup.setBanVisible;
|
|
335
340
|
} else if (isShape) {
|
|
336
341
|
// Shape: редактор занимает весь bounds фигуры, текст вертикально центрирован
|
|
337
342
|
const viewResShape = (this.app?.renderer?.resolution) ||
|
|
@@ -444,8 +449,12 @@ export function openTextEditor(object, create = false) {
|
|
|
444
449
|
const s = worldLayerRef?.scale?.x || 1;
|
|
445
450
|
const viewRes = (this.app?.renderer?.resolution) || (view.width && view.clientWidth ? (view.width / view.clientWidth) : 1);
|
|
446
451
|
// Синхронизируем стартовый размер шрифта textarea с текущим зумом (как HtmlTextLayer)
|
|
447
|
-
// Используем ранее вычисленный effectiveFontPx (до вставки в DOM), если он есть в
|
|
448
|
-
|
|
452
|
+
// Используем ранее вычисленный effectiveFontPx (до вставки в DOM), если он есть в замыкании.
|
|
453
|
+
// Для записки шрифт уже подобран autoSizeNote (fit-to-bounds): сброс к effectiveFontPx
|
|
454
|
+
// рассинхронизировал бы textarea (по нему считается каретка) с backdrop.
|
|
455
|
+
if (!isNote) {
|
|
456
|
+
textarea.style.fontSize = `${effectiveFontPx}px`;
|
|
457
|
+
}
|
|
449
458
|
// Высоту/ширину поля при входе в редактор берём как максимум из видимого DOM-бокса
|
|
450
459
|
// и размера в состоянии объекта. DOM-бокс .mb-text к этому моменту может быть схлопнут
|
|
451
460
|
// до высоты контента (auto), тогда как в состоянии хранится вручную заданная высота рамки —
|
|
@@ -528,6 +537,11 @@ export function openTextEditor(object, create = false) {
|
|
|
528
537
|
autoSize();
|
|
529
538
|
}
|
|
530
539
|
textarea.focus();
|
|
540
|
+
// Записка: после фокуса повторно подгоняем блок (focus может изменить layout),
|
|
541
|
+
// чтобы стартовая каретка считалась от уже подобранного размера шрифта, а не от 32px.
|
|
542
|
+
if (isNote && updateNoteEditor) {
|
|
543
|
+
try { updateNoteEditor(); } catch (_) {}
|
|
544
|
+
}
|
|
531
545
|
// Ручки скрыты в режиме input
|
|
532
546
|
// Локальная CSS-настройка placeholder (меньше базового шрифта)
|
|
533
547
|
const styleEl = attachTextEditorPlaceholderStyle(textarea, {
|
|
@@ -588,6 +602,7 @@ export function openTextEditor(object, create = false) {
|
|
|
588
602
|
isShape,
|
|
589
603
|
autoSize,
|
|
590
604
|
updateNoteEditor,
|
|
605
|
+
setNoteBanVisible,
|
|
591
606
|
finalize,
|
|
592
607
|
listType: properties.listType || 'none',
|
|
593
608
|
});
|
|
@@ -83,6 +83,7 @@ export class HoverLiftController {
|
|
|
83
83
|
this._isDragging = false;
|
|
84
84
|
this._isResizing = false;
|
|
85
85
|
this._isRotating = false;
|
|
86
|
+
this._isConnecting = false;
|
|
86
87
|
/** Set<id> — выделенные объекты (по _mb.objectId) */
|
|
87
88
|
this._selectedIds = new Set();
|
|
88
89
|
|
|
@@ -217,9 +218,20 @@ export class HoverLiftController {
|
|
|
217
218
|
return world?.scale?.x ?? 1;
|
|
218
219
|
}
|
|
219
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Внешняя блокировка hover на время протягивания коннектора.
|
|
223
|
+
* Цель коннектора не должна играть scale/lift: иначе её визуальные
|
|
224
|
+
* границы выходят за рамку подсветки, которая строится по логическим bounds.
|
|
225
|
+
* @param {boolean} active
|
|
226
|
+
*/
|
|
227
|
+
setConnecting(active) {
|
|
228
|
+
this._isConnecting = !!active;
|
|
229
|
+
if (this._isConnecting) this._snapBackAll();
|
|
230
|
+
}
|
|
231
|
+
|
|
220
232
|
/** Можно ли сейчас показывать hover */
|
|
221
233
|
_isBlocked(pixiObject) {
|
|
222
|
-
if (this._isDragging || this._isResizing || this._isRotating) return true;
|
|
234
|
+
if (this._isDragging || this._isResizing || this._isRotating || this._isConnecting) return true;
|
|
223
235
|
const id = pixiObject._mb?.objectId;
|
|
224
236
|
if (id && this._selectedIds.has(id)) return true;
|
|
225
237
|
return false;
|
|
@@ -676,6 +676,28 @@
|
|
|
676
676
|
border-radius: 1px;
|
|
677
677
|
}
|
|
678
678
|
|
|
679
|
+
/* Иконка запрета ввода при достижении лимита строк записки */
|
|
680
|
+
.moodboard-note-limit-ban {
|
|
681
|
+
position: absolute;
|
|
682
|
+
left: 50%;
|
|
683
|
+
top: 50%;
|
|
684
|
+
transform: translate(-50%, -50%);
|
|
685
|
+
width: 48px;
|
|
686
|
+
height: 48px;
|
|
687
|
+
align-items: center;
|
|
688
|
+
justify-content: center;
|
|
689
|
+
color: #b0b0b0;
|
|
690
|
+
opacity: 0.9;
|
|
691
|
+
pointer-events: none;
|
|
692
|
+
z-index: 10002;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
.moodboard-note-limit-ban svg {
|
|
696
|
+
width: 100%;
|
|
697
|
+
height: 100%;
|
|
698
|
+
display: block;
|
|
699
|
+
}
|
|
700
|
+
|
|
679
701
|
@keyframes mb-caret-blink {
|
|
680
702
|
0%, 100% { opacity: 1; }
|
|
681
703
|
50% { opacity: 0; }
|