customer-chat-sdk 1.1.8 → 1.1.10

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.
@@ -6,20 +6,26 @@ class IconManager {
6
6
  this.badgeElement = null;
7
7
  this.onClickCallback = null;
8
8
  this.notificationCallback = null;
9
- this.isDragging = false;
10
- this.dragStartX = 0;
11
- this.dragStartY = 0;
12
- this.iconStartX = 0;
13
- this.iconStartY = 0;
14
- this.dragMoveHandler = null;
15
- this.dragEndHandler = null;
16
- this.checkDragHandler = null; // 临时拖动检测监听器
17
- this.dragStartHandler = null; // 拖动开始事件监听器
18
- this.touchStartHandler = null; // 触摸开始事件监听器
19
9
  this.iconPosition = null; // 图标位置配置
20
10
  this.debug = false; // debug 模式标志
21
11
  this.isClickEnabled = true; // 是否允许点击(iframe 打开时禁用)
22
12
  this.target = null; // 图标传送目标元素(可以是 HTMLElement 或选择器字符串)
13
+ // 拖动相关状态
14
+ this.isDragging = false;
15
+ this.dragStarted = false; // 是否真正开始了拖拽
16
+ this.hasMoved = false; // 是否移动过
17
+ this.dragOffset = { x: 0, y: 0 }; // 拖动偏移量
18
+ this.lastTouchPosition = { x: 0, y: 0 }; // 最后触摸位置
19
+ this.touchStartTime = 0; // 触摸开始时间
20
+ this.clickThreshold = 15; // 点击阈值(像素)
21
+ // 事件处理器引用(用于清理)
22
+ this.onDragHandler = null;
23
+ this.stopDragHandler = null;
24
+ this.startDragHandler = null;
25
+ this.mouseLeaveHandler = null;
26
+ this.pointerLeaveHandler = null;
27
+ this.blurHandler = null;
28
+ this.dragTimeoutId = null; // 拖动超时定时器
23
29
  this.iconPosition = position || null;
24
30
  this.debug = debug;
25
31
  // 保存 target(可以是 HTMLElement 或字符串选择器)
@@ -120,8 +126,6 @@ class IconManager {
120
126
  });
121
127
  imgContainer.appendChild(iconImg);
122
128
  this.iconElement.appendChild(imgContainer);
123
- // 添加拖动和点击事件
124
- this.setupDragEvents();
125
129
  // 添加到目标元素(如果 target 是字符串,需要重新查找,因为可能在初始化时元素还不存在)
126
130
  const targetElement = this.getTargetElement();
127
131
  if (targetElement) {
@@ -134,6 +138,8 @@ class IconManager {
134
138
  console.warn('Target element not found, icon added to document.body');
135
139
  }
136
140
  }
141
+ // 设置拖动事件
142
+ this.setupDragEvents();
137
143
  if (this.debug) {
138
144
  console.log('CustomerSDK icon displayed');
139
145
  }
