@sequent-org/moodboard 1.4.42 → 1.4.44

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequent-org/moodboard",
3
- "version": "1.4.42",
3
+ "version": "1.4.44",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -28,7 +28,10 @@ export class AiClient {
28
28
 
29
29
  /**
30
30
  * Список доступных провайдеров.
31
- * @returns {Promise<Array<{id: string, label: string, enabled: boolean}>>}
31
+ *
32
+ * @returns {Promise<Array<{id: string, label: string, enabled: boolean, supportedRatios: string[]|null}>>}
33
+ * supportedRatios — массив id форматов из FORMAT_OPTIONS (например ['1:1','3:2','2:3']),
34
+ * либо null, если провайдер не ограничивает доступные соотношения сторон.
32
35
  */
33
36
  async listProviders() {
34
37
  const res = await this._fetch(`${this._baseUrl}/providers`, {
@@ -26,7 +26,7 @@ const CONTENT_TYPE_OPTIONS = [
26
26
  }
27
27
  ];
28
28
 
29
- /** Порядок: портрет (строка 1), авто + альбом (строка 2) — соответствует грид-меню */
29
+ /** Порядок: портрет (строка 1), альбом (строка 2), авто крайнее правое */
30
30
  const FORMAT_OPTIONS = [
31
31
  { id: '1:1', label: '1:1', icon: RATIO_ICONS['1:1'] },
32
32
  { id: '4:5', label: '4:5', icon: RATIO_ICONS['4:5'] },
@@ -35,13 +35,13 @@ const FORMAT_OPTIONS = [
35
35
  { id: '2:3', label: '2:3', icon: RATIO_ICONS['2:3'] },
36
36
  { id: '9:16', label: '9:16', icon: RATIO_ICONS['9:16'] },
37
37
  { id: '1:2', label: '1:2', icon: RATIO_ICONS['1:2'] },
38
- { id: 'auto', label: 'Auto', icon: RATIO_ICONS['auto'] },
39
38
  { id: '5:4', label: '5:4', icon: RATIO_ICONS['5:4'] },
40
39
  { id: '4:3', label: '4:3', icon: RATIO_ICONS['4:3'] },
41
40
  { id: '14:10', label: '14:10', icon: RATIO_ICONS['14:10'] },
42
41
  { id: '3:2', label: '3:2', icon: RATIO_ICONS['3:2'] },
43
42
  { id: '16:9', label: '16:9', icon: RATIO_ICONS['16:9'] },
44
43
  { id: '2:1', label: '2:1', icon: RATIO_ICONS['2:1'] },
44
+ { id: 'auto', label: 'Auto', icon: RATIO_ICONS['auto'] },
45
45
  ];
46
46
 
47
47
  const COUNT_OPTIONS = [
@@ -61,6 +61,10 @@ const BOARD_IMAGE_REARRANGE_MS = 520;
61
61
  const BOARD_IMAGE_PENDING_ENTER_FACTOR = 1.6;
62
62
  // Каскад между блоками одного батча (мс): пользователь видит, что они приезжают друг за другом.
63
63
  const BOARD_IMAGE_PENDING_STAGGER_MS = 90;
64
+ // Референсная высота ряда AI-картинок: центры выравниваются по одной оси независимо от формата.
65
+ const BOARD_IMAGE_LANE_REFERENCE_RATIO = [2, 3];
66
+ const BOARD_IMAGE_LANE_CENTER_OFFSET = 250;
67
+ const BOARD_IMAGE_LANE_UI_GAP = 16;
64
68
 
65
69
  const MODEL_OPTIONS = [
66
70
  {
@@ -135,6 +139,7 @@ export class ChatWindow {
135
139
  this._modelMenu = null;
136
140
  this._formatId = 'auto';
137
141
  this._formatMenu = null;
142
+ this._providers = [];
138
143
  this._countId = 'auto';
139
144
  this._countMenu = null;
140
145
  this._unsubscribe = null;
@@ -151,6 +156,7 @@ export class ChatWindow {
151
156
  this._aiImageLaneHandlers = null;
152
157
  this._selectionHandlers = null;
153
158
  this._viewportHandlers = null;
159
+ this._boardObjectHandlers = null;
154
160
  // Окно от BoxSelectStart до BoxSelectCommit: в это время SelectionAdd
155
161
  // приходит на каждый mousemove и не должен пушить превью в чат —
156
162
  // финальный набор картинок мы получим из BoxSelectCommit по strict-contains.
@@ -185,6 +191,7 @@ export class ChatWindow {
185
191
  this._attachSelectionEvents();
186
192
  this._attachViewportSync();
187
193
  this._attachAiImageLaneEvents();
194
+ this._attachBoardObjectEvents();
188
195
 
189
196
  this._extendedPromptModal = new ChatExtendedPromptModal(
190
197
  this._container,
@@ -219,6 +226,7 @@ export class ChatWindow {
219
226
  onSelect: (id) => {
220
227
  this._modelId = id;
221
228
  this._modelMenu.refresh();
229
+ this._clampFormatToModel();
222
230
  }
223
231
  }
224
232
  );
@@ -227,7 +235,7 @@ export class ChatWindow {
227
235
  this._formatMenu = new ChatPillMenu(
228
236
  { trigger: this._refs.formatPill, menu: this._refs.formatMenu, label: this._refs.formatLabel },
229
237
  {
230
- getOptions: () => FORMAT_OPTIONS,
238
+ getOptions: () => this._getSupportedFormatOptions(),
231
239
  getActiveId: () => this._formatId,
232
240
  onSelect: (id) => {
233
241
  this._formatId = id;
@@ -272,6 +280,7 @@ export class ChatWindow {
272
280
  this._detachSelectionEvents();
273
281
  this._detachViewportSync();
274
282
  this._detachAiImageLaneEvents();
283
+ this._detachBoardObjectEvents();
275
284
  this._shiftedForImageBatchKeys.clear();
276
285
  this._boardImageShiftHistory.clear();
277
286
  this._pendingBatchOffsets.clear();
@@ -332,13 +341,65 @@ export class ChatWindow {
332
341
  async _loadProviders() {
333
342
  try {
334
343
  const list = await this._aiClient.listProviders();
344
+ this._providers = Array.isArray(list) ? list : [];
335
345
  this._session.setAvailableProviders(list);
346
+ this._clampFormatToModel();
336
347
  } catch (err) {
337
348
  console.warn('[ChatWindow] cannot load providers:', err.message);
349
+ this._providers = [];
338
350
  this._session.setAvailableProviders([]);
339
351
  }
340
352
  }
341
353
 
354
+ /**
355
+ * Возвращает id провайдера изображений для текущей модели.
356
+ * Дублирует логику из _getImageRequestOptions, чтобы не привязываться к payload-контексту.
357
+ *
358
+ * @param {string} modelId
359
+ * @returns {string}
360
+ */
361
+ _imageProviderForModel(modelId) {
362
+ return modelId === 'gpt' ? 'openai-image' : 'yandex-art';
363
+ }
364
+
365
+ /**
366
+ * Возвращает подмножество FORMAT_OPTIONS, поддерживаемое текущим провайдером.
367
+ * Если провайдер не ограничивает форматы (supportedRatios === null) — возвращает все.
368
+ * Формат 'auto' присутствует всегда.
369
+ *
370
+ * @returns {Array}
371
+ */
372
+ _getSupportedFormatOptions() {
373
+ const providerId = this._imageProviderForModel(this._modelId);
374
+ const provider = this._providers.find((p) => p.id === providerId);
375
+ const ratios = provider?.supportedRatios;
376
+
377
+ if (!Array.isArray(ratios) || ratios.length === 0) {
378
+ return FORMAT_OPTIONS;
379
+ }
380
+
381
+ return FORMAT_OPTIONS.filter((opt) => opt.id === 'auto' || ratios.includes(opt.id));
382
+ }
383
+
384
+ /**
385
+ * При смене модели проверяет, что текущий формат входит в список поддерживаемых.
386
+ * Если нет — сбрасывает на 'auto'.
387
+ */
388
+ _clampFormatToModel() {
389
+ const supported = this._getSupportedFormatOptions();
390
+ const stillValid = supported.some((opt) => opt.id === this._formatId);
391
+ if (!stillValid) {
392
+ this._formatId = 'auto';
393
+ }
394
+ // refresh обновляет лейбл из option.label ('Auto'), поэтому _updateFormatPillLabel
395
+ // вызывается после — она выставляет человекочитаемый текст 'Соотношение сторон'.
396
+ this._formatMenu?.refresh();
397
+ if (!stillValid) {
398
+ this._updateFormatPillIcon();
399
+ this._updateFormatPillLabel();
400
+ }
401
+ }
402
+
342
403
  _render(state) {
343
404
  if (!this._attached && !this._refs) return;
344
405
  this._syncGeneratedImagesToBoard(state.messages);
@@ -494,6 +555,68 @@ export class ChatWindow {
494
555
  this._scheduleAnimationFrame(trigger);
495
556
  }
496
557
  });
558
+
559
+ // Детерминированная гарантия отсутствия наложения: считается в чистом world-space,
560
+ // поэтому не зависит ни от зума, ни от порядка/тайминга батчей. Срабатывает только
561
+ // когда после всех остальных сдвигов изображение всё ещё заходит на слот заглушки
562
+ // (idempotent: при overflow <= 0 — no-op, не трогает «подтягивание вправо» и drag).
563
+ this._ensureExistingImagesClearOfPending();
564
+ }
565
+
566
+ _ensureExistingImagesClearOfPending() {
567
+ if (this._pendingOverlays.size === 0) {
568
+ return;
569
+ }
570
+
571
+ let minPlaceholderLeft = Infinity;
572
+ for (const record of this._pendingOverlays.values()) {
573
+ if (Number.isFinite(record.worldX)) {
574
+ minPlaceholderLeft = Math.min(minPlaceholderLeft, record.worldX - BOARD_IMAGE_WIDTH / 2);
575
+ }
576
+ }
577
+ if (!Number.isFinite(minPlaceholderLeft)) {
578
+ return;
579
+ }
580
+
581
+ const boundary = minPlaceholderLeft - BOARD_IMAGE_GAP;
582
+ const objects = this._getBoardAiImageObjects()
583
+ .filter((obj) => obj?.position && !this._draggingAiImageIds.has(obj.id));
584
+ if (objects.length === 0) {
585
+ return;
586
+ }
587
+
588
+ let maxRight = -Infinity;
589
+ for (const obj of objects) {
590
+ const slot = this._getAiImageLaneSlotForObject(obj);
591
+ const left = slot?.x ?? obj.position.x;
592
+ const width = slot?.width ?? getBoardObjectWidth(obj);
593
+ maxRight = Math.max(maxRight, left + width);
594
+ }
595
+
596
+ const overflow = Math.ceil(maxRight - boundary);
597
+ if (overflow <= 0) {
598
+ return;
599
+ }
600
+
601
+ for (const obj of objects) {
602
+ const key = getAiImageLaneKeyForObject(obj);
603
+ const slot = this._getAiImageLaneSlotForObject(obj);
604
+ const targetX = Math.round((slot?.x ?? obj.position.x) - overflow);
605
+ const targetY = slot?.y ?? obj.position.y;
606
+ if (key) {
607
+ this._aiImageLaneSlots.set(key, {
608
+ x: targetX,
609
+ y: targetY,
610
+ width: slot?.width ?? getBoardObjectWidth(obj),
611
+ height: slot?.height ?? getBoardObjectHeight(obj)
612
+ });
613
+ }
614
+ this._animateBoardImageToPosition(
615
+ obj.id,
616
+ { x: obj.position.x, y: obj.position.y },
617
+ { x: targetX, y: targetY }
618
+ );
619
+ }
497
620
  }
498
621
 
499
622
  _cancelPendingOverlayTimer(id) {
@@ -553,8 +676,11 @@ export class ChatWindow {
553
676
  _shiftPendingOverlaysForNewBatch(batchId, count, scale) {
554
677
  if (!batchId || this._pendingBatchOffsets.has(batchId)) return;
555
678
 
556
- const step = Math.round(BOARD_IMAGE_STEP * scale);
557
- const shiftAmount = count * step;
679
+ // Offset храним в world-units, чтобы расстояние между батчами оставалось
680
+ // стабильным при изменении zoom между промптами. Если хранить в screen-px,
681
+ // накопленные смещения «протухают» при следующем масштабе и батчи едут друг
682
+ // на друга.
683
+ const shiftAmount = count * BOARD_IMAGE_STEP;
558
684
 
559
685
  for (const [existingId, offset] of this._pendingBatchOffsets) {
560
686
  this._pendingBatchOffsets.set(existingId, offset - shiftAmount);
@@ -578,7 +704,8 @@ export class ChatWindow {
578
704
  // правее заглушек и перекроются ими.
579
705
  if (!existingOverlaysShifted) return;
580
706
 
581
- const worldShift = shiftAmount / scale;
707
+ // shiftAmount уже в world-units (см. инициализацию выше).
708
+ const worldShift = shiftAmount;
582
709
  for (const obj of this._getBoardAiImageObjects()) {
583
710
  if (obj?.position) {
584
711
  const key = getAiImageLaneKeyForObject(obj);
@@ -710,6 +837,9 @@ export class ChatWindow {
710
837
  };
711
838
  const onViewportChange = () => {
712
839
  this._syncPendingOverlaysToViewport({ disableTransition: true, recomputeWorld: false });
840
+ // Зум меняет проекцию композера в мир: после него существующее изображение может
841
+ // оказаться под заглушкой. Перепроверяем инвариант сразу, не дожидаясь рендера сессии.
842
+ this._ensureExistingImagesClearOfPending();
713
843
  };
714
844
 
715
845
  this._viewportHandlers = { onPanUpdate, onViewportChange };
@@ -729,6 +859,59 @@ export class ChatWindow {
729
859
  this._viewportHandlers = null;
730
860
  }
731
861
 
862
+ _attachBoardObjectEvents() {
863
+ const eventBus = this._boardCore?.eventBus;
864
+ if (!eventBus || typeof eventBus.on !== 'function' || this._boardObjectHandlers) return;
865
+
866
+ // Если в момент рендера активных pending-заглушек state.objects ещё пустой
867
+ // (быстрый промпт сразу после refresh страницы, асинхронная загрузка доски с сервера),
868
+ // `_shiftExistingImagesForBatch` выходит без выставления флага в расчёте на повтор
869
+ // на следующем рендере. Но повтор не гарантирован: session-стейт может больше не меняться,
870
+ // пока генерация не завершится, и `_render` не вызовется. Поэтому материализацию объектов
871
+ // ловим напрямую и сами повторяем shift + safety-net.
872
+ const onBoardObjectChange = () => {
873
+ this._recheckPendingShifts();
874
+ };
875
+
876
+ this._boardObjectHandlers = { onBoardObjectChange };
877
+ eventBus.on(Events.Object.Created, onBoardObjectChange);
878
+ eventBus.on(Events.Board.Loaded, onBoardObjectChange);
879
+ }
880
+
881
+ _detachBoardObjectEvents() {
882
+ const eventBus = this._boardCore?.eventBus;
883
+ const handlers = this._boardObjectHandlers;
884
+ if (!eventBus || typeof eventBus.off !== 'function' || !handlers) return;
885
+
886
+ eventBus.off(Events.Object.Created, handlers.onBoardObjectChange);
887
+ eventBus.off(Events.Board.Loaded, handlers.onBoardObjectChange);
888
+ this._boardObjectHandlers = null;
889
+ }
890
+
891
+ _recheckPendingShifts() {
892
+ if (this._pendingOverlays.size === 0) return;
893
+
894
+ const messages = this._session?.getState?.()?.messages || [];
895
+ const pending = messages.filter((m) => m?.pending && m?.kind === 'image');
896
+ if (pending.length === 0) {
897
+ this._ensureExistingImagesClearOfPending();
898
+ return;
899
+ }
900
+
901
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
902
+ const s = world?.scale?.x || 1;
903
+
904
+ const shiftedBids = new Set();
905
+ for (const m of pending) {
906
+ const bid = m.batchId || m.id;
907
+ if (shiftedBids.has(bid)) continue;
908
+ shiftedBids.add(bid);
909
+ this._shiftExistingImagesForBatch(messages, m.id, s);
910
+ }
911
+
912
+ this._ensureExistingImagesClearOfPending();
913
+ }
914
+
732
915
  _attachAiImageLaneEvents() {
733
916
  const eventBus = this._boardCore?.eventBus;
734
917
  if (!eventBus || typeof eventBus.on !== 'function' || this._aiImageLaneHandlers) return;
@@ -885,8 +1068,12 @@ export class ChatWindow {
885
1068
  const step = Math.round(BOARD_IMAGE_STEP * scale);
886
1069
  const count = Math.max(batch.count, 1);
887
1070
  const index = Math.min(Math.max(batch.index, 0), count - 1);
888
- const batchOffset = batch.batchId ? (this._pendingBatchOffsets.get(batch.batchId) ?? 0) : 0;
889
- const leftmostCenter = anchor.x - ((count - 1) * step) / 2 + batchOffset;
1071
+ // _pendingBatchOffsets хранятся в world-units и переводятся в screen
1072
+ // под текущий scale: устаревшие при изменении zoom между батчами screen-смещения
1073
+ // больше не «протухают».
1074
+ const batchOffsetWorld = batch.batchId ? (this._pendingBatchOffsets.get(batch.batchId) ?? 0) : 0;
1075
+ const batchOffsetScreen = Math.round(batchOffsetWorld * scale);
1076
+ const leftmostCenter = anchor.x - ((count - 1) * step) / 2 + batchOffsetScreen;
890
1077
 
891
1078
  return {
892
1079
  x: Math.round(leftmostCenter + index * step),
@@ -894,23 +1081,77 @@ export class ChatWindow {
894
1081
  };
895
1082
  }
896
1083
 
897
- _getImageGroupAnchor() {
898
- const composerRect = this._refs?.composer?.getBoundingClientRect?.();
899
- if (composerRect) {
900
- let y = composerRect.top - 250;
901
-
902
- const errorBlock = this._refs?.errorBlock;
903
- if (errorBlock && errorBlock.classList.contains('is-visible')) {
904
- const errorRect = errorBlock.getBoundingClientRect();
905
- // Низ картинок примерно y + 150.
906
- // Хотим чтобы errorRect.top был хотя бы y + 150 + 16 (gap)
907
- // То есть y + 166 <= errorRect.top
908
- const minY = errorRect.top - 166;
1084
+ _getAiImageLaneCenterScreenY(scale = null) {
1085
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
1086
+ // scale по умолчанию null, иначе truthy-единица перекрывала бы реальный
1087
+ // world.scale.x: экранная Y считалась бы при масштабе 1, и на зуме (0.1)
1088
+ // обратная конвертация в мир завышала Y в 10 раз — ряд уезжал вниз.
1089
+ const s = scale || world?.scale?.x || 1;
1090
+ const worldOffsetY = world?.y || 0;
1091
+
1092
+ for (const record of this._pendingOverlays.values()) {
1093
+ if (Number.isFinite(record.worldY)) {
1094
+ return Math.round(record.worldY * s + worldOffsetY);
1095
+ }
1096
+ }
1097
+
1098
+ for (const object of this._getBoardAiImageObjects()) {
1099
+ if (!object?.position) continue;
1100
+
1101
+ const height = getBoardObjectHeight(object);
1102
+ return Math.round((object.position.y + height / 2) * s + worldOffsetY);
1103
+ }
1104
+
1105
+ for (const slot of this._aiImageLaneSlots.values()) {
1106
+ if (slot && Number.isFinite(slot.y) && Number.isFinite(slot.height)) {
1107
+ return Math.round((slot.y + slot.height / 2) * s + worldOffsetY);
1108
+ }
1109
+ }
1110
+
1111
+ return null;
1112
+ }
1113
+
1114
+ _clampImageGroupAnchorY(y, referenceHeight) {
1115
+ const clearance = Math.round(referenceHeight / 2) + BOARD_IMAGE_LANE_UI_GAP;
1116
+
1117
+ const errorBlock = this._refs?.errorBlock;
1118
+ if (errorBlock && errorBlock.classList.contains('is-visible')) {
1119
+ const errorRect = errorBlock.getBoundingClientRect();
1120
+ if (errorRect.height > 0) {
1121
+ const minY = errorRect.top - clearance;
1122
+ if (y > minY) {
1123
+ y = minY;
1124
+ }
1125
+ }
1126
+ }
1127
+
1128
+ const statusBar = this._refs?.statusBar;
1129
+ if (statusBar && statusBar.classList.contains('is-visible')) {
1130
+ const statusRect = statusBar.getBoundingClientRect();
1131
+ // jsdom отдаёт нулевой rect — без реальной геометрии не сдвигаем ряд.
1132
+ if (statusRect.height > 0 && statusRect.top > 0) {
1133
+ const minY = statusRect.top - clearance;
909
1134
  if (y > minY) {
910
1135
  y = minY;
911
1136
  }
912
1137
  }
913
-
1138
+ }
1139
+
1140
+ return y;
1141
+ }
1142
+
1143
+ _getImageGroupAnchor() {
1144
+ const composerRect = this._refs?.composer?.getBoundingClientRect?.();
1145
+ if (composerRect) {
1146
+ const existingCenterY = this._getAiImageLaneCenterScreenY();
1147
+ const [wr, hr] = parseFormatRatio(this._formatId);
1148
+ const actualHeight = Math.round(BOARD_IMAGE_WIDTH / (wr / hr));
1149
+ let y = existingCenterY ?? (composerRect.top - Math.round(actualHeight / 2) - BOARD_IMAGE_LANE_UI_GAP);
1150
+
1151
+ if (existingCenterY == null) {
1152
+ y = this._clampImageGroupAnchorY(y, actualHeight);
1153
+ }
1154
+
914
1155
  return {
915
1156
  x: Math.round(composerRect.left + composerRect.width / 2),
916
1157
  y: Math.round(y)
@@ -933,6 +1174,11 @@ export class ChatWindow {
933
1174
  const batchKey = getImageGenerationBatchKey(batch);
934
1175
  if (this._shiftedForImageBatchKeys.has(batchKey)) return;
935
1176
 
1177
+ // Объект готового изображения создаётся асинхронно (img.onload в ClipboardFlow).
1178
+ // Пока его нет в state.objects — не фиксируем флаг: следующий рендер повторит попытку,
1179
+ // когда объект уже появится, и сдвиг отработает корректно.
1180
+ if (this._getBoardAiImageObjects().length === 0) return;
1181
+
936
1182
  this._shiftedForImageBatchKeys.add(batchKey);
937
1183
  this._shiftBoardAiImagesLeft(this._getImageBatchWorldBounds(messages, messageId, scale), batchKey);
938
1184
  }
@@ -960,8 +1206,12 @@ export class ChatWindow {
960
1206
 
961
1207
  const existingRight = this._getAiImageLaneRightBoundary(aiObjects);
962
1208
  if (!Number.isFinite(existingRight)) return;
1209
+ // shift > 0 — ряд истории уезжает влево, освобождая место под новый батч.
1210
+ // shift < 0 — новый батч приземлился правее ряда (после зума «к точке»
1211
+ // мировая проекция композера уехала далеко от уже стоящих картинок),
1212
+ // поэтому ряд нужно подтянуть вправо к батчу, иначе картинки расползаются.
963
1213
  const shift = Math.ceil(existingRight + BOARD_IMAGE_GAP - nextBatchBounds.left);
964
- if (shift <= 0) return;
1214
+ if (shift === 0) return;
965
1215
 
966
1216
  const ids = new Set(aiObjects.map((object) => object.id));
967
1217
  const objects = this._boardCore?.state?.state?.objects;
@@ -1311,12 +1561,11 @@ export class ChatWindow {
1311
1561
  const key = getAiImageLaneKeyForObject(object);
1312
1562
  if (key === excludeKey) continue;
1313
1563
 
1314
- const slot = {
1315
- x: Math.round(object.position.x),
1316
- y: Math.round(object.position.y),
1317
- width: getBoardObjectWidth(object),
1318
- height: getBoardObjectHeight(object)
1319
- };
1564
+ // Используем слот из кэша — он отражает целевую позицию после анимации сдвига.
1565
+ // object.position содержит текущую (промежуточную) позицию, которая ещё не достигла цели,
1566
+ // что приводит к ложным «нет пересечения» и последующему наезду.
1567
+ const slot = this._getAiImageLaneSlotForObject(object);
1568
+ if (!slot) continue;
1320
1569
 
1321
1570
  if (rectsOverlap(candidate, slot)) {
1322
1571
  return true;
@@ -1386,6 +1635,11 @@ export class ChatWindow {
1386
1635
  }
1387
1636
  }
1388
1637
 
1638
+ function getBoardImageReferenceHeight() {
1639
+ const [widthRatio, heightRatio] = BOARD_IMAGE_LANE_REFERENCE_RATIO;
1640
+ return Math.round(BOARD_IMAGE_WIDTH / (widthRatio / heightRatio));
1641
+ }
1642
+
1389
1643
  function parseFormatRatio(formatId) {
1390
1644
  if (!formatId || formatId === 'auto') {
1391
1645
  return [1, 1];