customer-chat-sdk 1.1.11 → 1.1.13

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;
@@ -31,10 +31,13 @@ class IconManager {
31
31
  // 性能优化:缓存所有 iframe 的位置信息(用于检测鼠标是否在 iframe 上)
32
32
  this.cachedIframes = [];
33
33
  this.lastIframeUpdateTime = 0;
34
- this.iframeUpdateInterval = 50; // 每 50ms 更新一次 iframe 位置信息(更频繁,因为 iframe 可能移动)
34
+ this.iframeUpdateInterval = 16; // 每 16ms 更新一次 iframe 位置信息(约一帧,更频繁,因为 iframe 可能移动)
35
35
  // 性能优化:使用 requestAnimationFrame 节流位置更新
36
36
  this.rafId = null;
37
37
  this.pendingPosition = { x: 0, y: 0, needsUpdate: false };
38
+ // 主动检测机制:使用 requestAnimationFrame 定期检查鼠标位置(即使没有 mousemove 事件)
39
+ this.activeDetectionRafId = null;
40
+ this.lastMousePosition = { x: 0, y: 0, timestamp: 0 }; // 最后记录的鼠标位置和时间戳
38
41
  // 事件处理器引用(用于清理)
39
42
  this.onDragHandler = null;
40
43
  this.stopDragHandler = null;
@@ -43,10 +46,32 @@ class IconManager {
43
46
  this.pointerLeaveHandler = null;
44
47
  this.blurHandler = null;
45
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; // 自动吸附定时器
46
62
  this.iconPosition = position || null;
47
63
  this.debug = debug;
48
64
  // 保存 target(可以是 HTMLElement 或字符串选择器)
49
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
+ }
50
75
  }