@@ -142,49 +148,36 @@ class IconManager {
142
148
  * 强制清理所有拖动事件监听器
143
149
  */
144
150
  forceCleanupDragEvents() {
145
- // 强制清理document上的所有事件监听器
146
- if (this.dragMoveHandler) {
147
- try {
148
- document.removeEventListener('mousemove', this.dragMoveHandler);
149
- document.removeEventListener('touchmove', this.dragMoveHandler);
150
- }
151
- catch (e) {
152
- if (this.debug) {
153
- console.warn('Error removing drag move listeners:', e);
154
- }
155
- }
156
- }
157
- if (this.dragEndHandler) {
158
- try {
159
- document.removeEventListener('mouseup', this.dragEndHandler);
160
- document.removeEventListener('touchend', this.dragEndHandler);
161
- }
162
- catch (e) {
163
- if (this.debug) {
164
- console.warn('Error removing drag end listeners:', e);
165
- }
166
- }
167
- }
168
- // 重置拖动状态
169
- this.isDragging = false;
170
- // 恢复图标样式
171
- if (this.iconElement) {
172
- this.iconElement.style.transition = 'transform 0.2s ease';
173
- this.iconElement.style.cursor = 'pointer';
174
- }
151
+ this.cleanupDragEvents();
175
152
  }
176
153
  /**
177
154
  * 隐藏悬浮图标
178
155
  */
179
156
  hide() {
180
- // 清理所有事件监听器
181
- this.forceCleanupDragEvents();
157
+ // 清理拖动事件
158
+ this.cleanupDragEvents();
182
159
  if (this.iconElement) {
160
+ // 在隐藏前保存当前位置(如果图标已经被拖动过)
161
+ const computedStyle = window.getComputedStyle(this.iconElement);
162
+ const left = computedStyle.left;
163
+ const top = computedStyle.top;
164
+ // 如果 left/top 是有效的像素值(不是 auto),保存到 iconPosition
165
+ if (left !== 'auto' && top !== 'auto' && left !== '' && top !== '') {
166
+ const leftValue = parseFloat(left);
167
+ const topValue = parseFloat(top);
168
+ if (!isNaN(leftValue) && !isNaN(topValue)) {
169
+ this.iconPosition = {
170
+ x: leftValue,
171
+ y: topValue
172
+ };
173
+ if (this.debug) {
174
+ console.log('Icon position saved before hide:', this.iconPosition);
175
+ }
176
+ }
177
+ }
183
178
  this.iconElement.remove();
184
179
  this.iconElement = null;
185
180
  // 注意:不清空 onClickCallback,以便再次显示时能继续使用
186
- this.dragMoveHandler = null;
187
- this.dragEndHandler = null;
188
181
  if (this.debug) {
189
182
  console.log('CustomerSDK icon hidden');
190
183
  }
@@ -215,10 +208,7 @@ class IconManager {
215
208
  : position.y;
216
209
  this.iconElement.style.bottom = 'auto';
217
210
  }
218
- // 保存当前位置用于拖动
219
- const rect = this.iconElement.getBoundingClientRect();
220
- this.iconStartX = rect.left;
221
- this.iconStartY = rect.top;
211
+ // 保存当前位置用于拖动(使用 data-x 和 data-y,由 interact.js 管理)
222
212
  }
223
213
  }
224
214
  /**
@@ -275,186 +265,310 @@ class IconManager {
275
265
  }
276
266
  }
277
267
  /**
278
- * 设置拖动和点击事件
268
+ * 设置拖动事件(使用原生事件处理,参考用户提供的代码)
279
269
  */
280
270
  setupDragEvents() {
281
271
  if (!this.iconElement)
282
272
  return;
283
273
  // 绑定事件处理器(用于后续清理)
284
- this.dragMoveHandler = this.handleDragMove.bind(this);
285
- this.dragEndHandler = this.handleDragEnd.bind(this);
286
- this.dragStartHandler = this.handleDragStart.bind(this);
287
- // 只在图标上监听开始事件(保存引用以便后续移除)
288
- this.iconElement.addEventListener('mousedown', this.dragStartHandler);
289
- this.touchStartHandler = this.handleDragStart.bind(this);
290
- this.iconElement.addEventListener('touchstart', this.touchStartHandler, { passive: false });
274
+ this.startDragHandler = this.startDrag.bind(this);
275
+ this.onDragHandler = this.onDrag.bind(this);
276
+ this.stopDragHandler = this.stopDrag.bind(this);
277
+ // 添加事件监听器
278
+ this.iconElement.addEventListener('mousedown', this.startDragHandler);
279
+ this.iconElement.addEventListener('touchstart', this.startDragHandler, { passive: false });
291
280
  }
292
281
  /**
293
282
  * 开始拖动
294
283
  */
295
- handleDragStart(e) {
296
- if (!this.iconElement)
284
+ startDrag(e) {
285
+ if (!this.iconElement || !this.isClickEnabled)
297
286
  return;
298
- // 只处理图标上的事件
299
- if (e.target !== this.iconElement && !this.iconElement.contains(e.target)) {
287
+ // 检查事件目标:如果是 iframe 相关元素,不处理
288
+ const target = e.target;
289
+ if (target && (target.tagName === 'IFRAME' ||
290
+ target.closest('iframe') ||
291
+ target.closest('.customer-sdk-container') ||
292
+ target.closest('.customer-sdk-overlay'))) {
300
293
  return;
301
294
  }
302
295
  e.preventDefault();
303
296
  e.stopPropagation();
297
+ // 重置状态
298
+ this.hasMoved = false;
299
+ this.dragStarted = false;
300
+ this.isDragging = false;
301
+ // 记录开始时间和位置
302
+ this.touchStartTime = Date.now();
304
303
  const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
305
304
  const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
306
- this.isDragging = false;
307
- this.dragStartX = clientX;
308
- this.dragStartY = clientY;
309
- // 获取图标当前位置
310
- const rect = this.iconElement.getBoundingClientRect();
311
- this.iconStartX = rect.left;
312
- this.iconStartY = rect.top;
313
- // 注意:不要在这里立即移除 transition 和设置 cursor
314
- // 只有在真正开始拖动时才修改样式,避免点击时图标位置跳动
315
- // 只在真正开始拖动时添加document事件监听
316
- // 先添加一个临时的move监听器来检测是否真的在拖动
317
- const checkDrag = (moveEvent) => {
318
- // 检测事件目标:如果事件发生在iframe或其他元素上,停止检测拖动
319
- const target = moveEvent.target;
320
- if (target && target !== this.iconElement && !this.iconElement?.contains(target)) {
321
- // 检查是否是iframe相关元素
322
- const isIframeElement = target.tagName === 'IFRAME' ||
323
- target.closest('iframe') ||
324
- target.closest('.customer-sdk-container') ||
325
- target.closest('.customer-sdk-overlay');
326
- if (isIframeElement) {
327
- // 如果事件发生在iframe相关元素上,停止检测并清理监听器
328
- if (this.checkDragHandler) {
329
- if ('touches' in moveEvent) {
330
- document.removeEventListener('touchmove', this.checkDragHandler);
331
- }
332
- else {
333
- document.removeEventListener('mousemove', this.checkDragHandler);
334
- }
335
- this.checkDragHandler = null;
336
- }
337
- return;
305
+ this.lastTouchPosition.x = clientX;
306
+ this.lastTouchPosition.y = clientY;
307
+ try {
308
+ const iconRect = this.iconElement.getBoundingClientRect();
309
+ const container = this.getTargetElement();
310
+ if (!container) {
311
+ if (this.debug) {
312
+ console.warn('Container not found');
338
313
  }
314
+ return;
339
315
  }
340
- const moveX = 'touches' in moveEvent ? moveEvent.touches[0].clientX : moveEvent.clientX;
341
- const moveY = 'touches' in moveEvent ? moveEvent.touches[0].clientY : moveEvent.clientY;
342
- const deltaX = moveX - this.dragStartX;
343
- const deltaY = moveY - this.dragStartY;
344
- // 如果移动距离超过5px,开始拖动
345
- if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) {
346
- this.isDragging = true;
347
- // 只有在真正开始拖动时才移除 transition 和设置 cursor
348
- if (this.iconElement) {
349
- this.iconElement.style.transition = 'none';
350
- this.iconElement.style.cursor = 'grabbing';
351
- }
352
- // 移除临时监听器
353
- if (this.checkDragHandler) {
354
- if ('touches' in moveEvent) {
355
- document.removeEventListener('touchmove', this.checkDragHandler);
356
- }
357
- else {
358
- document.removeEventListener('mousemove', this.checkDragHandler);
316
+ // 计算拖动偏移量
317
+ this.dragOffset.x = clientX - iconRect.left;
318
+ this.dragOffset.y = clientY - iconRect.top;
319
+ // 注意:不在这里转换位置,只在真正开始拖动时才转换(在 onDrag 中)
320
+ // 添加 document 事件监听器
321
+ if (this.onDragHandler) {
322
+ document.addEventListener('mousemove', this.onDragHandler);
323
+ document.addEventListener('touchmove', this.onDragHandler, { passive: false });
324
+ }
325
+ if (this.stopDragHandler) {
326
+ document.addEventListener('mouseup', this.stopDragHandler);
327
+ document.addEventListener('touchend', this.stopDragHandler);
328
+ }
329
+ // 添加处理 iframe 上事件丢失的机制
330
+ // 1. 监听 mouseleave 和 pointerleave 事件(鼠标离开窗口时停止拖动)
331
+ this.mouseLeaveHandler = (e) => {
332
+ // 只有当鼠标真正离开窗口时才停止拖动
333
+ if (!e.relatedTarget && (e.clientY <= 0 || e.clientX <= 0 ||
334
+ e.clientX >= window.innerWidth || e.clientY >= window.innerHeight)) {
335
+ if (this.debug) {
336
+ console.log('Mouse left window, stopping drag');
359
337
  }
360
- this.checkDragHandler = null;
338
+ this.stopDrag();
361
339
  }
362
- // 添加正式的事件监听器
363
- if (this.dragMoveHandler && this.dragEndHandler) {
364
- if ('touches' in moveEvent) {
365
- document.addEventListener('touchmove', this.dragMoveHandler, { passive: false });
366
- document.addEventListener('touchend', this.dragEndHandler);
340
+ };
341
+ this.pointerLeaveHandler = (e) => {
342
+ // 只有当指针真正离开窗口时才停止拖动
343
+ if (!e.relatedTarget && (e.clientY <= 0 || e.clientX <= 0 ||
344
+ e.clientX >= window.innerWidth || e.clientY >= window.innerHeight)) {
345
+ if (this.debug) {
346
+ console.log('Pointer left window, stopping drag');
367
347
  }
368
- else {
369
- document.addEventListener('mousemove', this.dragMoveHandler);
370
- document.addEventListener('mouseup', this.dragEndHandler);
348
+ this.stopDrag();
349
+ }
350
+ };
351
+ document.addEventListener('mouseleave', this.mouseLeaveHandler);
352
+ document.addEventListener('pointerleave', this.pointerLeaveHandler);
353
+ // 2. 监听 blur 事件(窗口失去焦点时停止拖动)
354
+ this.blurHandler = () => {
355
+ if (this.debug) {
356
+ console.log('Window lost focus, stopping drag');
357
+ }
358
+ this.stopDrag();
359
+ };
360
+ window.addEventListener('blur', this.blurHandler);
361
+ // 3. 添加超时机制(如果一段时间没有收到 mousemove 事件,自动停止拖动)
362
+ // 这可以处理鼠标移动到 iframe 上的情况
363
+ this.dragTimeoutId = window.setTimeout(() => {
364
+ if (this.isDragging) {
365
+ if (this.debug) {
366
+ console.log('Drag timeout, stopping drag (likely mouse moved over iframe)');
371
367
  }
368
+ this.stopDrag();
372
369
  }
370
+ }, 500); // 500ms 没有移动事件,自动停止拖动(处理鼠标移动到 iframe 上的情况)
371
+ if (this.debug) {
372
+ console.log('Drag start');
373
373
  }
374
- };
375
- // 保存 checkDrag 引用,以便后续清理
376
- this.checkDragHandler = checkDrag;
377
- // 添加临时检测监听器
378
- if ('touches' in e) {
379
- document.addEventListener('touchmove', this.checkDragHandler, { passive: false });
380
- document.addEventListener('touchend', this.dragEndHandler);
381
374
  }
382
- else {
383
- document.addEventListener('mousemove', this.checkDragHandler);
384
- document.addEventListener('mouseup', this.dragEndHandler);
375
+ catch (error) {
376
+ if (this.debug) {
377
+ console.error('Error in startDrag:', error);
378
+ }
385
379
  }
386
380
  }
387
381
  /**
388
382
  * 拖动中
389
383
  */
390
- handleDragMove(e) {
391
- if (!this.iconElement || !this.isDragging)
384
+ onDrag(e) {
385
+ if (!this.iconElement)
392
386
  return;
387
+ e.preventDefault();
388
+ // 重置超时定时器(每次移动都重置,确保只有真正停止移动时才触发超时)
389
+ if (this.dragTimeoutId !== null) {
390
+ window.clearTimeout(this.dragTimeoutId);
391
+ this.dragTimeoutId = window.setTimeout(() => {
392
+ if (this.isDragging) {
393
+ if (this.debug) {
394
+ console.log('Drag timeout, stopping drag (likely mouse moved over iframe)');
395
+ }
396
+ this.stopDrag();
397
+ }
398
+ }, 500); // 500ms 没有移动事件,自动停止拖动
399
+ }
393
400
  const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
394
401
  const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
395
- // 注意:拖动过程中不检测 iframe 元素,因为用户可能只是想将图标拖动到 iframe 附近或上方
396
- // 只有在拖动开始时(checkDrag 阶段)才检测 iframe,防止误判为拖动
397
- // 一旦开始拖动,就应该允许拖动到任何位置,包括 iframe 上方
398
- // 计算从拖动开始位置到当前位置的距离
399
- const deltaX = clientX - this.dragStartX;
400
- const deltaY = clientY - this.dragStartY;
401
- // 检测鼠标/手指是否还在图标附近(允许一定的容差范围,比如图标大小的3倍)
402
- const rect = this.iconElement.getBoundingClientRect();
403
- const iconCenterX = rect.left + rect.width / 2;
404
- const iconCenterY = rect.top + rect.height / 2;
405
- const distanceFromIcon = Math.sqrt(Math.pow(clientX - iconCenterX, 2) + Math.pow(clientY - iconCenterY, 2));
406
- const maxDragDistance = Math.max(rect.width, rect.height) * 3; // 允许拖动距离为图标大小的3倍
407
- // 如果鼠标/手指移出图标太远,停止拖动
408
- if (distanceFromIcon > maxDragDistance) {
409
- // 立即结束拖动,清理事件监听器
410
- this.handleDragEnd();
402
+ // 检查是否有足够的移动距离
403
+ const deltaX = Math.abs(clientX - this.lastTouchPosition.x);
404
+ const deltaY = Math.abs(clientY - this.lastTouchPosition.y);
405
+ const totalMovement = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
406
+ if (totalMovement > this.clickThreshold) {
407
+ this.hasMoved = true;
408
+ if (!this.dragStarted) {
409
+ // 开始真正的拖拽
410
+ this.dragStarted = true;
411
+ this.isDragging = true;
412
+ // 如果是第一次拖动,需要转换位置(从 right/bottom left/top)
413
+ const computedStyle = window.getComputedStyle(this.iconElement);
414
+ if (computedStyle.right !== 'auto' || computedStyle.bottom !== 'auto') {
415
+ // 获取当前实际位置
416
+ const currentRect = this.iconElement.getBoundingClientRect();
417
+ const container = this.getTargetElement();
418
+ if (container) {
419
+ const containerRect = container.getBoundingClientRect();
420
+ // 计算相对于容器的位置
421
+ const relativeX = currentRect.left - containerRect.left;
422
+ const relativeY = currentRect.top - containerRect.top;
423
+ // 使用相对位置
424
+ this.iconElement.style.left = `${relativeX}px`;
425
+ this.iconElement.style.top = `${relativeY}px`;
426
+ this.iconElement.style.right = 'auto';
427
+ this.iconElement.style.bottom = 'auto';
428
+ // 重新计算偏移量(相对于容器)
429
+ this.dragOffset.x = clientX - currentRect.left;
430
+ this.dragOffset.y = clientY - currentRect.top;
431
+ }
432
+ }
433
+ // 移除过渡动画,使拖动更流畅
434
+ this.iconElement.style.transition = 'none';
435
+ this.iconElement.style.cursor = 'grabbing';
436
+ if (this.debug) {
437
+ console.log('Drag started (moved > threshold)');
438
+ }
439
+ }
440
+ }
441
+ if (!this.isDragging) {
411
442
  return;
412
443
  }
413
- e.preventDefault();
414
- e.stopPropagation();
415
- // 计算新位置
416
- const newX = this.iconStartX + deltaX;
417
- const newY = this.iconStartY + deltaY;
418
- // 限制在视口内
419
- const maxX = window.innerWidth - this.iconElement.offsetWidth;
420
- const maxY = window.innerHeight - this.iconElement.offsetHeight;
421
- const clampedX = Math.max(0, Math.min(newX, maxX));
422
- const clampedY = Math.max(0, Math.min(newY, maxY));
423
- // 更新位置
424
- this.iconElement.style.left = `${clampedX}px`;
425
- this.iconElement.style.top = `${clampedY}px`;
426
- this.iconElement.style.right = 'auto';
427
- this.iconElement.style.bottom = 'auto';
444
+ try {
445
+ const container = this.getTargetElement();
446
+ if (!container) {
447
+ return;
448
+ }
449
+ const containerRect = container.getBoundingClientRect();
450
+ // 计算新位置
451
+ let newX = clientX - this.dragOffset.x - containerRect.left;
452
+ let newY = clientY - this.dragOffset.y - containerRect.top;
453
+ // 限制在容器内
454
+ const iconWidth = this.iconElement.offsetWidth;
455
+ const iconHeight = this.iconElement.offsetHeight;
456
+ if (container === document.body) {
457
+ // 限制在视口内
458
+ newX = Math.max(0, Math.min(newX, window.innerWidth - iconWidth));
459
+ newY = Math.max(0, Math.min(newY, window.innerHeight - iconHeight));
460
+ }
461
+ else {
462
+ // 限制在容器内
463
+ const containerWidth = containerRect.width;
464
+ const containerHeight = containerRect.height;
465
+ newX = Math.max(0, Math.min(newX, containerWidth - iconWidth));
466
+ newY = Math.max(0, Math.min(newY, containerHeight - iconHeight));
467
+ }
468
+ // 更新位置
469
+ this.iconElement.style.left = `${newX}px`;
470
+ this.iconElement.style.top = `${newY}px`;
471
+ this.iconElement.style.right = 'auto';
472
+ this.iconElement.style.bottom = 'auto';
473
+ // 更新最后位置
474
+ this.lastTouchPosition.x = clientX;
475
+ this.lastTouchPosition.y = clientY;
476
+ }
477
+ catch (error) {
478
+ if (this.debug) {
479
+ console.error('Error in onDrag:', error);
480
+ }
481
+ }
428
482
  }
429
483
  /**
430
- * 结束拖动
484
+ * 停止拖动
431
485
  */
432
- handleDragEnd(_e) {
486
+ stopDrag(_e) {
487
+ this.cleanupDragEvents();
433
488
  if (!this.iconElement)
434
489
  return;
435
- // 清理document上的所有事件监听器(包括临时检测监听器)
436
- if (this.checkDragHandler) {
437
- document.removeEventListener('mousemove', this.checkDragHandler);
438
- document.removeEventListener('touchmove', this.checkDragHandler);
439
- this.checkDragHandler = null;
440
- }
441
- if (this.dragMoveHandler) {
442
- document.removeEventListener('mousemove', this.dragMoveHandler);
443
- document.removeEventListener('touchmove', this.dragMoveHandler);
444
- }
445
- if (this.dragEndHandler) {
446
- document.removeEventListener('mouseup', this.dragEndHandler);
447
- document.removeEventListener('touchend', this.dragEndHandler);
448
- }
449
- // 恢复样式(如果之前被修改过)
450
- // 注意:如果只是点击(没有拖动),这些样式可能没有被修改,但恢复操作是安全的
490
+ // 恢复样式
451
491
  this.iconElement.style.transition = 'transform 0.2s ease';
452
492
  this.iconElement.style.cursor = 'pointer';
453
- // 如果只是点击(没有拖动),触发点击事件
454
- if (!this.isDragging) {
455
- this.handleClick();
493
+ // 检查是否是点击
494
+ const touchDuration = Date.now() - this.touchStartTime;
495
+ const isValidClick = !this.hasMoved && touchDuration < 1000 && !this.dragStarted;
496
+ if (this.debug) {
497
+ console.log('Drag end', {
498
+ hasMoved: this.hasMoved,
499
+ dragStarted: this.dragStarted,
500
+ touchDuration,
501
+ isValidClick
502
+ });
456
503
  }
504
+ // 先保存点击状态,然后重置拖动状态
505
+ const wasClick = isValidClick;
506
+ // 如果真正拖动过,保存当前位置到 iconPosition
507
+ if (this.dragStarted && this.isDragging && this.iconElement) {
508
+ const computedStyle = window.getComputedStyle(this.iconElement);
509
+ const left = computedStyle.left;
510
+ const top = computedStyle.top;
511
+ // 如果 left/top 是有效的像素值,保存到 iconPosition
512
+ if (left !== 'auto' && top !== 'auto') {
513
+ const leftValue = parseFloat(left);
514
+ const topValue = parseFloat(top);
515
+ if (!isNaN(leftValue) && !isNaN(topValue)) {
516
+ this.iconPosition = {
517
+ x: leftValue,
518
+ y: topValue
519
+ };
520
+ if (this.debug) {
521
+ console.log('Icon position saved:', this.iconPosition);
522
+ }
523
+ }
524
+ }
525
+ }
526
+ // 重置状态
527
+ this.hasMoved = false;
457
528
  this.isDragging = false;
529
+ this.dragStarted = false;
530
+ if (wasClick) {
531
+ // 是点击,触发点击事件
532
+ // 延迟触发,确保拖动状态已完全重置,并且没有新的拖动开始
533
+ setTimeout(() => {
534
+ // 再次检查,确保没有在延迟期间开始拖动
535
+ if (this.isClickEnabled && !this.dragStarted && !this.hasMoved && !this.isDragging) {
536
+ this.handleClick();
537
+ }
538
+ }, 100); // 增加延迟到 100ms,确保拖动状态完全重置
539
+ }
540
+ }
541
+ /**
542
+ * 清理拖动事件
543
+ */
544
+ cleanupDragEvents() {
545
+ // 清理拖动事件
546
+ if (this.onDragHandler) {
547
+ document.removeEventListener('mousemove', this.onDragHandler);
548
+ document.removeEventListener('touchmove', this.onDragHandler);
549
+ }
550
+ if (this.stopDragHandler) {
551
+ document.removeEventListener('mouseup', this.stopDragHandler);
552
+ document.removeEventListener('touchend', this.stopDragHandler);
553
+ }
554
+ // 清理处理 iframe 事件丢失的监听器
555
+ if (this.mouseLeaveHandler) {
556
+ document.removeEventListener('mouseleave', this.mouseLeaveHandler);
557
+ this.mouseLeaveHandler = null;
558
+ }
559
+ if (this.pointerLeaveHandler) {
560
+ document.removeEventListener('pointerleave', this.pointerLeaveHandler);
561
+ this.pointerLeaveHandler = null;
562
+ }
563
+ if (this.blurHandler) {
564
+ window.removeEventListener('blur', this.blurHandler);
565
+ this.blurHandler = null;
566
+ }
567
+ // 清理超时定时器
568
+ if (this.dragTimeoutId !== null) {
569
+ window.clearTimeout(this.dragTimeoutId);
570
+ this.dragTimeoutId = null;
571
+ }
458
572
  }
459
573
  /**
460
574
  * 处理点击事件
@@ -474,14 +588,12 @@ class IconManager {
474
588
  disableClick() {
475
589
  this.isClickEnabled = false;
476
590
  // 移除拖动事件监听器
591
+ if (this.iconElement && this.startDragHandler) {
592
+ this.iconElement.removeEventListener('mousedown', this.startDragHandler);
593
+ this.iconElement.removeEventListener('touchstart', this.startDragHandler);
594
+ }
595
+ // 禁用所有鼠标事件(包括点击和拖拽)
477
596
  if (this.iconElement) {
478
- if (this.dragStartHandler) {
479
- this.iconElement.removeEventListener('mousedown', this.dragStartHandler);
480
- }
481
- if (this.touchStartHandler) {
482
- this.iconElement.removeEventListener('touchstart', this.touchStartHandler);
483
- }
484
- // 禁用所有鼠标事件(包括点击和拖拽)
485
597
  this.iconElement.style.pointerEvents = 'none';
486
598
  this.iconElement.style.cursor = 'default';
487
599
  }
@@ -494,14 +606,16 @@ class IconManager {
494
606
  enableClick() {
495
607
  this.isClickEnabled = true;
496
608
  // 重新添加拖动事件监听器
609
+ if (this.iconElement && this.startDragHandler) {
610
+ this.iconElement.addEventListener('mousedown', this.startDragHandler);
611
+ this.iconElement.addEventListener('touchstart', this.startDragHandler, { passive: false });
612
+ }
613
+ else if (this.iconElement) {
614
+ // 如果事件处理器不存在,重新设置
615
+ this.setupDragEvents();
616
+ }
617
+ // 恢复鼠标事件
497
618
  if (this.iconElement) {
498
- if (this.dragStartHandler) {
499
- this.iconElement.addEventListener('mousedown', this.dragStartHandler);
500
- }
501
- if (this.touchStartHandler) {
502
- this.iconElement.addEventListener('touchstart', this.touchStartHandler, { passive: false });
503
- }
504
- // 恢复鼠标事件
505
619
  this.iconElement.style.pointerEvents = 'auto';
506
620
  this.iconElement.style.cursor = 'pointer';
507
621
  }
@@ -605,7 +719,8 @@ class IframeManager {
605
719
  this.isOpen = false;
606
720
  this.isCreated = false;
607
721
  this.debug = false; // debug 模式标志
608
- this.messageHandler = null; // 消息监听器引用,用于清理
722
+ this.targetElement = null; // 目标元素(用于自适应宽度)
723
+ this.messageHandler = null; // 消息监听器引用(用于清理)
609
724
  this.config = {
610
725
  src: '',
611
726
  mode: 'auto', // 默认自动检测设备类型
@@ -615,7 +730,8 @@ class IframeManager {
615
730
  ...config
616
731
  };
617
732
  this.debug = config.debug ?? false;
618
- this.setupMessageListener();
733
+ // 不在构造函数中添加消息监听器,延迟到 init() 中添加
734
+ // 这样可以避免创建多个实例时产生多个监听器
619
735
  }
620
736
  /**
621
737
  * 初始化iframe(隐藏状态)
@@ -623,6 +739,8 @@ class IframeManager {
623
739
  */
624
740
  async init() {
625
741
  try {
742
+ // 设置消息监听器(在 init() 中而不是构造函数中,避免多次创建实例时产生多个监听器)
743
+ this.setupMessageListener();
626
744
  // 关键修复:在初始化前,先清理页面上所有旧的容器元素
627
745
  // 防止切换模式或多次初始化时产生重复的元素
628
746
  this.cleanupOrphanedElements();
@@ -637,6 +755,11 @@ class IframeManager {
637
755
  catch (error) {
638
756
  // 错误始终输出
639
757
  console.error('Failed to initialize iframe:', error);
758
+ // 如果初始化失败,清理消息监听器
759
+ if (this.messageHandler) {
760
+ window.removeEventListener('message', this.messageHandler);
761
+ this.messageHandler = null;
762
+ }
640
763
  throw error;
641
764
  }
642
765
  }
@@ -728,9 +851,6 @@ class IframeManager {
728
851
  if (this.messageHandler) {
729
852
  window.removeEventListener('message', this.messageHandler);
730
853
  this.messageHandler = null;
731
- if (this.debug) {
732
- console.log('Message listener removed');
733
- }
734
854
  }
735
855
  // 移除容器
736
856
  if (this.containerElement) {
@@ -814,13 +934,44 @@ class IframeManager {
814
934
  'allow-pointer-lock', // 允许指针锁定
815
935
  'allow-storage-access-by-user-activation' // 允许用户激活的存储访问
816
936
  ].join(' '));
937
+ // 获取目标元素(如果提供了 target)
938
+ if (this.config.target) {
939
+ this.targetElement = this.getTargetElement(this.config.target);
940
+ }
817
941
  // 根据设备类型设置模式
818
942
  const actualMode = this.getActualMode();
819
943
  const isPC = actualMode === 'popup';
820
944
  this.iframeElement.scrolling = 'auto'; // PC和移动端都显示滚动条
821
- // PC模式:使用配置的宽度,高度100%;移动端:100%宽度和高度
822
- const containerStyles = isPC ? {
823
- // PC模式:配置的宽度,高度100%
945
+ // 判断是否使用 target 的自适应宽度
946
+ const useTargetWidth = !!this.targetElement && this.targetElement !== document.body;
947
+ // PC模式:如果传了 target,使用 target 的宽度(自适应);否则使用配置的宽度
948
+ // 移动端:如果传了 target,使用 target 的宽度(自适应);否则全屏
949
+ const containerStyles = useTargetWidth ? {
950
+ // 使用 target 的宽度(自适应),PC 和移动端都适用
951
+ // 宽度 100% 自适应 target 的实际宽度(target 本身可能有 max-width 限制)
952
+ width: '100%',
953
+ height: '100%',
954
+ maxWidth: '100%', // 不超过 target 的宽度
955
+ maxHeight: '100%',
956
+ backgroundColor: '#ffffff',
957
+ borderRadius: isPC ? '0' : '12px 12px 0 0',
958
+ boxShadow: isPC ? 'none' : '0 -4px 16px rgba(0, 0, 0, 0.25)',
959
+ border: 'none',
960
+ position: 'absolute', // 相对于 target 元素定位
961
+ zIndex: '999999',
962
+ // 占满 target 元素
963
+ top: '0',
964
+ left: '0',
965
+ bottom: '0',
966
+ right: '0',
967
+ transform: 'none',
968
+ overflow: 'hidden',
969
+ // 初始隐藏的关键样式
970
+ visibility: 'hidden',
971
+ opacity: '0',
972
+ display: 'none'
973
+ } : (isPC ? {
974
+ // PC模式:没有 target,使用配置的宽度,高度100%
824
975
  width: `${this.config.width || 450}px`,
825
976
  height: '100%',
826
977
  maxWidth: '90vw',
@@ -843,7 +994,7 @@ class IframeManager {
843
994
  opacity: '0',
844
995
  display: 'none'
845
996
  } : {
846
- // 移动端全屏模式(强制 100% 宽度和高度)
997
+ // 移动端全屏模式(没有 target,强制 100% 宽度和高度)
847
998
  width: '100%',
848
999
  height: '100%',
849
1000
  maxWidth: '100%',
@@ -865,7 +1016,7 @@ class IframeManager {
865
1016
  visibility: 'hidden',
866
1017
  opacity: '0',
867
1018
  display: 'none'
868
- };
1019
+ });
869
1020
  Object.assign(this.containerElement.style, containerStyles);
870
1021
  // iframe填充整个容器
871
1022
  const iframeStyles = {
@@ -884,8 +1035,30 @@ class IframeManager {
884
1035
  this.injectMobileStyles();
885
1036
  }
886
1037
  });
887
- // 关键优化:容器直接添加到body,避免后续移动DOM导致iframe重新加载
888
- document.body.appendChild(this.containerElement);
1038
+ // 添加到目标元素或 body
1039
+ if (this.targetElement && this.targetElement !== document.body) {
1040
+ // 如果提供了 target,添加到 target 元素内
1041
+ // 需要设置 target 为相对定位,以便 iframe 可以相对于它定位
1042
+ const targetStyle = window.getComputedStyle(this.targetElement);
1043
+ if (targetStyle.position === 'static') {
1044
+ // 如果 target 是静态定位,设置为相对定位
1045
+ this.targetElement.style.position = 'relative';
1046
+ if (this.debug) {
1047
+ console.log('Set target element position to relative for iframe container');
1048
+ }
1049
+ }
1050
+ this.targetElement.appendChild(this.containerElement);
1051
+ if (this.debug) {
1052
+ console.log('Iframe container added to target element');
1053
+ }
1054
+ }
1055
+ else {
1056
+ // 没有 target 或 target 是 body,添加到 body
1057
+ document.body.appendChild(this.containerElement);
1058
+ if (this.debug) {
1059
+ console.log('Iframe container added to document.body');
1060
+ }
1061
+ }
889
1062
  if (this.debug) {
890
1063
  console.log('CustomerSDK container created (hidden, ready for SSE)');
891
1064
  }
@@ -1006,12 +1179,9 @@ class IframeManager {
1006
1179
  * 设置消息监听
1007
1180
  */
1008
1181
  setupMessageListener() {
1009
- // 如果已存在监听器,先移除旧的(防止重复添加)
1182
+ // 如果已存在,先移除旧的监听器(防止重复添加)
1010
1183
  if (this.messageHandler) {
1011
1184
  window.removeEventListener('message', this.messageHandler);
1012
- if (this.debug) {
1013
- console.log('Removed old message listener');
1014
- }
1015
1185
  }
1016
1186
  // 创建新的消息处理器并保存引用
1017
1187
  this.messageHandler = (event) => {
@@ -1021,9 +1191,6 @@ class IframeManager {
1021
1191
  }
1022
1192
  };
1023
1193
  window.addEventListener('message', this.messageHandler, false);
1024
- if (this.debug) {
1025
- console.log('Message listener setup completed');
1026
- }
1027
1194
  }
1028
1195
  /**
1029
1196
  * 处理来自iframe的消息
@@ -1093,6 +1260,35 @@ class IframeManager {
1093
1260
  this.containerElement.style.height = `${height}px`;
1094
1261
  }
1095
1262
  }
1263
+ /**
1264
+ * 获取目标元素(支持字符串选择器或 HTMLElement)
1265
+ */
1266
+ getTargetElement(target) {
1267
+ if (typeof target === 'string') {
1268
+ const element = document.querySelector(target);
1269
+ if (element) {
1270
+ return element;
1271
+ }
1272
+ else {
1273
+ if (this.debug) {
1274
+ console.warn(`Target element not found: ${target}, falling back to document.body`);
1275
+ }
1276
+ return document.body;
1277
+ }
1278
+ }
1279
+ if (target instanceof HTMLElement) {
1280
+ if (document.body.contains(target)) {
1281
+ return target;
1282
+ }
1283
+ else {
1284
+ if (this.debug) {
1285
+ console.warn('Target element no longer in DOM, falling back to document.body');
1286
+ }
1287
+ return document.body;
1288
+ }
1289
+ }
1290
+ return document.body;
1291
+ }
1096
1292
  }
1097
1293
 
1098
1294
  function changeJpegDpi(uint8Array, dpi) {
@@ -20807,6 +21003,7 @@ class CustomerServiceSDK {
20807
21003
  this.screenshotManager = null;
20808
21004
  this.config = null;
20809
21005
  this.isInitialized = false;
21006
+ this.isInitializing = false; // 初始化锁,防止并发初始化
20810
21007
  this.initResult = null; // 保存初始化结果
20811
21008
  this.debug = false; // debug 模式标志
20812
21009
  this.lastIconConfig = null; // 保存上一次的图标配置
@@ -20819,6 +21016,10 @@ class CustomerServiceSDK {
20819
21016
  * @returns 返回初始化信息(包含设备ID等)
20820
21017
  */
20821
21018
  async init(config, options, forceReinit = false) {
21019
+ // 防止并发初始化
21020
+ if (this.isInitializing) {
21021
+ throw new Error('SDK is already initializing. Please wait for the current initialization to complete.');
21022
+ }
20822
21023
  // 如果已经初始化且不强制重新初始化,返回之前保存的初始化信息
20823
21024
  if (this.isInitialized && !forceReinit) {
20824
21025
  if (this.debug) {
@@ -20829,6 +21030,8 @@ class CustomerServiceSDK {
20829
21030
  }
20830
21031
  throw new Error('SDK already initialized but cannot retrieve initialization info');
20831
21032
  }
21033
+ // 设置初始化锁
21034
+ this.isInitializing = true;
20832
21035
  // 如果需要强制重新初始化,先清理旧资源
20833
21036
  if (this.isInitialized && forceReinit) {
20834
21037
  if (this.debug) {
@@ -20875,6 +21078,7 @@ class CustomerServiceSDK {
20875
21078
  // 配置变化了,需要重新创建图标管理器
20876
21079
  if (this.iconManager) {
20877
21080
  this.iconManager.hide();
21081
+ this.iconManager = null; // 先清空引用,避免引用混乱
20878
21082
  if (this.debug) {
20879
21083
  console.log('Icon config changed, recreating icon manager');
20880
21084
  }
@@ -20902,11 +21106,14 @@ class CustomerServiceSDK {
20902
21106
  this.lastIconConfig = { position: iconPosition, target: iconTarget };
20903
21107
  }
20904
21108
  // 创建iframe管理器(自动检测设备类型)
21109
+ // 如果提供了 target,iframe 会自适应 target 的宽度(PC 和移动端都适用)
21110
+ // 如果没有 target,PC 模式使用默认宽度,移动端全屏
20905
21111
  this.iframeManager = new IframeManager({
20906
21112
  src: iframeUrl,
20907
21113
  mode: 'auto', // 自动根据设备类型选择模式(PC弹窗,移动端全屏)
20908
- width: options?.width || 450, // PC模式宽度(像素,默认450px),移动端不使用
20909
- height: options?.height || 600, // PC模式高度(像素),移动端不使用(强制全屏)
21114
+ width: options?.width || 450, // PC模式宽度(像素,默认450px),仅在未提供 target 时使用
21115
+ height: options?.height || 600, // PC模式高度(像素),仅在未提供 target 时使用
21116
+ target: options?.target, // 目标元素(如果提供,iframe 会自适应其宽度)
20910
21117
  allowClose: true,
20911
21118
  debug: this.debug, // 传递 debug 标志
20912
21119
  onMessage: (messageType, _data) => {
@@ -20966,8 +21173,33 @@ class CustomerServiceSDK {
20966
21173
  catch (error) {
20967
21174
  // 错误始终输出
20968
21175
  console.error('Failed to initialize CustomerSDK:', error);
21176
+ // 清理已创建的资源(防止资源泄漏)
21177
+ try {
21178
+ if (this.iconManager) {
21179
+ this.iconManager.hide();
21180
+ this.iconManager = null;
21181
+ }
21182
+ if (this.iframeManager) {
21183
+ this.iframeManager.destroy();
21184
+ this.iframeManager = null;
21185
+ }
21186
+ if (this.screenshotManager) {
21187
+ this.screenshotManager.destroy();
21188
+ this.screenshotManager = null;
21189
+ }
21190
+ this.isInitialized = false;
21191
+ this.initResult = null;
21192
+ }
21193
+ catch (cleanupError) {
21194
+ // 清理过程中的错误不应该影响原始错误的抛出
21195
+ console.error('Error during cleanup after initialization failure:', cleanupError);
21196
+ }
20969
21197
  throw error;
20970
21198
  }
21199
+ finally {
21200
+ // 释放初始化锁
21201
+ this.isInitializing = false;
21202
+ }
20971
21203
  }
20972
21204
  /**
20973
21205
  * 显示/隐藏悬浮图标
@@ -21141,7 +21373,7 @@ class CustomerServiceSDK {
21141
21373
  */
21142
21374
  destroy() {
21143
21375
  this.iconManager?.hide();
21144
- this.iframeManager?.close();
21376
+ this.iframeManager?.destroy(); // 使用 destroy 而不是 close,确保完全清理
21145
21377
  this.screenshotManager?.destroy();
21146
21378
  this.iconManager = null;
21147
21379
  this.iframeManager = null;