customer-chat-sdk 1.1.12 → 1.1.15

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.
@@ -5,7 +5,7 @@ Object.defineProperty(exports, '__esModule', { value: true });
5
5
  // 直接使用base64字符串,避免打包后路径问题
6
6
  const iconImage = 'data:image/png;base64,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';
7
7
  class IconManager {
8
- constructor(position, debug = false, target) {
8
+ constructor(position, debug = false, target, options) {
9
9
  this.iconElement = null;
10
10
  this.badgeElement = null;
11
11
  this.onClickCallback = null;
@@ -46,10 +46,32 @@ class IconManager {
46
46
  this.pointerLeaveHandler = null;
47
47
  this.blurHandler = null;
48
48
  this.dragTimeoutId = null; // 拖动超时定时器
49
+ this.mouseLeaveTimeout = null; // 鼠标离开防抖定时器
50
+ this.pointerLeaveTimeout = null; // 指针离开防抖定时器
51
+ this.isStoppingDrag = false; // 防止 stopDrag 重复调用
52
+ // 侧边吸附相关状态
53
+ this.sideAttach = true; // 是否启用侧边吸附(默认 true)
54
+ this.sideHideRatio = 0.5; // 侧边吸附时的隐藏比例(默认 0.5,显示一半)
55
+ this.magnetic = true; // 是否启用磁性吸附(默认 true)
56
+ this.magneticDirection = 'x'; // 磁性吸附方向(默认 'x')
57
+ this.margin = 10; // 边距(默认 10px)
58
+ this.isAttachedToSide = false; // 是否吸附到侧边
59
+ this.attachedSide = ''; // 吸附到哪一边
60
+ this.autoAttachDelay = 3000; // 自动吸附延迟时间(默认 3000ms,3秒)
61
+ this.autoAttachTimer = null; // 自动吸附定时器
49
62
  this.iconPosition = position || null;
50
63
  this.debug = debug;
51
64
  // 保存 target(可以是 HTMLElement 或字符串选择器)
52
65
  this.target = target || null;
66
+ // 侧边吸附配置
67
+ if (options) {
68
+ this.sideAttach = options.sideAttach !== undefined ? options.sideAttach : true;
69
+ this.sideHideRatio = options.sideHideRatio !== undefined ? options.sideHideRatio : 0.5;
70
+ this.magnetic = options.magnetic !== undefined ? options.magnetic : true;
71
+ this.magneticDirection = options.magneticDirection || 'x';
72
+ this.margin = options.margin !== undefined ? options.margin : 10;
73
+ this.autoAttachDelay = options.autoAttachDelay !== undefined ? options.autoAttachDelay : 3000;
74
+ }
53
75
  }
54
76
  /**
55
77
  * 显示悬浮图标
@@ -103,8 +125,42 @@ class IconManager {
103
125
  }
104
126
  else {
105
127
  // 默认位置:右下角
106
- defaultStyle.bottom = '80px';
107
- defaultStyle.right = '20px';
128
+ // 如果启用侧边吸附且没有指定位置,初始化时直接应用右边吸附样式
129
+ if (this.sideAttach && this.magnetic) {
130
+ const iconWidth = 30; // 图标宽度
131
+ const hideDistance = iconWidth * this.sideHideRatio; // 隐藏部分(在容器外)
132
+ // 右边吸附:图标右边缘对齐容器右边缘,隐藏部分在容器外
133
+ // right = -hideDistance,让图标向右移动 hideDistance,这样隐藏部分在容器外
134
+ defaultStyle.right = `-${hideDistance}px`; // 负值,让图标超出容器右边缘
135
+ defaultStyle.left = 'auto';
136
+ defaultStyle.bottom = '80px';
137
+ defaultStyle.transform = 'translateX(0)';
138
+ // 标记为已吸附到右边
139
+ this.isAttachedToSide = true;
140
+ this.attachedSide = 'right';
141
+ }
142
+ else {
143
+ defaultStyle.bottom = '80px';
144
+ defaultStyle.right = '20px';
145
+ }
146
+ }
147
+ // 如果侧边吸附已启用且已吸附,应用侧边吸附样式
148
+ if (this.sideAttach && this.isAttachedToSide && this.attachedSide) {
149
+ const iconWidth = 30; // 图标宽度
150
+ const hideDistance = iconWidth * this.sideHideRatio; // 隐藏部分(在容器外)
151
+ if (this.attachedSide === 'left') {
152
+ // 左边吸附:图标左边缘对齐容器左边缘,隐藏部分在容器外
153
+ defaultStyle.left = `-${hideDistance}px`; // 负值,让图标超出容器左边缘
154
+ defaultStyle.right = 'auto';
155
+ defaultStyle.transform = 'translateX(0)';
156
+ }
157
+ else if (this.attachedSide === 'right') {
158
+ // 右边吸附:图标右边缘对齐容器右边缘,隐藏部分在容器外
159
+ // right = -hideDistance,让图标向右移动 hideDistance,这样隐藏部分在容器外
160
+ defaultStyle.right = `-${hideDistance}px`; // 负值,让图标超出容器右边缘
161
+ defaultStyle.left = 'auto';
162
+ defaultStyle.transform = 'translateX(0)';
163
+ }
108
164
  }
109
165
  Object.assign(this.iconElement.style, defaultStyle);
110
166
  // 添加图标图片(直接使用base64字符串,避免打包后路径问题)
@@ -148,6 +204,36 @@ class IconManager {
148
204
  this.iconElement.appendChild(imgContainer);
149
205
  // 添加到目标元素(如果 target 是字符串,需要重新查找,因为可能在初始化时元素还不存在)
150
206
  const targetElement = this.getTargetElement();
207
+ // 确保目标容器有 overflow-x: hidden,防止图标超出边界时出现横向滚动条
208
+ // 注意:只在启用侧边吸附时才设置,避免影响其他场景
209
+ if (this.sideAttach && targetElement !== document.body) {
210
+ const computedStyle = window.getComputedStyle(targetElement);
211
+ const currentOverflowX = computedStyle.overflowX;
212
+ const currentOverflowY = computedStyle.overflowY;
213
+ // 如果当前 overflow-x 不是 hidden,设置 overflow-x: hidden
214
+ // 同时设置 overflow-y: hidden 或 auto,避免纵向滚动条
215
+ if (currentOverflowX !== 'hidden') {
216
+ // 保存原始 overflow 值(如果还没有保存)
217
+ if (!targetElement.dataset.originalOverflowX) {
218
+ targetElement.dataset.originalOverflowX = currentOverflowX || 'visible';
219
+ targetElement.dataset.originalOverflowY = currentOverflowY || 'visible';
220
+ }
221
+ // 设置 overflow-x: hidden,防止横向滚动条
222
+ targetElement.style.overflowX = 'hidden';
223
+ // 如果当前 overflow-y 是 visible,设置为 auto,避免纵向滚动条
224
+ // 如果已经是 auto 或 scroll,保持不变
225
+ if (currentOverflowY === 'visible') {
226
+ targetElement.style.overflowY = 'auto';
227
+ }
228
+ if (this.debug) {
229
+ console.log('[IconManager] Set overflow-x: hidden on target container', {
230
+ target: this.target,
231
+ originalOverflowX: currentOverflowX,
232
+ originalOverflowY: currentOverflowY
233
+ });
234
+ }
235
+ }
236
+ }
151
237
  if (targetElement) {
152
238
  targetElement.appendChild(this.iconElement);
153
239
  }
@@ -160,6 +246,72 @@ class IconManager {
160
246
  }
161
247
  // 设置拖动事件
162
248
  this.setupDragEvents();
249
+ // 如果启用磁性吸附且没有指定位置,初始化后自动吸附到右边(默认显示一半)
250
+ if (this.magnetic && !this.iconPosition) {
251
+ // 等待图标渲染完成后再执行吸附
252
+ setTimeout(() => {
253
+ if (this.iconElement) {
254
+ // 先转换位置(从 right/bottom 到 left/top)
255
+ const container = this.getTargetElement();
256
+ if (container) {
257
+ const containerRect = container.getBoundingClientRect();
258
+ const iconWidth = this.iconElement.offsetWidth || 30;
259
+ const iconHeight = this.iconElement.offsetHeight || 30;
260
+ // 计算默认右下角位置对应的 left/top
261
+ const defaultRight = 20; // 默认 right: 20px
262
+ const defaultBottom = 80; // 默认 bottom: 80px
263
+ // 转换为 left/top(相对于容器)
264
+ const initialLeft = containerRect.width - iconWidth - defaultRight;
265
+ const initialTop = containerRect.height - iconHeight - defaultBottom;
266
+ // 临时设置位置,用于磁性吸附计算
267
+ this.iconElement.style.left = `${initialLeft}px`;
268
+ this.iconElement.style.top = `${initialTop}px`;
269
+ this.iconElement.style.right = 'auto';
270
+ this.iconElement.style.bottom = 'auto';
271
+ // 如果启用侧边吸附,强制吸附到右边并显示一半
272
+ if (this.sideAttach) {
273
+ this.isAttachedToSide = true;
274
+ this.attachedSide = 'right';
275
+ const hideDistance = iconWidth * this.sideHideRatio; // 隐藏部分(在容器外)
276
+ // right = -hideDistance,让图标向右移动 hideDistance,这样隐藏部分在容器外
277
+ this.iconElement.style.right = `-${hideDistance}px`; // 负值,让图标超出容器右边缘
278
+ this.iconElement.style.left = 'auto';
279
+ this.iconElement.style.bottom = `${defaultBottom}px`; // 保持底部位置
280
+ this.iconElement.style.top = 'auto';
281
+ this.iconElement.style.transform = 'translateX(0)';
282
+ this.iconElement.style.transition = 'all 0.3s ease';
283
+ if (this.debug) {
284
+ console.log('[IconManager] Auto side attach on init', {
285
+ isAttachedToSide: this.isAttachedToSide,
286
+ attachedSide: this.attachedSide,
287
+ hideDistance,
288
+ right: `-${hideDistance}px`
289
+ });
290
+ }
291
+ }
292
+ else {
293
+ // 执行磁性吸附(不侧边吸附,只磁性吸附)
294
+ this.magneticSnap();
295
+ if (this.debug) {
296
+ console.log('[IconManager] Auto magnetic snap on init', {
297
+ isAttachedToSide: this.isAttachedToSide,
298
+ attachedSide: this.attachedSide
299
+ });
300
+ }
301
+ }
302
+ }
303
+ }
304
+ }, 200); // 延迟 200ms,确保容器和图标都已渲染完成
305
+ }
306
+ // 启动自动吸附定时器(如果启用侧边吸附且当前未吸附)
307
+ // 注意:初始化时如果已经吸附到侧边,不会启动定时器
308
+ // 但如果图标从隐藏状态恢复显示,且未吸附,应该启动定时器
309
+ if (this.sideAttach && this.autoAttachDelay > 0 && !this.isAttachedToSide) {
310
+ // 延迟启动,确保图标已经完全渲染
311
+ setTimeout(() => {
312
+ this.startAutoAttachTimer();
313
+ }, 300); // 延迟 300ms,确保初始化吸附逻辑已完成
314
+ }
163
315
  if (this.debug) {
164
316
  console.log('CustomerSDK icon displayed');
165
317
  }
@@ -176,6 +328,8 @@ class IconManager {
176
328
  hide() {
177
329
  // 清理拖动事件
178
330
  this.cleanupDragEvents();
331
+ // 清除自动吸附定时器
332
+ this.clearAutoAttachTimer();
179
333
  if (this.iconElement) {
180
334
  // 在隐藏前保存当前位置(如果图标已经被拖动过)
181
335
  const computedStyle = window.getComputedStyle(this.iconElement);
@@ -237,6 +391,110 @@ class IconManager {
237
391
  setStyle(style) {
238
392
  // 现在样式写死,不做处理
239
393
  }
394
+ /**
395
+ * 启动自动吸附定时器
396
+ */
397
+ startAutoAttachTimer() {
398
+ // 清除之前的定时器
399
+ this.clearAutoAttachTimer();
400
+ // 如果已经吸附到侧边,不需要启动定时器
401
+ if (this.isAttachedToSide) {
402
+ return;
403
+ }
404
+ this.autoAttachTimer = window.setTimeout(() => {
405
+ if (this.iconElement && !this.isDragging && !this.dragStarted) {
406
+ // 执行自动吸附到侧边
407
+ this.autoAttachToSide();
408
+ }
409
+ this.autoAttachTimer = null;
410
+ }, this.autoAttachDelay);
411
+ if (this.debug) {
412
+ console.log(`[IconManager] Auto attach timer started, will attach in ${this.autoAttachDelay}ms`);
413
+ }
414
+ }
415
+ /**
416
+ * 清除自动吸附定时器
417
+ */
418
+ clearAutoAttachTimer() {
419
+ if (this.autoAttachTimer !== null) {
420
+ window.clearTimeout(this.autoAttachTimer);
421
+ this.autoAttachTimer = null;
422
+ if (this.debug) {
423
+ console.log('[IconManager] Auto attach timer cleared');
424
+ }
425
+ }
426
+ }
427
+ /**
428
+ * 自动吸附到侧边
429
+ */
430
+ autoAttachToSide() {
431
+ if (!this.iconElement || !this.sideAttach)
432
+ return;
433
+ try {
434
+ const container = this.getTargetElement();
435
+ if (!container)
436
+ return;
437
+ const containerRect = container.getBoundingClientRect();
438
+ const iconWidth = this.iconElement.offsetWidth || 30;
439
+ // 获取当前图标位置
440
+ const computedStyle = window.getComputedStyle(this.iconElement);
441
+ const currentLeft = parseFloat(computedStyle.left) || 0;
442
+ const currentRight = computedStyle.right;
443
+ // 判断应该吸附到哪一边(根据当前位置)
444
+ const containerWidth = containerRect.width;
445
+ let shouldAttachToRight = true;
446
+ // 如果使用 right 定位,计算实际 left 位置
447
+ if (currentRight !== 'auto' && currentRight !== '') {
448
+ const rightValue = parseFloat(currentRight);
449
+ if (!isNaN(rightValue)) {
450
+ const actualLeft = containerWidth - iconWidth - rightValue;
451
+ shouldAttachToRight = actualLeft >= containerWidth / 2;
452
+ }
453
+ }
454
+ else {
455
+ shouldAttachToRight = currentLeft >= containerWidth / 2;
456
+ }
457
+ if (shouldAttachToRight) {
458
+ // 吸附到右边
459
+ this.isAttachedToSide = true;
460
+ this.attachedSide = 'right';
461
+ const hideDistance = iconWidth * this.sideHideRatio;
462
+ this.iconElement.style.right = `-${hideDistance}px`;
463
+ this.iconElement.style.left = 'auto';
464
+ }
465
+ else {
466
+ // 吸附到左边
467
+ this.isAttachedToSide = true;
468
+ this.attachedSide = 'left';
469
+ const hideDistance = iconWidth * this.sideHideRatio;
470
+ this.iconElement.style.left = `-${hideDistance}px`;
471
+ this.iconElement.style.right = 'auto';
472
+ }
473
+ // 保持 Y 轴位置不变
474
+ const computedStyleAfter = window.getComputedStyle(this.iconElement);
475
+ if (computedStyleAfter.top !== 'auto' && computedStyleAfter.top !== '') {
476
+ this.iconElement.style.top = computedStyleAfter.top;
477
+ this.iconElement.style.bottom = 'auto';
478
+ }
479
+ else if (computedStyleAfter.bottom !== 'auto' && computedStyleAfter.bottom !== '') {
480
+ this.iconElement.style.bottom = computedStyleAfter.bottom;
481
+ this.iconElement.style.top = 'auto';
482
+ }
483
+ this.iconElement.style.transform = 'translateX(0)';
484
+ this.iconElement.style.transition = 'all 0.3s ease';
485
+ if (this.debug) {
486
+ console.log('[IconManager] Auto attached to side', {
487
+ attachedSide: this.attachedSide,
488
+ hideDistance: iconWidth * this.sideHideRatio
489
+ });
490
+ }
491
+ }
492
+ catch (error) {
493
+ if (this.debug) {
494
+ console.error('[IconManager] Error in autoAttachToSide:', error);
495
+ }
496
+ }
497
+ }
240
498
  /**
241
499
  * 设置点击回调
242
500
  */
@@ -304,6 +562,37 @@ class IconManager {
304
562
  startDrag(e) {
305
563
  if (!this.iconElement || !this.isClickEnabled)
306
564
  return;
565
+ // 清除自动吸附定时器(用户开始拖动)
566
+ this.clearAutoAttachTimer();
567
+ // 如果正在停止拖动,等待完成后再允许新的拖动
568
+ if (this.isStoppingDrag) {
569
+ if (this.debug) {
570
+ console.warn('[IconManager] startDrag called while stopping, ignoring');
571
+ }
572
+ return;
573
+ }
574
+ // 如果已经在拖动中,忽略新的拖动开始事件(防止重复调用)
575
+ // 注意:只检查实际的拖动状态,不检查可能残留的资源(这些应该在 cleanup 中清理)
576
+ if (this.isDragging || this.dragStarted) {
577
+ if (this.debug) {
578
+ console.warn('[IconManager] startDrag called but already dragging, ignoring', {
579
+ isDragging: this.isDragging,
580
+ dragStarted: this.dragStarted
581
+ });
582
+ }
583
+ return;
584
+ }
585
+ // 如果发现残留的资源,先清理它们(防御性编程)
586
+ if (this.dragTimeoutId !== null || this.activeDetectionRafId !== null || this.rafId !== null) {
587
+ if (this.debug) {
588
+ console.warn('[IconManager] startDrag found leftover resources, cleaning up', {
589
+ hasTimeout: this.dragTimeoutId !== null,
590
+ hasActiveDetection: this.activeDetectionRafId !== null,
591
+ hasRaf: this.rafId !== null
592
+ });
593
+ }
594
+ this.cleanupDragEvents();
595
+ }
307
596
  // 检查事件目标:如果是 iframe 相关元素,不处理
308
597
  const target = e.target;
309
598
  if (target && (target.tagName === 'IFRAME' ||
@@ -368,32 +657,59 @@ class IconManager {
368
657
  }
369
658
  // 添加处理 iframe 上事件丢失的机制
370
659
  // 1. 监听 mouseleave 和 pointerleave 事件(鼠标离开窗口时停止拖动)
660
+ // 注意:这些事件可能会在拖动过程中多次触发,需要添加防抖机制
371
661
  this.mouseLeaveHandler = (e) => {
372
662
  // 只有当鼠标真正离开窗口时才停止拖动
373
663
  if (!e.relatedTarget && (e.clientY <= 0 || e.clientX <= 0 ||
374
664
  e.clientX >= window.innerWidth || e.clientY >= window.innerHeight)) {
375
- if (this.debug) {
376
- console.log('Mouse left window, stopping drag');
665
+ // 添加防抖,避免重复触发
666
+ if (this.mouseLeaveTimeout) {
667
+ clearTimeout(this.mouseLeaveTimeout);
377
668
  }
378
- this.stopDrag();
669
+ this.mouseLeaveTimeout = window.setTimeout(() => {
670
+ if ((this.isDragging || this.dragStarted) && !this.isStoppingDrag) {
671
+ if (this.debug) {
672
+ console.log('[IconManager] Mouse left window, stopping drag');
673
+ }
674
+ this.stopDrag();
675
+ }
676
+ this.mouseLeaveTimeout = null;
677
+ }, 50); // 50ms 防抖
379
678
  }
380
679
  };
381
680
  this.pointerLeaveHandler = (e) => {
382
681
  // 只有当指针真正离开窗口时才停止拖动
383
682
  if (!e.relatedTarget && (e.clientY <= 0 || e.clientX <= 0 ||
384
683
  e.clientX >= window.innerWidth || e.clientY >= window.innerHeight)) {
385
- if (this.debug) {
386
- console.log('Pointer left window, stopping drag');
684
+ // 添加防抖,避免重复触发
685
+ if (this.pointerLeaveTimeout) {
686
+ clearTimeout(this.pointerLeaveTimeout);
387
687
  }
388
- this.stopDrag();
688
+ this.pointerLeaveTimeout = window.setTimeout(() => {
689
+ if ((this.isDragging || this.dragStarted) && !this.isStoppingDrag) {
690
+ if (this.debug) {
691
+ console.log('[IconManager] Pointer left window, stopping drag');
692
+ }
693
+ this.stopDrag();
694
+ }
695
+ this.pointerLeaveTimeout = null;
696
+ }, 50); // 50ms 防抖
389
697
  }
390
698
  };
391
699
  document.addEventListener('mouseleave', this.mouseLeaveHandler);
392
700
  document.addEventListener('pointerleave', this.pointerLeaveHandler);
393
701
  // 2. 监听 blur 事件(窗口失去焦点时停止拖动)
394
702
  this.blurHandler = () => {
703
+ // 如果已经在停止拖动,直接返回
704
+ if (this.isStoppingDrag) {
705
+ return;
706
+ }
707
+ // 检查是否真的在拖动中
708
+ if (!this.isDragging && !this.dragStarted) {
709
+ return;
710
+ }
395
711
  if (this.debug) {
396
- console.log('Window lost focus, stopping drag');
712
+ console.log('[IconManager] Window lost focus, stopping drag');
397
713
  }
398
714
  this.stopDrag();
399
715
  };
@@ -533,6 +849,14 @@ class IconManager {
533
849
  // 开始真正的拖拽
534
850
  this.dragStarted = true;
535
851
  this.isDragging = true;
852
+ // 拖拽开始时重置侧边吸附状态
853
+ if (this.isAttachedToSide) {
854
+ this.isAttachedToSide = false;
855
+ this.attachedSide = '';
856
+ if (this.iconElement) {
857
+ this.iconElement.style.transform = '';
858
+ }
859
+ }
536
860
  // 如果是第一次拖动,需要转换位置(从 right/bottom 到 left/top)
537
861
  const computedStyle = window.getComputedStyle(this.iconElement);
538
862
  if (computedStyle.right !== 'auto' || computedStyle.bottom !== 'auto') {
@@ -633,6 +957,21 @@ class IconManager {
633
957
  * 停止拖动
634
958
  */
635
959
  stopDrag(_e) {
960
+ // 防止重复调用
961
+ if (this.isStoppingDrag) {
962
+ if (this.debug) {
963
+ console.warn('[IconManager] stopDrag already in progress, ignoring duplicate call');
964
+ }
965
+ return;
966
+ }
967
+ // 如果已经停止且没有相关资源,直接返回
968
+ if (!this.isDragging && !this.dragStarted && !this.dragTimeoutId && !this.activeDetectionRafId) {
969
+ if (this.debug) {
970
+ console.warn('[IconManager] stopDrag called but not dragging, ignoring');
971
+ }
972
+ return;
973
+ }
974
+ this.isStoppingDrag = true;
636
975
  const stopReason = this.isDragging ? 'drag_stopped' : 'not_dragging';
637
976
  const finalPosition = this.iconElement ? {
638
977
  left: this.iconElement.style.left,
@@ -641,9 +980,16 @@ class IconManager {
641
980
  ? { left: window.getComputedStyle(this.iconElement).left, top: window.getComputedStyle(this.iconElement).top }
642
981
  : null
643
982
  } : null;
983
+ // 立即清理事件监听器,防止后续事件触发
644
984
  this.cleanupDragEvents();
645
- if (!this.iconElement)
985
+ if (!this.iconElement) {
986
+ // 即使没有 iconElement,也要重置状态,确保可以再次拖动
987
+ this.hasMoved = false;
988
+ this.isDragging = false;
989
+ this.dragStarted = false;
990
+ this.isStoppingDrag = false;
646
991
  return;
992
+ }
647
993
  // 恢复样式
648
994
  this.iconElement.style.transition = 'transform 0.2s ease';
649
995
  this.iconElement.style.cursor = 'pointer';
@@ -663,6 +1009,10 @@ class IconManager {
663
1009
  }
664
1010
  // 先保存点击状态,然后重置拖动状态
665
1011
  const wasClick = isValidClick;
1012
+ // 如果真正拖动过,执行磁性吸附
1013
+ if (this.dragStarted && this.magnetic && this.iconElement) {
1014
+ this.magneticSnap();
1015
+ }
666
1016
  // 如果真正拖动过,保存当前位置到 iconPosition
667
1017
  if (this.dragStarted && this.isDragging && this.iconElement) {
668
1018
  const computedStyle = window.getComputedStyle(this.iconElement);
@@ -687,6 +1037,11 @@ class IconManager {
687
1037
  this.hasMoved = false;
688
1038
  this.isDragging = false;
689
1039
  this.dragStarted = false;
1040
+ this.isStoppingDrag = false; // 重置停止标志
1041
+ // 如果拖动后没有吸附到侧边,启动自动吸附定时器
1042
+ if (!this.isAttachedToSide && this.sideAttach && this.autoAttachDelay > 0) {
1043
+ this.startAutoAttachTimer();
1044
+ }
690
1045
  if (wasClick) {
691
1046
  // 是点击,触发点击事件
692
1047
  // 延迟触发,确保拖动状态已完全重置,并且没有新的拖动开始
@@ -713,9 +1068,10 @@ class IconManager {
713
1068
  console.log('[IconManager] Starting active detection mechanism');
714
1069
  }
715
1070
  const checkMousePosition = () => {
716
- if (!this.isDragging) {
1071
+ // 检查是否还在拖动中,如果不在拖动中或正在停止,则停止检测
1072
+ if (!this.isDragging || this.isStoppingDrag) {
717
1073
  if (this.debug) {
718
- console.log('[IconManager] Active detection stopped (drag ended)');
1074
+ console.log('[IconManager] Active detection stopped (drag ended or stopping)');
719
1075
  }
720
1076
  this.activeDetectionRafId = null;
721
1077
  return;
@@ -723,15 +1079,19 @@ class IconManager {
723
1079
  // 检查是否长时间没有收到 mousemove 事件(超过 50ms)
724
1080
  const now = Date.now();
725
1081
  const timeSinceLastMove = now - this.lastMousePosition.timestamp;
1082
+ // 如果超过 50ms 没有收到事件,进行检测
726
1083
  if (timeSinceLastMove > 50) {
727
1084
  // 长时间没有收到事件,可能鼠标已经进入 iframe
728
1085
  // 使用最后记录的鼠标位置进行检测
729
1086
  const clientX = this.lastMousePosition.x;
730
1087
  const clientY = this.lastMousePosition.y;
731
- if (this.debug) {
732
- console.log(`[IconManager] Active detection: No mousemove event for ${timeSinceLastMove}ms`, {
1088
+ if (this.debug && timeSinceLastMove > 100) {
1089
+ // 只在超过 100ms 时记录警告,避免日志过多
1090
+ console.warn(`[IconManager] Active detection: No mousemove event for ${timeSinceLastMove}ms`, {
733
1091
  lastMousePosition: { x: clientX, y: clientY },
734
- timestamp: this.lastMousePosition.timestamp
1092
+ timestamp: this.lastMousePosition.timestamp,
1093
+ isDragging: this.isDragging,
1094
+ dragStarted: this.dragStarted
735
1095
  });
736
1096
  }
737
1097
  // 更新 iframe 缓存(更频繁,每帧更新)
@@ -803,6 +1163,7 @@ class IconManager {
803
1163
  document.removeEventListener('touchend', this.stopDragHandler);
804
1164
  }
805
1165
  // 清理处理 iframe 事件丢失的监听器
1166
+ // 注意:必须先移除监听器,再清理防抖定时器,防止事件在清理过程中触发
806
1167
  if (this.mouseLeaveHandler) {
807
1168
  document.removeEventListener('mouseleave', this.mouseLeaveHandler);
808
1169
  this.mouseLeaveHandler = null;
@@ -815,6 +1176,15 @@ class IconManager {
815
1176
  window.removeEventListener('blur', this.blurHandler);
816
1177
  this.blurHandler = null;
817
1178
  }
1179
+ // 清理防抖定时器(在移除监听器之后)
1180
+ if (this.mouseLeaveTimeout !== null) {
1181
+ window.clearTimeout(this.mouseLeaveTimeout);
1182
+ this.mouseLeaveTimeout = null;
1183
+ }
1184
+ if (this.pointerLeaveTimeout !== null) {
1185
+ window.clearTimeout(this.pointerLeaveTimeout);
1186
+ this.pointerLeaveTimeout = null;
1187
+ }
818
1188
  // 清理超时定时器
819
1189
  if (this.dragTimeoutId !== null) {
820
1190
  window.clearTimeout(this.dragTimeoutId);
@@ -825,13 +1195,31 @@ class IconManager {
825
1195
  cancelAnimationFrame(this.rafId);
826
1196
  this.rafId = null;
827
1197
  }
828
- // 清理主动检测机制
1198
+ // 清理主动检测机制(必须在最后,确保完全清理)
829
1199
  this.stopActiveDetection();
1200
+ // 确保 activeDetectionRafId 被清理(双重保险)
1201
+ if (this.activeDetectionRafId !== null) {
1202
+ if (this.debug) {
1203
+ console.warn('[IconManager] Active detection RAF ID still exists, force cleaning');
1204
+ }
1205
+ cancelAnimationFrame(this.activeDetectionRafId);
1206
+ this.activeDetectionRafId = null;
1207
+ }
830
1208
  // 清理缓存
831
1209
  this.cachedContainer = null;
832
1210
  this.cachedContainerRect = null;
833
1211
  this.cachedIframes = []; // 清理 iframe 缓存
834
1212
  this.pendingPosition.needsUpdate = false;
1213
+ if (this.debug) {
1214
+ console.log('[IconManager] Cleanup completed', {
1215
+ isDragging: this.isDragging,
1216
+ dragStarted: this.dragStarted,
1217
+ isStoppingDrag: this.isStoppingDrag,
1218
+ hasTimeout: this.dragTimeoutId !== null,
1219
+ hasActiveDetection: this.activeDetectionRafId !== null,
1220
+ hasRaf: this.rafId !== null
1221
+ });
1222
+ }
835
1223
  }
836
1224
  /**
837
1225
  * 处理点击事件
@@ -841,6 +1229,8 @@ class IconManager {
841
1229
  if (!this.isClickEnabled) {
842
1230
  return;
843
1231
  }
1232
+ // 清除自动吸附定时器(用户有操作)
1233
+ this.clearAutoAttachTimer();
844
1234
  if (this.onClickCallback) {
845
1235
  this.onClickCallback();
846
1236
  }
@@ -883,6 +1273,142 @@ class IconManager {
883
1273
  this.iconElement.style.cursor = 'pointer';
884
1274
  }
885
1275
  }
1276
+ /**
1277
+ * 磁性吸附
1278
+ */
1279
+ magneticSnap() {
1280
+ if (!this.iconElement)
1281
+ return;
1282
+ try {
1283
+ const container = this.getTargetElement();
1284
+ if (!container)
1285
+ return;
1286
+ const containerRect = container.getBoundingClientRect();
1287
+ const iconWidth = this.iconElement.offsetWidth || 30;
1288
+ const iconHeight = this.iconElement.offsetHeight || 30;
1289
+ // 获取当前图标位置
1290
+ const computedStyle = window.getComputedStyle(this.iconElement);
1291
+ const currentLeft = parseFloat(computedStyle.left) || 0;
1292
+ const currentTop = parseFloat(computedStyle.top) || 0;
1293
+ let newX = currentLeft;
1294
+ let newY = currentTop;
1295
+ // X 轴磁性吸附
1296
+ if (this.magneticDirection === 'x' || this.magneticDirection === 'both') {
1297
+ const containerWidth = containerRect.width;
1298
+ const centerX = containerWidth / 2;
1299
+ if (currentLeft < centerX) {
1300
+ // 吸附到左边
1301
+ newX = this.margin;
1302
+ // 侧边吸附逻辑
1303
+ if (this.sideAttach && currentLeft < iconWidth / 2) {
1304
+ this.isAttachedToSide = true;
1305
+ this.attachedSide = 'left';
1306
+ // 计算隐藏部分:图标宽度 * sideHideRatio
1307
+ // 例如:30px * 0.5 = 15px 隐藏,15px 显示
1308
+ const hideDistance = iconWidth * this.sideHideRatio;
1309
+ // 图标左边缘对齐容器左边缘,隐藏部分在容器外
1310
+ newX = -hideDistance; // 负值,让图标超出容器左边缘
1311
+ }
1312
+ else {
1313
+ this.isAttachedToSide = false;
1314
+ this.attachedSide = '';
1315
+ }
1316
+ }
1317
+ else {
1318
+ // 吸附到右边
1319
+ newX = containerWidth - iconWidth - this.margin;
1320
+ // 侧边吸附逻辑:如果启用侧边吸附,默认吸附到右边并显示一半
1321
+ if (this.sideAttach) {
1322
+ // 如果靠近右边(距离右边 < 图标宽度的一半),或者从默认位置初始化
1323
+ // 默认位置(右下角)应该自动侧边吸附
1324
+ const distanceFromRight = containerWidth - currentLeft - iconWidth;
1325
+ if (distanceFromRight < iconWidth / 2 ||
1326
+ (isNaN(currentLeft) || currentLeft === 0) ||
1327
+ currentLeft >= containerWidth - iconWidth - this.margin - iconWidth / 2) {
1328
+ this.isAttachedToSide = true;
1329
+ this.attachedSide = 'right';
1330
+ // 计算显示部分:图标宽度 * (1 - sideHideRatio)
1331
+ // 例如:30px * (1 - 0.5) = 15px 显示,15px 隐藏
1332
+ const visibleWidth = iconWidth * (1 - this.sideHideRatio);
1333
+ // 图标右边缘对齐容器右边缘,但只显示 visibleWidth
1334
+ newX = containerWidth - visibleWidth;
1335
+ }
1336
+ else {
1337
+ this.isAttachedToSide = false;
1338
+ this.attachedSide = '';
1339
+ }
1340
+ }
1341
+ else {
1342
+ this.isAttachedToSide = false;
1343
+ this.attachedSide = '';
1344
+ }
1345
+ }
1346
+ }
1347
+ // Y 轴磁性吸附
1348
+ if (this.magneticDirection === 'y' || this.magneticDirection === 'both') {
1349
+ const containerHeight = containerRect.height;
1350
+ const centerY = containerHeight / 2;
1351
+ if (currentTop < centerY) {
1352
+ newY = this.margin; // 吸附到上边
1353
+ }
1354
+ else {
1355
+ newY = containerHeight - iconHeight - this.margin; // 吸附到下边
1356
+ }
1357
+ }
1358
+ // 应用新位置
1359
+ // 如果侧边吸附到右边,使用 right 属性(负值),让图标超出容器右边缘
1360
+ if (this.isAttachedToSide && this.attachedSide === 'right') {
1361
+ const hideDistance = iconWidth * this.sideHideRatio; // 隐藏部分(在容器外)
1362
+ this.iconElement.style.right = `-${hideDistance}px`; // 负值,让图标超出容器右边缘
1363
+ this.iconElement.style.left = 'auto';
1364
+ }
1365
+ else if (this.isAttachedToSide && this.attachedSide === 'left') {
1366
+ // 左边吸附使用 left(负值),让图标超出容器左边缘
1367
+ const hideDistance = iconWidth * this.sideHideRatio;
1368
+ this.iconElement.style.left = `-${hideDistance}px`;
1369
+ this.iconElement.style.right = 'auto';
1370
+ }
1371
+ else {
1372
+ // 正常位置使用 left
1373
+ this.iconElement.style.left = `${newX}px`;
1374
+ this.iconElement.style.right = 'auto';
1375
+ }
1376
+ this.iconElement.style.top = `${newY}px`;
1377
+ this.iconElement.style.bottom = 'auto';
1378
+ // 如果侧边吸附,应用 transform
1379
+ if (this.isAttachedToSide && this.attachedSide) {
1380
+ this.iconElement.style.transform = 'translateX(0)';
1381
+ this.iconElement.style.transition = 'all 0.3s ease';
1382
+ }
1383
+ else {
1384
+ this.iconElement.style.transform = '';
1385
+ }
1386
+ // 保存位置(如果是右边吸附,保存为 right 值,否则保存为 left 值)
1387
+ if (this.isAttachedToSide && this.attachedSide === 'right') {
1388
+ // 右边吸附时,不保存到 iconPosition(因为使用的是 right 属性)
1389
+ // 或者可以保存一个特殊标记
1390
+ this.iconPosition = null; // 清除位置,让下次显示时重新计算
1391
+ }
1392
+ else {
1393
+ this.iconPosition = {
1394
+ x: newX,
1395
+ y: newY
1396
+ };
1397
+ }
1398
+ if (this.debug) {
1399
+ console.log('[IconManager] Magnetic snap applied', {
1400
+ newPosition: { x: newX, y: newY },
1401
+ isAttachedToSide: this.isAttachedToSide,
1402
+ attachedSide: this.attachedSide
1403
+ });
1404
+ }
1405
+ }
1406
+ catch (error) {
1407
+ if (this.debug) {
1408
+ console.error('[IconManager] Error in magneticSnap:', error);
1409
+ }
1410
+ }
1411
+ }
886
1412
  /**
887
1413
  * 获取目标元素(支持动态查找)
888
1414
  */
@@ -21346,7 +21872,14 @@ class CustomerServiceSDK {
21346
21872
  console.log('Icon config changed, recreating icon manager');
21347
21873
  }
21348
21874
  }
21349
- this.iconManager = new IconManager(iconPosition, this.debug, iconTarget);
21875
+ this.iconManager = new IconManager(iconPosition, this.debug, iconTarget, {
21876
+ sideAttach: options?.sideAttach !== undefined ? options.sideAttach : true,
21877
+ sideHideRatio: options?.sideHideRatio !== undefined ? options.sideHideRatio : 0.5,
21878
+ magnetic: options?.magnetic !== undefined ? options.magnetic : true,
21879
+ magneticDirection: options?.magneticDirection || 'x',
21880
+ margin: options?.margin !== undefined ? options.margin : 10,
21881
+ autoAttachDelay: options?.autoAttachDelay !== undefined ? options.autoAttachDelay : 3000
21882
+ });
21350
21883
  await this.iconManager.show();
21351
21884
  // 保存新的配置
21352
21885
  this.lastIconConfig = { position: iconPosition, target: iconTarget };
@@ -21355,7 +21888,14 @@ class CustomerServiceSDK {
21355
21888
  // 配置没变化,保留图标管理器(避免闪烁)
21356
21889
  if (!this.iconManager) {
21357
21890
  // 如果不存在,创建新的
21358
- this.iconManager = new IconManager(iconPosition, this.debug, iconTarget);
21891
+ this.iconManager = new IconManager(iconPosition, this.debug, iconTarget, {
21892
+ sideAttach: options?.sideAttach !== undefined ? options.sideAttach : true,
21893
+ sideHideRatio: options?.sideHideRatio !== undefined ? options.sideHideRatio : 0.5,
21894
+ magnetic: options?.magnetic !== undefined ? options.magnetic : true,
21895
+ magneticDirection: options?.magneticDirection || 'x',
21896
+ margin: options?.margin !== undefined ? options.margin : 10,
21897
+ autoAttachDelay: options?.autoAttachDelay !== undefined ? options.autoAttachDelay : 3000
21898
+ });
21359
21899
  await this.iconManager.show();
21360
21900
  }
21361
21901
  else {