@sequent-org/moodboard 1.4.72 → 1.4.73
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/flows/ClipboardFlow.js +11 -2
- package/src/core/index.js +75 -1
- package/src/moodboard/integration/MoodBoardEventBindings.js +9 -1
- package/src/objects/MindmapObject.js +8 -3
- package/src/tools/manager/ToolEventRouter.js +88 -3
- package/src/tools/object-tools/selection/MindmapInlineEditorController.js +16 -14
- package/src/tools/object-tools/selection/TextEditorCaretService.js +17 -3
- package/src/ui/styles/workspace.css +2 -2
- package/src/ui/toolbar/ToolbarDialogsController.js +22 -0
- package/src/utils/dropPlaceholder.js +141 -0
package/package.json
CHANGED
|
@@ -296,7 +296,7 @@ export function setupClipboardFlow(core) {
|
|
|
296
296
|
}
|
|
297
297
|
});
|
|
298
298
|
|
|
299
|
-
core.eventBus.on(Events.UI.PasteImageAt, async ({ x, y, src, name, skipUpload, aiMessageId, objectType, modelUrl, format }) => {
|
|
299
|
+
core.eventBus.on(Events.UI.PasteImageAt, async ({ x, y, src, name, skipUpload, aiMessageId, objectType, modelUrl, format, placeholderId }) => {
|
|
300
300
|
if (!src) return;
|
|
301
301
|
|
|
302
302
|
// Отдельный путь для 3D-скрина: без resolveRevitImagePayload и без загрузки на сервер
|
|
@@ -327,7 +327,10 @@ export function setupClipboardFlow(core) {
|
|
|
327
327
|
}
|
|
328
328
|
|
|
329
329
|
const uploaded = await ensureServerImage({ src, name, skipUpload });
|
|
330
|
-
if (!uploaded?.src)
|
|
330
|
+
if (!uploaded?.src) {
|
|
331
|
+
if (placeholderId) core.removeDropPlaceholder?.(placeholderId);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
331
334
|
const world = core.pixi.worldLayer || core.pixi.app.stage;
|
|
332
335
|
const s = world?.scale?.x || 1;
|
|
333
336
|
const worldX = (x - (world?.x || 0)) / s;
|
|
@@ -358,6 +361,10 @@ export function setupClipboardFlow(core) {
|
|
|
358
361
|
{ x: Math.round(worldX - Math.round(w / 2)), y: Math.round(worldY - Math.round(h / 2)) },
|
|
359
362
|
properties
|
|
360
363
|
);
|
|
364
|
+
// Связываем drop-заглушку с объектом: снимется, когда картинка станет видимой.
|
|
365
|
+
if (placeholderId && createdData?.id) {
|
|
366
|
+
core.linkDropPlaceholderToObject?.(createdData.id, placeholderId);
|
|
367
|
+
}
|
|
361
368
|
// data:-URL изображения (AI-генерация) не проходят через SaveManager,
|
|
362
369
|
// поэтому Events.Save.Success никогда не придёт и объект останется скрытым.
|
|
363
370
|
// Раскрываем сразу, минуя ожидание подтверждения сохранения.
|
|
@@ -372,10 +379,12 @@ export function setupClipboardFlow(core) {
|
|
|
372
379
|
img.decoding = 'async';
|
|
373
380
|
img.onload = () => { void placeWithAspect(img.naturalWidth || 0, img.naturalHeight || 0); };
|
|
374
381
|
img.onerror = () => {
|
|
382
|
+
if (placeholderId) core.removeDropPlaceholder?.(placeholderId);
|
|
375
383
|
alert('Не удалось загрузить изображение с сервера. Изображение не добавлено.');
|
|
376
384
|
};
|
|
377
385
|
img.src = uploaded.src;
|
|
378
386
|
} catch (_) {
|
|
387
|
+
if (placeholderId) core.removeDropPlaceholder?.(placeholderId);
|
|
379
388
|
alert('Не удалось загрузить изображение с сервера. Изображение не добавлено.');
|
|
380
389
|
}
|
|
381
390
|
});
|
package/src/core/index.js
CHANGED
|
@@ -69,6 +69,11 @@ export class CoreMoodBoard {
|
|
|
69
69
|
this.gridSnapResolver = new GridSnapResolver(this);
|
|
70
70
|
// Объекты, требующие подтверждения сохранения (image/file), показываем только после save:success.
|
|
71
71
|
this._pendingPersistAckVisibilityIds = new Set();
|
|
72
|
+
|
|
73
|
+
// Градиентные заглушки на месте перетащенных картинок (пока идёт аплоад/сохранение).
|
|
74
|
+
this._dropPlaceholders = new Map(); // placeholderId -> { destroy, timeoutId, objectId }
|
|
75
|
+
this._dropPlaceholderByObject = new Map(); // objectId -> placeholderId
|
|
76
|
+
this._dropPlaceholderSeq = 0;
|
|
72
77
|
|
|
73
78
|
// Связываем SaveManager с ApiClient для правильной обработки изображений
|
|
74
79
|
this.saveManager.setApiClient(this.apiClient);
|
|
@@ -490,6 +495,10 @@ export class CoreMoodBoard {
|
|
|
490
495
|
if (pixiObject) {
|
|
491
496
|
pixiObject.visible = !!visible;
|
|
492
497
|
}
|
|
498
|
+
// Картинка реально появилась на доске → снимаем её drop-заглушку.
|
|
499
|
+
if (visible) {
|
|
500
|
+
this._removeDropPlaceholderForObject(objectId);
|
|
501
|
+
}
|
|
493
502
|
}
|
|
494
503
|
|
|
495
504
|
revealPendingObjectsAfterSave() {
|
|
@@ -500,6 +509,66 @@ export class CoreMoodBoard {
|
|
|
500
509
|
this._pendingPersistAckVisibilityIds.clear();
|
|
501
510
|
}
|
|
502
511
|
|
|
512
|
+
/**
|
|
513
|
+
* Регистрирует градиентную заглушку drag-and-drop. Заглушку создаёт вызывающий
|
|
514
|
+
* код (ToolEventRouter) и добавляет в worldLayer; здесь храним только её lifecycle.
|
|
515
|
+
* @param {{ destroy: () => void }} entry
|
|
516
|
+
* @returns {string} placeholderId
|
|
517
|
+
*/
|
|
518
|
+
registerDropPlaceholder(entry) {
|
|
519
|
+
if (!entry || typeof entry.destroy !== 'function') return null;
|
|
520
|
+
const placeholderId = `dph_${++this._dropPlaceholderSeq}`;
|
|
521
|
+
// Страховка от утечки: если объект так и не появится (ошибка сохранения и т.п.).
|
|
522
|
+
const timeoutId = setTimeout(() => this.removeDropPlaceholder(placeholderId), 30000);
|
|
523
|
+
this._dropPlaceholders.set(placeholderId, {
|
|
524
|
+
destroy: entry.destroy,
|
|
525
|
+
timeoutId,
|
|
526
|
+
objectId: null,
|
|
527
|
+
});
|
|
528
|
+
return placeholderId;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Связывает заглушку с созданным объектом; если объект уже видим — снимает сразу. */
|
|
532
|
+
linkDropPlaceholderToObject(objectId, placeholderId) {
|
|
533
|
+
if (!placeholderId) return;
|
|
534
|
+
const entry = this._dropPlaceholders.get(placeholderId);
|
|
535
|
+
if (!entry) return;
|
|
536
|
+
entry.objectId = objectId;
|
|
537
|
+
this._dropPlaceholderByObject.set(objectId, placeholderId);
|
|
538
|
+
const pixiObject = this.pixi?.objects?.get?.(objectId);
|
|
539
|
+
if (pixiObject && pixiObject.visible) {
|
|
540
|
+
this.removeDropPlaceholder(placeholderId);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
removeDropPlaceholder(placeholderId) {
|
|
545
|
+
const entry = this._dropPlaceholders.get(placeholderId);
|
|
546
|
+
if (!entry) return;
|
|
547
|
+
this._dropPlaceholders.delete(placeholderId);
|
|
548
|
+
if (entry.objectId) {
|
|
549
|
+
this._dropPlaceholderByObject.delete(entry.objectId);
|
|
550
|
+
}
|
|
551
|
+
try {
|
|
552
|
+
if (entry.timeoutId) clearTimeout(entry.timeoutId);
|
|
553
|
+
} catch (_) { /* no-op */ }
|
|
554
|
+
try {
|
|
555
|
+
entry.destroy();
|
|
556
|
+
} catch (_) { /* no-op */ }
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
_removeDropPlaceholderForObject(objectId) {
|
|
560
|
+
const placeholderId = this._dropPlaceholderByObject.get(objectId);
|
|
561
|
+
if (placeholderId) {
|
|
562
|
+
this.removeDropPlaceholder(placeholderId);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
clearDropPlaceholders() {
|
|
567
|
+
for (const placeholderId of Array.from(this._dropPlaceholders.keys())) {
|
|
568
|
+
this.removeDropPlaceholder(placeholderId);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
503
572
|
// === Прикрепления к фреймам ===
|
|
504
573
|
// Логика фреймов перенесена в FrameService
|
|
505
574
|
|
|
@@ -701,7 +770,12 @@ export class CoreMoodBoard {
|
|
|
701
770
|
|
|
702
771
|
// Устанавливаем флаг уничтожения
|
|
703
772
|
this.destroyed = true;
|
|
704
|
-
|
|
773
|
+
|
|
774
|
+
// Снимаем незакрытые drop-заглушки (тикеры/текстуры/ноды)
|
|
775
|
+
try {
|
|
776
|
+
this.clearDropPlaceholders();
|
|
777
|
+
} catch (_) { /* no-op */ }
|
|
778
|
+
|
|
705
779
|
// Останавливаем ResizeObserver
|
|
706
780
|
if (this.resizeObserver) {
|
|
707
781
|
this.resizeObserver.disconnect();
|
|
@@ -18,6 +18,14 @@ function shouldOpenEditorForObject(objectType) {
|
|
|
18
18
|
|| objectType === 'file';
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// Картинки при размещении не выделяем: выделение с попаданием в AI-композер —
|
|
22
|
+
// только осознанный клик пользователя по уже лежащему на мудборде изображению.
|
|
23
|
+
function shouldSelectOnCreate(objectType) {
|
|
24
|
+
return objectType !== 'image'
|
|
25
|
+
&& objectType !== 'revit-screenshot-img'
|
|
26
|
+
&& objectType !== 'model3d-screenshot-img';
|
|
27
|
+
}
|
|
28
|
+
|
|
21
29
|
function focusCreatedObject(board, createdObject) {
|
|
22
30
|
if (!board?.coreMoodboard?.eventBus || !createdObject?.id) return;
|
|
23
31
|
const objectType = createdObject?.type || null;
|
|
@@ -32,7 +40,7 @@ function focusCreatedObject(board, createdObject) {
|
|
|
32
40
|
setTimeout(() => {
|
|
33
41
|
board.coreMoodboard.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'select' });
|
|
34
42
|
const selectTool = getSelectTool(board);
|
|
35
|
-
if (typeof selectTool?.setSelection === 'function') {
|
|
43
|
+
if (shouldSelectOnCreate(objectType) && typeof selectTool?.setSelection === 'function') {
|
|
36
44
|
selectTool.setSelection([createdObject.id]);
|
|
37
45
|
}
|
|
38
46
|
if (shouldOpenEditorForObject(objectType)) {
|
|
@@ -56,9 +56,14 @@ export class MindmapObject {
|
|
|
56
56
|
const rot = g.rotation || 0;
|
|
57
57
|
|
|
58
58
|
this._draw();
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
// Точный pivot (size/2, без floor) и точный центр: при нечётной ширине/высоте
|
|
60
|
+
// floor терял 0.5px, из-за чего капсула смещалась вниз-вправо относительно
|
|
61
|
+
// бокса объекта (position — целые числа). Рамка выделения строится по боксу
|
|
62
|
+
// объекта, поэтому во время резайза/редактирования её верх уходил выше капсулы.
|
|
63
|
+
// top-left = center - pivot = position (целое) → грани попадают в целый пиксель.
|
|
64
|
+
g.pivot.set(this.width / 2, this.height / 2);
|
|
65
|
+
g.x = centerX;
|
|
66
|
+
g.y = centerY;
|
|
62
67
|
g.rotation = rot;
|
|
63
68
|
}
|
|
64
69
|
|
|
@@ -1,5 +1,68 @@
|
|
|
1
1
|
import { Events } from '../../core/events/Events.js';
|
|
2
2
|
import { ToolManagerGuards } from './ToolManagerGuards.js';
|
|
3
|
+
import { createDropPlaceholder } from '../../utils/dropPlaceholder.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Считывает натуральные пропорции файла-картинки локально (без сети), чтобы
|
|
7
|
+
* заглушка сразу получила тот же размер, что и будущее изображение.
|
|
8
|
+
* @returns {Promise<number>} aspectRatio (w/h), либо дефолт 3/2 при сбое.
|
|
9
|
+
*/
|
|
10
|
+
function readImageAspectRatio(file) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
let url = null;
|
|
13
|
+
const done = (ar) => {
|
|
14
|
+
try { if (url) URL.revokeObjectURL(url); } catch (_) { /* no-op */ }
|
|
15
|
+
resolve(ar);
|
|
16
|
+
};
|
|
17
|
+
try {
|
|
18
|
+
url = URL.createObjectURL(file);
|
|
19
|
+
const img = new Image();
|
|
20
|
+
img.onload = () => {
|
|
21
|
+
const w = img.naturalWidth || 0;
|
|
22
|
+
const h = img.naturalHeight || 0;
|
|
23
|
+
done(w > 0 && h > 0 ? w / h : 1.5);
|
|
24
|
+
};
|
|
25
|
+
img.onerror = () => done(1.5);
|
|
26
|
+
img.src = url;
|
|
27
|
+
} catch (_) {
|
|
28
|
+
done(1.5);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Создаёт градиентную заглушку в worldLayer точно там и того размера,
|
|
35
|
+
* какими окажется приземлённое изображение (см. ClipboardFlow PasteImageAt:
|
|
36
|
+
* ширина 300 мировых ед., высота 300/AR, центр в точке дропа + веерный сдвиг).
|
|
37
|
+
* @returns {Promise<string|null>} placeholderId
|
|
38
|
+
*/
|
|
39
|
+
async function createImageDropPlaceholder(manager, file, baseScreenX, baseScreenY, index) {
|
|
40
|
+
const core = manager.core;
|
|
41
|
+
const app = core?.pixi?.app;
|
|
42
|
+
const world = core?.pixi?.worldLayer || app?.stage;
|
|
43
|
+
if (!core || !app || !world || typeof core.registerDropPlaceholder !== 'function') {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ar = await readImageAspectRatio(file);
|
|
48
|
+
const w = 300;
|
|
49
|
+
const h = Math.max(1, Math.round(w / ar));
|
|
50
|
+
|
|
51
|
+
// Тот же экранный сдвиг веером, что и в emitAt (25 px на каждую следующую картинку).
|
|
52
|
+
const offset = 25 * index;
|
|
53
|
+
const screenX = baseScreenX + offset;
|
|
54
|
+
const screenY = baseScreenY + offset;
|
|
55
|
+
const scale = world?.scale?.x || 1;
|
|
56
|
+
const worldX = (screenX - (world?.x || 0)) / scale;
|
|
57
|
+
const worldY = (screenY - (world?.y || 0)) / scale;
|
|
58
|
+
|
|
59
|
+
const placeholder = createDropPlaceholder(app, w, h);
|
|
60
|
+
placeholder.node.x = Math.round(worldX - Math.round(w / 2));
|
|
61
|
+
placeholder.node.y = Math.round(worldY - Math.round(h / 2));
|
|
62
|
+
world.addChild(placeholder.node);
|
|
63
|
+
|
|
64
|
+
return core.registerDropPlaceholder({ destroy: placeholder.destroy });
|
|
65
|
+
}
|
|
3
66
|
|
|
4
67
|
function createPointerEvent(manager, event, extras = {}) {
|
|
5
68
|
const rect = manager.container.getBoundingClientRect();
|
|
@@ -275,9 +338,10 @@ export class ToolEventRouter {
|
|
|
275
338
|
return { dx: direction.x * step * ring, dy: direction.y * step * ring };
|
|
276
339
|
};
|
|
277
340
|
|
|
278
|
-
const emitAt = (src, name, offsetIndex = 0) => {
|
|
341
|
+
const emitAt = (src, name, offsetIndex = 0, placeholderId = null) => {
|
|
279
342
|
if (!isCurrentDrop()) {
|
|
280
343
|
logDropDebug(diagnostics, 'emit_skipped_stale_drop', { route: 'image', offsetIndex });
|
|
344
|
+
if (placeholderId) manager.core?.removeDropPlaceholder?.(placeholderId);
|
|
281
345
|
return;
|
|
282
346
|
}
|
|
283
347
|
const offset = 25 * offsetIndex;
|
|
@@ -285,7 +349,8 @@ export class ToolEventRouter {
|
|
|
285
349
|
x: x + offset,
|
|
286
350
|
y: y + offset,
|
|
287
351
|
src,
|
|
288
|
-
name
|
|
352
|
+
name,
|
|
353
|
+
placeholderId
|
|
289
354
|
});
|
|
290
355
|
};
|
|
291
356
|
|
|
@@ -314,6 +379,23 @@ export class ToolEventRouter {
|
|
|
314
379
|
const imageFiles = limitedFiles.filter((file) => file.type && file.type.startsWith('image/'));
|
|
315
380
|
if (imageFiles.length > 0) {
|
|
316
381
|
logDropDebug(diagnostics, 'route_image_files', { count: imageFiles.length });
|
|
382
|
+
|
|
383
|
+
// Мгновенно рисуем градиентные заглушки на местах будущих картинок,
|
|
384
|
+
// чтобы перекрыть лаг аплоада и подтверждения сохранения.
|
|
385
|
+
const placeholderIds = await Promise.all(
|
|
386
|
+
imageFiles.map((file, index) => {
|
|
387
|
+
if (!isCurrentDrop()) return null;
|
|
388
|
+
return createImageDropPlaceholder(manager, file, x, y, index)
|
|
389
|
+
.catch(() => null);
|
|
390
|
+
})
|
|
391
|
+
);
|
|
392
|
+
// Если дроп устарел, пока считали пропорции — снимаем созданные заглушки.
|
|
393
|
+
if (!isCurrentDrop()) {
|
|
394
|
+
for (const pid of placeholderIds) {
|
|
395
|
+
if (pid) manager.core?.removeDropPlaceholder?.(pid);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
317
399
|
const imagePlacements = await mapWithConcurrency(imageFiles, 2, async (file, index) => {
|
|
318
400
|
logDropDebug(diagnostics, 'image_upload_start', {
|
|
319
401
|
fileName: file.name || 'image',
|
|
@@ -327,6 +409,7 @@ export class ToolEventRouter {
|
|
|
327
409
|
logDropDebug(diagnostics, 'image_upload_stale_drop_ignored', {
|
|
328
410
|
fileName: uploadResult?.name || file.name || 'image'
|
|
329
411
|
});
|
|
412
|
+
if (placeholderIds[index]) manager.core?.removeDropPlaceholder?.(placeholderIds[index]);
|
|
330
413
|
return null;
|
|
331
414
|
}
|
|
332
415
|
logDropDebug(diagnostics, 'image_upload_success', {
|
|
@@ -344,6 +427,7 @@ export class ToolEventRouter {
|
|
|
344
427
|
diagnostics,
|
|
345
428
|
{ fileName: file.name || 'image' }
|
|
346
429
|
);
|
|
430
|
+
if (placeholderIds[index]) manager.core?.removeDropPlaceholder?.(placeholderIds[index]);
|
|
347
431
|
return null;
|
|
348
432
|
}
|
|
349
433
|
} catch (error) {
|
|
@@ -361,12 +445,13 @@ export class ToolEventRouter {
|
|
|
361
445
|
message: error?.message || String(error)
|
|
362
446
|
}
|
|
363
447
|
);
|
|
448
|
+
if (placeholderIds[index]) manager.core?.removeDropPlaceholder?.(placeholderIds[index]);
|
|
364
449
|
return null;
|
|
365
450
|
}
|
|
366
451
|
});
|
|
367
452
|
for (const placement of imagePlacements) {
|
|
368
453
|
if (!placement) continue;
|
|
369
|
-
emitAt(placement.src, placement.name, placement.index);
|
|
454
|
+
emitAt(placement.src, placement.name, placement.index, placeholderIds[placement.index]);
|
|
370
455
|
}
|
|
371
456
|
logDropDebug(diagnostics, 'drop_done', { route: 'image_files', itemsProcessed: imageFiles.length });
|
|
372
457
|
return;
|
|
@@ -315,7 +315,10 @@ export function openMindmapEditor(object, create = false) {
|
|
|
315
315
|
backdrop.style.height = '100%';
|
|
316
316
|
backdrop.style.display = 'flex';
|
|
317
317
|
backdrop.style.alignItems = 'center';
|
|
318
|
-
|
|
318
|
+
// flex-start, а не center: textarea (источник координат каретки) left-aligned,
|
|
319
|
+
// backdrop должен совпадать с ней по горизонтали, иначе видимый текст уезжает
|
|
320
|
+
// относительно каретки на короткой строке/placeholder.
|
|
321
|
+
backdrop.style.justifyContent = 'flex-start';
|
|
319
322
|
}
|
|
320
323
|
|
|
321
324
|
const initialCssWidth = targetWidth;
|
|
@@ -332,16 +335,6 @@ export function openMindmapEditor(object, create = false) {
|
|
|
332
335
|
: ((typeof properties.width === 'number' && properties.width > 0) ? properties.width : MINDMAP_LAYOUT.width))
|
|
333
336
|
)
|
|
334
337
|
);
|
|
335
|
-
const stableBaseWorldHeight = Math.max(
|
|
336
|
-
1,
|
|
337
|
-
Math.round(
|
|
338
|
-
(typeof properties.capsuleBaseHeight === 'number' && properties.capsuleBaseHeight > 0)
|
|
339
|
-
? properties.capsuleBaseHeight
|
|
340
|
-
: ((typeof objectHeight === 'number' && objectHeight > 0)
|
|
341
|
-
? objectHeight
|
|
342
|
-
: ((typeof properties.height === 'number' && properties.height > 0) ? properties.height : MINDMAP_LAYOUT.height))
|
|
343
|
-
)
|
|
344
|
-
);
|
|
345
338
|
const resizeSession = {
|
|
346
339
|
started: false,
|
|
347
340
|
oldSize: null,
|
|
@@ -449,9 +442,12 @@ export function openMindmapEditor(object, create = false) {
|
|
|
449
442
|
const placeholderWidth = measureMindmapTextWidthPx(textarea, measureEl, textarea.placeholder || '');
|
|
450
443
|
const baseCssWidth = Math.max(1, Math.round(stableBaseWorldWidth * getWorldToCssScale()));
|
|
451
444
|
const placeholderCssWidth = Math.max(1, Math.ceil(placeholderWidth + padding.left + padding.right));
|
|
445
|
+
// Ширина пустого состояния (плейсхолдер/база) — нижняя граница и при вводе,
|
|
446
|
+
// чтобы капсула не схлопывалась на первой букве, а росла только когда текст длиннее.
|
|
447
|
+
const emptyStateCssWidth = Math.max(baseCssWidth, placeholderCssWidth);
|
|
452
448
|
const rawNextCssWidth = hasText
|
|
453
|
-
? Math.max(
|
|
454
|
-
:
|
|
449
|
+
? Math.max(emptyStateCssWidth, Math.ceil(textWidth + padding.left + padding.right))
|
|
450
|
+
: emptyStateCssWidth;
|
|
455
451
|
const level = properties?.mindmap?.level ?? 0;
|
|
456
452
|
const isRoot = level === 0;
|
|
457
453
|
const minCssW = Math.max(1, Math.round(
|
|
@@ -463,7 +459,13 @@ export function openMindmapEditor(object, create = false) {
|
|
|
463
459
|
const nextCssWidth = Math.max(minCssW, Math.min(maxCssW, rawNextCssWidth));
|
|
464
460
|
const lineCount = getEditorLineCount();
|
|
465
461
|
const lineHeightPx = getEditorLineHeightPx();
|
|
466
|
-
|
|
462
|
+
// Высота одной строки капсулы = вертикальный паддинг + line-height — та же формула,
|
|
463
|
+
// что в статическом слое (MindmapHtmlTextLayer._autoFitNodeWidth: scrollHeight).
|
|
464
|
+
// Раньше базой служил capsuleBaseHeight (MINDMAP_LAYOUT.height=40), который меньше
|
|
465
|
+
// паддинга+строки, из-за чего поле сжималось при вводе и «прыгало» обратно на blur.
|
|
466
|
+
const basePaddingYWorld = Math.max(0, Math.round(properties.paddingY ?? MINDMAP_LAYOUT.paddingY));
|
|
467
|
+
const paddingYCss = Math.max(0, Math.round(basePaddingYWorld * getWorldToCssScale()));
|
|
468
|
+
const baseCssHeight = Math.max(1, 2 * paddingYCss + lineHeightPx);
|
|
467
469
|
const nextCssHeight = Math.max(1, Math.ceil(baseCssHeight + (Math.max(1, lineCount) - 1) * lineHeightPx));
|
|
468
470
|
|
|
469
471
|
const currentCssWidth = Math.max(1, Math.round(parseFloat(wrapper.style.width || `${initialCssWidth}`)));
|
|
@@ -115,10 +115,24 @@ export function updateCustomCaret(textarea, caretEl) {
|
|
|
115
115
|
|
|
116
116
|
const pos = textarea.selectionStart;
|
|
117
117
|
const coords = getCaretCoordinates(textarea, pos);
|
|
118
|
-
|
|
118
|
+
|
|
119
|
+
// Смещение textarea относительно родителя каретки (wrapper). В обычном редакторе
|
|
120
|
+
// textarea прижата к top:0/left:0 wrapper — смещение нулевое. В mindmap-редакторе
|
|
121
|
+
// textarea центрируется flex-ом и дополнительно сдвигается через transform: translateY,
|
|
122
|
+
// поэтому без этой компенсации каретка "прилипает" к верхнему левому углу капсулы.
|
|
123
|
+
let offsetX = 0;
|
|
124
|
+
let offsetY = 0;
|
|
125
|
+
const caretParent = caretEl.parentElement;
|
|
126
|
+
if (caretParent) {
|
|
127
|
+
const parentRect = caretParent.getBoundingClientRect();
|
|
128
|
+
const textareaRect = textarea.getBoundingClientRect();
|
|
129
|
+
offsetX = textareaRect.left - parentRect.left;
|
|
130
|
+
offsetY = textareaRect.top - parentRect.top;
|
|
131
|
+
}
|
|
132
|
+
|
|
119
133
|
// Adjust for scroll
|
|
120
|
-
const top = coords.top - textarea.scrollTop;
|
|
121
|
-
const left = coords.left - textarea.scrollLeft;
|
|
134
|
+
const top = offsetY + coords.top - textarea.scrollTop;
|
|
135
|
+
const left = offsetX + coords.left - textarea.scrollLeft;
|
|
122
136
|
|
|
123
137
|
// Calculate width based on font size to match stroke thickness
|
|
124
138
|
const computed = window.getComputedStyle(textarea);
|
|
@@ -24,6 +24,28 @@ export class ToolbarDialogsController {
|
|
|
24
24
|
return;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
const isImage = !!(file.type && file.type.startsWith('image/'));
|
|
28
|
+
|
|
29
|
+
if (isImage) {
|
|
30
|
+
// Изображение, выбранное через диалог вложений, размещаем как картинку
|
|
31
|
+
// (тот же путь, что и OS drag-and-drop), а не как файл-карточку.
|
|
32
|
+
this.toolbar.eventBus.emit(Events.Place.ImageSelected, {
|
|
33
|
+
file: file,
|
|
34
|
+
fileName: file.name,
|
|
35
|
+
fileSize: file.size,
|
|
36
|
+
mimeType: file.type,
|
|
37
|
+
properties: {
|
|
38
|
+
width: 300,
|
|
39
|
+
height: 200
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
this.toolbar.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'place' });
|
|
44
|
+
this.toolbar.placeSelectedButtonId = 'image';
|
|
45
|
+
this.toolbar.setActiveToolbarButton('place');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
27
49
|
// Файл выбран - запускаем режим "призрака"
|
|
28
50
|
this.toolbar.eventBus.emit(Events.Place.FileSelected, {
|
|
29
51
|
file: file,
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import * as PIXI from 'pixi.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Градиентная заглушка, которую показываем в мировом слое на месте будущего
|
|
5
|
+
* изображения, пока идёт загрузка на сервер и подтверждение сохранения
|
|
6
|
+
* (OS drag-and-drop). Заглушка живёт в worldLayer, поэтому панится/зумится
|
|
7
|
+
* вместе с доской и точно совпадает по месту и размеру с приземляемой картинкой.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function roundRectPath(ctx, x, y, w, h, r) {
|
|
11
|
+
const radius = Math.max(0, Math.min(r, w / 2, h / 2));
|
|
12
|
+
ctx.beginPath();
|
|
13
|
+
ctx.moveTo(x + radius, y);
|
|
14
|
+
ctx.arcTo(x + w, y, x + w, y + h, radius);
|
|
15
|
+
ctx.arcTo(x + w, y + h, x, y + h, radius);
|
|
16
|
+
ctx.arcTo(x, y + h, x, y, radius);
|
|
17
|
+
ctx.arcTo(x, y, x + w, y, radius);
|
|
18
|
+
ctx.closePath();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildGradientCanvas(w, h, radius) {
|
|
22
|
+
const canvas = document.createElement('canvas');
|
|
23
|
+
// Ограничиваем разрешение текстуры: заглушка временная, огромные битмапы не нужны.
|
|
24
|
+
const maxDim = 1024;
|
|
25
|
+
const scale = Math.min(1, maxDim / Math.max(w, h));
|
|
26
|
+
const cw = Math.max(1, Math.round(w * scale));
|
|
27
|
+
const ch = Math.max(1, Math.round(h * scale));
|
|
28
|
+
canvas.width = cw;
|
|
29
|
+
canvas.height = ch;
|
|
30
|
+
|
|
31
|
+
const ctx = canvas.getContext('2d');
|
|
32
|
+
roundRectPath(ctx, 0, 0, cw, ch, radius * scale);
|
|
33
|
+
ctx.clip();
|
|
34
|
+
|
|
35
|
+
const grad = ctx.createLinearGradient(0, 0, cw, ch);
|
|
36
|
+
grad.addColorStop(0, '#e9edf2');
|
|
37
|
+
grad.addColorStop(0.5, '#f5f7f9');
|
|
38
|
+
grad.addColorStop(1, '#dde3ea');
|
|
39
|
+
ctx.fillStyle = grad;
|
|
40
|
+
ctx.fillRect(0, 0, cw, ch);
|
|
41
|
+
|
|
42
|
+
return canvas;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildShimmerCanvas(bandW, h) {
|
|
46
|
+
const canvas = document.createElement('canvas');
|
|
47
|
+
const cw = Math.max(1, Math.round(bandW));
|
|
48
|
+
const ch = Math.max(1, Math.round(Math.min(h, 512)));
|
|
49
|
+
canvas.width = cw;
|
|
50
|
+
canvas.height = ch;
|
|
51
|
+
|
|
52
|
+
const ctx = canvas.getContext('2d');
|
|
53
|
+
const grad = ctx.createLinearGradient(0, 0, cw, 0);
|
|
54
|
+
grad.addColorStop(0, 'rgba(255,255,255,0)');
|
|
55
|
+
grad.addColorStop(0.5, 'rgba(255,255,255,0.55)');
|
|
56
|
+
grad.addColorStop(1, 'rgba(255,255,255,0)');
|
|
57
|
+
ctx.fillStyle = grad;
|
|
58
|
+
ctx.fillRect(0, 0, cw, ch);
|
|
59
|
+
|
|
60
|
+
return canvas;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {PIXI.Application} app - для тикера shimmer-анимации
|
|
65
|
+
* @param {number} width - мировая ширина будущего изображения
|
|
66
|
+
* @param {number} height - мировая высота будущего изображения
|
|
67
|
+
* @returns {{ node: PIXI.Container, destroy: () => void }}
|
|
68
|
+
*/
|
|
69
|
+
export function createDropPlaceholder(app, width, height) {
|
|
70
|
+
const w = Math.max(1, Math.round(width));
|
|
71
|
+
const h = Math.max(1, Math.round(height));
|
|
72
|
+
const radius = Math.max(4, Math.min(12, Math.min(w, h) * 0.08));
|
|
73
|
+
|
|
74
|
+
const container = new PIXI.Container();
|
|
75
|
+
container.name = 'futurello-drop-placeholder';
|
|
76
|
+
container.eventMode = 'none';
|
|
77
|
+
container.interactiveChildren = false;
|
|
78
|
+
|
|
79
|
+
const textures = [];
|
|
80
|
+
let tickerFn = null;
|
|
81
|
+
|
|
82
|
+
const baseTexture = PIXI.Texture.from(buildGradientCanvas(w, h, radius));
|
|
83
|
+
textures.push(baseTexture);
|
|
84
|
+
const base = new PIXI.Sprite(baseTexture);
|
|
85
|
+
base.width = w;
|
|
86
|
+
base.height = h;
|
|
87
|
+
container.addChild(base);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const bandW = Math.max(48, Math.round(w * 0.35));
|
|
91
|
+
const shimmerTexture = PIXI.Texture.from(buildShimmerCanvas(bandW, h));
|
|
92
|
+
textures.push(shimmerTexture);
|
|
93
|
+
const shimmer = new PIXI.Sprite(shimmerTexture);
|
|
94
|
+
shimmer.width = bandW;
|
|
95
|
+
shimmer.height = h;
|
|
96
|
+
shimmer.x = -bandW;
|
|
97
|
+
container.addChild(shimmer);
|
|
98
|
+
|
|
99
|
+
const mask = new PIXI.Graphics();
|
|
100
|
+
mask.beginFill(0xffffff, 1);
|
|
101
|
+
mask.drawRoundedRect(0, 0, w, h, radius);
|
|
102
|
+
mask.endFill();
|
|
103
|
+
container.addChild(mask);
|
|
104
|
+
shimmer.mask = mask;
|
|
105
|
+
|
|
106
|
+
const travel = w + bandW;
|
|
107
|
+
const speed = travel / 72; // ~1.2 c при 60 fps
|
|
108
|
+
tickerFn = () => {
|
|
109
|
+
shimmer.x += speed;
|
|
110
|
+
if (shimmer.x > w) {
|
|
111
|
+
shimmer.x = -bandW;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
app?.ticker?.add(tickerFn);
|
|
115
|
+
} catch (_) {
|
|
116
|
+
// shimmer необязателен — при сбое остаётся статичный градиент
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const destroy = () => {
|
|
120
|
+
try {
|
|
121
|
+
if (tickerFn) {
|
|
122
|
+
app?.ticker?.remove(tickerFn);
|
|
123
|
+
}
|
|
124
|
+
} catch (_) { /* no-op */ }
|
|
125
|
+
try {
|
|
126
|
+
if (container.parent) {
|
|
127
|
+
container.parent.removeChild(container);
|
|
128
|
+
}
|
|
129
|
+
} catch (_) { /* no-op */ }
|
|
130
|
+
try {
|
|
131
|
+
container.destroy({ children: true });
|
|
132
|
+
} catch (_) { /* no-op */ }
|
|
133
|
+
for (const texture of textures) {
|
|
134
|
+
try {
|
|
135
|
+
texture.destroy(true);
|
|
136
|
+
} catch (_) { /* no-op */ }
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return { node: container, destroy };
|
|
141
|
+
}
|