customer-chat-sdk 1.0.38 → 1.0.40

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.
@@ -2468,12 +2468,24 @@ async function domToDataUrl(node, options) {
2468
2468
  return dataUrl;
2469
2469
  }
2470
2470
 
2471
+ async function domToJpeg(node, options) {
2472
+ return domToDataUrl(
2473
+ await orCreateContext(node, { ...options, type: "image/jpeg" })
2474
+ );
2475
+ }
2476
+
2471
2477
  async function domToPng(node, options) {
2472
2478
  return domToDataUrl(
2473
2479
  await orCreateContext(node, { ...options, type: "image/png" })
2474
2480
  );
2475
2481
  }
2476
2482
 
2483
+ async function domToWebp(node, options) {
2484
+ return domToDataUrl(
2485
+ await orCreateContext(node, { ...options, type: "image/webp" })
2486
+ );
2487
+ }
2488
+
2477
2489
  /*
2478
2490
  * snapdom
2479
2491
  * v.1.9.14
@@ -14307,6 +14319,8 @@ class ScreenshotManager {
14307
14319
  this.screenshotTimer = null;
14308
14320
  // modern-screenshot Worker 上下文(用于复用,避免频繁创建和销毁)
14309
14321
  this.screenshotContext = null;
14322
+ // 截图锁,防止并发截图
14323
+ this.isScreenshotInProgress = false;
14310
14324
  // PostMessage 监听器
14311
14325
  this.messageHandler = null;
14312
14326
  // 动态轮询间隔(由 iframe 消息控制)
@@ -14332,7 +14346,7 @@ class ScreenshotManager {
14332
14346
  this.globalRejectionHandler = null;
14333
14347
  this.targetElement = targetElement;
14334
14348
  this.options = {
14335
- interval: options.interval ?? 5000,
14349
+ interval: options.interval ?? 1000,
14336
14350
  quality: options.quality ?? 0.3, // 降低默认质量:0.4 -> 0.3,减少 base64 大小
14337
14351
  scale: options.scale ?? 1,
14338
14352
  maxHistory: options.maxHistory ?? 10,
@@ -14941,16 +14955,22 @@ class ScreenshotManager {
14941
14955
  console.log('📸 使用 html2canvas 引擎截图...');
14942
14956
  }
14943
14957
  try {
14958
+ // 检测 iOS 设备
14959
+ const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);
14944
14960
  // html2canvas 需要确保样式完全加载,额外等待
14945
- // 等待所有样式表加载完成
14961
+ // iOS 设备需要更长的等待时间,因为样式表加载和处理方式不同
14946
14962
  await this.waitForAllStylesLoaded();
14947
14963
  // 等待字体加载完成
14948
14964
  await this.waitForFonts();
14965
+ // iOS 设备需要额外的等待时间,确保样式完全应用
14966
+ if (isIOS) {
14967
+ await new Promise(resolve => setTimeout(resolve, 300)); // iOS 额外等待 300ms
14968
+ }
14949
14969
  // 等待 DOM 完全渲染
14950
14970
  await new Promise(resolve => {
14951
14971
  requestAnimationFrame(() => {
14952
14972
  requestAnimationFrame(() => {
14953
- setTimeout(() => resolve(), 100);
14973
+ setTimeout(() => resolve(), isIOS ? 200 : 100); // iOS 使用更长的等待时间
14954
14974
  });
14955
14975
  });
14956
14976
  });
@@ -14986,9 +15006,11 @@ class ScreenshotManager {
14986
15006
  // width: finalWidth, // ❌ 移除,会导致宽度不正确
14987
15007
  // height: finalHeight, // ❌ 移除,会导致高度不正确
14988
15008
  // 关键配置:确保样式正确渲染
14989
- // 注意:不要设置 foreignObjectRendering,让 html2canvas 自动选择最佳渲染方式
14990
- // foreignObjectRendering 可能导致样式问题,所以不设置它
14991
- onclone: (clonedDoc, _clonedElement) => {
15009
+ // iOS 特定配置:启用 foreignObjectRendering 以确保样式正确渲染
15010
+ // iOS Safari 和 Chrome 对样式表的处理方式不同,需要启用此选项
15011
+ onclone: (clonedDoc, clonedElement) => {
15012
+ // 检测 iOS 设备(需要在 onclone 内部检测,因为这是回调函数)
15013
+ const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);
14992
15014
  // 在克隆的文档中,确保所有样式都正确应用
14993
15015
  // html2canvas 会自动处理样式,但我们需要确保样式表被正确复制
14994
15016
  // 确保 clonedDoc.head 存在
@@ -15077,10 +15099,53 @@ class ScreenshotManager {
15077
15099
  catch (e) {
15078
15100
  // 忽略错误,继续执行
15079
15101
  }
15102
+ // 4. iOS 特定处理:将计算后的样式内联化(确保样式正确应用)
15103
+ // iOS Safari/Chrome 对样式表的处理方式不同,需要将计算后的样式内联化
15104
+ if (isIOS) {
15105
+ try {
15106
+ // 遍历克隆文档中的所有元素,将计算后的样式内联化
15107
+ const allElements = clonedElement.querySelectorAll('*');
15108
+ allElements.forEach((el) => {
15109
+ try {
15110
+ const htmlEl = el;
15111
+ const computedStyle = window.getComputedStyle(htmlEl);
15112
+ // 获取关键样式属性并内联化
15113
+ const importantStyles = [];
15114
+ // 获取所有样式属性(iOS 需要更完整的样式)
15115
+ for (let i = 0; i < computedStyle.length; i++) {
15116
+ const prop = computedStyle[i];
15117
+ const value = computedStyle.getPropertyValue(prop);
15118
+ const priority = computedStyle.getPropertyPriority(prop);
15119
+ // 只内联化非默认值的重要样式
15120
+ if (value && value !== 'none' && value !== 'auto' && value !== 'normal') {
15121
+ importantStyles.push(`${prop}: ${value}${priority === 'important' ? ' !important' : ''}`);
15122
+ }
15123
+ }
15124
+ // 如果有关键样式,添加到内联样式
15125
+ if (importantStyles.length > 0) {
15126
+ const currentStyle = htmlEl.getAttribute('style') || '';
15127
+ const newStyle = currentStyle
15128
+ ? `${currentStyle}; ${importantStyles.join('; ')}`
15129
+ : importantStyles.join('; ');
15130
+ htmlEl.setAttribute('style', newStyle);
15131
+ }
15132
+ }
15133
+ catch (e) {
15134
+ // 忽略单个元素的错误
15135
+ }
15136
+ });
15137
+ }
15138
+ catch (e) {
15139
+ // 忽略内联化错误
15140
+ if (!this.options.silentMode) {
15141
+ console.warn('📸 iOS 样式内联化失败:', e);
15142
+ }
15143
+ }
15144
+ }
15080
15145
  if (!this.options.silentMode) {
15081
15146
  const styleLinks = clonedDoc.querySelectorAll('link[rel="stylesheet"]').length;
15082
15147
  const styleTags = clonedDoc.querySelectorAll('style').length;
15083
- console.log(`📸 onclone: 已复制 ${styleLinks} 个样式表链接和 ${styleTags} 个内联样式标签`);
15148
+ console.log(`📸 onclone: 已复制 ${styleLinks} 个样式表链接和 ${styleTags} 个内联样式标签${isIOS ? ' (iOS 模式:已内联化计算样式)' : ''}`);
15084
15149
  }
15085
15150
  },
15086
15151
  // 性能优化
@@ -15206,387 +15271,413 @@ class ScreenshotManager {
15206
15271
  * - 页面资源较少
15207
15272
  */