51
76
  /**
52
77
  * 显示悬浮图标
@@ -100,8 +125,42 @@ class IconManager {
100
125
  }
101
126
  else {
102
127
  // 默认位置:右下角
103
- defaultStyle.bottom = '80px';
104
- 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
+ }
105
164
  }
106
165
  Object.assign(this.iconElement.style, defaultStyle);
107
166
  // 添加图标图片(直接使用base64字符串,避免打包后路径问题)
@@ -157,6 +216,72 @@ class IconManager {
157
216
  }
158
217
  // 设置拖动事件
159
218
  this.setupDragEvents();
219
+ // 如果启用磁性吸附且没有指定位置,初始化后自动吸附到右边(默认显示一半)
220
+ if (this.magnetic && !this.iconPosition) {
221
+ // 等待图标渲染完成后再执行吸附
222
+ setTimeout(() => {
223
+ if (this.iconElement) {
224
+ // 先转换位置(从 right/bottom 到 left/top)
225
+ const container = this.getTargetElement();
226
+ if (container) {
227
+ const containerRect = container.getBoundingClientRect();
228
+ const iconWidth = this.iconElement.offsetWidth || 30;
229
+ const iconHeight = this.iconElement.offsetHeight || 30;
230
+ // 计算默认右下角位置对应的 left/top
231
+ const defaultRight = 20; // 默认 right: 20px
232
+ const defaultBottom = 80; // 默认 bottom: 80px
233
+ // 转换为 left/top(相对于容器)
234
+ const initialLeft = containerRect.width - iconWidth - defaultRight;
235
+ const initialTop = containerRect.height - iconHeight - defaultBottom;
236
+ // 临时设置位置,用于磁性吸附计算
237
+ this.iconElement.style.left = `${initialLeft}px`;
238
+ this.iconElement.style.top = `${initialTop}px`;
239
+ this.iconElement.style.right = 'auto';
240
+ this.iconElement.style.bottom = 'auto';
241
+ // 如果启用侧边吸附,强制吸附到右边并显示一半
242
+ if (this.sideAttach) {
243
+ this.isAttachedToSide = true;
244
+ this.attachedSide = 'right';
245
+ const hideDistance = iconWidth * this.sideHideRatio; // 隐藏部分(在容器外)
246
+ // right = -hideDistance,让图标向右移动 hideDistance,这样隐藏部分在容器外
247
+ this.iconElement.style.right = `-${hideDistance}px`; // 负值,让图标超出容器右边缘
248
+ this.iconElement.style.left = 'auto';
249
+ this.iconElement.style.bottom = `${defaultBottom}px`; // 保持底部位置
250
+ this.iconElement.style.top = 'auto';
251
+ this.iconElement.style.transform = 'translateX(0)';
252
+ this.iconElement.style.transition = 'all 0.3s ease';
253
+ if (this.debug) {
254
+ console.log('[IconManager] Auto side attach on init', {
255
+ isAttachedToSide: this.isAttachedToSide,
256
+ attachedSide: this.attachedSide,
257
+ hideDistance,
258
+ right: `-${hideDistance}px`
259
+ });
260
+ }
261
+ }
262
+ else {
263
+ // 执行磁性吸附(不侧边吸附,只磁性吸附)
264
+ this.magneticSnap();
265
+ if (this.debug) {
266
+ console.log('[IconManager] Auto magnetic snap on init', {
267
+ isAttachedToSide: this.isAttachedToSide,
268
+ attachedSide: this.attachedSide
269
+ });
270
+ }
271
+ }
272
+ }
273
+ }
274
+ }, 200); // 延迟 200ms,确保容器和图标都已渲染完成
275
+ }
276
+ // 启动自动吸附定时器(如果启用侧边吸附且当前未吸附)
277
+ // 注意:初始化时如果已经吸附到侧边,不会启动定时器
278
+ // 但如果图标从隐藏状态恢复显示,且未吸附,应该启动定时器
279
+ if (this.sideAttach && this.autoAttachDelay > 0 && !this.isAttachedToSide) {
280
+ // 延迟启动,确保图标已经完全渲染
281
+ setTimeout(() => {
282
+ this.startAutoAttachTimer();
283
+ }, 300); // 延迟 300ms,确保初始化吸附逻辑已完成
284
+ }
160
285
  if (this.debug) {
161
286
  console.log('CustomerSDK icon displayed');
162
287
  }
@@ -173,6 +298,8 @@ class IconManager {
173
298
  hide() {
174
299
  // 清理拖动事件
175
300
  this.cleanupDragEvents();
301
+ // 清除自动吸附定时器
302
+ this.clearAutoAttachTimer();
176
303
  if (this.iconElement) {
177
304
  // 在隐藏前保存当前位置(如果图标已经被拖动过)
178
305
  const computedStyle = window.getComputedStyle(this.iconElement);
@@ -234,6 +361,110 @@ class IconManager {
234
361
  setStyle(style) {
235
362
  // 现在样式写死,不做处理
236
363
  }
364
+ /**
365
+ * 启动自动吸附定时器
366
+ */
367
+ startAutoAttachTimer() {
368
+ // 清除之前的定时器
369
+ this.clearAutoAttachTimer();
370
+ // 如果已经吸附到侧边,不需要启动定时器
371
+ if (this.isAttachedToSide) {
372
+ return;
373
+ }
374
+ this.autoAttachTimer = window.setTimeout(() => {
375
+ if (this.iconElement && !this.isDragging && !this.dragStarted) {
376
+ // 执行自动吸附到侧边
377
+ this.autoAttachToSide();
378
+ }
379
+ this.autoAttachTimer = null;
380
+ }, this.autoAttachDelay);
381
+ if (this.debug) {
382
+ console.log(`[IconManager] Auto attach timer started, will attach in ${this.autoAttachDelay}ms`);
383
+ }
384
+ }
385
+ /**
386
+ * 清除自动吸附定时器
387
+ */
388
+ clearAutoAttachTimer() {
389
+ if (this.autoAttachTimer !== null) {
390
+ window.clearTimeout(this.autoAttachTimer);
391
+ this.autoAttachTimer = null;
392
+ if (this.debug) {
393
+ console.log('[IconManager] Auto attach timer cleared');
394
+ }
395
+ }
396
+ }
397
+ /**
398
+ * 自动吸附到侧边
399
+ */
400
+ autoAttachToSide() {
401
+ if (!this.iconElement || !this.sideAttach)
402
+ return;
403
+ try {
404
+ const container = this.getTargetElement();
405
+ if (!container)
406
+ return;
407
+ const containerRect = container.getBoundingClientRect();
408
+ const iconWidth = this.iconElement.offsetWidth || 30;
409
+ // 获取当前图标位置
410
+ const computedStyle = window.getComputedStyle(this.iconElement);
411
+ const currentLeft = parseFloat(computedStyle.left) || 0;
412
+ const currentRight = computedStyle.right;
413
+ // 判断应该吸附到哪一边(根据当前位置)
414
+ const containerWidth = containerRect.width;
415
+ let shouldAttachToRight = true;
416
+ // 如果使用 right 定位,计算实际 left 位置
417
+ if (currentRight !== 'auto' && currentRight !== '') {
418
+ const rightValue = parseFloat(currentRight);
419
+ if (!isNaN(rightValue)) {
420
+ const actualLeft = containerWidth - iconWidth - rightValue;
421
+ shouldAttachToRight = actualLeft >= containerWidth / 2;
422
+ }
423
+ }
424
+ else {
425
+ shouldAttachToRight = currentLeft >= containerWidth / 2;
426
+ }
427
+ if (shouldAttachToRight) {
428
+ // 吸附到右边
429
+ this.isAttachedToSide = true;
430
+ this.attachedSide = 'right';
431
+ const hideDistance = iconWidth * this.sideHideRatio;
432
+ this.iconElement.style.right = `-${hideDistance}px`;
433
+ this.iconElement.style.left = 'auto';
434
+ }
435
+ else {
436
+ // 吸附到左边
437
+ this.isAttachedToSide = true;
438
+ this.attachedSide = 'left';
439
+ const hideDistance = iconWidth * this.sideHideRatio;
440
+ this.iconElement.style.left = `-${hideDistance}px`;
441
+ this.iconElement.style.right = 'auto';
442
+ }
443
+ // 保持 Y 轴位置不变
444
+ const computedStyleAfter = window.getComputedStyle(this.iconElement);
445
+ if (computedStyleAfter.top !== 'auto' && computedStyleAfter.top !== '') {
446
+ this.iconElement.style.top = computedStyleAfter.top;
447
+ this.iconElement.style.bottom = 'auto';
448
+ }
449
+ else if (computedStyleAfter.bottom !== 'auto' && computedStyleAfter.bottom !== '') {
450
+ this.iconElement.style.bottom = computedStyleAfter.bottom;
451
+ this.iconElement.style.top = 'auto';
452
+ }
453
+ this.iconElement.style.transform = 'translateX(0)';
454
+ this.iconElement.style.transition = 'all 0.3s ease';
455
+ if (this.debug) {
456
+ console.log('[IconManager] Auto attached to side', {
457
+ attachedSide: this.attachedSide,
458
+ hideDistance: iconWidth * this.sideHideRatio
459
+ });
460
+ }
461
+ }
462
+ catch (error) {
463
+ if (this.debug) {
464
+ console.error('[IconManager] Error in autoAttachToSide:', error);
465
+ }
466
+ }
467
+ }
237
468
  /**
238
469
  * 设置点击回调
239
470
  */
