customer-chat-sdk 1.1.16 → 1.1.17

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.
@@ -18,6 +18,7 @@ class IconManager {
18
18
  this.lastTouchPosition = { x: 0, y: 0 }; // 最后触摸位置
19
19
  this.touchStartTime = 0; // 触摸开始时间
20
20
  this.clickThreshold = 15; // 点击阈值(像素)
21
+ this.stoppedByIframe = false; // 是否因为 iframe 而停止拖动(用于避免触发磁性吸附)
21
22
  // 性能优化:缓存容器信息,避免频繁查询 DOM
22
23
  this.cachedContainer = null;
23
24
  this.cachedContainerRect = null;
@@ -80,6 +81,7 @@ class IconManager {
80
81
  this.iconElement = document.createElement('div');
81
82
  this.iconElement.className = 'customer-sdk-icon';
82
83
  // 直接设置样式 - 图标容器
84
+ // 关键优化:使用 pointer-events: none 让事件穿透到 iframe,子元素使用 pointer-events: auto 保持可点击
83
85
  const defaultStyle = {
84
86
  position: 'absolute',
85
87
  width: '30px',
@@ -96,7 +98,8 @@ class IconManager {
96
98
  transition: 'transform 0.2s ease',
97
99
  border: 'none',
98
100
  outline: 'none',
99
- overflow: 'visible' // 允许红点显示在图标外部
101
+ overflow: 'visible', // 允许红点显示在图标外部
102
+ pointerEvents: 'none' // 让事件穿透到 iframe,只有子元素可以接收事件
100
103
  };
101
104
  // 如果指定了位置,使用left/top;否则使用默认的bottom/right
102
105
  if (this.iconPosition) {
@@ -194,7 +197,8 @@ class IconManager {
194
197
  height: '100%',
195
198
  borderRadius: '50%',
196
199
  overflow: 'hidden', // 限制图片溢出圆形边界
197
- position: 'relative'
200
+ position: 'relative',
201
+ pointerEvents: 'auto' // 子元素启用指针事件,让 icon 可以点击和拖动
198
202
  });
199
203
  imgContainer.appendChild(iconImg);
200
204
  this.iconElement.appendChild(imgContainer);
@@ -597,8 +601,38 @@ class IconManager {
597
601
  target.closest('.customer-sdk-overlay'))) {
598
602
  return;
599
603
  }
604
+ // 关键优化:检查触摸点是否真的在 icon 的圆形区域内
605
+ // 如果不在,让事件穿透到 iframe,不触发拖动
606
+ if (this.iconElement) {
607
+ const iconRect = this.iconElement.getBoundingClientRect();
608
+ const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
609
+ const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
610
+ // 计算触摸点相对于 icon 中心的位置
611
+ const iconCenterX = iconRect.left + iconRect.width / 2;
612
+ const iconCenterY = iconRect.top + iconRect.height / 2;
613
+ const iconRadius = Math.min(iconRect.width, iconRect.height) / 2;
614
+ const distanceFromCenter = Math.sqrt(Math.pow(clientX - iconCenterX, 2) + Math.pow(clientY - iconCenterY, 2));
615
+ // 如果触摸点在 icon 圆形区域外(留一点边距,比如 10px),不处理拖动
616
+ // 这样可以让 iframe 内的滑动正常工作
617
+ // 但边距不能太大,否则无法拖动
618
+ if (distanceFromCenter > iconRadius + 10) {
619
+ if (this.debug) {
620
+ console.log('[IconManager] Touch point outside icon circle, allowing event to pass through', {
621
+ distance: distanceFromCenter,
622
+ radius: iconRadius,
623
+ touchPoint: { x: clientX, y: clientY },
624
+ iconCenter: { x: iconCenterX, y: iconCenterY }
625
+ });
626
+ }
627
+ return;
628
+ }
629
+ }
600
630
  e.preventDefault();
601
631
  e.stopPropagation();
632
+ // 拖动开始时,临时启用 pointer-events 确保拖动事件能正常接收
633
+ if (this.iconElement) {
634
+ this.iconElement.style.pointerEvents = 'auto';
635
+ }
602
636
  // 重置状态
603
637
  this.hasMoved = false;
604
638
  this.dragStarted = false;
@@ -778,29 +812,8 @@ class IconManager {
778
812
  });
779
813
  }