15208
15273
  async takeScreenshotWithModernScreenshot(element) {
15274
+ // 检查是否有截图正在进行(防止并发冲突)
15275
+ if (this.isScreenshotInProgress) {
15276
+ throw new Error('截图正在进行中,请稍后再试');
15277
+ }
15278
+ this.isScreenshotInProgress = true;
15209
15279
  if (!this.options.silentMode) {
15210
15280
  console.log('📸 使用 modern-screenshot 引擎截图(Worker 模式)...');
15211
15281
  }
15212
- // 获取元素的实际尺寸(使用 scrollWidth/scrollHeight 获取完整内容尺寸)
15213
- // 对于 document.body,需要特殊处理,确保截取完整页面内容
15214
- let elementWidth;
15215
- let elementHeight;
15216
- if (element === document.body || element === document.documentElement) {
15217
- // 对于 body 或 html 元素,使用页面的完整尺寸(包括滚动内容)
15218
- elementWidth = Math.max(element.scrollWidth, element.offsetWidth, document.documentElement.scrollWidth, document.documentElement.offsetWidth, window.innerWidth);
15219
- elementHeight = Math.max(element.scrollHeight, element.offsetHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight, window.innerHeight);
15220
- }
15221
- else {
15222
- // 对于其他元素,使用元素的完整尺寸
15223
- elementWidth = element.scrollWidth || element.clientWidth || element.offsetWidth;
15224
- elementHeight = element.scrollHeight || element.clientHeight || element.offsetHeight;
15225
- }
15226
- if (!this.options.silentMode) {
15227
- console.log(`📸 目标元素: ${element.tagName}${element.id ? '#' + element.id : ''}${element.className ? '.' + element.className.split(' ').join('.') : ''}`);
15228
- console.log(`📸 元素尺寸: ${elementWidth}x${elementHeight}`);
15229
- console.log(`📸 scrollWidth: ${element.scrollWidth}, scrollHeight: ${element.scrollHeight}`);
15230
- console.log(`📸 clientWidth: ${element.clientWidth}, clientHeight: ${element.clientHeight}`);
15231
- console.log(`📸 offsetWidth: ${element.offsetWidth}, offsetHeight: ${element.offsetHeight}`);
15282
+ try {
15283
+ // 获取元素的实际尺寸(使用 scrollWidth/scrollHeight 获取完整内容尺寸)
15284
+ // 对于 document.body,需要特殊处理,确保截取完整页面内容
15285
+ let elementWidth;
15286
+ let elementHeight;
15232
15287
  if (element === document.body || element === document.documentElement) {
15233
- console.log(`📸 页面完整尺寸: ${document.documentElement.scrollWidth}x${document.documentElement.scrollHeight}`);
15234
- console.log(`📸 窗口尺寸: ${window.innerWidth}x${window.innerHeight}`);
15235
- }
15236
- }
15237
- const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
15238
- const isLowEndDevice = navigator.hardwareConcurrency && navigator.hardwareConcurrency <= 4;
15239
- // 进一步降低质量以减少 base64 大小
15240
- // 桌面设备:使用配置的质量(默认 0.3)
15241
- // 移动设备/低端设备:进一步降低到 0.2(最低)
15242
- const finalQuality = isMobile || isLowEndDevice
15243
- ? Math.max(this.options.quality * 0.65, 0.2) // 移动设备:质量 * 0.65,最低 0.2
15244
- : this.options.quality; // 桌面设备:使用配置的质量(默认 0.3)
15245
- // 计算压缩后的尺寸(对所有元素都应用,包括 document.body)
15246
- // 这样可以避免生成过大的截图,减少 base64 大小
15247
- const { width, height } = this.calculateCompressedSize(elementWidth, elementHeight, this.options.maxWidth, this.options.maxHeight);
15248
- // 对于所有元素都应用尺寸限制(包括 body),避免截图过大
15249
- // 如果计算后的尺寸小于元素实际尺寸,使用压缩尺寸;否则使用元素实际尺寸(但不超过最大值)
15250
- const finalWidth = width < elementWidth ? width : Math.min(elementWidth, this.options.maxWidth);
15251
- const finalHeight = height < elementHeight ? height : Math.min(elementHeight, this.options.maxHeight);
15252
- // 处理跨域图片的函数
15253
- const handleCrossOriginImage = async (url) => {
15254
- // 如果是 data URL 或 blob URL,直接返回
15255
- if (url.startsWith('data:') || url.startsWith('blob:')) {
15256
- return url;
15288
+ // 对于 body 或 html 元素,使用页面的完整尺寸(包括滚动内容)
15289
+ elementWidth = Math.max(element.scrollWidth, element.offsetWidth, document.documentElement.scrollWidth, document.documentElement.offsetWidth, window.innerWidth);
15290
+ elementHeight = Math.max(element.scrollHeight, element.offsetHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight, window.innerHeight);
15257
15291
  }
15258
- // 如果是同源图片,直接返回
15259
- try {
15260
- const imgUrl = new URL(url, window.location.href);
15261
- if (imgUrl.origin === window.location.origin) {
15262
- return url;
15263
- }
15292
+ else {
15293
+ // 对于其他元素,使用元素的完整尺寸
15294
+ elementWidth = element.scrollWidth || element.clientWidth || element.offsetWidth;
15295
+ elementHeight = element.scrollHeight || element.clientHeight || element.offsetHeight;
15264
15296
  }
15265
- catch (e) {
15266
- // URL 解析失败,继续处理
15297
+ if (!this.options.silentMode) {
15298
+ console.log(`📸 目标元素: ${element.tagName}${element.id ? '#' + element.id : ''}${element.className ? '.' + element.className.split(' ').join('.') : ''}`);
15299
+ console.log(`📸 元素尺寸: ${elementWidth}x${elementHeight}`);
15300
+ console.log(`📸 scrollWidth: ${element.scrollWidth}, scrollHeight: ${element.scrollHeight}`);
15301
+ console.log(`📸 clientWidth: ${element.clientWidth}, clientHeight: ${element.clientHeight}`);
15302
+ console.log(`📸 offsetWidth: ${element.offsetWidth}, offsetHeight: ${element.offsetHeight}`);
15303
+ if (element === document.body || element === document.documentElement) {
15304
+ console.log(`📸 页面完整尺寸: ${document.documentElement.scrollWidth}x${document.documentElement.scrollHeight}`);
15305
+ console.log(`📸 窗口尺寸: ${window.innerWidth}x${window.innerHeight}`);
15306
+ }
15267
15307
  }
15268
- // 如果配置了代理服务器,使用代理处理跨域图片
15269
- // 只有当 useProxy true proxyUrl 存在时才使用代理
15270
- const shouldUseProxy = this.options.useProxy && this.options.proxyUrl && this.options.proxyUrl.trim() !== '';
15271
- if (shouldUseProxy) {
15272
- // 检查内存缓存(优先使用缓存,带过期时间检查)
15273
- const cachedDataUrl = this.getCachedImage(url);
15274
- if (cachedDataUrl) {
15275
- if (!this.options.silentMode) {
15276
- console.log(`📸 使用内存缓存图片: ${url.substring(0, 50)}...`);
15308
+ const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
15309
+ const isLowEndDevice = navigator.hardwareConcurrency && navigator.hardwareConcurrency <= 4;
15310
+ // 进一步降低质量以减少 base64 大小
15311
+ // 桌面设备:使用配置的质量(默认 0.3)
15312
+ // 移动设备/低端设备:进一步降低到 0.2(最低)
15313
+ const finalQuality = isMobile || isLowEndDevice
15314
+ ? Math.max(this.options.quality * 0.65, 0.2) // 移动设备:质量 * 0.65,最低 0.2
15315
+ : this.options.quality; // 桌面设备:使用配置的质量(默认 0.3)
15316
+ // 计算压缩后的尺寸(对所有元素都应用,包括 document.body)
15317
+ // 这样可以避免生成过大的截图,减少 base64 大小
15318
+ const { width, height } = this.calculateCompressedSize(elementWidth, elementHeight, this.options.maxWidth, this.options.maxHeight);
15319
+ // 对于所有元素都应用尺寸限制(包括 body),避免截图过大
15320
+ // 如果计算后的尺寸小于元素实际尺寸,使用压缩尺寸;否则使用元素实际尺寸(但不超过最大值)
15321
+ const finalWidth = width < elementWidth ? width : Math.min(elementWidth, this.options.maxWidth);
15322
+ const finalHeight = height < elementHeight ? height : Math.min(elementHeight, this.options.maxHeight);
15323
+ // 处理跨域图片的函数
15324
+ const handleCrossOriginImage = async (url) => {
15325
+ // 如果是 data URL 或 blob URL,直接返回
15326
+ if (url.startsWith('data:') || url.startsWith('blob:')) {
15327
+ return url;
15328
+ }
15329
+ // 如果是同源图片,直接返回
15330
+ try {
15331
+ const imgUrl = new URL(url, window.location.href);
15332
+ if (imgUrl.origin === window.location.origin) {
15333
+ return url;
15277
15334
  }
15278
- return cachedDataUrl;
15279
15335
  }
15280
- // 检查 IndexedDB 缓存(如果启用)
15281
- if (this.options.useIndexedDB) {
15282
- const indexedDBCache = await this.getIndexedDBCache(url);
15283
- if (indexedDBCache) {
15284
- // 同步到内存缓存
15285
- this.setCachedImage(url, indexedDBCache);
15336
+ catch (e) {
15337
+ // URL 解析失败,继续处理
15338
+ }
15339
+ // 如果配置了代理服务器,使用代理处理跨域图片
15340
+ // 只有当 useProxy 为 true 且 proxyUrl 存在时才使用代理
15341
+ const shouldUseProxy = this.options.useProxy && this.options.proxyUrl && this.options.proxyUrl.trim() !== '';
15342
+ if (shouldUseProxy) {
15343
+ // 检查内存缓存(优先使用缓存,带过期时间检查)
15344
+ const cachedDataUrl = this.getCachedImage(url);
15345
+ if (cachedDataUrl) {
15286
15346
  if (!this.options.silentMode) {
15287
- console.log(`📸 ✅ 使用 IndexedDB 缓存图片: ${url.substring(0, 50)}...`);
15347
+ console.log(`📸 ✅ 使用内存缓存图片: ${url.substring(0, 50)}...`);
15288
15348
  }
15289
- return indexedDBCache;
15349
+ return cachedDataUrl;
15290
15350
  }
15291
- }
15292
- try {
15293
- // 构建代理请求参数
15294
- const params = new URLSearchParams({
15295
- url: url,
15296
- maxWidth: String(this.options.maxWidth || 1600),
15297
- maxHeight: String(this.options.maxHeight || 900),
15298
- quality: String(Math.round((this.options.quality || 0.4) * 100)),
15299
- format: this.options.outputFormat || 'webp'
15300
- });
15301
- let baseUrl = this.options.proxyUrl;
15302
- baseUrl = baseUrl.replace(/[?&]$/, '');
15303
- const proxyUrl = `${baseUrl}?${params.toString()}`;
15304
- // 请求代理服务器(优化:添加超时控制和优先级)
15305
- const controller = new AbortController();
15306
- const timeoutId = setTimeout(() => controller.abort(), this.options.imageLoadTimeout);
15307
- try {
15308
- const fetchOptions = {
15309
- method: 'GET',
15310
- mode: 'cors',
15311
- credentials: 'omit',
15312
- headers: {
15313
- 'Accept': 'image/*'
15314
- },
15315
- cache: 'no-cache',
15316
- signal: controller.signal
15317
- };
15318
- // 添加 fetch priority(如果支持)
15319
- if ('priority' in fetchOptions) {
15320
- fetchOptions.priority = this.options.fetchPriority;
15351
+ // 检查 IndexedDB 缓存(如果启用)
15352
+ if (this.options.useIndexedDB) {
15353
+ const indexedDBCache = await this.getIndexedDBCache(url);
15354
+ if (indexedDBCache) {
15355
+ // 同步到内存缓存
15356
+ this.setCachedImage(url, indexedDBCache);
15357
+ if (!this.options.silentMode) {
15358
+ console.log(`📸 使用 IndexedDB 缓存图片: ${url.substring(0, 50)}...`);
15359
+ }
15360
+ return indexedDBCache;
15321
15361
  }
15322
- const response = await fetch(proxyUrl, fetchOptions);
15323
- clearTimeout(timeoutId);
15324
- if (!response.ok) {
15325
- throw new Error(`代理请求失败: ${response.status}`);
15362
+ }
15363
+ try {
15364
+ // 构建代理请求参数
15365
+ const params = new URLSearchParams({
15366
+ url: url,
15367
+ maxWidth: String(this.options.maxWidth || 1600),
15368
+ maxHeight: String(this.options.maxHeight || 900),
15369
+ quality: String(Math.round((this.options.quality || 0.4) * 100)),
15370
+ format: this.options.outputFormat || 'webp'
15371
+ });
15372
+ let baseUrl = this.options.proxyUrl;
15373
+ baseUrl = baseUrl.replace(/[?&]$/, '');
15374
+ const proxyUrl = `${baseUrl}?${params.toString()}`;
15375
+ // 请求代理服务器(优化:添加超时控制和优先级)
15376
+ const controller = new AbortController();
15377
+ const timeoutId = setTimeout(() => controller.abort(), this.options.imageLoadTimeout);
15378
+ try {
15379
+ const fetchOptions = {
15380
+ method: 'GET',
15381
+ mode: 'cors',
15382
+ credentials: 'omit',
15383
+ headers: {
15384
+ 'Accept': 'image/*'
15385
+ },
15386
+ cache: 'no-cache',
15387
+ signal: controller.signal
15388
+ };
15389
+ // 添加 fetch priority(如果支持)
15390
+ if ('priority' in fetchOptions) {
15391
+ fetchOptions.priority = this.options.fetchPriority;
15392
+ }
15393
+ const response = await fetch(proxyUrl, fetchOptions);
15394
+ clearTimeout(timeoutId);
15395
+ if (!response.ok) {
15396
+ throw new Error(`代理请求失败: ${response.status}`);
15397
+ }
15398
+ const blob = await response.blob();
15399
+ const dataUrl = await this.blobToDataUrl(blob);
15400
+ // 缓存结果(带时间戳,10分钟有效)
15401
+ this.setCachedImage(url, dataUrl);
15402
+ // 如果启用 IndexedDB,也保存到 IndexedDB
15403
+ if (this.options.useIndexedDB) {
15404
+ await this.setIndexedDBCache(url, dataUrl);
15405
+ }
15406
+ return dataUrl;
15326
15407
  }
15327
- const blob = await response.blob();
15328
- const dataUrl = await this.blobToDataUrl(blob);
15329
- // 缓存结果(带时间戳,10分钟有效)
15330
- this.setCachedImage(url, dataUrl);
15331
- // 如果启用 IndexedDB,也保存到 IndexedDB
15332
- if (this.options.useIndexedDB) {
15333
- await this.setIndexedDBCache(url, dataUrl);
15408
+ catch (fetchError) {
15409
+ clearTimeout(timeoutId);
15410
+ throw fetchError;
15334
15411
  }
15335
- return dataUrl;
15336
15412
  }
15337
- catch (fetchError) {
15338
- clearTimeout(timeoutId);
15339
- throw fetchError;
15413
+ catch (error) {
15414
+ if (!this.options.silentMode) {
15415
+ console.warn(`📸 代理处理图片失败: ${url.substring(0, 100)}...`, error);
15416
+ }
15417
+ // 失败时返回原 URL
15418
+ return url;
15340
15419
  }
15341
15420
  }
15342
- catch (error) {
15343
- if (!this.options.silentMode) {
15344
- console.warn(`📸 代理处理图片失败: ${url.substring(0, 100)}...`, error);
15421
+ // 如果没有配置代理,需要添加内存保护机制
15422
+ // 不使用代理时,modern-screenshot 会直接下载图片,可能导致内存问题
15423
+ // 由于已配置 CORS,可以直接下载并检查大小
15424
+ if (this.options.enableCORS) {
15425
+ // 对于不使用代理的情况,添加内存保护和缓存机制:
15426
+ // 1. 先检查内存缓存(避免重复下载)
15427
+ // 2. 检查 IndexedDB 缓存
15428
+ // 3. 使用下载队列避免并发重复下载
15429
+ // 4. 下载时检查大小,如果过大则使用占位符
15430
+ // 先检查内存缓存(优先使用缓存,避免重复下载)
15431
+ const cachedDataUrl = this.getCachedImage(url);
15432
+ if (cachedDataUrl) {
15433
+ if (!this.options.silentMode) {
15434
+ console.log(`📸 ✅ 使用内存缓存图片(无代理模式): ${url.substring(0, 50)}...`);
15435
+ }
15436
+ return cachedDataUrl;
15345
15437
  }
15346
- // 失败时返回原 URL
15347
- return url;
15348
- }
15349
- }
15350
- // 如果没有配置代理,需要添加内存保护机制
15351
- // 不使用代理时,modern-screenshot 会直接下载图片,可能导致内存问题
15352
- // 由于已配置 CORS,可以直接下载并检查大小
15353
- if (this.options.enableCORS) {
15354
- // 对于不使用代理的情况,添加内存保护和缓存机制:
15355
- // 1. 先检查内存缓存(避免重复下载)
15356
- // 2. 检查 IndexedDB 缓存
15357
- // 3. 使用下载队列避免并发重复下载
15358
- // 4. 下载时检查大小,如果过大则使用占位符
15359
- // 先检查内存缓存(优先使用缓存,避免重复下载)
15360
- const cachedDataUrl = this.getCachedImage(url);
15361
- if (cachedDataUrl) {
15362
- if (!this.options.silentMode) {
15363
- console.log(`📸 ✅ 使用内存缓存图片(无代理模式): ${url.substring(0, 50)}...`);
15438
+ // 检查 IndexedDB 缓存(如果启用)
15439
+ if (this.options.useIndexedDB) {
15440
+ const indexedDBCache = await this.getIndexedDBCache(url);
15441
+ if (indexedDBCache) {
15442
+ // 同步到内存缓存
15443
+ this.setCachedImage(url, indexedDBCache);
15444
+ if (!this.options.silentMode) {
15445
+ console.log(`📸 ✅ 使用 IndexedDB 缓存图片(无代理模式): ${url.substring(0, 50)}...`);
15446
+ }
15447
+ return indexedDBCache;
15448
+ }
15364
15449
  }
15365
- return cachedDataUrl;
15366
- }
15367
- // 检查 IndexedDB 缓存(如果启用)
15368
- if (this.options.useIndexedDB) {
15369
- const indexedDBCache = await this.getIndexedDBCache(url);
15370
- if (indexedDBCache) {
15371
- // 同步到内存缓存
15372
- this.setCachedImage(url, indexedDBCache);
15450
+ // 检查是否正在下载(避免重复下载)
15451
+ if (this.imageDownloadQueue.has(url)) {
15452
+ // 如果已经在下载队列中,等待现有下载完成
15373
15453
  if (!this.options.silentMode) {
15374
- console.log(`📸 使用 IndexedDB 缓存图片(无代理模式): ${url.substring(0, 50)}...`);
15454
+ console.log(`📸 等待图片下载完成: ${url.substring(0, 50)}...`);
15375
15455
  }
15376
- return indexedDBCache;
15456
+ return await this.imageDownloadQueue.get(url);
15377
15457
  }
15378
- }
15379
- // 检查是否正在下载(避免重复下载)
15380
- if (this.imageDownloadQueue.has(url)) {
15381
- // 如果已经在下载队列中,等待现有下载完成
15382
- if (!this.options.silentMode) {
15383
- console.log(`📸 ⏳ 等待图片下载完成: ${url.substring(0, 50)}...`);
15458
+ // 检查并发下载数限制
15459
+ if (this.activeDownloads.size >= this.maxConcurrentImageDownloads) {
15460
+ // 并发数已满,返回原 URL,让 modern-screenshot 自己处理(可能会失败,但不阻塞)
15461
+ if (!this.options.silentMode) {
15462
+ console.warn(`📸 ⚠️ 并发下载数已满(${this.activeDownloads.size}/${this.maxConcurrentImageDownloads}),跳过: ${url.substring(0, 50)}...`);
15463
+ }
15464
+ return url;
15384
15465
  }
15385
- return await this.imageDownloadQueue.get(url);
15386
- }
15387
- // 检查并发下载数限制
15388
- if (this.activeDownloads.size >= this.maxConcurrentImageDownloads) {
15389
- // 并发数已满,返回原 URL,让 modern-screenshot 自己处理(可能会失败,但不阻塞)
15390
- if (!this.options.silentMode) {
15391
- console.warn(`📸 ⚠️ 并发下载数已满(${this.activeDownloads.size}/${this.maxConcurrentImageDownloads}),跳过: ${url.substring(0, 50)}...`);
15466
+ // 创建下载 Promise 并加入队列
15467
+ const downloadPromise = this.downloadImageWithoutProxy(url);
15468
+ this.imageDownloadQueue.set(url, downloadPromise);
15469
+ this.activeDownloads.add(url);
15470
+ try {
15471
+ const result = await downloadPromise;
15472
+ return result;
15473
+ }
15474
+ finally {
15475
+ // 下载完成后清理
15476
+ this.imageDownloadQueue.delete(url);
15477
+ this.activeDownloads.delete(url);
15392
15478
  }
15393
- return url;
15394
15479
  }
15395
- // 创建下载 Promise 并加入队列
15396
- const downloadPromise = this.downloadImageWithoutProxy(url);
15397
- this.imageDownloadQueue.set(url, downloadPromise);
15398
- this.activeDownloads.add(url);
15480
+ // 默认返回原 URL
15481
+ return url;
15482
+ };
15483
+ // 检查元素是否可见且有尺寸
15484
+ const rect = element.getBoundingClientRect();
15485
+ if (rect.width === 0 || rect.height === 0) {
15486
+ throw new Error('元素尺寸为 0,无法截图');
15487
+ }
15488
+ // 每次截图都重新创建 context,确保使用最新的元素状态
15489
+ // 如果已有 context,先清理
15490
+ if (this.screenshotContext) {
15399
15491
  try {
15400
- const result = await downloadPromise;
15401
- return result;
15492
+ destroyContext(this.screenshotContext);
15402
15493
  }
15403
- finally {
15404
- // 下载完成后清理
15405
- this.imageDownloadQueue.delete(url);
15406
- this.activeDownloads.delete(url);
15494
+ catch (e) {
15495
+ // 忽略清理错误
15407
15496
  }
15408
- }
15409
- // 默认返回原 URL
15410
- return url;
15411
- };
15412
- // 检查元素是否可见且有尺寸
15413
- const rect = element.getBoundingClientRect();
15414
- if (rect.width === 0 || rect.height === 0) {
15415
- throw new Error('元素尺寸为 0,无法截图');
15416
- }
15417
- // 每次截图都重新创建 context,确保使用最新的元素状态
15418
- // 如果已有 context,先清理
15419
- if (this.screenshotContext) {
15420
- try {
15421
- destroyContext(this.screenshotContext);
15422
- }
15423
- catch (e) {
15424
- // 忽略清理错误
15425
- }
15426
- this.screenshotContext = null;
15427
- }
15428
- // Worker 数量配置:移动设备/低端设备使用 1 个 Worker,桌面设备使用 2 个
15429
- // workerNumber > 0 会启用 Worker 模式,截图处理在后台线程执行,不会阻塞主线程 UI
15430
- const workerNumber = isMobile || isLowEndDevice ? 1 : 2;
15431
- // 构建 createContext 配置
15432
- // 参考: https://github.com/qq15725/modern-screenshot/blob/main/src/options.ts
15433
- const contextOptions = {
15434
- workerNumber, // Worker 数量,> 0 启用 Worker 模式
15435
- quality: finalQuality, // 图片质量(0-1),已优化为更低的值以减少 base64 大小
15436
- fetchFn: handleCrossOriginImage, // 使用代理服务器处理跨域图片
15437
- fetch: {
15438
- requestInit: {
15439
- cache: 'no-cache',
15497
+ this.screenshotContext = null;
15498
+ }
15499
+ // Worker 数量配置:移动设备/低端设备使用 1 个 Worker,桌面设备使用 2 个
15500
+ // workerNumber > 0 会启用 Worker 模式,截图处理在后台线程执行,不会阻塞主线程 UI
15501
+ const workerNumber = isMobile || isLowEndDevice ? 1 : 2;
15502
+ // 构建 createContext 配置
15503
+ // 参考: https://github.com/qq15725/modern-screenshot/blob/main/src/options.ts
15504
+ const contextOptions = {
15505
+ workerNumber, // Worker 数量,> 0 启用 Worker 模式
15506
+ quality: finalQuality, // 图片质量(0-1),已优化为更低的值以减少 base64 大小
15507
+ fetchFn: handleCrossOriginImage, // 使用代理服务器处理跨域图片
15508
+ fetch: {
15509
+ requestInit: {
15510
+ cache: 'no-cache',
15511
+ },
15512
+ bypassingCache: true,
15440
15513
  },
15441
- bypassingCache: true,
15442
- },
15443
- // 设置最大 canvas 尺寸,防止生成过大的 canvas(避免内存问题)
15444
- // 参考: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas#maximum_canvas_size
15445
- // 大多数浏览器限制为 16,777,216 像素(4096x4096),这里设置为更保守的值
15446
- maximumCanvasSize: 16777216, // 16M 像素(约 4096x4096)
15447
- };
15448
- // 对所有元素都设置尺寸限制(包括 document.body),避免截图过大
15449
- // 这样可以减少 base64 大小,提高性能
15450
- if (finalWidth && finalHeight) {
15451
- contextOptions.width = finalWidth;
15452
- contextOptions.height = finalHeight;
15453
- if (!this.options.silentMode) {
15454
- if (element === document.body || element === document.documentElement) {
15455
- console.log(`📸 截取完整页面(document.body),使用压缩尺寸: ${finalWidth}x${finalHeight}`);
15514
+ // 设置最大 canvas 尺寸,防止生成过大的 canvas(避免内存问题)
15515
+ // 参考: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas#maximum_canvas_size
15516
+ // 大多数浏览器限制为 16,777,216 像素(4096x4096),这里设置为更保守的值
15517
+ maximumCanvasSize: 16777216, // 16M 像素(约 4096x4096)
15518
+ };
15519
+ // 对所有元素都设置尺寸限制(包括 document.body),避免截图过大
15520
+ // 这样可以减少 base64 大小,提高性能
15521
+ if (finalWidth && finalHeight) {
15522
+ contextOptions.width = finalWidth;
15523
+ contextOptions.height = finalHeight;
15524
+ if (!this.options.silentMode) {
15525
+ if (element === document.body || element === document.documentElement) {
15526
+ console.log(`📸 截取完整页面(document.body),使用压缩尺寸: ${finalWidth}x${finalHeight}`);
15527
+ }
15528
+ else {
15529
+ console.log(`📸 使用压缩尺寸: ${finalWidth}x${finalHeight}`);
15530
+ }
15456
15531
  }
15457
- else {
15458
- console.log(`📸 使用压缩尺寸: ${finalWidth}x${finalHeight}`);
15532
+ }
15533
+ else {
15534
+ if (!this.options.silentMode) {
15535
+ console.log(`📸 使用元素实际尺寸: ${elementWidth}x${elementHeight}`);
15459
15536
  }
15460
15537
  }
15461
- }
15462
- else {
15538
+ // 缩放配置:移动设备使用更低的缩放比例,进一步减少图片大小
15539
+ // scale < 1 会降低图片分辨率,减少 base64 大小
15540
+ if (this.options.scale !== 1) {
15541
+ contextOptions.scale = isMobile ? 0.7 : this.options.scale; // 移动设备:0.8 -> 0.7
15542
+ }
15543
+ else if (isMobile) {
15544
+ // 如果未指定 scale,移动设备默认使用 0.7
15545
+ contextOptions.scale = 0.7;
15546
+ }
15547
+ // modern-screenshot 会自动处理 worker URL,不需要手动设置 workerUrl
15548
+ // 当 workerNumber > 0 时,截图处理会在 Worker 线程中执行,不会阻塞主线程 UI
15549
+ // 创建 Worker 上下文(每次截图都创建新的,确保元素状态最新)
15463
15550
  if (!this.options.silentMode) {
15464
- console.log(`📸 使用元素实际尺寸: ${elementWidth}x${elementHeight}`);
15551
+ console.log(`📸 Worker 模式: ${workerNumber} 个 Worker,质量: ${finalQuality.toFixed(2)},缩放: ${contextOptions.scale || 1}`);
15465
15552
  }
15466
- }
15467
- // 缩放配置:移动设备使用更低的缩放比例,进一步减少图片大小
15468
- // scale < 1 会降低图片分辨率,减少 base64 大小
15469
- if (this.options.scale !== 1) {
15470
- contextOptions.scale = isMobile ? 0.7 : this.options.scale; // 移动设备:0.8 -> 0.7
15471
- }
15472
- else if (isMobile) {
15473
- // 如果未指定 scale,移动设备默认使用 0.7
15474
- contextOptions.scale = 0.7;
15475
- }
15476
- // modern-screenshot 会自动处理 worker URL,不需要手动设置 workerUrl
15477
- // workerNumber > 0 时,截图处理会在 Worker 线程中执行,不会阻塞主线程 UI
15478
- // 创建 Worker 上下文(每次截图都创建新的,确保元素状态最新)
15479
- if (!this.options.silentMode) {
15480
- console.log(`📸 Worker 模式: ${workerNumber} 个 Worker,质量: ${finalQuality.toFixed(2)},缩放: ${contextOptions.scale || 1}`);
15481
- }
15482
- this.screenshotContext = await createContext$1(element, contextOptions);
15483
- try {
15484
- // 使用 Worker 上下文进行截图
15485
- const dataUrl = await domToPng(this.screenshotContext);
15486
- // 验证截图结果
15487
- if (!dataUrl || dataUrl.length < 100) {
15488
- throw new Error('生成的截图数据无效或过短');
15553
+ // 添加重试机制
15554
+ let retries = 0;
15555
+ const maxRetries = this.options.maxRetries || 2;
15556
+ let screenshotContext = null;
15557
+ while (retries <= maxRetries) {
15558
+ try {
15559
+ screenshotContext = await createContext$1(element, contextOptions);
15560
+ this.screenshotContext = screenshotContext;
15561
+ break;
15562
+ }
15563
+ catch (error) {
15564
+ if (retries === maxRetries) {
15565
+ throw new Error(`创建截图上下文失败(已重试 ${maxRetries} 次): ${error instanceof Error ? error.message : String(error)}`);
15566
+ }
15567
+ retries++;
15568
+ const delay = 1000 * retries; // 递增延迟:1秒、2秒...
15569
+ if (!this.options.silentMode) {
15570
+ console.warn(`📸 ⚠️ 创建截图上下文失败,${delay}ms 后重试 (${retries}/${maxRetries})...`);
15571
+ }
15572
+ await new Promise(resolve => setTimeout(resolve, delay));
15573
+ }
15489
15574
  }
15490
- // 根据输出格式转换
15491
- if (this.options.outputFormat !== 'png') {
15492
- // modern-screenshot 默认输出 PNG,如果需要其他格式,需要转换
15493
- const canvas = document.createElement('canvas');
15494
- const ctx = canvas.getContext('2d');
15495
- if (!ctx) {
15496
- throw new Error('无法获取 canvas context');
15575
+ try {
15576
+ // 根据输出格式选择对应的 API,避免格式转换(性能优化)
15577
+ // 添加超时机制,防止卡住(30秒超时)
15578
+ const timeoutMs = 30000; // 30秒超时
15579
+ const timeoutPromise = new Promise((_, reject) => {
15580
+ setTimeout(() => {
15581
+ reject(new Error(`截图超时(${timeoutMs}ms),可能页面过大或 Worker 处理时间过长`));
15582
+ }, timeoutMs);
15583
+ });
15584
+ let dataUrl;
15585
+ const outputFormat = this.options.outputFormat || 'webp';
15586
+ if (!this.options.silentMode) {
15587
+ console.log(`📸 使用 ${outputFormat.toUpperCase()} 格式截图(直接输出,无需转换)...`);
15497
15588
  }
15498
- const img = new Image();
15499
- let convertedDataUrl;
15500
- try {
15501
- await new Promise((resolve, reject) => {
15502
- img.onload = () => {
15503
- canvas.width = img.width;
15504
- canvas.height = img.height;
15505
- ctx.drawImage(img, 0, 0);
15506
- resolve();
15507
- };
15508
- img.onerror = reject;
15509
- img.src = dataUrl;
15589
+ // 根据输出格式选择对应的 API
15590
+ if (outputFormat === 'webp') {
15591
+ // 使用 domToWebp,直接输出 WebP 格式,无需转换
15592
+ dataUrl = await Promise.race([
15593
+ domToWebp(this.screenshotContext),
15594
+ timeoutPromise
15595
+ ]);
15596
+ }
15597
+ else if (outputFormat === 'jpeg') {
15598
+ // 使用 domToJpeg,直接输出 JPEG 格式,无需转换
15599
+ dataUrl = await Promise.race([
15600
+ domToJpeg(this.screenshotContext),
15601
+ timeoutPromise
15602
+ ]);
15603
+ }
15604
+ else {
15605
+ // 默认使用 domToPng
15606
+ dataUrl = await Promise.race([
15607
+ domToPng(this.screenshotContext),
15608
+ timeoutPromise
15609
+ ]);
15610
+ }
15611
+ // 验证截图结果
15612
+ if (!dataUrl || dataUrl.length < 100) {
15613
+ throw new Error('生成的截图数据无效或过短');
15614
+ }
15615
+ if (!this.options.silentMode) {
15616
+ console.log(`📸 ✅ modern-screenshot 截图成功(${outputFormat.toUpperCase()} 格式,直接输出,无需转换)`);
15617
+ }
15618
+ return dataUrl;
15619
+ }
15620
+ catch (error) {
15621
+ const errorMessage = error instanceof Error ? error.message : String(error);
15622
+ if (!this.options.silentMode) {
15623
+ console.error('📸 modern-screenshot 截图失败:', errorMessage);
15624
+ console.error('📸 元素信息:', {
15625
+ width: rect.width,
15626
+ height: rect.height,
15627
+ scrollWidth: element.scrollWidth,
15628
+ scrollHeight: element.scrollHeight,
15629
+ display: window.getComputedStyle(element).display,
15630
+ visibility: window.getComputedStyle(element).visibility
15510
15631
  });
15511
- let mimeType = 'image/jpeg';
15512
- // 使用与 createContext 相同的质量设置
15513
- let conversionQuality = finalQuality;
15514
- if (this.options.outputFormat === 'webp' && !isMobile) {
15515
- try {
15516
- const testCanvas = document.createElement('canvas');
15517
- testCanvas.width = 1;
15518
- testCanvas.height = 1;
15519
- const testDataUrl = testCanvas.toDataURL('image/webp');
15520
- if (testDataUrl.indexOf('webp') !== -1) {
15521
- mimeType = 'image/webp';
15522
- }
15632
+ }
15633
+ throw error;
15634
+ }
15635
+ finally {
15636
+ // 每次截图后立即清理 context,释放 Worker 和内存
15637
+ // 这是防止内存泄漏的关键步骤
15638
+ if (this.screenshotContext) {
15639
+ try {
15640
+ destroyContext(this.screenshotContext);
15641
+ if (!this.options.silentMode) {
15642
+ console.log('📸 modern-screenshot context 已清理');
15523
15643
  }
15524
- catch {
15525
- mimeType = 'image/jpeg';
15644
+ }
15645
+ catch (e) {
15646
+ if (!this.options.silentMode) {
15647
+ console.warn('📸 ⚠️ 清理 context 失败:', e);
15526
15648
  }
15527
15649
  }
15528
- // 使用优化后的质量进行格式转换
15529
- convertedDataUrl = mimeType === 'image/png'
15530
- ? canvas.toDataURL(mimeType)
15531
- : canvas.toDataURL(mimeType, conversionQuality);
15650
+ finally {
15651
+ // 确保 context 引用被清除
15652
+ this.screenshotContext = null;
15653
+ }
15532
15654
  }
15533
- finally {
15534
- // 清理资源,释放内存
15535
- img.src = ''; // 清除图片引用
15536
- img.onload = null;
15537
- img.onerror = null;
15538
- canvas.width = 0; // 清空 canvas
15539
- canvas.height = 0;
15540
- ctx.clearRect(0, 0, 0, 0); // 清除绘制内容
15655
+ // 释放截图锁
15656
+ this.isScreenshotInProgress = false;
15657
+ // 强制触发垃圾回收(如果可能)
15658
+ // 注意:这需要浏览器支持,不是所有浏览器都有效
15659
+ if (typeof window !== 'undefined' && window.gc && typeof window.gc === 'function') {
15660
+ try {
15661
+ window.gc();
15662
+ }
15663
+ catch {
15664
+ // 忽略 GC 错误
15665
+ }
15541
15666
  }
15542
- return convertedDataUrl;
15543
15667
  }
15544
- return dataUrl;
15545
15668
  }
15546
15669
  catch (error) {
15670
+ // 外层错误处理:确保即使发生错误也释放锁
15547
15671
  const errorMessage = error instanceof Error ? error.message : String(error);
15548
15672
  if (!this.options.silentMode) {
15549
- console.error('📸 modern-screenshot 截图失败:', errorMessage);
15550
- console.error('📸 元素信息:', {
15551
- width: rect.width,
15552
- height: rect.height,
15553
- scrollWidth: element.scrollWidth,
15554
- scrollHeight: element.scrollHeight,
15555
- display: window.getComputedStyle(element).display,
15556
- visibility: window.getComputedStyle(element).visibility
15557
- });
15673
+ console.error('📸 modern-screenshot 截图异常:', errorMessage);
15558
15674
  }
15559
15675
  throw error;
15560
15676
  }
15561
15677
  finally {
15562
- // 每次截图后立即清理 context,释放 Worker 和内存
15563
- // 这是防止内存泄漏的关键步骤
15564
- if (this.screenshotContext) {
15565
- try {
15566
- destroyContext(this.screenshotContext);
15567
- if (!this.options.silentMode) {
15568
- console.log('📸 ✅ modern-screenshot context 已清理');
15569
- }
15570
- }
15571
- catch (e) {
15572
- if (!this.options.silentMode) {
15573
- console.warn('📸 ⚠️ 清理 context 失败:', e);
15574
- }
15575
- }
15576
- finally {
15577
- // 确保 context 引用被清除
15578
- this.screenshotContext = null;
15579
- }
15580
- }
15581
- // 强制触发垃圾回收(如果可能)
15582
- // 注意:这需要浏览器支持,不是所有浏览器都有效
15583
- if (typeof window !== 'undefined' && window.gc && typeof window.gc === 'function') {
15584
- try {
15585
- window.gc();
15586
- }
15587
- catch {
15588
- // 忽略 GC 错误
15589
- }
15678
+ // 确保截图锁被释放(即使发生未捕获的错误)
15679
+ if (this.isScreenshotInProgress) {
15680
+ this.isScreenshotInProgress = false;
15590
15681
  }
15591
15682
  }
15592
15683
  }