@@ -301,6 +532,37 @@ class IconManager {
301
532
  startDrag(e) {
302
533
  if (!this.iconElement || !this.isClickEnabled)
303
534
  return;
535
+ // 清除自动吸附定时器(用户开始拖动)
536
+ this.clearAutoAttachTimer();
537
+ // 如果正在停止拖动,等待完成后再允许新的拖动
538
+ if (this.isStoppingDrag) {
539
+ if (this.debug) {
540
+ console.warn('[IconManager] startDrag called while stopping, ignoring');
541
+ }
542
+ return;
543
+ }
544
+ // 如果已经在拖动中,忽略新的拖动开始事件(防止重复调用)
545
+ // 注意:只检查实际的拖动状态,不检查可能残留的资源(这些应该在 cleanup 中清理)
546
+ if (this.isDragging || this.dragStarted) {
547
+ if (this.debug) {
548
+ console.warn('[IconManager] startDrag called but already dragging, ignoring', {
549
+ isDragging: this.isDragging,
550
+ dragStarted: this.dragStarted
551
+ });
552
+ }
553
+ return;
554
+ }
555
+ // 如果发现残留的资源,先清理它们(防御性编程)
556
+ if (this.dragTimeoutId !== null || this.activeDetectionRafId !== null || this.rafId !== null) {
557
+ if (this.debug) {
558
+ console.warn('[IconManager] startDrag found leftover resources, cleaning up', {
559
+ hasTimeout: this.dragTimeoutId !== null,
560
+ hasActiveDetection: this.activeDetectionRafId !== null,
561
+ hasRaf: this.rafId !== null
562
+ });
563
+ }
564
+ this.cleanupDragEvents();
565
+ }
304
566
  // 检查事件目标:如果是 iframe 相关元素,不处理
305
567
  const target = e.target;
306
568
  if (target && (target.tagName === 'IFRAME' ||
@@ -342,6 +604,18 @@ class IconManager {
342
604
  rect: iframe.getBoundingClientRect()
343
605
  }));
344
606
  this.lastIframeUpdateTime = Date.now();
607
+ if (this.debug) {
608
+ console.log(`[IconManager] Drag start - Found ${this.cachedIframes.length} iframe(s)`, {
609
+ iframes: this.cachedIframes.map(({ rect }) => ({
610
+ left: rect.left,
611
+ top: rect.top,
612
+ right: rect.right,
613
+ bottom: rect.bottom,
614
+ width: rect.width,
615
+ height: rect.height
616
+ }))
617
+ });
618
+ }
345
619
  // 添加 document 事件监听器
346
620
  if (this.onDragHandler) {
347
621
  document.addEventListener('mousemove', this.onDragHandler);
@@ -353,38 +627,65 @@ class IconManager {
353
627
  }
354
628
  // 添加处理 iframe 上事件丢失的机制
355
629
  // 1. 监听 mouseleave 和 pointerleave 事件(鼠标离开窗口时停止拖动)
630
+ // 注意:这些事件可能会在拖动过程中多次触发,需要添加防抖机制
356
631
  this.mouseLeaveHandler = (e) => {
357
632
  // 只有当鼠标真正离开窗口时才停止拖动
358
633
  if (!e.relatedTarget && (e.clientY <= 0 || e.clientX <= 0 ||
359
634
  e.clientX >= window.innerWidth || e.clientY >= window.innerHeight)) {
360
- if (this.debug) {
361
- console.log('Mouse left window, stopping drag');
635
+ // 添加防抖,避免重复触发
636
+ if (this.mouseLeaveTimeout) {
637
+ clearTimeout(this.mouseLeaveTimeout);
362
638
  }
363
- this.stopDrag();
639
+ this.mouseLeaveTimeout = window.setTimeout(() => {
640
+ if ((this.isDragging || this.dragStarted) && !this.isStoppingDrag) {
641
+ if (this.debug) {
642
+ console.log('[IconManager] Mouse left window, stopping drag');
643
+ }
644
+ this.stopDrag();
645
+ }
646
+ this.mouseLeaveTimeout = null;
647
+ }, 50); // 50ms 防抖
364
648
  }
365
649
  };
366
650
  this.pointerLeaveHandler = (e) => {
367
651
  // 只有当指针真正离开窗口时才停止拖动
368
652
  if (!e.relatedTarget && (e.clientY <= 0 || e.clientX <= 0 ||
369
653
  e.clientX >= window.innerWidth || e.clientY >= window.innerHeight)) {
370
- if (this.debug) {
371
- console.log('Pointer left window, stopping drag');
654
+ // 添加防抖,避免重复触发
655
+ if (this.pointerLeaveTimeout) {
656
+ clearTimeout(this.pointerLeaveTimeout);
372
657
  }
373
- this.stopDrag();
658
+ this.pointerLeaveTimeout = window.setTimeout(() => {
659
+ if ((this.isDragging || this.dragStarted) && !this.isStoppingDrag) {
660
+ if (this.debug) {
661
+ console.log('[IconManager] Pointer left window, stopping drag');
662
+ }
663
+ this.stopDrag();
664
+ }
665
+ this.pointerLeaveTimeout = null;
666
+ }, 50); // 50ms 防抖
374
667
  }
375
668
  };
376
669
  document.addEventListener('mouseleave', this.mouseLeaveHandler);
377
670
  document.addEventListener('pointerleave', this.pointerLeaveHandler);
378
671
  // 2. 监听 blur 事件(窗口失去焦点时停止拖动)
379
672
  this.blurHandler = () => {
673
+ // 如果已经在停止拖动,直接返回
674
+ if (this.isStoppingDrag) {
675
+ return;
676
+ }
677
+ // 检查是否真的在拖动中
678
+ if (!this.isDragging && !this.dragStarted) {
679
+ return;
680
+ }
380
681
  if (this.debug) {
381
- console.log('Window lost focus, stopping drag');
682
+ console.log('[IconManager] Window lost focus, stopping drag');
382
683
  }
383
684
  this.stopDrag();
384
685
  };
385
686
  window.addEventListener('blur', this.blurHandler);
386
687
  // 3. 添加超时机制(如果一段时间没有收到 mousemove 事件,自动停止拖动)
387
- // 优化:缩短超时时间到 100ms,更快检测到事件丢失(特别是嵌套 iframe 场景)
688
+ // 优化:缩短超时时间到 50ms,更快检测到事件丢失(特别是嵌套 iframe 场景)
388
689
  this.dragTimeoutId = window.setTimeout(() => {
389
690
  if (this.isDragging) {
390
691
  if (this.debug) {
@@ -392,9 +693,21 @@ class IconManager {
392
693
  }
393
694
  this.stopDrag();
394
695
  }
395
- }, 100); // 缩短到 100ms,更快检测到事件丢失(特别是嵌套 iframe 场景)
696
+ }, 50); // 缩短到 50ms,更快检测到事件丢失(特别是嵌套 iframe 场景)
697
+ // 4. 启动主动检测机制:使用 requestAnimationFrame 定期检查鼠标位置
698
+ // 即使没有 mousemove 事件,也能检测到鼠标是否进入 iframe
699
+ this.startActiveDetection();
396
700
  if (this.debug) {
397
- console.log('Drag start');
701
+ console.log('[IconManager] Drag start', {
702
+ startPosition: { x: clientX, y: clientY },
703
+ iconRect: {
704
+ left: iconRect.left,
705
+ top: iconRect.top,
706
+ width: iconRect.width,
707
+ height: iconRect.height
708
+ },
709
+ dragOffset: this.dragOffset
710
+ });
398
711
  }
399
712
  }
400
713
  catch (error) {
@@ -422,39 +735,79 @@ class IconManager {
422
735
  now - this.lastIframeUpdateTime > this.iframeUpdateInterval) {
423
736
  // 更新 iframe 缓存
424
737
  const allIframes = document.querySelectorAll('iframe');
738
+ const previousCount = this.cachedIframes.length;
425
739
  this.cachedIframes = Array.from(allIframes).map(iframe => ({
426
740
  element: iframe,
427
741
  rect: iframe.getBoundingClientRect()
428
742
  }));
429
743
  this.lastIframeUpdateTime = now;
744
+ if (this.debug && this.cachedIframes.length !== previousCount) {
745
+ console.log(`[IconManager] Iframe cache updated - Found ${this.cachedIframes.length} iframe(s)`, {
746
+ iframes: this.cachedIframes.map(({ rect }) => ({
747
+ left: rect.left,
748
+ top: rect.top,
749
+ right: rect.right,
750
+ bottom: rect.bottom
751
+ }))
752
+ });
753
+ }
430
754
  }
431
755
  // 检查鼠标是否在任何 iframe 上
432
- for (const { rect } of this.cachedIframes) {
756
+ for (const { rect, element } of this.cachedIframes) {
433
757
  if (clientX >= rect.left &&
434
758
  clientX <= rect.right &&
435
759
  clientY >= rect.top &&
436
760
  clientY <= rect.bottom) {
437
761
  // 鼠标在 iframe 上,立即停止拖动
438
762
  if (this.debug) {
439
- console.log('Mouse over iframe, stopping drag immediately');
763
+ console.log('[IconManager] Mouse over iframe detected, stopping drag immediately', {
764
+ mousePosition: { x: clientX, y: clientY },
765
+ iframeRect: {
766
+ left: rect.left,
767
+ top: rect.top,
768
+ right: rect.right,
769
+ bottom: rect.bottom
770
+ },
771
+ iframeSrc: element.src || 'no src'
772
+ });
440
773
  }
441
774
  this.stopDrag();
442
775
  return;
443
776
  }
444
777
  }
445
778
  }
779
+ // 更新最后记录的鼠标位置和时间戳(用于主动检测)
780
+ const now = Date.now();
781
+ const timeSinceLastUpdate = now - this.lastMousePosition.timestamp;
782
+ this.lastMousePosition = {
783
+ x: clientX,
784
+ y: clientY,
785
+ timestamp: now
786
+ };
787
+ // 如果距离上次更新超过 50ms,记录警告(可能事件丢失)
788
+ if (this.debug && timeSinceLastUpdate > 50 && this.lastMousePosition.timestamp > 0) {
789
+ console.warn(`[IconManager] Long gap between mousemove events: ${timeSinceLastUpdate}ms`, {
790
+ lastPosition: { x: this.lastMousePosition.x, y: this.lastMousePosition.y },
791
+ currentPosition: { x: clientX, y: clientY }
792
+ });
793
+ }
446
794
  // 重置超时定时器(每次移动都重置,确保只有真正停止移动时才触发超时)
447
- // 优化:缩短超时时间到 100ms,更快检测到事件丢失(特别是嵌套 iframe 场景)
795
+ // 优化:缩短超时时间到 50ms,更快检测到事件丢失(特别是嵌套 iframe 场景)
448
796
  if (this.dragTimeoutId !== null) {
449
797
  window.clearTimeout(this.dragTimeoutId);
450
798
  this.dragTimeoutId = window.setTimeout(() => {
451
799
  if (this.isDragging) {
452
800
  if (this.debug) {
453
- console.log('Drag timeout, stopping drag (likely mouse moved over iframe)');
801
+ const timeSinceLastMove = Date.now() - this.lastMousePosition.timestamp;
802
+ console.warn('[IconManager] Drag timeout triggered, stopping drag (likely mouse moved over iframe)', {
803
+ timeSinceLastMove,
804
+ lastMousePosition: this.lastMousePosition,
805
+ iframeCount: this.cachedIframes.length
806
+ });
454
807
  }
455
808
  this.stopDrag();
456
809
  }
457
- }, 100); // 缩短到 100ms,更快检测到事件丢失(特别是嵌套 iframe 场景)
810
+ }, 50); // 缩短到 50ms,更快检测到事件丢失(特别是嵌套 iframe 场景)
458
811
  }
459
812
  // 检查是否有足够的移动距离
460
813
  const deltaX = Math.abs(clientX - this.lastTouchPosition.x);
@@ -466,6 +819,14 @@ class IconManager {
466
819
  // 开始真正的拖拽
467
820
  this.dragStarted = true;
468
821
  this.isDragging = true;
822
+ // 拖拽开始时重置侧边吸附状态
823
+ if (this.isAttachedToSide) {
824
+ this.isAttachedToSide = false;
825
+ this.attachedSide = '';
826
+ if (this.iconElement) {
827
+ this.iconElement.style.transform = '';
828
+ }
829
+ }
469
830
  // 如果是第一次拖动,需要转换位置(从 right/bottom 到 left/top)
470
831
  const computedStyle = window.getComputedStyle(this.iconElement);
471
832
  if (computedStyle.right !== 'auto' || computedStyle.bottom !== 'auto') {
@@ -566,9 +927,39 @@ class IconManager {
566
927
  * 停止拖动
567
928
  */
568
929
  stopDrag(_e) {
930
+ // 防止重复调用
931
+ if (this.isStoppingDrag) {
932
+ if (this.debug) {
933
+ console.warn('[IconManager] stopDrag already in progress, ignoring duplicate call');
934
+ }
935
+ return;
936
+ }
937
+ // 如果已经停止且没有相关资源,直接返回
938
+ if (!this.isDragging && !this.dragStarted && !this.dragTimeoutId && !this.activeDetectionRafId) {
939
+ if (this.debug) {
940
+ console.warn('[IconManager] stopDrag called but not dragging, ignoring');
941
+ }
942
+ return;
943
+ }
944
+ this.isStoppingDrag = true;
945
+ const stopReason = this.isDragging ? 'drag_stopped' : 'not_dragging';
946
+ const finalPosition = this.iconElement ? {
947
+ left: this.iconElement.style.left,
948
+ top: this.iconElement.style.top,
949
+ computed: window.getComputedStyle(this.iconElement).left !== 'auto'
950
+ ? { left: window.getComputedStyle(this.iconElement).left, top: window.getComputedStyle(this.iconElement).top }
951
+ : null
952
+ } : null;
953
+ // 立即清理事件监听器,防止后续事件触发
569
954
  this.cleanupDragEvents();
570
- if (!this.iconElement)
955
+ if (!this.iconElement) {
956
+ // 即使没有 iconElement,也要重置状态,确保可以再次拖动
957
+ this.hasMoved = false;
958
+ this.isDragging = false;
959
+ this.dragStarted = false;
960
+ this.isStoppingDrag = false;
571
961
  return;
962
+ }
572
963
  // 恢复样式
573
964
  this.iconElement.style.transition = 'transform 0.2s ease';
574
965
  this.iconElement.style.cursor = 'pointer';
@@ -576,15 +967,22 @@ class IconManager {
576
967
  const touchDuration = Date.now() - this.touchStartTime;
577
968
  const isValidClick = !this.hasMoved && touchDuration < 1000 && !this.dragStarted;
578
969
  if (this.debug) {
579
- console.log('Drag end', {
970
+ console.log('[IconManager] Drag end', {
971
+ stopReason,
580
972
  hasMoved: this.hasMoved,
581
973
  dragStarted: this.dragStarted,
582
974
  touchDuration,
583
- isValidClick
975
+ isValidClick,
976
+ finalPosition,
977
+ lastMousePosition: this.lastMousePosition
584
978
  });
585
979
  }
586
980
  // 先保存点击状态,然后重置拖动状态
587
981
  const wasClick = isValidClick;
982
+ // 如果真正拖动过,执行磁性吸附
983
+ if (this.dragStarted && this.magnetic && this.iconElement) {
984
+ this.magneticSnap();
985
+ }
588
986
  // 如果真正拖动过,保存当前位置到 iconPosition
589
987
  if (this.dragStarted && this.isDragging && this.iconElement) {
590
988
  const computedStyle = window.getComputedStyle(this.iconElement);
@@ -609,6 +1007,11 @@ class IconManager {
609
1007
  this.hasMoved = false;
610
1008
  this.isDragging = false;
611
1009
  this.dragStarted = false;
1010
+ this.isStoppingDrag = false; // 重置停止标志
1011
+ // 如果拖动后没有吸附到侧边,启动自动吸附定时器
1012
+ if (!this.isAttachedToSide && this.sideAttach && this.autoAttachDelay > 0) {
1013
+ this.startAutoAttachTimer();
1014
+ }
612
1015
  if (wasClick) {
613
1016
  // 是点击,触发点击事件
614
1017
  // 延迟触发,确保拖动状态已完全重置,并且没有新的拖动开始
@@ -620,6 +1023,102 @@ class IconManager {
620
1023
  }, 100); // 增加延迟到 100ms,确保拖动状态完全重置
621
1024
  }
622
1025
  }
1026
+ /**
1027
+ * 启动主动检测机制:使用 requestAnimationFrame 定期检查鼠标位置
1028
+ * 即使没有 mousemove 事件,也能检测到鼠标是否进入 iframe
1029
+ */
1030
+ startActiveDetection() {
1031
+ if (this.activeDetectionRafId !== null) {
1032
+ if (this.debug) {
1033
+ console.log('[IconManager] Active detection already running');
1034
+ }
1035
+ return; // 已经在运行
1036
+ }
1037
+ if (this.debug) {
1038
+ console.log('[IconManager] Starting active detection mechanism');
1039
+ }
1040
+ const checkMousePosition = () => {
1041
+ // 检查是否还在拖动中,如果不在拖动中或正在停止,则停止检测
1042
+ if (!this.isDragging || this.isStoppingDrag) {
1043
+ if (this.debug) {
1044
+ console.log('[IconManager] Active detection stopped (drag ended or stopping)');
1045
+ }
1046
+ this.activeDetectionRafId = null;
1047
+ return;
1048
+ }
1049
+ // 检查是否长时间没有收到 mousemove 事件(超过 50ms)
1050
+ const now = Date.now();
1051
+ const timeSinceLastMove = now - this.lastMousePosition.timestamp;
1052
+ // 如果超过 50ms 没有收到事件,进行检测
1053
+ if (timeSinceLastMove > 50) {
1054
+ // 长时间没有收到事件,可能鼠标已经进入 iframe
1055
+ // 使用最后记录的鼠标位置进行检测
1056
+ const clientX = this.lastMousePosition.x;
1057
+ const clientY = this.lastMousePosition.y;
1058
+ if (this.debug && timeSinceLastMove > 100) {
1059
+ // 只在超过 100ms 时记录警告,避免日志过多
1060
+ console.warn(`[IconManager] Active detection: No mousemove event for ${timeSinceLastMove}ms`, {
1061
+ lastMousePosition: { x: clientX, y: clientY },
1062
+ timestamp: this.lastMousePosition.timestamp,
1063
+ isDragging: this.isDragging,
1064
+ dragStarted: this.dragStarted
1065
+ });
1066
+ }
1067
+ // 更新 iframe 缓存(更频繁,每帧更新)
1068
+ if (this.cachedIframes.length === 0 ||
1069
+ now - this.lastIframeUpdateTime > this.iframeUpdateInterval) {
1070
+ const allIframes = document.querySelectorAll('iframe');
1071
+ this.cachedIframes = Array.from(allIframes).map(iframe => ({
1072
+ element: iframe,
1073
+ rect: iframe.getBoundingClientRect()
1074
+ }));
1075
+ this.lastIframeUpdateTime = now;
1076
+ if (this.debug) {
1077
+ console.log(`[IconManager] Active detection: Updated iframe cache (${this.cachedIframes.length} iframes)`);
1078
+ }
1079
+ }
1080
+ // 检查最后记录的鼠标位置是否在任何 iframe 上
1081
+ for (const { rect, element } of this.cachedIframes) {
1082
+ if (clientX >= rect.left &&
1083
+ clientX <= rect.right &&
1084
+ clientY >= rect.top &&
1085
+ clientY <= rect.bottom) {
1086
+ // 鼠标在 iframe 上,立即停止拖动
1087
+ if (this.debug) {
1088
+ console.log('[IconManager] Active detection: Mouse over iframe detected, stopping drag immediately', {
1089
+ mousePosition: { x: clientX, y: clientY },
1090
+ iframeRect: {
1091
+ left: rect.left,
1092
+ top: rect.top,
1093
+ right: rect.right,
1094
+ bottom: rect.bottom
1095
+ },
1096
+ iframeSrc: element.src || 'no src',
1097
+ timeSinceLastMove
1098
+ });
1099
+ }
1100
+ this.stopDrag();
1101
+ return;
1102
+ }
1103
+ }
1104
+ }
1105
+ // 继续检测
1106
+ this.activeDetectionRafId = requestAnimationFrame(checkMousePosition);
1107
+ };
1108
+ this.activeDetectionRafId = requestAnimationFrame(checkMousePosition);
1109
+ }
1110
+ /**
1111
+ * 停止主动检测机制
1112
+ */
1113
+ stopActiveDetection() {
1114
+ if (this.activeDetectionRafId !== null) {
1115
+ if (this.debug) {
1116
+ console.log('[IconManager] Stopping active detection mechanism');
1117
+ }
1118
+ cancelAnimationFrame(this.activeDetectionRafId);
1119
+ this.activeDetectionRafId = null;
1120
+ }
1121
+ }
623
1122
  /**
624
1123
  * 清理拖动事件
625
1124
  */
@@ -634,6 +1133,7 @@ class IconManager {
634
1133
  document.removeEventListener('touchend', this.stopDragHandler);
635
1134
  }
636
1135
  // 清理处理 iframe 事件丢失的监听器
1136
+ // 注意:必须先移除监听器,再清理防抖定时器,防止事件在清理过程中触发
637
1137
  if (this.mouseLeaveHandler) {
638
1138
  document.removeEventListener('mouseleave', this.mouseLeaveHandler);
639
1139
  this.mouseLeaveHandler = null;
@@ -646,6 +1146,15 @@ class IconManager {
646
1146
  window.removeEventListener('blur', this.blurHandler);
647
1147
  this.blurHandler = null;
648
1148
  }
1149
+ // 清理防抖定时器(在移除监听器之后)
1150
+ if (this.mouseLeaveTimeout !== null) {
1151
+ window.clearTimeout(this.mouseLeaveTimeout);
1152
+ this.mouseLeaveTimeout = null;
1153
+ }
1154
+ if (this.pointerLeaveTimeout !== null) {
1155
+ window.clearTimeout(this.pointerLeaveTimeout);
1156
+ this.pointerLeaveTimeout = null;
1157
+ }
649
1158
  // 清理超时定时器
650
1159
  if (this.dragTimeoutId !== null) {
651
1160
  window.clearTimeout(this.dragTimeoutId);
@@ -656,11 +1165,31 @@ class IconManager {
656
1165
  cancelAnimationFrame(this.rafId);
657
1166
  this.rafId = null;
658
1167
  }
1168
+ // 清理主动检测机制(必须在最后,确保完全清理)
1169
+ this.stopActiveDetection();
1170
+ // 确保 activeDetectionRafId 被清理(双重保险)
1171
+ if (this.activeDetectionRafId !== null) {
1172
+ if (this.debug) {
1173
+ console.warn('[IconManager] Active detection RAF ID still exists, force cleaning');
1174
+ }
1175
+ cancelAnimationFrame(this.activeDetectionRafId);
1176
+ this.activeDetectionRafId = null;
1177
+ }
659
1178
  // 清理缓存
660
1179
  this.cachedContainer = null;
661
1180
  this.cachedContainerRect = null;
662
1181
  this.cachedIframes = []; // 清理 iframe 缓存
663
1182
  this.pendingPosition.needsUpdate = false;
1183
+ if (this.debug) {
1184
+ console.log('[IconManager] Cleanup completed', {
1185
+ isDragging: this.isDragging,
1186
+ dragStarted: this.dragStarted,
1187
+ isStoppingDrag: this.isStoppingDrag,
1188
+ hasTimeout: this.dragTimeoutId !== null,
1189
+ hasActiveDetection: this.activeDetectionRafId !== null,
1190
+ hasRaf: this.rafId !== null
1191
+ });
1192
+ }
664
1193
  }
665
1194
  /**
666
1195
  * 处理点击事件
@@ -670,6 +1199,8 @@ class IconManager {
670
1199
  if (!this.isClickEnabled) {
671
1200
  return;
672
1201
  }
1202
+ // 清除自动吸附定时器(用户有操作)
1203
+ this.clearAutoAttachTimer();
673
1204
  if (this.onClickCallback) {
674
1205
  this.onClickCallback();
675
1206
  }
@@ -712,6 +1243,142 @@ class IconManager {
712
1243
  this.iconElement.style.cursor = 'pointer';
713
1244
  }
714
1245
  }
1246
+ /**
1247
+ * 磁性吸附
1248
+ */
1249
+ magneticSnap() {
1250
+ if (!this.iconElement)
1251
+ return;
1252
+ try {
1253
+ const container = this.getTargetElement();
1254
+ if (!container)
1255
+ return;
1256
+ const containerRect = container.getBoundingClientRect();
1257
+ const iconWidth = this.iconElement.offsetWidth || 30;
1258
+ const iconHeight = this.iconElement.offsetHeight || 30;
1259
+ // 获取当前图标位置
1260
+ const computedStyle = window.getComputedStyle(this.iconElement);
1261
+ const currentLeft = parseFloat(computedStyle.left) || 0;
1262
+ const currentTop = parseFloat(computedStyle.top) || 0;
1263
+ let newX = currentLeft;
1264
+ let newY = currentTop;
1265
+ // X 轴磁性吸附
1266
+ if (this.magneticDirection === 'x' || this.magneticDirection === 'both') {
1267
+ const containerWidth = containerRect.width;
1268
+ const centerX = containerWidth / 2;
1269
+ if (currentLeft < centerX) {
1270
+ // 吸附到左边
1271
+ newX = this.margin;
1272
+ // 侧边吸附逻辑
1273
+ if (this.sideAttach && currentLeft < iconWidth / 2) {
1274
+ this.isAttachedToSide = true;
1275
+ this.attachedSide = 'left';
1276
+ // 计算隐藏部分:图标宽度 * sideHideRatio
1277
+ // 例如:30px * 0.5 = 15px 隐藏,15px 显示
1278
+ const hideDistance = iconWidth * this.sideHideRatio;
1279
+ // 图标左边缘对齐容器左边缘,隐藏部分在容器外
1280
+ newX = -hideDistance; // 负值,让图标超出容器左边缘
1281
+ }
1282
+ else {
1283
+ this.isAttachedToSide = false;
1284
+ this.attachedSide = '';
1285
+ }
1286
+ }
1287
+ else {
1288
+ // 吸附到右边
1289
+ newX = containerWidth - iconWidth - this.margin;
1290
+ // 侧边吸附逻辑:如果启用侧边吸附,默认吸附到右边并显示一半
1291
+ if (this.sideAttach) {
1292
+ // 如果靠近右边(距离右边 < 图标宽度的一半),或者从默认位置初始化
1293
+ // 默认位置(右下角)应该自动侧边吸附
1294
+ const distanceFromRight = containerWidth - currentLeft - iconWidth;
1295
+ if (distanceFromRight < iconWidth / 2 ||
1296
+ (isNaN(currentLeft) || currentLeft === 0) ||
1297
+ currentLeft >= containerWidth - iconWidth - this.margin - iconWidth / 2) {
1298
+ this.isAttachedToSide = true;
1299
+ this.attachedSide = 'right';
1300
+ // 计算显示部分:图标宽度 * (1 - sideHideRatio)
1301
+ // 例如:30px * (1 - 0.5) = 15px 显示,15px 隐藏
1302
+ const visibleWidth = iconWidth * (1 - this.sideHideRatio);
1303
+ // 图标右边缘对齐容器右边缘,但只显示 visibleWidth
1304
+ newX = containerWidth - visibleWidth;
1305
+ }
1306
+ else {
1307
+ this.isAttachedToSide = false;
1308
+ this.attachedSide = '';
1309
+ }
1310
+ }
1311
+ else {
1312
+ this.isAttachedToSide = false;
1313
+ this.attachedSide = '';
1314
+ }
1315
+ }
1316
+ }
1317
+ // Y 轴磁性吸附
1318
+ if (this.magneticDirection === 'y' || this.magneticDirection === 'both') {
1319
+ const containerHeight = containerRect.height;
1320
+ const centerY = containerHeight / 2;
1321
+ if (currentTop < centerY) {
1322
+ newY = this.margin; // 吸附到上边
1323
+ }
1324
+ else {
1325
+ newY = containerHeight - iconHeight - this.margin; // 吸附到下边
1326
+ }
1327
+ }
1328
+ // 应用新位置
1329
+ // 如果侧边吸附到右边,使用 right 属性(负值),让图标超出容器右边缘
1330
+ if (this.isAttachedToSide && this.attachedSide === 'right') {
1331
+ const hideDistance = iconWidth * this.sideHideRatio; // 隐藏部分(在容器外)
1332
+ this.iconElement.style.right = `-${hideDistance}px`; // 负值,让图标超出容器右边缘
1333
+ this.iconElement.style.left = 'auto';
1334
+ }
1335
+ else if (this.isAttachedToSide && this.attachedSide === 'left') {
1336
+ // 左边吸附使用 left(负值),让图标超出容器左边缘
1337
+ const hideDistance = iconWidth * this.sideHideRatio;
1338
+ this.iconElement.style.left = `-${hideDistance}px`;
1339
+ this.iconElement.style.right = 'auto';
1340
+ }
1341
+ else {
1342
+ // 正常位置使用 left
1343
+ this.iconElement.style.left = `${newX}px`;
1344
+ this.iconElement.style.right = 'auto';
1345
+ }
1346
+ this.iconElement.style.top = `${newY}px`;
1347
+ this.iconElement.style.bottom = 'auto';
1348
+ // 如果侧边吸附,应用 transform
1349
+ if (this.isAttachedToSide && this.attachedSide) {
1350
+ this.iconElement.style.transform = 'translateX(0)';
1351
+ this.iconElement.style.transition = 'all 0.3s ease';
1352
+ }
1353
+ else {
1354
+ this.iconElement.style.transform = '';
1355
+ }
1356
+ // 保存位置(如果是右边吸附,保存为 right 值,否则保存为 left 值)
1357
+ if (this.isAttachedToSide && this.attachedSide === 'right') {
1358
+ // 右边吸附时,不保存到 iconPosition(因为使用的是 right 属性)
1359
+ // 或者可以保存一个特殊标记
1360
+ this.iconPosition = null; // 清除位置,让下次显示时重新计算
1361
+ }
1362
+ else {
1363
+ this.iconPosition = {
1364
+ x: newX,
1365
+ y: newY
1366
+ };
1367
+ }
1368
+ if (this.debug) {
1369
+ console.log('[IconManager] Magnetic snap applied', {
1370
+ newPosition: { x: newX, y: newY },
1371
+ isAttachedToSide: this.isAttachedToSide,
1372
+ attachedSide: this.attachedSide
1373
+ });
1374
+ }
1375
+ }
1376
+ catch (error) {
1377
+ if (this.debug) {
1378
+ console.error('[IconManager] Error in magneticSnap:', error);
1379
+ }
1380
+ }
1381
+ }
715
1382
  /**
716
1383
  * 获取目标元素(支持动态查找)
717
1384
  */
@@ -21175,7 +21842,14 @@ class CustomerServiceSDK {
21175
21842
  console.log('Icon config changed, recreating icon manager');
21176
21843
  }
21177
21844
  }
21178
- this.iconManager = new IconManager(iconPosition, this.debug, iconTarget);
21845
+ this.iconManager = new IconManager(iconPosition, this.debug, iconTarget, {
21846
+ sideAttach: options?.sideAttach !== undefined ? options.sideAttach : true,
21847
+ sideHideRatio: options?.sideHideRatio !== undefined ? options.sideHideRatio : 0.5,
21848
+ magnetic: options?.magnetic !== undefined ? options.magnetic : true,
21849
+ magneticDirection: options?.magneticDirection || 'x',
21850
+ margin: options?.margin !== undefined ? options.margin : 10,
21851
+ autoAttachDelay: options?.autoAttachDelay !== undefined ? options.autoAttachDelay : 3000
21852
+ });
21179
21853
  await this.iconManager.show();
21180
21854
  // 保存新的配置
21181
21855
  this.lastIconConfig = { position: iconPosition, target: iconTarget };
@@ -21184,7 +21858,14 @@ class CustomerServiceSDK {
21184
21858
  // 配置没变化,保留图标管理器(避免闪烁)
21185
21859
  if (!this.iconManager) {
21186
21860
  // 如果不存在,创建新的
21187
- this.iconManager = new IconManager(iconPosition, this.debug, iconTarget);
21861
+ this.iconManager = new IconManager(iconPosition, this.debug, iconTarget, {
21862
+ sideAttach: options?.sideAttach !== undefined ? options.sideAttach : true,
21863
+ sideHideRatio: options?.sideHideRatio !== undefined ? options.sideHideRatio : 0.5,
21864
+ magnetic: options?.magnetic !== undefined ? options.magnetic : true,
21865
+ magneticDirection: options?.magneticDirection || 'x',
21866
+ margin: options?.margin !== undefined ? options.margin : 10,
21867
+ autoAttachDelay: options?.autoAttachDelay !== undefined ? options.autoAttachDelay : 3000
21868
+ });
21188
21869
  await this.iconManager.show();
21189
21870
  }
21190
21871
  else {