@sequent-org/moodboard 1.4.29 → 1.4.31
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 +3 -1
- package/src/core/PixiEngine.js +34 -5
- package/src/core/bootstrap/CoreInitializer.js +4 -0
- package/src/core/commands/CreateConnectorCommand.js +25 -0
- package/src/core/commands/GroupMoveCommand.js +2 -2
- package/src/core/commands/MoveObjectCommand.js +1 -1
- package/src/core/commands/UpdateConnectorCommand.js +38 -0
- package/src/core/events/Events.js +1 -0
- package/src/mindmap/MindmapCompoundContract.js +1 -0
- package/src/moodboard/bootstrap/MoodBoardUiFactory.js +14 -0
- package/src/moodboard/lifecycle/MoodBoardDestroyer.js +18 -0
- package/src/objects/ConnectorObject.js +85 -0
- package/src/objects/DrawingObject.js +47 -0
- package/src/objects/MindmapObject.js +21 -3
- package/src/objects/NoteObject.js +16 -8
- package/src/objects/ObjectFactory.js +3 -1
- package/src/objects/ShapeObject.js +1 -1
- package/src/services/ConnectorBindingResolver.js +204 -0
- package/src/services/ai/AiClient.js +30 -2
- package/src/services/ai/ChatSessionController.js +1 -0
- package/src/tools/ToolManager.js +3 -0
- package/src/tools/manager/PointerGestureController.js +206 -0
- package/src/tools/manager/ToolEventRouter.js +10 -0
- package/src/tools/manager/ToolManagerGuards.js +3 -1
- package/src/tools/manager/ToolManagerLifecycle.js +70 -58
- package/src/tools/object-tools/ConnectorTool.js +147 -0
- package/src/tools/object-tools/PlacementTool.js +2 -2
- package/src/tools/object-tools/connector/ConnectorDragController.js +296 -0
- package/src/tools/object-tools/connector/connectorGesture.js +108 -0
- package/src/tools/object-tools/placement/GhostController.js +4 -4
- package/src/tools/object-tools/placement/PlacementEventsBridge.js +2 -2
- package/src/tools/object-tools/placement/PlacementInputRouter.js +5 -5
- package/src/tools/object-tools/selection/MindmapInlineEditorController.js +11 -2
- package/src/tools/object-tools/selection/SelectInputRouter.js +33 -4
- package/src/tools/object-tools/selection/SelectToolLifecycleController.js +12 -0
- package/src/tools/object-tools/selection/SelectToolSetup.js +3 -0
- package/src/tools/object-tools/selection/TextEditorDomFactory.js +1 -2
- package/src/tools/object-tools/selection/TextEditorSyncService.js +4 -4
- package/src/tools/object-tools/selection/TextInlineEditorController.js +21 -3
- package/src/tools/object-tools/selection/TransformInteractionController.js +4 -6
- package/src/ui/HtmlTextLayer.js +212 -5
- package/src/ui/animation/HoverLiftController.js +395 -0
- package/src/ui/chat/ChatComposer.js +7 -5
- package/src/ui/chat/ChatWindow.js +652 -112
- package/src/ui/chat/icons.js +17 -1
- package/src/ui/connectors/ConnectionAnchorsLayer.js +231 -0
- package/src/ui/connectors/ConnectorLayer.js +251 -0
- package/src/ui/handles/HandlesDomRenderer.js +11 -7
- package/src/ui/handles/HandlesInteractionController.js +65 -34
- package/src/ui/handles/HandlesPositioningService.js +41 -6
- package/src/ui/mindmap/MindmapCollapseGraph.js +169 -0
- package/src/ui/mindmap/MindmapCollapseLayer.js +380 -0
- package/src/ui/mindmap/MindmapConnectionLayer.js +50 -25
- package/src/ui/mindmap/MindmapHtmlTextLayer.js +223 -2
- package/src/ui/mindmap/MindmapLayoutConfig.js +12 -0
- package/src/ui/styles/chat.css +40 -3
- package/src/ui/styles/toolbar.css +6 -0
- package/src/ui/styles/workspace.css +83 -21
- package/src/ui/toolbar/ToolbarPopupsController.js +1 -1
|
@@ -51,6 +51,17 @@ const COUNT_OPTIONS = [
|
|
|
51
51
|
{ id: '4', label: '4 Изображения', icon: COUNT_ICONS[4] },
|
|
52
52
|
];
|
|
53
53
|
|
|
54
|
+
const BOARD_IMAGE_WIDTH = 300;
|
|
55
|
+
const BOARD_IMAGE_STEP = 320;
|
|
56
|
+
const BOARD_IMAGE_GAP = BOARD_IMAGE_STEP - BOARD_IMAGE_WIDTH;
|
|
57
|
+
// Скорость перестановки AI-изображений на доске и въезда заглушек регулируется здесь.
|
|
58
|
+
const BOARD_IMAGE_REARRANGE_MS = 520;
|
|
59
|
+
// На сколько колонок «справа» появляется блок-заглушка перед въездом в финальную позицию.
|
|
60
|
+
const BOARD_IMAGE_PENDING_ENTER_FACTOR = 1.6;
|
|
61
|
+
// Каскад между блоками одного батча (мс): пользователь видит, что они приезжают друг за другом.
|
|
62
|
+
const BOARD_IMAGE_PENDING_STAGGER_MS = 90;
|
|
63
|
+
const REFERENCE_DRAG_PREVIEW_SIZE = 96;
|
|
64
|
+
|
|
54
65
|
const MODEL_OPTIONS = [
|
|
55
66
|
{
|
|
56
67
|
id: 'auto',
|
|
@@ -61,25 +72,25 @@ const MODEL_OPTIONS = [
|
|
|
61
72
|
{
|
|
62
73
|
id: 'yandex',
|
|
63
74
|
label: 'Алиса',
|
|
64
|
-
icon:
|
|
75
|
+
icon: ICONS.modelAlice,
|
|
65
76
|
description: 'YandexGPT'
|
|
66
77
|
},
|
|
67
78
|
{
|
|
68
79
|
id: 'gpt',
|
|
69
80
|
label: 'GPT',
|
|
70
|
-
icon:
|
|
81
|
+
icon: ICONS.modelGpt,
|
|
71
82
|
description: 'OpenAI'
|
|
72
83
|
},
|
|
73
84
|
{
|
|
74
85
|
id: 'google',
|
|
75
86
|
label: 'Google',
|
|
76
|
-
icon:
|
|
87
|
+
icon: ICONS.modelGoogle,
|
|
77
88
|
description: 'Gemini'
|
|
78
89
|
},
|
|
79
90
|
{
|
|
80
91
|
id: 'qwen',
|
|
81
92
|
label: 'Qwen',
|
|
82
|
-
icon:
|
|
93
|
+
icon: ICONS.modelQwen,
|
|
83
94
|
description: 'Alibaba'
|
|
84
95
|
}
|
|
85
96
|
];
|
|
@@ -129,17 +140,17 @@ export class ChatWindow {
|
|
|
129
140
|
this._unsubscribe = null;
|
|
130
141
|
this._attached = false;
|
|
131
142
|
this._boardImageMessageIds = new Set();
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
this.
|
|
135
|
-
this.
|
|
136
|
-
this.
|
|
137
|
-
this.
|
|
138
|
-
this.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
+
this._shiftedForImageBatchKeys = new Set();
|
|
144
|
+
this._boardImageShiftHistory = new Map();
|
|
145
|
+
this._pendingOverlays = new Map();
|
|
146
|
+
this._pendingOverlayTimers = new Map();
|
|
147
|
+
this._boardImageShiftAnimations = new Map();
|
|
148
|
+
this._boardCursor = null;
|
|
149
|
+
this._draggedReferenceObject = null;
|
|
150
|
+
this._draggedReferenceStartPosition = null;
|
|
151
|
+
this._referenceDragPreview = null;
|
|
152
|
+
this._referenceDragHandlers = null;
|
|
153
|
+
this._clearSelectionOnSendClick = null;
|
|
143
154
|
}
|
|
144
155
|
|
|
145
156
|
attach() {
|
|
@@ -160,11 +171,17 @@ export class ChatWindow {
|
|
|
160
171
|
statusBar: this._refs.statusBar
|
|
161
172
|
},
|
|
162
173
|
{
|
|
163
|
-
onSubmit: (text, attachments) =>
|
|
174
|
+
onSubmit: (text, attachments) => {
|
|
175
|
+
this._clearBoardSelection();
|
|
176
|
+
return this._session.send(text, { ...this._getImageRequestOptions(), referenceImages: attachments });
|
|
177
|
+
},
|
|
164
178
|
onAbort: () => this._session.abort()
|
|
165
179
|
}
|
|
166
180
|
);
|
|
181
|
+
this._clearSelectionOnSendClick = () => this._clearBoardSelection();
|
|
182
|
+
this._refs.send.addEventListener('click', this._clearSelectionOnSendClick);
|
|
167
183
|
this._composer.attach();
|
|
184
|
+
this._attachReferenceDragEvents();
|
|
168
185
|
|
|
169
186
|
this._extendedPromptModal = new ChatExtendedPromptModal(
|
|
170
187
|
this._container,
|
|
@@ -233,8 +250,6 @@ export class ChatWindow {
|
|
|
233
250
|
);
|
|
234
251
|
this._countMenu.attach();
|
|
235
252
|
|
|
236
|
-
this._boardCore?.eventBus?.on?.(Events.Object.Created, this._onBoardObjectCreated);
|
|
237
|
-
|
|
238
253
|
const initialState = this._session.getState();
|
|
239
254
|
this._markExistingBoardImages(initialState.messages);
|
|
240
255
|
this._unsubscribe = this._session.subscribe((state) => this._render(state));
|
|
@@ -248,11 +263,16 @@ export class ChatWindow {
|
|
|
248
263
|
detach() {
|
|
249
264
|
if (!this._attached) return;
|
|
250
265
|
this._clearPendingOverlays();
|
|
266
|
+
this._cancelBoardImageShiftAnimations();
|
|
267
|
+
this._clearReferenceDragState();
|
|
251
268
|
if (this._unsubscribe) { this._unsubscribe(); this._unsubscribe = null; }
|
|
252
|
-
this.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
269
|
+
if (this._clearSelectionOnSendClick && this._refs?.send) {
|
|
270
|
+
this._refs.send.removeEventListener('click', this._clearSelectionOnSendClick);
|
|
271
|
+
this._clearSelectionOnSendClick = null;
|
|
272
|
+
}
|
|
273
|
+
this._detachReferenceDragEvents();
|
|
274
|
+
this._shiftedForImageBatchKeys.clear();
|
|
275
|
+
this._boardImageShiftHistory.clear();
|
|
256
276
|
this._composer?.destroy();
|
|
257
277
|
this._extendedPromptModal?.destroy();
|
|
258
278
|
this._contentTypeMenu?.destroy();
|
|
@@ -277,6 +297,15 @@ export class ChatWindow {
|
|
|
277
297
|
this.detach();
|
|
278
298
|
}
|
|
279
299
|
|
|
300
|
+
_clearBoardSelection() {
|
|
301
|
+
if (typeof this._boardCore?.selectTool?.clearSelection === 'function') {
|
|
302
|
+
this._boardCore.selectTool.clearSelection();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
this._boardCore?.eventBus?.emit(Events.Tool.SelectionClear);
|
|
307
|
+
}
|
|
308
|
+
|
|
280
309
|
_updateCountPillIcon() {
|
|
281
310
|
const active = COUNT_OPTIONS.find((o) => o.id === this._countId);
|
|
282
311
|
if (!active) return;
|
|
@@ -309,6 +338,9 @@ export class ChatWindow {
|
|
|
309
338
|
_render(state) {
|
|
310
339
|
if (!this._attached && !this._refs) return;
|
|
311
340
|
this._syncGeneratedImagesToBoard(state.messages);
|
|
341
|
+
if (state.status !== 'streaming') {
|
|
342
|
+
this._revertFailedBatchShifts(state.messages);
|
|
343
|
+
}
|
|
312
344
|
this._messageList.render(state.messages);
|
|
313
345
|
this._contentTypeMenu.refresh();
|
|
314
346
|
this._modelMenu.refresh();
|
|
@@ -322,79 +354,106 @@ export class ChatWindow {
|
|
|
322
354
|
}
|
|
323
355
|
|
|
324
356
|
_updatePendingImages(messages) {
|
|
325
|
-
this._clearPendingOverlays();
|
|
326
|
-
|
|
327
357
|
const pending = (messages || []).filter((m) => m.pending && m.kind === 'image');
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
|
|
331
|
-
const s = world?.scale?.x || 1;
|
|
358
|
+
const activeIds = new Set(pending.map((m) => m.id));
|
|
332
359
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
this.
|
|
337
|
-
|
|
338
|
-
this._pendingShiftCount++;
|
|
360
|
+
for (const [id, record] of this._pendingOverlays) {
|
|
361
|
+
if (!activeIds.has(id)) {
|
|
362
|
+
record.el.remove();
|
|
363
|
+
this._pendingOverlays.delete(id);
|
|
364
|
+
this._cancelPendingOverlayTimer(id);
|
|
339
365
|
}
|
|
340
366
|
}
|
|
341
367
|
|
|
342
|
-
|
|
343
|
-
// чтобы блок не перекрывал их. 320 - ширина блока + отступ в мировых координатах.
|
|
344
|
-
if (newPendingCount > 0 && this._boardAiImageIds.length > 0) {
|
|
345
|
-
const worldShift = Math.round(320 * newPendingCount);
|
|
346
|
-
const objects = this._boardCore?.state?.state?.objects;
|
|
347
|
-
for (const id of this._boardAiImageIds) {
|
|
348
|
-
const obj = objects?.find((o) => o.id === id);
|
|
349
|
-
if (obj?.position) {
|
|
350
|
-
this._boardCore.updateObjectPositionDirect?.(
|
|
351
|
-
id,
|
|
352
|
-
{ x: Math.round(obj.position.x - worldShift), y: obj.position.y },
|
|
353
|
-
{ snap: false }
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
}
|
|
368
|
+
if (pending.length === 0) return;
|
|
358
369
|
|
|
359
|
-
const
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
: 400;
|
|
364
|
-
const cy = composerRect
|
|
365
|
-
? Math.round(composerRect.top - 250)
|
|
366
|
-
: 200;
|
|
370
|
+
const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
|
|
371
|
+
const s = world?.scale?.x || 1;
|
|
372
|
+
|
|
373
|
+
this._shiftExistingImagesForBatch(messages, pending[0].id, s);
|
|
367
374
|
|
|
368
375
|
const [wr, hr] = parseFormatRatio(this._formatId);
|
|
369
376
|
const ratio = wr / hr;
|
|
370
|
-
const wScreen = Math.round(
|
|
377
|
+
const wScreen = Math.round(BOARD_IMAGE_WIDTH * s);
|
|
371
378
|
const hScreen = Math.round(wScreen / ratio);
|
|
379
|
+
const enterDistance = Math.round(BOARD_IMAGE_STEP * s * BOARD_IMAGE_PENDING_ENTER_FACTOR);
|
|
372
380
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
const left = Math.round(
|
|
377
|
-
const top = Math.round(
|
|
381
|
+
let newIndex = 0;
|
|
382
|
+
pending.forEach((message) => {
|
|
383
|
+
const slot = this._getImageBatchSlot(messages, message.id, s);
|
|
384
|
+
const left = Math.round(slot.x - wScreen / 2);
|
|
385
|
+
const top = Math.round(slot.y - hScreen / 2);
|
|
386
|
+
|
|
387
|
+
const existing = this._pendingOverlays.get(message.id);
|
|
388
|
+
if (existing) {
|
|
389
|
+
const el = existing.el;
|
|
390
|
+
el.style.left = `${left}px`;
|
|
391
|
+
el.style.top = `${top}px`;
|
|
392
|
+
el.style.width = `${wScreen}px`;
|
|
393
|
+
el.style.height = `${hScreen}px`;
|
|
394
|
+
el.style.setProperty('--moodboard-chat-board-animation-ms', `${BOARD_IMAGE_REARRANGE_MS}ms`);
|
|
395
|
+
el.style.setProperty('--moodboard-chat-pending-enter-x', `${enterDistance}px`);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
378
398
|
|
|
379
399
|
const overlay = document.createElement('div');
|
|
380
|
-
overlay.className = 'moodboard-chat__pending-overlay';
|
|
400
|
+
overlay.className = 'moodboard-chat__pending-overlay moodboard-chat__pending-overlay--enter';
|
|
381
401
|
overlay.style.cssText = `left:${left}px;top:${top}px;width:${wScreen}px;height:${hScreen}px`;
|
|
402
|
+
overlay.style.setProperty('--moodboard-chat-board-animation-ms', `${BOARD_IMAGE_REARRANGE_MS}ms`);
|
|
403
|
+
overlay.style.setProperty('--moodboard-chat-pending-enter-x', `${enterDistance}px`);
|
|
382
404
|
|
|
383
405
|
const label = document.createElement('span');
|
|
384
406
|
label.className = 'moodboard-chat__pending-image-label';
|
|
385
|
-
label.textContent = 'В
|
|
407
|
+
label.textContent = 'В процессе...';
|
|
386
408
|
overlay.appendChild(label);
|
|
387
409
|
|
|
388
410
|
document.body.appendChild(overlay);
|
|
389
|
-
|
|
411
|
+
|
|
412
|
+
// Принудительный reflow: фиксируем стартовое состояние (translateX справа + opacity 0)
|
|
413
|
+
// в layout до переключения класса. Без этого браузер может смерджить два состояния
|
|
414
|
+
// в один кадр и transition не запустится — заглушка появится мгновенно.
|
|
415
|
+
void overlay.offsetWidth;
|
|
416
|
+
|
|
417
|
+
this._pendingOverlays.set(message.id, { el: overlay });
|
|
418
|
+
|
|
419
|
+
const stagger = newIndex * BOARD_IMAGE_PENDING_STAGGER_MS;
|
|
420
|
+
newIndex += 1;
|
|
421
|
+
|
|
422
|
+
const trigger = () => {
|
|
423
|
+
if (!overlay.isConnected) return;
|
|
424
|
+
overlay.classList.remove('moodboard-chat__pending-overlay--enter');
|
|
425
|
+
overlay.classList.add('moodboard-chat__pending-overlay--entered');
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
if (stagger > 0) {
|
|
429
|
+
const timer = setTimeout(() => {
|
|
430
|
+
this._pendingOverlayTimers.delete(message.id);
|
|
431
|
+
this._scheduleAnimationFrame(trigger);
|
|
432
|
+
}, stagger);
|
|
433
|
+
this._pendingOverlayTimers.set(message.id, timer);
|
|
434
|
+
} else {
|
|
435
|
+
this._scheduleAnimationFrame(trigger);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
_cancelPendingOverlayTimer(id) {
|
|
441
|
+
const timer = this._pendingOverlayTimers.get(id);
|
|
442
|
+
if (timer !== undefined) {
|
|
443
|
+
clearTimeout(timer);
|
|
444
|
+
this._pendingOverlayTimers.delete(id);
|
|
390
445
|
}
|
|
391
446
|
}
|
|
392
447
|
|
|
393
448
|
_clearPendingOverlays() {
|
|
394
|
-
for (const
|
|
395
|
-
el.remove();
|
|
449
|
+
for (const record of this._pendingOverlays.values()) {
|
|
450
|
+
record.el.remove();
|
|
396
451
|
}
|
|
397
|
-
this.
|
|
452
|
+
this._pendingOverlays.clear();
|
|
453
|
+
for (const timer of this._pendingOverlayTimers.values()) {
|
|
454
|
+
clearTimeout(timer);
|
|
455
|
+
}
|
|
456
|
+
this._pendingOverlayTimers.clear();
|
|
398
457
|
}
|
|
399
458
|
|
|
400
459
|
_getImageRequestOptions() {
|
|
@@ -407,55 +466,391 @@ export class ChatWindow {
|
|
|
407
466
|
};
|
|
408
467
|
}
|
|
409
468
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const view = this._boardCore.pixi?.app?.view;
|
|
414
|
-
const world = this._boardCore.pixi?.worldLayer || this._boardCore.pixi?.app?.stage;
|
|
415
|
-
const s = world?.scale?.x || 1;
|
|
469
|
+
_attachReferenceDragEvents() {
|
|
470
|
+
const eventBus = this._boardCore?.eventBus;
|
|
471
|
+
if (!eventBus || typeof eventBus.on !== 'function' || this._referenceDragHandlers) return;
|
|
416
472
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
this._pendingShiftCount--;
|
|
422
|
-
myShiftIndex = this._pendingShiftCount;
|
|
423
|
-
} else if (this._boardAiImageIds.length > 0) {
|
|
424
|
-
const worldShift = 320;
|
|
425
|
-
const objects = this._boardCore.state?.state?.objects;
|
|
426
|
-
for (const id of this._boardAiImageIds) {
|
|
427
|
-
const obj = objects?.find((o) => o.id === id);
|
|
428
|
-
if (obj?.position) {
|
|
429
|
-
this._boardCore.updateObjectPositionDirect?.(
|
|
430
|
-
id,
|
|
431
|
-
{ x: Math.round(obj.position.x - worldShift), y: obj.position.y },
|
|
432
|
-
{ snap: false }
|
|
433
|
-
);
|
|
434
|
-
}
|
|
473
|
+
const onCursorMove = ({ x, y } = {}) => {
|
|
474
|
+
if (Number.isFinite(x) && Number.isFinite(y)) {
|
|
475
|
+
this._boardCursor = { x, y };
|
|
476
|
+
this._updateReferenceDragPreview();
|
|
435
477
|
}
|
|
478
|
+
};
|
|
479
|
+
const onDragStart = (data) => {
|
|
480
|
+
this._handleReferenceDragStart(data);
|
|
481
|
+
};
|
|
482
|
+
const onDragEnd = (data) => {
|
|
483
|
+
void this._handleReferenceDragEnd(data);
|
|
484
|
+
};
|
|
485
|
+
const onSelectionAdd = (data) => {
|
|
486
|
+
void this._handleSelectionAdd(data);
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
this._referenceDragHandlers = { onCursorMove, onDragStart, onDragEnd, onSelectionAdd };
|
|
490
|
+
eventBus.on(Events.UI.CursorMove, onCursorMove);
|
|
491
|
+
eventBus.on(Events.Tool.DragStart, onDragStart);
|
|
492
|
+
eventBus.on(Events.Tool.DragEnd, onDragEnd);
|
|
493
|
+
eventBus.on(Events.Tool.SelectionAdd, onSelectionAdd);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
_detachReferenceDragEvents() {
|
|
497
|
+
const eventBus = this._boardCore?.eventBus;
|
|
498
|
+
const handlers = this._referenceDragHandlers;
|
|
499
|
+
if (!eventBus || typeof eventBus.off !== 'function' || !handlers) return;
|
|
500
|
+
|
|
501
|
+
eventBus.off(Events.UI.CursorMove, handlers.onCursorMove);
|
|
502
|
+
eventBus.off(Events.Tool.DragStart, handlers.onDragStart);
|
|
503
|
+
eventBus.off(Events.Tool.DragEnd, handlers.onDragEnd);
|
|
504
|
+
eventBus.off(Events.Tool.SelectionAdd, handlers.onSelectionAdd);
|
|
505
|
+
this._referenceDragHandlers = null;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async _handleSelectionAdd(data = {}) {
|
|
509
|
+
const objectId = data?.object;
|
|
510
|
+
const object = this._boardCore?.state?.state?.objects?.find((item) => item?.id === objectId);
|
|
511
|
+
|
|
512
|
+
if (!isReferenceImageObject(object)) return;
|
|
513
|
+
|
|
514
|
+
await this._addImageObjectAsReference(object);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
_handleReferenceDragStart(data = {}) {
|
|
518
|
+
const objectId = data?.object;
|
|
519
|
+
const object = this._boardCore?.state?.state?.objects?.find((item) => item?.id === objectId);
|
|
520
|
+
this._draggedReferenceObject = isReferenceImageObject(object) ? object : null;
|
|
521
|
+
this._draggedReferenceStartPosition = this._draggedReferenceObject?.position
|
|
522
|
+
? { ...this._draggedReferenceObject.position }
|
|
523
|
+
: null;
|
|
524
|
+
this._updateReferenceDragPreview();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async _handleReferenceDragEnd(data = {}) {
|
|
528
|
+
const isDropTarget = this._isBoardCursorOverInput();
|
|
529
|
+
const objectId = data?.object;
|
|
530
|
+
const object = this._boardCore?.state?.state?.objects?.find((item) => item?.id === objectId);
|
|
531
|
+
const startPosition = this._draggedReferenceStartPosition;
|
|
532
|
+
this._clearReferenceDragState();
|
|
533
|
+
if (!isDropTarget || !isReferenceImageObject(object)) return null;
|
|
534
|
+
|
|
535
|
+
this._restoreReferenceObjectPosition(objectId, startPosition);
|
|
536
|
+
await this._addImageObjectAsReference(object);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
_restoreReferenceObjectPosition(objectId, position) {
|
|
540
|
+
if (!objectId || !position) return;
|
|
541
|
+
|
|
542
|
+
const updatePosition = this._boardCore?.updateObjectPositionDirect;
|
|
543
|
+
if (typeof updatePosition === 'function') {
|
|
544
|
+
updatePosition.call(this._boardCore, objectId, position, { snap: false });
|
|
545
|
+
return;
|
|
436
546
|
}
|
|
437
547
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
548
|
+
const object = this._boardCore?.state?.state?.objects?.find((item) => item?.id === objectId);
|
|
549
|
+
if (object?.position) {
|
|
550
|
+
object.position = { ...position };
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
_updateReferenceDragPreview() {
|
|
555
|
+
const object = this._draggedReferenceObject;
|
|
556
|
+
if (!object || !this._isBoardCursorOverInput()) {
|
|
557
|
+
this._hideReferenceDragPreview();
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const src = getImageObjectSource(object);
|
|
562
|
+
if (!src) {
|
|
563
|
+
this._hideReferenceDragPreview();
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const preview = this._ensureReferenceDragPreview(object, src);
|
|
568
|
+
const { clientX, clientY } = this._getBoardCursorClientPosition();
|
|
569
|
+
preview.style.left = `${Math.round(clientX)}px`;
|
|
570
|
+
preview.style.top = `${Math.round(clientY)}px`;
|
|
571
|
+
this._refs?.textarea?.closest?.('.moodboard-chat__input-row')?.classList.add('is-reference-drop-target');
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
_ensureReferenceDragPreview(object, src) {
|
|
575
|
+
if (!this._referenceDragPreview) {
|
|
576
|
+
const preview = document.createElement('img');
|
|
577
|
+
preview.className = 'moodboard-chat__reference-drag-preview';
|
|
578
|
+
preview.alt = getImageObjectFileName(object, src);
|
|
579
|
+
preview.width = REFERENCE_DRAG_PREVIEW_SIZE;
|
|
580
|
+
preview.height = REFERENCE_DRAG_PREVIEW_SIZE;
|
|
581
|
+
document.body.appendChild(preview);
|
|
582
|
+
this._referenceDragPreview = preview;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (this._referenceDragPreview.src !== src) {
|
|
586
|
+
this._referenceDragPreview.src = src;
|
|
587
|
+
}
|
|
588
|
+
this._referenceDragPreview.alt = getImageObjectFileName(object, src);
|
|
589
|
+
|
|
590
|
+
return this._referenceDragPreview;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
_hideReferenceDragPreview() {
|
|
594
|
+
this._referenceDragPreview?.remove();
|
|
595
|
+
this._referenceDragPreview = null;
|
|
596
|
+
this._refs?.textarea?.closest?.('.moodboard-chat__input-row')?.classList.remove('is-reference-drop-target');
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
_clearReferenceDragState() {
|
|
600
|
+
this._draggedReferenceObject = null;
|
|
601
|
+
this._draggedReferenceStartPosition = null;
|
|
602
|
+
this._boardCursor = null;
|
|
603
|
+
this._hideReferenceDragPreview();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
_isBoardCursorOverInput() {
|
|
607
|
+
const cursor = this._boardCursor;
|
|
608
|
+
const inputRow = this._refs?.textarea?.closest?.('.moodboard-chat__input-row');
|
|
609
|
+
if (!cursor || !inputRow) return false;
|
|
610
|
+
|
|
611
|
+
const containerRect = this._container.getBoundingClientRect?.();
|
|
612
|
+
const rect = inputRow.getBoundingClientRect();
|
|
613
|
+
const { clientX, clientY } = this._getBoardCursorClientPosition(containerRect);
|
|
614
|
+
|
|
615
|
+
return clientX >= rect.left
|
|
616
|
+
&& clientX <= rect.right
|
|
617
|
+
&& clientY >= rect.top
|
|
618
|
+
&& clientY <= rect.bottom;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
_getBoardCursorClientPosition(containerRect = null) {
|
|
622
|
+
const rect = containerRect || this._container.getBoundingClientRect?.();
|
|
623
|
+
const cursor = this._boardCursor || { x: 0, y: 0 };
|
|
624
|
+
|
|
625
|
+
return {
|
|
626
|
+
clientX: (rect?.left || 0) + cursor.x,
|
|
627
|
+
clientY: (rect?.top || 0) + cursor.y
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async _addImageObjectAsReference(object) {
|
|
632
|
+
if (!object || !this._composer) return;
|
|
633
|
+
|
|
634
|
+
try {
|
|
635
|
+
const file = await createFileFromImageObject(object);
|
|
636
|
+
if (!file) return;
|
|
637
|
+
this._composer.addAttachment(file);
|
|
638
|
+
this._composer.focus();
|
|
639
|
+
} catch (err) {
|
|
640
|
+
console.warn('[ChatWindow] cannot add selected image reference:', err);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
_getImageBatchSlot(messages, messageId, scale = 1) {
|
|
645
|
+
const batch = findImageGenerationBatch(messages, messageId);
|
|
646
|
+
const anchor = this._getImageGroupAnchor();
|
|
647
|
+
const step = Math.round(BOARD_IMAGE_STEP * scale);
|
|
648
|
+
const count = Math.max(batch.count, 1);
|
|
649
|
+
const index = Math.min(Math.max(batch.index, 0), count - 1);
|
|
650
|
+
const leftmostCenter = anchor.x - ((count - 1) * step) / 2;
|
|
651
|
+
|
|
652
|
+
return {
|
|
653
|
+
x: Math.round(leftmostCenter + index * step),
|
|
654
|
+
y: anchor.y
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
_getImageGroupAnchor() {
|
|
444
659
|
const composerRect = this._refs?.composer?.getBoundingClientRect?.();
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
:
|
|
660
|
+
if (composerRect) {
|
|
661
|
+
return {
|
|
662
|
+
x: Math.round(composerRect.left + composerRect.width / 2),
|
|
663
|
+
y: Math.round(composerRect.top - 250)
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const chatRect = this._refs?.root?.getBoundingClientRect?.();
|
|
668
|
+
if (chatRect) {
|
|
669
|
+
return {
|
|
670
|
+
x: Math.round(chatRect.left + chatRect.width / 2),
|
|
671
|
+
y: Math.round(chatRect.top - 150)
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
return { x: 400, y: 200 };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
_shiftExistingImagesForBatch(messages, messageId, scale = 1) {
|
|
679
|
+
const batch = findImageGenerationBatch(messages, messageId);
|
|
680
|
+
const batchKey = getImageGenerationBatchKey(batch);
|
|
681
|
+
if (this._shiftedForImageBatchKeys.has(batchKey)) return;
|
|
682
|
+
|
|
683
|
+
this._shiftedForImageBatchKeys.add(batchKey);
|
|
684
|
+
this._shiftBoardAiImagesLeft(this._getImageBatchWorldBounds(messages, messageId, scale), batchKey);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
_getImageBatchWorldBounds(messages, messageId, scale = 1) {
|
|
688
|
+
const batch = findImageGenerationBatch(messages, messageId);
|
|
689
|
+
const anchor = this._getImageGroupAnchor();
|
|
690
|
+
const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
|
|
691
|
+
const s = scale || 1;
|
|
692
|
+
const step = Math.round(BOARD_IMAGE_STEP * s);
|
|
693
|
+
const width = Math.round(BOARD_IMAGE_WIDTH * s);
|
|
694
|
+
const count = Math.max(batch.count, 1);
|
|
695
|
+
const leftScreen = anchor.x - ((count - 1) * step) / 2 - width / 2;
|
|
696
|
+
const rightScreen = anchor.x + ((count - 1) * step) / 2 + width / 2;
|
|
697
|
+
|
|
698
|
+
return {
|
|
699
|
+
left: Math.round((leftScreen - (world?.x || 0)) / s),
|
|
700
|
+
right: Math.round((rightScreen - (world?.x || 0)) / s)
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
_shiftBoardAiImagesLeft(nextBatchBounds, batchKey) {
|
|
705
|
+
const aiObjects = this._getBoardAiImageObjects();
|
|
706
|
+
if (aiObjects.length === 0 || !nextBatchBounds) return;
|
|
707
|
+
|
|
708
|
+
const existingRight = Math.max(...aiObjects.map((object) => object.position.x + getBoardObjectWidth(object)));
|
|
709
|
+
const shift = Math.ceil(existingRight + BOARD_IMAGE_GAP - nextBatchBounds.left);
|
|
710
|
+
if (shift <= 0) return;
|
|
711
|
+
|
|
712
|
+
const ids = new Set(aiObjects.map((object) => object.id));
|
|
713
|
+
const objects = this._boardCore?.state?.state?.objects;
|
|
714
|
+
const shiftRecord = [];
|
|
715
|
+
for (const id of ids) {
|
|
716
|
+
const obj = objects?.find((item) => item.id === id);
|
|
717
|
+
if (obj?.position) {
|
|
718
|
+
const from = { x: obj.position.x, y: obj.position.y };
|
|
719
|
+
const to = { x: Math.round(obj.position.x - shift), y: obj.position.y };
|
|
720
|
+
shiftRecord.push({ id, from });
|
|
721
|
+
this._animateBoardImageToPosition(id, from, to);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (batchKey && shiftRecord.length > 0) {
|
|
726
|
+
this._boardImageShiftHistory.set(batchKey, shiftRecord);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
_revertBoardImageShiftForBatch(batchKey) {
|
|
731
|
+
const record = this._boardImageShiftHistory.get(batchKey);
|
|
732
|
+
if (!record) return;
|
|
733
|
+
|
|
734
|
+
const objects = this._boardCore?.state?.state?.objects;
|
|
735
|
+
for (const { id, from } of record) {
|
|
736
|
+
const obj = objects?.find((item) => item.id === id);
|
|
737
|
+
if (obj?.position) {
|
|
738
|
+
this._animateBoardImageToPosition(id, obj.position, from);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
this._boardImageShiftHistory.delete(batchKey);
|
|
743
|
+
this._shiftedForImageBatchKeys.delete(batchKey);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
_revertFailedBatchShifts(messages) {
|
|
747
|
+
if (this._boardImageShiftHistory.size === 0) return;
|
|
748
|
+
|
|
749
|
+
for (const batchKey of [...this._boardImageShiftHistory.keys()]) {
|
|
750
|
+
if (batchKey === 'unknown') continue;
|
|
751
|
+
|
|
752
|
+
const messageIds = batchKey.split('|');
|
|
753
|
+
const batchMessages = messageIds
|
|
754
|
+
.map((id) => messages?.find((m) => m.id === id))
|
|
755
|
+
.filter(Boolean);
|
|
756
|
+
|
|
757
|
+
if (batchMessages.length === 0) continue;
|
|
758
|
+
|
|
759
|
+
const allResolved = batchMessages.every((m) => !m.pending);
|
|
760
|
+
const anyImage = batchMessages.some((m) => Boolean(m.imageBase64));
|
|
761
|
+
|
|
762
|
+
if (allResolved && !anyImage) {
|
|
763
|
+
this._revertBoardImageShiftForBatch(batchKey);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
_animateBoardImageToPosition(id, fromPosition, toPosition) {
|
|
769
|
+
const updatePosition = this._boardCore?.updateObjectPositionDirect;
|
|
770
|
+
if (!id || typeof updatePosition !== 'function' || !fromPosition || !toPosition) return;
|
|
771
|
+
|
|
772
|
+
this._cancelBoardImageShiftAnimation(id);
|
|
773
|
+
|
|
774
|
+
if (BOARD_IMAGE_REARRANGE_MS <= 0 || prefersReducedMotion()) {
|
|
775
|
+
updatePosition.call(this._boardCore, id, toPosition, { snap: false });
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const from = {
|
|
780
|
+
x: Number(fromPosition.x) || 0,
|
|
781
|
+
y: Number(fromPosition.y) || 0
|
|
782
|
+
};
|
|
783
|
+
const to = {
|
|
784
|
+
x: Math.round(Number(toPosition.x) || 0),
|
|
785
|
+
y: Math.round(Number(toPosition.y) || 0)
|
|
786
|
+
};
|
|
787
|
+
const startAt = getAnimationTime();
|
|
788
|
+
const record = { frame: null };
|
|
789
|
+
|
|
790
|
+
const step = (now) => {
|
|
791
|
+
const progress = Math.min(Math.max((now - startAt) / BOARD_IMAGE_REARRANGE_MS, 0), 1);
|
|
792
|
+
const eased = easeOutCubic(progress);
|
|
793
|
+
const next = {
|
|
794
|
+
x: Math.round(from.x + (to.x - from.x) * eased),
|
|
795
|
+
y: Math.round(from.y + (to.y - from.y) * eased)
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
updatePosition.call(this._boardCore, id, next, { snap: false });
|
|
799
|
+
|
|
800
|
+
if (progress < 1) {
|
|
801
|
+
record.frame = this._scheduleAnimationFrame(step);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
updatePosition.call(this._boardCore, id, to, { snap: false });
|
|
806
|
+
this._boardImageShiftAnimations.delete(id);
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
record.frame = this._scheduleAnimationFrame(step);
|
|
810
|
+
this._boardImageShiftAnimations.set(id, record);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
_cancelBoardImageShiftAnimation(id) {
|
|
814
|
+
const record = this._boardImageShiftAnimations.get(id);
|
|
815
|
+
if (!record) return;
|
|
816
|
+
|
|
817
|
+
cancelAnimationFrameSafe(record.frame);
|
|
818
|
+
this._boardImageShiftAnimations.delete(id);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
_cancelBoardImageShiftAnimations() {
|
|
822
|
+
for (const id of this._boardImageShiftAnimations.keys()) {
|
|
823
|
+
this._cancelBoardImageShiftAnimation(id);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
_scheduleAnimationFrame(callback) {
|
|
828
|
+
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
|
829
|
+
return window.requestAnimationFrame(callback);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
return setTimeout(() => callback(getAnimationTime()), 16);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
_getBoardAiImageObjects() {
|
|
836
|
+
const objects = this._boardCore?.state?.state?.objects;
|
|
837
|
+
if (!Array.isArray(objects)) return [];
|
|
838
|
+
|
|
839
|
+
return objects.filter((object) => isBoardAiImageObject(object));
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
_addImageToBoard(msg) {
|
|
843
|
+
if (!this._boardCore?.eventBus) return;
|
|
844
|
+
const dataUrl = `data:${msg.mimeType || 'image/jpeg'};base64,${msg.imageBase64}`;
|
|
845
|
+
const world = this._boardCore.pixi?.worldLayer || this._boardCore.pixi?.app?.stage;
|
|
846
|
+
const s = world?.scale?.x || 1;
|
|
847
|
+
const messages = this._session.getState().messages;
|
|
848
|
+
this._shiftExistingImagesForBatch(messages, msg.id, s);
|
|
849
|
+
const slot = this._getImageBatchSlot(messages, msg.id, s);
|
|
456
850
|
|
|
457
851
|
this._boardCore.eventBus.emit(Events.UI.PasteImageAt, {
|
|
458
|
-
x
|
|
852
|
+
x: slot.x,
|
|
853
|
+
y: slot.y,
|
|
459
854
|
src: dataUrl,
|
|
460
855
|
name: 'ai-generated.jpg',
|
|
461
856
|
skipUpload: true
|
|
@@ -509,3 +904,148 @@ function parseImageCount(countId) {
|
|
|
509
904
|
|
|
510
905
|
return Math.min(Math.max(count, 1), 4);
|
|
511
906
|
}
|
|
907
|
+
|
|
908
|
+
function findImageGenerationBatch(messages, messageId) {
|
|
909
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
910
|
+
const targetIndex = list.findIndex((message) => message?.id === messageId);
|
|
911
|
+
if (targetIndex === -1) {
|
|
912
|
+
return { index: 0, count: 1 };
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
let start = targetIndex;
|
|
916
|
+
while (start > 0 && isImageGenerationMessage(list[start - 1])) {
|
|
917
|
+
start--;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
let end = targetIndex;
|
|
921
|
+
while (end + 1 < list.length && isImageGenerationMessage(list[end + 1])) {
|
|
922
|
+
end++;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
return {
|
|
926
|
+
index: targetIndex - start,
|
|
927
|
+
count: end - start + 1,
|
|
928
|
+
ids: list.slice(start, end + 1).map((message) => message.id)
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function getImageGenerationBatchKey(batch) {
|
|
933
|
+
return batch.ids?.join('|') || 'unknown';
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function isImageGenerationMessage(message) {
|
|
937
|
+
return message?.role === 'assistant'
|
|
938
|
+
&& (message.kind === 'image' || message.pending || Boolean(message.imageBase64));
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function isBoardAiImageObject(object) {
|
|
942
|
+
return Boolean(object?.id)
|
|
943
|
+
&& object.type === 'image'
|
|
944
|
+
&& object.properties?.name === 'ai-generated.jpg'
|
|
945
|
+
&& object.position
|
|
946
|
+
&& Number.isFinite(object.position.x);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function isReferenceImageObject(object) {
|
|
950
|
+
return Boolean(object?.id)
|
|
951
|
+
&& (object.type === 'image' || object.type === 'revit-screenshot-img')
|
|
952
|
+
&& typeof getImageObjectSource(object) === 'string';
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
async function createFileFromImageObject(object) {
|
|
956
|
+
const src = getImageObjectSource(object);
|
|
957
|
+
if (!src) return null;
|
|
958
|
+
|
|
959
|
+
const name = getImageObjectFileName(object, src);
|
|
960
|
+
const blob = src.startsWith('data:')
|
|
961
|
+
? dataUrlToBlob(src)
|
|
962
|
+
: await fetchImageBlob(src);
|
|
963
|
+
|
|
964
|
+
return createNamedBlob(blob, name);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function getImageObjectSource(object) {
|
|
968
|
+
const src = object?.src || object?.properties?.src || object?.properties?.url || object?.url;
|
|
969
|
+
return typeof src === 'string' && src.trim() ? src.trim() : null;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function getImageObjectFileName(object, src) {
|
|
973
|
+
const explicitName = object?.properties?.name || object?.name;
|
|
974
|
+
if (typeof explicitName === 'string' && explicitName.trim()) {
|
|
975
|
+
return explicitName.trim();
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
if (!src.startsWith('data:')) {
|
|
979
|
+
const lastPathPart = src.split(/[?#]/)[0].split('/').pop();
|
|
980
|
+
if (lastPathPart) return lastPathPart;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
return 'board-reference.png';
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function dataUrlToBlob(dataUrl) {
|
|
987
|
+
const [meta = '', data = ''] = dataUrl.split(',');
|
|
988
|
+
const mimeMatch = meta.match(/^data:([^;]+)/);
|
|
989
|
+
const mimeType = mimeMatch?.[1] || 'image/png';
|
|
990
|
+
const isBase64 = /;base64/i.test(meta);
|
|
991
|
+
const binary = isBase64 ? atob(data) : decodeURIComponent(data);
|
|
992
|
+
const bytes = new Uint8Array(binary.length);
|
|
993
|
+
|
|
994
|
+
for (let i = 0; i < binary.length; i++) {
|
|
995
|
+
bytes[i] = binary.charCodeAt(i);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
return new Blob([bytes], { type: mimeType });
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
async function fetchImageBlob(src) {
|
|
1002
|
+
const response = await fetch(src);
|
|
1003
|
+
if (!response.ok) {
|
|
1004
|
+
throw new Error(`Cannot load image reference (${response.status})`);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
return response.blob();
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function createNamedBlob(blob, name) {
|
|
1011
|
+
if (typeof File === 'function') {
|
|
1012
|
+
return new File([blob], name, { type: blob.type || 'image/png' });
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
blob.name = name;
|
|
1016
|
+
return blob;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function getBoardObjectWidth(object) {
|
|
1020
|
+
const width = object?.width ?? object?.properties?.width ?? BOARD_IMAGE_WIDTH;
|
|
1021
|
+
return Number.isFinite(width) ? Math.max(1, Math.round(width)) : BOARD_IMAGE_WIDTH;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function easeOutCubic(progress) {
|
|
1025
|
+
return 1 - Math.pow(1 - progress, 3);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function getAnimationTime() {
|
|
1029
|
+
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
|
1030
|
+
return performance.now();
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
return Date.now();
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function cancelAnimationFrameSafe(frame) {
|
|
1037
|
+
if (!frame) return;
|
|
1038
|
+
|
|
1039
|
+
if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
|
|
1040
|
+
window.cancelAnimationFrame(frame);
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
clearTimeout(frame);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function prefersReducedMotion() {
|
|
1048
|
+
return typeof window !== 'undefined'
|
|
1049
|
+
&& typeof window.matchMedia === 'function'
|
|
1050
|
+
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
1051
|
+
}
|