780
814
  }
781
- // 检查鼠标是否在任何 iframe
782
- for (const { rect, element } of this.cachedIframes) {
783
- if (clientX >= rect.left &&
784
- clientX <= rect.right &&
785
- clientY >= rect.top &&
786
- clientY <= rect.bottom) {
787
- // 鼠标在 iframe 上,立即停止拖动
788
- if (this.debug) {
789
- console.log('[IconManager] Mouse over iframe detected, stopping drag immediately', {
790
- mousePosition: { x: clientX, y: clientY },
791
- iframeRect: {
792
- left: rect.left,
793
- top: rect.top,
794
- right: rect.right,
795
- bottom: rect.bottom
796
- },
797
- iframeSrc: element.src || 'no src'
798
- });
799
- }
800
- this.stopDrag();
801
- return;
802
- }
803
- }
815
+ // 移除 iframe 检测停止拖动的逻辑,让拖动可以在 iframe 上顺畅进行
816
+ // 不再因为检测到 iframe 而停止拖动,保持拖动的流畅性
804
817
  }
805
818
  // 更新最后记录的鼠标位置和时间戳(用于主动检测)
806
819
  const now = Date.now();
@@ -910,19 +923,18 @@ class IconManager {
910
923
  // 计算新位置
911
924
  let newX = clientX - this.dragOffset.x - containerRect.left;
912
925
  let newY = clientY - this.dragOffset.y - containerRect.top;
913
- // 限制在容器内
914
- if (container === document.body) {
915
- // 限制在视口内
916
- newX = Math.max(0, Math.min(newX, window.innerWidth - iconWidth));
917
- newY = Math.max(0, Math.min(newY, window.innerHeight - iconHeight));
918
- }
919
- else {
920
- // 限制在容器内
921
- const containerWidth = containerRect.width;
922
- const containerHeight = containerRect.height;
923
- newX = Math.max(0, Math.min(newX, containerWidth - iconWidth));
924
- newY = Math.max(0, Math.min(newY, containerHeight - iconHeight));
925
- }
926
+ // 允许拖动到容器外(允许负值),但限制在合理范围内(避免拖得太远)
927
+ // 允许拖动到容器外,最多隐藏到只剩一小部分可见
928
+ const minX = -iconWidth + 5; // 允许向左拖动,最多隐藏到只剩 5px
929
+ const maxX = container === document.body
930
+ ? window.innerWidth - 5 // 允许向右拖动,最多隐藏到只剩 5px
931
+ : containerRect.width + iconWidth - 5; // 允许超出容器,最多隐藏到只剩 5px
932
+ const minY = -iconHeight + 5; // 允许向上拖动,最多隐藏到只剩 5px
933
+ const maxY = container === document.body
934
+ ? window.innerHeight - 5 // 允许向下拖动,最多隐藏到只剩 5px
935
+ : containerRect.height + iconHeight - 5; // 允许超出容器,最多隐藏到只剩 5px
936
+ newX = Math.max(minX, Math.min(newX, maxX));
937
+ newY = Math.max(minY, Math.min(newY, maxY));
926
938
  // 性能优化:使用 requestAnimationFrame 节流位置更新
927
939
  this.pendingPosition.x = newX;
928
940
  this.pendingPosition.y = newY;
@@ -989,6 +1001,8 @@ class IconManager {
989
1001
  // 恢复样式
990
1002
  this.iconElement.style.transition = 'transform 0.2s ease';
991
1003
  this.iconElement.style.cursor = 'pointer';
1004
+ // 拖动结束时,恢复 pointer-events: none 让事件穿透到 iframe
1005
+ this.iconElement.style.pointerEvents = 'none';
992
1006
  // 检查是否是点击
993
1007
  const touchDuration = Date.now() - this.touchStartTime;
994
1008
  const isValidClick = !this.hasMoved && touchDuration < 1000 && !this.dragStarted;
@@ -1005,26 +1019,37 @@ class IconManager {
1005
1019
  }
1006
1020
  // 先保存点击状态,然后重置拖动状态
1007
1021
  const wasClick = isValidClick;
1008
- // 如果真正拖动过,执行磁性吸附
1009
- if (this.dragStarted && this.magnetic && this.iconElement) {
1010
- this.magneticSnap();
1022
+ // 保存 stoppedByIframe 状态,用于后续判断是否启动自动吸附
1023
+ const wasStoppedByIframe = this.stoppedByIframe;
1024
+ // 如果是因为 iframe 而停止拖动,不执行磁性吸附和自动吸附
1025
+ // 这样可以避免拖动到 iframe 上时 icon 缩回去
1026
+ if (this.stoppedByIframe) {
1027
+ if (this.debug) {
1028
+ console.log('[IconManager] Drag stopped by iframe, skipping magnetic snap and auto attach');
1029
+ }
1011
1030
  }
1012
- // 如果真正拖动过,保存当前位置到 iconPosition
1013
- if (this.dragStarted && this.isDragging && this.iconElement) {
1014
- const computedStyle = window.getComputedStyle(this.iconElement);
1015
- const left = computedStyle.left;
1016
- const top = computedStyle.top;
1017
- // 如果 left/top 是有效的像素值,保存到 iconPosition
1018
- if (left !== 'auto' && top !== 'auto') {
1019
- const leftValue = parseFloat(left);
1020
- const topValue = parseFloat(top);
1021
- if (!isNaN(leftValue) && !isNaN(topValue)) {
1022
- this.iconPosition = {
1023
- x: leftValue,
1024
- y: topValue
1025
- };
1026
- if (this.debug) {
1027
- console.log('Icon position saved:', this.iconPosition);
1031
+ else {
1032
+ // 如果真正拖动过,执行磁性吸附
1033
+ if (this.dragStarted && this.magnetic && this.iconElement) {
1034
+ this.magneticSnap();
1035
+ }
1036
+ // 如果真正拖动过,保存当前位置到 iconPosition
1037
+ if (this.dragStarted && this.isDragging && this.iconElement) {
1038
+ const computedStyle = window.getComputedStyle(this.iconElement);
1039
+ const left = computedStyle.left;
1040
+ const top = computedStyle.top;
1041
+ // 如果 left/top 是有效的像素值,保存到 iconPosition
1042
+ if (left !== 'auto' && top !== 'auto') {
1043
+ const leftValue = parseFloat(left);
1044
+ const topValue = parseFloat(top);
1045
+ if (!isNaN(leftValue) && !isNaN(topValue)) {
1046
+ this.iconPosition = {
1047
+ x: leftValue,
1048
+ y: topValue
1049
+ };
1050
+ if (this.debug) {
1051
+ console.log('Icon position saved:', this.iconPosition);
1052
+ }
1028
1053
  }
1029
1054
  }
1030
1055
  }
@@ -1034,8 +1059,10 @@ class IconManager {
1034
1059
  this.isDragging = false;
1035
1060
  this.dragStarted = false;
1036
1061
  this.isStoppingDrag = false; // 重置停止标志
1062
+ this.stoppedByIframe = false; // 重置 iframe 停止标志
1037
1063
  // 如果拖动后没有吸附到侧边,启动自动吸附定时器
1038
- if (!this.isAttachedToSide && this.sideAttach && this.autoAttachDelay > 0) {
1064
+ // 但如果是因为 iframe 而停止拖动,不启动自动吸附
1065
+ if (!wasStoppedByIframe && !this.isAttachedToSide && this.sideAttach && this.autoAttachDelay > 0) {
1039
1066
  this.startAutoAttachTimer();
1040
1067
  }
1041
1068
  if (wasClick) {
@@ -1103,30 +1130,8 @@ class IconManager {
1103
1130
  console.log(`[IconManager] Active detection: Updated iframe cache (${this.cachedIframes.length} iframes)`);
1104
1131
  }
1105
1132
  }
1106
- // 检查最后记录的鼠标位置是否在任何 iframe
1107
- for (const { rect, element } of this.cachedIframes) {
1108
- if (clientX >= rect.left &&
1109
- clientX <= rect.right &&
1110
- clientY >= rect.top &&
1111
- clientY <= rect.bottom) {
1112
- // 鼠标在 iframe 上,立即停止拖动
1113
- if (this.debug) {
1114
- console.log('[IconManager] Active detection: Mouse over iframe detected, stopping drag immediately', {
1115
- mousePosition: { x: clientX, y: clientY },
1116
- iframeRect: {
1117
- left: rect.left,
1118
- top: rect.top,
1119
- right: rect.right,
1120
- bottom: rect.bottom
1121
- },
1122
- iframeSrc: element.src || 'no src',
1123
- timeSinceLastMove
1124
- });
1125
- }
1126
- this.stopDrag();
1127
- return;
1128
- }
1129
- }
1133
+ // 移除 iframe 检测停止拖动的逻辑,让拖动可以在 iframe 上顺畅进行
1134
+ // 不再因为检测到 iframe 而停止拖动,保持拖动的流畅性
1130
1135
  }
1131
1136
  // 继续检测
1132
1137
  this.activeDetectionRafId = requestAnimationFrame(checkMousePosition);
@@ -1479,6 +1484,7 @@ class IconManager {
1479
1484
  justifyContent: 'center',
1480
1485
  zIndex: '1000001',
1481
1486
  boxShadow: '0 -1px 4px rgba(0, 0, 0, 0.3), 0 2px 8px rgba(0, 0, 0, 0.15)',
1487
+ pointerEvents: 'auto', // 子元素启用指针事件,让 badge 可以接收事件
1482
1488
  ...(count === 0 && text === '' && {
1483
1489
  width: '12px',
1484
1490
  height: '12px',
@@ -1970,10 +1976,23 @@ class IframeManager {
1970
1976
  }
1971
1977
  // 创建新的消息处理器并保存引用
1972
1978
  this.messageHandler = (event) => {
1973
- // 验证消息来源(可选的安全检查)
1974
- if (!this.config.src || event.origin === new URL(this.config.src).origin) {
1975
- this.handleIframeMessage(event.data);
1979
+ // 关键:过滤掉自己通过 dispatchEvent 发送的消息,避免无限循环
1980
+ // 自己发送的消息 source window,而 iframe 发送的消息 source 是 iframe.contentWindow
1981
+ if (event.source === window) {
1982
+ if (this.debug) {
1983
+ console.log('[IframeManager] Ignoring self-broadcasted message to prevent infinite loop:', event.data);
1984
+ }
1985
+ return;
1986
+ }
1987
+ // 不验证来源,直接处理所有消息(确保消息能够被接收)
1988
+ if (this.debug) {
1989
+ console.log('[IframeManager] Message received:', {
1990
+ data: event.data,
1991
+ origin: event.origin,
1992
+ source: event.source
1993
+ });
1976
1994
  }
1995
+ this.handleIframeMessage(event.data);
1977
1996
  };
1978
1997
  window.addEventListener('message', this.messageHandler, false);
1979
1998
  }
@@ -1982,7 +2001,7 @@ class IframeManager {
1982
2001
  */
1983
2002
  handleIframeMessage(data) {
1984
2003
  if (this.debug) {
1985
- console.log('Message from iframe:', data);
2004
+ console.log('[IframeManager] Message from iframe received:', data);
1986
2005
  }
1987
2006
  // 判断data是字符串还是对象,兼容两种格式
1988
2007
  let messageType;
@@ -1994,10 +2013,13 @@ class IframeManager {
1994
2013
  }
1995
2014
  else {
1996
2015
  if (this.debug) {
1997
- console.log('Unknown message format:', data);
2016
+ console.log('[IframeManager] Unknown message format:', data);
1998
2017
  }
1999
2018
  return;
2000
2019
  }
2020
+ if (this.debug) {
2021
+ console.log('[IframeManager] Parsed message type:', messageType);
2022
+ }
2001
2023
  // 根据消息类型处理不同的操作
2002
2024
  switch (messageType) {
2003
2025
  case 'iframe_ready':
@@ -2030,11 +2052,22 @@ class IframeManager {
2030
2052
  }
2031
2053
  break;
2032
2054
  case 'goto-login':
2033
- // 登录跳转消息 - 通过 window.postMessage 抛出给外层
2055
+ // 登录跳转消息 - 广播给调用 SDK 的 Vue 页面
2056
+ if (this.debug) {
2057
+ console.log('Received goto-login message, broadcasting to Vue page');
2058
+ }
2059
+ this.broadcastMessageToPage(data);
2060
+ if (this.config.onMessage) {
2061
+ this.config.onMessage(messageType, data);
2062
+ }
2063
+ break;
2064
+ case 'DCR_DEPOSIT':
2065
+ // 存款跳转消息 - 广播给调用 SDK 的 Vue 页面
2066
+ // payload.path 是动态的,例如: "?depositType=0&walletId=69382"
2034
2067
  if (this.debug) {
2035
- console.log('Received goto-login message, forwarding to parent window');
2068
+ console.log('Received DCR_DEPOSIT message, broadcasting to Vue page:', data);
2036
2069
  }
2037
- this.forwardMessageToParent(data);
2070
+ this.broadcastMessageToPage(data);
2038
2071
  if (this.config.onMessage) {
2039
2072
  this.config.onMessage(messageType, data);
2040
2073
  }
@@ -2042,11 +2075,11 @@ class IframeManager {
2042
2075
  default:
2043
2076
  // 处理动态消息:gotoActivityDetailById:xxx
2044
2077
  if (typeof messageType === 'string' && messageType.startsWith('gotoActivityDetailById:')) {
2045
- // 活动详情跳转消息 - 通过 window.postMessage 抛出给外层
2078
+ // 活动详情跳转消息 - 广播给调用 SDK 的 Vue 页面
2046
2079
  if (this.debug) {
2047
- console.log('Received gotoActivityDetailById message, forwarding to parent window:', messageType);
2080
+ console.log('Received gotoActivityDetailById message, broadcasting to Vue page:', messageType);
2048
2081
  }
2049
- this.forwardMessageToParent(data);
2082
+ this.broadcastMessageToPage(data);
2050
2083
  if (this.config.onMessage) {
2051
2084
  this.config.onMessage(messageType, data);
2052
2085
  }
@@ -2061,31 +2094,42 @@ class IframeManager {
2061
2094
  }
2062
2095
  }
2063
2096
  /**
2064
- * 将消息转发给父窗口(通过 window.postMessage)
2065
- * 这样 Vue 页面中的监听器也能收到消息
2097
+ * 向调用 SDK 的地方(Vue 页面)广播消息
2098
+ * 通过 window.dispatchEvent 触发事件,让 Vue 页面中的 window.addEventListener('message') 可以收到
2099
+ *
2100
+ * 重要说明:
2101
+ * 1. window.dispatchEvent 创建的事件会在同一窗口内触发所有监听器,无论 origin 是什么,Vue 页面都能收到
2102
+ * 2. MessageEvent 的 origin 属性不能是 '*',必须是有效的 origin 字符串(如 'http://localhost:5173')
2103
+ * 3. 我们使用 window.location.origin 来标识消息来源(当前页面的 origin)
2104
+ * 4. 如果 Vue 页面需要检查 origin,应该检查 window.location.origin 而不是 '*'
2066
2105
  */
2067
- forwardMessageToParent(data) {
2106
+ broadcastMessageToPage(data) {
2068
2107
  try {
2069
- // 方式1:使用 window.postMessage 发送消息(跨窗口通信)
2070
- // 如果存在父窗口且不是当前窗口,发送给父窗口
2071
- if (window.parent && window.parent !== window) {
2072
- window.parent.postMessage(data, '*');
2073
- }
2074
- // 方式2:使用 dispatchEvent 创建 MessageEvent(在同一窗口内触发所有监听器)
2075
- // 这样 Vue 页面中的 window.addEventListener('message') 也能收到
2108
+ // 使用 dispatchEvent 创建 MessageEvent,在同一窗口内触发所有监听器
2109
+ // 这样调用 SDK 的 Vue 页面中的 window.addEventListener('message') 也能收到
2110
+ // 注意:MessageEvent origin 属性不能是 '*',必须是有效的 origin 字符串
2111
+ // 我们使用 window.location.origin 来标识消息来源(当前页面的 origin)
2112
+ // Vue 页面可以收到消息,因为 dispatchEvent 会在同一窗口内触发所有监听器
2076
2113
  const messageEvent = new MessageEvent('message', {
2077
2114
  data: data,
2078
- origin: window.location.origin,
2079
- source: window
2115
+ origin: window.location.origin, // 使用当前页面的 origin,不是 '*'(MessageEvent 不支持 '*')
2116
+ source: window,
2117
+ bubbles: true,
2118
+ cancelable: true
2080
2119
  });
2081
2120
  window.dispatchEvent(messageEvent);
2082
2121
  if (this.debug) {
2083
- console.log('Message forwarded via window.postMessage and dispatchEvent:', data);
2122
+ console.log('[IframeManager] Message broadcasted to Vue page via window.dispatchEvent:', {
2123
+ data: data,
2124
+ origin: window.location.origin,
2125
+ type: 'message',
2126
+ note: 'origin is window.location.origin, not "*" (MessageEvent does not support "*")'
2127
+ });
2084
2128
  }
2085
2129
  }
2086
2130
  catch (error) {
2087
2131
  if (this.debug) {
2088
- console.error('Failed to forward message:', error);
2132
+ console.error('[IframeManager] Failed to broadcast message:', error);
2089
2133
  }
2090
2134
  }
2091
2135
  }
@@ -2922,20 +2966,20 @@ function copyInputValue(node, cloned) {
2922
2966
  }
2923
2967
 
2924
2968
  const pseudoClasses = [
2925
- ":before",
2926
- ":after"
2927
- // ':placeholder', TODO
2969
+ "::before",
2970
+ "::after"
2971
+ // '::placeholder', TODO
2928
2972
  ];
2929
2973
  const scrollbarPseudoClasses = [
2930
- ":-webkit-scrollbar",
2931
- ":-webkit-scrollbar-button",
2932
- // ':-webkit-scrollbar:horizontal', TODO
2933
- ":-webkit-scrollbar-thumb",
2934
- ":-webkit-scrollbar-track",
2935
- ":-webkit-scrollbar-track-piece",
2936
- // ':-webkit-scrollbar:vertical', TODO
2937
- ":-webkit-scrollbar-corner",
2938
- ":-webkit-resizer"
2974
+ "::-webkit-scrollbar",
2975
+ "::-webkit-scrollbar-button",
2976
+ // '::-webkit-scrollbar:horizontal', TODO
2977
+ "::-webkit-scrollbar-thumb",
2978
+ "::-webkit-scrollbar-track",
2979
+ "::-webkit-scrollbar-track-piece",
2980
+ // '::-webkit-scrollbar:vertical', TODO
2981
+ "::-webkit-scrollbar-corner",
2982
+ "::-webkit-resizer"
2939
2983
  ];
2940
2984
  function copyPseudoClass(node, cloned, copyScrollbar, context, addWordToFontFamilies) {
2941
2985
  const { ownerWindow, svgStyleElement, svgStyles, currentNodeStyle } = context;
@@ -2979,7 +3023,7 @@ function copyPseudoClass(node, cloned, copyScrollbar, context, addWordToFontFami
2979
3023
  allClasses = [];
2980
3024
  svgStyles.set(cssText, allClasses);
2981
3025
  }
2982
- allClasses.push(`.${klasses[0]}:${pseudoClass}`);
3026
+ allClasses.push(`.${klasses[0]}${pseudoClass}`);
2983
3027
  }
2984
3028
  pseudoClasses.forEach(copyBy);
2985
3029
  if (copyScrollbar)