@sequent-org/moodboard 1.4.57 → 1.4.59

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.
Files changed (27) hide show
  1. package/package.json +1 -1
  2. package/src/core/ApiClient.js +41 -2
  3. package/src/core/PixiEngine.js +10 -1
  4. package/src/core/commands/UpdateContentCommand.js +48 -2
  5. package/src/core/events/Events.js +1 -0
  6. package/src/core/flows/ClipboardFlow.js +48 -0
  7. package/src/objects/ObjectFactory.js +2 -0
  8. package/src/objects/VideoObject.js +171 -0
  9. package/src/services/text/TextBoxMetrics.js +133 -0
  10. package/src/tools/object-tools/selection/MindmapInlineEditorController.js +98 -7
  11. package/src/tools/object-tools/selection/SelectInputRouter.js +55 -2
  12. package/src/tools/object-tools/selection/TextEditorCaretService.js +113 -0
  13. package/src/tools/object-tools/selection/TextEditorDomFactory.js +37 -21
  14. package/src/tools/object-tools/selection/TextEditorInteractionController.js +86 -1
  15. package/src/tools/object-tools/selection/TextEditorPositioningService.js +19 -1
  16. package/src/tools/object-tools/selection/TextEditorSyncService.js +2 -1
  17. package/src/tools/object-tools/selection/TextInlineEditorController.js +68 -43
  18. package/src/tools/object-tools/selection/TransformInteractionController.js +7 -1
  19. package/src/ui/HtmlTextLayer.js +113 -70
  20. package/src/ui/TextPropertiesPanel.js +154 -7
  21. package/src/ui/chat/ChatWindow.js +39 -8
  22. package/src/ui/comments/CommentThreadPopover.js +3 -1
  23. package/src/ui/handles/HandlesDomRenderer.js +2 -0
  24. package/src/ui/handles/HandlesInteractionController.js +40 -0
  25. package/src/ui/styles/workspace.css +64 -1
  26. package/src/ui/text-properties/TextPropertiesPanelBindings.js +8 -0
  27. package/src/ui/text-properties/TextPropertiesPanelMapper.js +29 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequent-org/moodboard",
3
- "version": "1.4.57",
3
+ "version": "1.4.59",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -241,7 +241,30 @@ export class ApiClient {
241
241
  }
242
242
  return cleanedObj;
243
243
  }
244
-
244
+
245
+ if (obj.type === 'video') {
246
+ const topSrcRaw = typeof obj.src === 'string' ? obj.src : '';
247
+ const propSrcRaw = typeof obj.properties?.src === 'string' ? obj.properties.src : '';
248
+ const normalizedSrc = topSrcRaw.trim() || propSrcRaw.trim();
249
+
250
+ if (!normalizedSrc) {
251
+ throw new Error(`Video object "${obj.id || 'unknown'}" has no src. Save is blocked.`);
252
+ }
253
+ if (/^data:/i.test(normalizedSrc) || /^blob:/i.test(normalizedSrc)) {
254
+ throw new Error(`Video object "${obj.id || 'unknown'}" contains forbidden data/blob src. Save is blocked.`);
255
+ }
256
+
257
+ const cleanedObj = {
258
+ ...obj,
259
+ src: normalizedSrc
260
+ };
261
+ if (cleanedObj.properties?.src) {
262
+ cleanedObj.properties = { ...cleanedObj.properties };
263
+ delete cleanedObj.properties.src;
264
+ }
265
+ return cleanedObj;
266
+ }
267
+
245
268
  return obj;
246
269
  });
247
270
 
@@ -296,7 +319,23 @@ export class ApiClient {
296
319
  }
297
320
  return restoredObj;
298
321
  }
299
-
322
+
323
+ if (obj.type === 'video') {
324
+ const topSrc = typeof obj.src === 'string' ? obj.src.trim() : '';
325
+ const propSrc = typeof obj.properties?.src === 'string' ? obj.properties.src.trim() : '';
326
+ const normalizedSrc = topSrc || propSrc;
327
+ if (!normalizedSrc) return obj;
328
+ const restoredObj = {
329
+ ...obj,
330
+ src: normalizedSrc
331
+ };
332
+ if (restoredObj.properties?.src) {
333
+ restoredObj.properties = { ...restoredObj.properties };
334
+ delete restoredObj.properties.src;
335
+ }
336
+ return restoredObj;
337
+ }
338
+
300
339
  return obj;
301
340
  })
302
341
  );
@@ -248,7 +248,16 @@ export class PixiEngine {
248
248
  const pixiObject = this.objects.get(objectId);
249
249
  if (pixiObject) {
250
250
  console.log('🗑️ PixiEngine: удаляем объект из сцены:', objectId);
251
-
251
+
252
+ // Видео: останавливаем HTMLVideoElement и снимаем listeners до уничтожения
253
+ // спрайта. Без этого видео продолжает играть и тикать текстуру — утечка.
254
+ // Хук сужен до 'video', чтобы не задеть destroy() фреймов/коннекторов,
255
+ // у которых контейнер уничтожается общим путём ниже (иначе двойной destroy).
256
+ const mbMeta = pixiObject._mb;
257
+ if (mbMeta?.type === 'video' && typeof mbMeta.instance?.destroy === 'function') {
258
+ try { mbMeta.instance.destroy(); } catch (_) {}
259
+ }
260
+
252
261
  // Удаляем из родительского контейнера
253
262
  if (this.worldLayer) {
254
263
  this.worldLayer.removeChild(pixiObject);
@@ -47,11 +47,19 @@ export class UpdateContentCommand extends BaseCommand {
47
47
  if (!object.properties) {
48
48
  object.properties = {};
49
49
  }
50
- // Пересчитываем диапазоны ссылок при изменении контента
50
+ // Пересчитываем диапазоны ссылок и подсветки при изменении контента
51
+ const oldContent = object.properties.content ?? '';
51
52
  if (Array.isArray(object.properties.links) && object.properties.links.length > 0) {
52
53
  object.properties.links = _adjustLinks(
53
54
  object.properties.links,
54
- object.properties.content ?? '',
55
+ oldContent,
56
+ content
57
+ );
58
+ }
59
+ if (Array.isArray(object.properties.highlights) && object.properties.highlights.length > 0) {
60
+ object.properties.highlights = _adjustHighlights(
61
+ object.properties.highlights,
62
+ oldContent,
55
63
  content
56
64
  );
57
65
  }
@@ -142,3 +150,41 @@ function _adjustLinks(links, oldText, newText) {
142
150
  return acc;
143
151
  }, []);
144
152
  }
153
+
154
+ /**
155
+ * Пересчитывает диапазоны подсветки после изменения контента.
156
+ * Алгоритм идентичен _adjustLinks: общий префикс/суффикс, сдвиг или удаление.
157
+ * @param {Array<{start:number,end:number,color:string}>} highlights
158
+ * @param {string} oldText
159
+ * @param {string} newText
160
+ * @returns {Array<{start:number,end:number,color:string}>}
161
+ */
162
+ function _adjustHighlights(highlights, oldText, newText) {
163
+ if (!highlights || highlights.length === 0) return highlights;
164
+
165
+ let prefixLen = 0;
166
+ const minLen = Math.min(oldText.length, newText.length);
167
+ while (prefixLen < minLen && oldText[prefixLen] === newText[prefixLen]) prefixLen++;
168
+
169
+ let oldSuffix = 0;
170
+ while (
171
+ oldSuffix < oldText.length - prefixLen &&
172
+ oldSuffix < newText.length - prefixLen &&
173
+ oldText[oldText.length - 1 - oldSuffix] === newText[newText.length - 1 - oldSuffix]
174
+ ) oldSuffix++;
175
+
176
+ const oldChangeEnd = oldText.length - oldSuffix;
177
+ const newChangeEnd = newText.length - oldSuffix;
178
+ const delta = newChangeEnd - oldChangeEnd;
179
+
180
+ return highlights.reduce((acc, hi) => {
181
+ const { start, end, color } = hi;
182
+ if (end <= prefixLen) {
183
+ acc.push({ start, end, color });
184
+ } else if (start >= oldChangeEnd) {
185
+ acc.push({ start: start + delta, end: end + delta, color });
186
+ }
187
+ // Подсветка перекрывает изменённый диапазон — удаляем
188
+ return acc;
189
+ }, []);
190
+ }
@@ -63,6 +63,7 @@ export const Events = {
63
63
  PasteAt: 'ui:paste-at',
64
64
  PasteImage: 'ui:paste-image',
65
65
  PasteImageAt: 'ui:paste-image-at',
66
+ PasteVideoAt: 'ui:paste-video-at',
66
67
  ZoomIn: 'ui:zoom:in',
67
68
  ZoomOut: 'ui:zoom:out',
68
69
  ZoomReset: 'ui:zoom:reset',
@@ -380,6 +380,54 @@ export function setupClipboardFlow(core) {
380
380
  }
381
381
  });
382
382
 
383
+ core.eventBus.on(Events.UI.PasteVideoAt, ({ x, y, src, mimeType, poster, aiMessageId, skipUpload = true }) => {
384
+ if (!src) return;
385
+ const world = core.pixi.worldLayer || core.pixi.app.stage;
386
+ const s = world?.scale?.x || 1;
387
+ const worldX = (x - (world?.x || 0)) / s;
388
+ const worldY = (y - (world?.y || 0)) / s;
389
+
390
+ const placeVideo = (natW, natH) => {
391
+ const ar = (natW > 0 && natH > 0) ? natW / natH : (16 / 9);
392
+ const w = 300;
393
+ const h = Math.max(1, Math.round(w / ar));
394
+ const created = core.createObject(
395
+ 'video',
396
+ { x: Math.round(worldX - Math.round(w / 2)), y: Math.round(worldY - Math.round(h / 2)) },
397
+ {
398
+ src,
399
+ width: w,
400
+ height: h,
401
+ mimeType: mimeType || 'video/mp4',
402
+ poster: poster || null,
403
+ loop: true,
404
+ muted: true,
405
+ ...(aiMessageId ? { aiMessageId } : {})
406
+ }
407
+ );
408
+ // Видео не проходит через SaveManager до подтверждения — раскрываем сразу,
409
+ // как для AI-картинок и 3D-скринов.
410
+ if (skipUpload && created?.id) {
411
+ core._pendingPersistAckVisibilityIds?.delete(created.id);
412
+ core._setObjectVisibility?.(created.id, true);
413
+ }
414
+ };
415
+
416
+ // Узнаём натуральные пропорции из метаданных видео (для aspect-ratio).
417
+ try {
418
+ const probe = document.createElement('video');
419
+ probe.preload = 'metadata';
420
+ probe.muted = true;
421
+ probe.addEventListener('loadedmetadata', () => {
422
+ placeVideo(probe.videoWidth || 0, probe.videoHeight || 0);
423
+ }, { once: true });
424
+ probe.addEventListener('error', () => placeVideo(0, 0), { once: true });
425
+ probe.src = src;
426
+ } catch (_) {
427
+ placeVideo(0, 0);
428
+ }
429
+ });
430
+
383
431
  core.eventBus.on(Events.Tool.DuplicateRequest, (data) => {
384
432
  const { originalId, position } = data || {};
385
433
  if (!originalId) return;
@@ -4,6 +4,7 @@ import { DrawingObject } from './DrawingObject.js';
4
4
  import { TextObject } from './TextObject.js';
5
5
  import { EmojiObject } from './EmojiObject.js';
6
6
  import { ImageObject } from './ImageObject.js';
7
+ import { VideoObject } from './VideoObject.js';
7
8
  import { RevitScreenshotImageObject } from './RevitScreenshotImageObject.js';
8
9
  import { Model3dScreenshotImageObject } from './Model3dScreenshotImageObject.js';
9
10
  import { CommentObject } from './CommentObject.js';
@@ -25,6 +26,7 @@ export class ObjectFactory {
25
26
  ['simple-text', TextObject],
26
27
  ['emoji', EmojiObject],
27
28
  ['image', ImageObject],
29
+ ['video', VideoObject],
28
30
  ['revit-screenshot-img', RevitScreenshotImageObject],
29
31
  ['model3d-screenshot-img', Model3dScreenshotImageObject],
30
32
  ['comment', CommentObject],
@@ -0,0 +1,171 @@
1
+ import * as PIXI from 'pixi.js';
2
+
3
+ /**
4
+ * VideoObject — отображение видео как спрайт с видео-текстурой.
5
+ *
6
+ * MVP: спрайт с muted-видео, клик по телу переключает play/pause.
7
+ * Трансформации (resize/rotate/move/z-order) переиспользуют конвейер ImageObject:
8
+ * единственный display-object — Sprite с anchor(0.5).
9
+ */
10
+ export class VideoObject {
11
+ constructor(objectData = {}) {
12
+ this.objectData = objectData;
13
+ let src = objectData.src || objectData.properties?.src;
14
+ // blob: недолговечны — не используем (ERR_FILE_NOT_FOUND после перезагрузки)
15
+ if (typeof src === 'string' && src.startsWith('blob:')) {
16
+ src = null;
17
+ }
18
+ this.src = src;
19
+ this.width = Math.max(1, Math.round(objectData.width || objectData.properties?.width || 300));
20
+ this.height = Math.max(1, Math.round(objectData.height || objectData.properties?.height || 169));
21
+
22
+ this.videoEl = null;
23
+ let texture = PIXI.Texture.WHITE;
24
+ if (src) {
25
+ const video = document.createElement('video');
26
+ video.src = src;
27
+ video.muted = true;
28
+ video.loop = objectData.properties?.loop ?? true;
29
+ video.playsInline = true;
30
+ video.crossOrigin = 'anonymous';
31
+ video.preload = 'auto';
32
+ this.videoEl = video;
33
+ // autoPlay:false — стартуем на паузе, первый кадр показываем после loadeddata
34
+ texture = PIXI.Texture.from(video, { resourceOptions: { autoPlay: false } });
35
+ }
36
+
37
+ this.sprite = new PIXI.Sprite(texture);
38
+ this.sprite.anchor.set(0.5, 0.5);
39
+ if (!src) {
40
+ this.sprite.tint = 0xcccccc;
41
+ }
42
+
43
+ this._fitToSize = () => {
44
+ const texW = this.sprite.texture.width || 1;
45
+ const texH = this.sprite.texture.height || 1;
46
+ this.sprite._mb = {
47
+ ...(this.sprite._mb || {}),
48
+ type: this.objectData?.type || 'video',
49
+ properties: {
50
+ ...((this.sprite._mb || {}).properties || {}),
51
+ src,
52
+ baseW: texW,
53
+ baseH: texH
54
+ }
55
+ };
56
+ this.sprite.scale.set(this.width / texW, this.height / texH);
57
+ };
58
+
59
+ const onError = () => {
60
+ this.sprite.texture = PIXI.Texture.WHITE;
61
+ this.sprite.tint = 0xcccccc;
62
+ this._fitToSize();
63
+ };
64
+
65
+ if (this.videoEl) {
66
+ // Показать первый кадр на паузе: после loadeddata толкнуть кадр в GPU
67
+ this.videoEl.addEventListener('loadeddata', () => {
68
+ try { this.sprite.texture.update(); } catch (_) {}
69
+ this._fitToSize();
70
+ }, { once: true });
71
+ this.videoEl.addEventListener('error', onError, { once: true });
72
+ }
73
+
74
+ if (this.sprite.texture.baseTexture?.valid) {
75
+ this._fitToSize();
76
+ } else if (this.sprite.texture.baseTexture) {
77
+ this.sprite.texture.baseTexture.once('loaded', this._fitToSize);
78
+ }
79
+
80
+ this._setupPlaybackToggle();
81
+ }
82
+
83
+ /**
84
+ * Клик по телу видео (без перетаскивания) переключает play/pause.
85
+ * Порог движения отсекает drag, чтобы перемещение не триггерило тоггл.
86
+ */
87
+ _setupPlaybackToggle() {
88
+ this.sprite.eventMode = 'static';
89
+ let down = null;
90
+ this._onDown = (e) => { down = { x: e.global.x, y: e.global.y }; };
91
+ this._onUp = (e) => {
92
+ if (!down) return;
93
+ const dx = e.global.x - down.x;
94
+ const dy = e.global.y - down.y;
95
+ down = null;
96
+ if (Math.hypot(dx, dy) < 5) {
97
+ this.toggle();
98
+ }
99
+ };
100
+ this.sprite.on('pointerdown', this._onDown);
101
+ this.sprite.on('pointerup', this._onUp);
102
+ this.sprite.on('pointerupoutside', () => { down = null; });
103
+ }
104
+
105
+ play() {
106
+ if (!this.videoEl) return;
107
+ const p = this.videoEl.play();
108
+ if (p && typeof p.catch === 'function') {
109
+ p.catch(() => {});
110
+ }
111
+ }
112
+
113
+ pause() {
114
+ if (this.videoEl) {
115
+ this.videoEl.pause();
116
+ }
117
+ }
118
+
119
+ toggle() {
120
+ if (!this.videoEl) return;
121
+ if (this.videoEl.paused) {
122
+ this.play();
123
+ } else {
124
+ this.pause();
125
+ }
126
+ }
127
+
128
+ getPixi() {
129
+ return this.sprite;
130
+ }
131
+
132
+ updateSize(size) {
133
+ if (!size) return;
134
+ this.width = Math.max(1, Math.round(size.width || 1));
135
+ this.height = Math.max(1, Math.round(size.height || 1));
136
+ const apply = () => {
137
+ const texW = this.sprite.texture.width || 1;
138
+ const texH = this.sprite.texture.height || 1;
139
+ this.sprite.scale.set(this.width / texW, this.height / texH);
140
+ };
141
+ if (this.sprite.texture.baseTexture?.valid) {
142
+ apply();
143
+ } else {
144
+ this.sprite.texture.baseTexture?.once('loaded', apply);
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Остановить видео и освободить ресурсы (вызывается при удалении объекта).
150
+ * Без этого HTMLVideoElement продолжает жить и тикать текстуру — утечка.
151
+ */
152
+ destroy() {
153
+ if (this.videoEl) {
154
+ try {
155
+ this.videoEl.pause();
156
+ this.videoEl.removeAttribute('src');
157
+ this.videoEl.load();
158
+ } catch (_) {}
159
+ this.videoEl = null;
160
+ }
161
+ try {
162
+ this.sprite?.off('pointerdown', this._onDown);
163
+ this.sprite?.off('pointerup', this._onUp);
164
+ } catch (_) {}
165
+ try {
166
+ if (this.sprite?.texture && this.sprite.texture !== PIXI.Texture.WHITE) {
167
+ this.sprite.texture.destroy(true);
168
+ }
169
+ } catch (_) {}
170
+ }
171
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Единый источник истины для геометрии текстового бокса.
3
+ *
4
+ * Один и тот же объект рисуется двумя независимыми механизмами: статический слой
5
+ * `HtmlTextLayer` (.mb-text) и inline-редактор (textarea + backdrop). Чтобы рамка
6
+ * выделения, межстрочный интервал и паддинги не расходились между режимами и не
7
+ * разъезжались после очередной правки, все метрики бокса считаются здесь.
8
+ */
9
+
10
+ // Соотношение line-height по базовому (немасштабированному) размеру шрифта.
11
+ // Берётся от базового размера, а не от отрисованного, поэтому пропорция строк
12
+ // не зависит от зума и не страдает от округления.
13
+ const LINE_HEIGHT_BREAKPOINTS = [
14
+ [12, 1.40],
15
+ [18, 1.34],
16
+ [36, 1.26],
17
+ [48, 1.24],
18
+ [72, 1.22],
19
+ [96, 1.20],
20
+ ];
21
+ const LINE_HEIGHT_FALLBACK_RATIO = 1.18;
22
+
23
+ // Нижний запас под хвосты глифов (например, «з», «у»), общий для обоих режимов.
24
+ export const TEXT_BOX_BOTTOM_PAD_PX = 2;
25
+
26
+ // Вертикальный паддинг inline-редактора (textarea/backdrop). Дублируется в CSS
27
+ // (.moodboard-text-input / .moodboard-text-backdrop) и учитывается при сведении
28
+ // высоты поля к мировой высоте объекта.
29
+ export const TEXT_EDITOR_VERTICAL_PAD_PX = 1;
30
+
31
+ /**
32
+ * @param {number} baseFontSizePx — базовый размер шрифта без учёта зума
33
+ * @param {object|undefined} properties — допускается явный properties.lineHeight
34
+ * @returns {number} коэффициент (не пиксели)
35
+ */
36
+ export function resolveLineHeightRatio(baseFontSizePx, properties) {
37
+ if (properties && typeof properties.lineHeight === 'number') {
38
+ return properties.lineHeight;
39
+ }
40
+ const fs = baseFontSizePx;
41
+ for (const [maxFs, ratio] of LINE_HEIGHT_BREAKPOINTS) {
42
+ if (fs <= maxFs) {
43
+ return ratio;
44
+ }
45
+ }
46
+ return LINE_HEIGHT_FALLBACK_RATIO;
47
+ }
48
+
49
+ /**
50
+ * Межстрочный интервал в пикселях (для inline-редактора, где line-height задаётся в px).
51
+ * @param {number} fontSizePx
52
+ * @param {object|undefined} properties
53
+ * @returns {number}
54
+ */
55
+ export function computeLineHeightPx(fontSizePx, properties) {
56
+ return Math.round(fontSizePx * resolveLineHeightRatio(fontSizePx, properties));
57
+ }
58
+
59
+ /**
60
+ * Правый запас, чтобы рамка не прилипала к тексту справа. Одинаков для статического
61
+ * слоя и для авторазмера редактора — иначе ширина рамки в режимах различается.
62
+ * @param {number} fontSizePx
63
+ * @returns {number}
64
+ */
65
+ export function computeTextRightPadPx(fontSizePx) {
66
+ return Math.ceil(fontSizePx * 0.7) + 6;
67
+ }
68
+
69
+ /**
70
+ * Применяет ВСЕ текстовые параметры на DOM-элемент в одинаковом формате.
71
+ *
72
+ * Используется как для статического .mb-text, так и для textarea/backdrop
73
+ * в inline-редакторе, гарантируя идентичный рендеринг глифов в обоих режимах.
74
+ *
75
+ * Ключевое: `line-height` всегда задаётся как **безразмерный коэффициент** (e.g. `"1.34"`),
76
+ * а не в пикселях. Безразмерный коэффициент масштабируется браузером вместе с font-size
77
+ * и не страдает от округления — строки в редакторе и статике совпадают точно.
78
+ *
79
+ * Цвет (`color`) умышленно вынесен за пределы функции: textarea рендерит текст
80
+ * прозрачным (видимый текст рисует backdrop), backdrop и .mb-text — реальным цветом.
81
+ *
82
+ * @param {HTMLElement} el
83
+ * @param {{ fontSizePx: number, baseFontSizePx: number, fontFamily: string, properties?: object }} params
84
+ */
85
+ export function applyTextStyles(el, { fontSizePx, baseFontSizePx, fontFamily, properties = {} }) {
86
+ const ratio = resolveLineHeightRatio(baseFontSizePx, properties);
87
+ el.style.fontSize = `${fontSizePx}px`;
88
+ el.style.lineHeight = `${ratio}`;
89
+ el.style.fontFamily = fontFamily || '';
90
+ el.style.fontWeight = properties.bold ? 'bold' : '';
91
+ el.style.fontStyle = properties.italic ? 'italic' : '';
92
+ const dec = [properties.underline && 'underline', properties.strikethrough && 'line-through']
93
+ .filter(Boolean).join(' ');
94
+ el.style.textDecoration = dec || '';
95
+ el.style.textAlign = properties.textAlign || '';
96
+ el.style.letterSpacing = '0px';
97
+ el.style.fontKerning = 'normal';
98
+ el.style.textRendering = 'optimizeLegibility';
99
+ }
100
+
101
+ /**
102
+ * Применяет параметры размера поля для inline-редактора (textarea/backdrop).
103
+ * Отдельно от applyTextStyles, чтобы не дублировать логику в статическом слое.
104
+ *
105
+ * @param {HTMLElement} el
106
+ * @param {number} lineHeightPx — вычисленный lineHeight в px (для initialHeight)
107
+ */
108
+ export function applyEditorSizing(el, lineHeightPx) {
109
+ const initialHeightPx = Math.max(1, lineHeightPx);
110
+ el.style.minHeight = `${initialHeightPx}px`;
111
+ el.style.height = `${initialHeightPx}px`;
112
+ el.setAttribute('rows', '1');
113
+ el.style.overflowY = 'hidden';
114
+ el.style.whiteSpace = 'pre';
115
+ }
116
+
117
+ /**
118
+ * Inline-значение `padding` статического .mb-text по текущему режиму.
119
+ * Обычный текст → '0' (рамка плотно по глифам, как в редакторе);
120
+ * список/markdown → '' (берётся CSS `.mb-text { padding: 0.3em }`).
121
+ *
122
+ * Применяется детерминированно при каждом проходе: если возвращать паддинг только
123
+ * при смене режима, обычный текст после режима списка сохраняет CSS-отступ 0.3em
124
+ * сверху, и статическая рамка оказывается выше глифов, чем поле ввода.
125
+ * @param {{ isMarkdown?: boolean, useList?: boolean }} mode
126
+ * @returns {string}
127
+ */
128
+ export function resolveStaticTextPadding({ isMarkdown = false, useList = false } = {}) {
129
+ if (useList || isMarkdown) {
130
+ return '';
131
+ }
132
+ return '0';
133
+ }