templateRenderer.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. // 模板渲染器
  2. // 渲染模板缩略图列表
  3. export function renderTemplates(templates, category = 'all') {
  4. const templatesContainer = document.getElementById('templates-container');
  5. if (!templatesContainer) return;
  6. templatesContainer.innerHTML = '';
  7. // 根据分类筛选模板
  8. const filteredTemplates = category === 'all'
  9. ? templates
  10. : templates.filter(template => template.category === category);
  11. // 创建一个文档片段,减少DOM操作
  12. const fragment = document.createDocumentFragment();
  13. filteredTemplates.forEach(template => {
  14. const templateItem = document.createElement('div');
  15. templateItem.className = 'template-item';
  16. templateItem.dataset.templateId = template.id;
  17. const thumbnail = document.createElement('img');
  18. // 使用较小的缩略图尺寸来加快加载
  19. thumbnail.src = template.thumbnail;
  20. thumbnail.className = 'template-thumbnail';
  21. thumbnail.alt = template.name;
  22. thumbnail.loading = 'lazy'; // 懒加载图片
  23. const templateName = document.createElement('div');
  24. templateName.className = 'template-name';
  25. templateName.textContent = template.name;
  26. templateItem.appendChild(thumbnail);
  27. templateItem.appendChild(templateName);
  28. fragment.appendChild(templateItem);
  29. // 添加点击事件
  30. templateItem.addEventListener('click', () => {
  31. // 移除其他模板的选中状态
  32. document.querySelectorAll('.template-item').forEach(item => {
  33. item.classList.remove('selected');
  34. });
  35. // 添加选中状态
  36. templateItem.classList.add('selected');
  37. // 初始化编辑器
  38. initializeEditor(template);
  39. });
  40. });
  41. // 一次性将所有元素添加到容器中
  42. templatesContainer.appendChild(fragment);
  43. }
  44. // 初始化编辑器
  45. export function initializeEditor(template) {
  46. const editForm = document.getElementById('edit-form');
  47. const posterPreview = document.getElementById('poster-preview');
  48. const downloadBtn = document.getElementById('download-btn');
  49. if (!editForm || !posterPreview) return;
  50. // 清空编辑表单
  51. editForm.innerHTML = '';
  52. // 存储当前模板数据
  53. window.currentTemplate = template;
  54. window.currentValues = {};
  55. // 创建一个空的数据对象,用于生成模板HTML
  56. const emptyData = {};
  57. template.fields.forEach(field => {
  58. // 为每个字段提供默认值,避免undefined
  59. emptyData[field.name] = field.default || '';
  60. });
  61. // 检查模板HTML中是否有硬编码的内容
  62. const templateHTML = template.template(emptyData);
  63. const hardcodedTexts = findHardcodedTexts(templateHTML);
  64. // 组合所有需要编辑的字段
  65. let allFields = [...template.fields];
  66. // 添加从模板中检测到的硬编码文本作为可编辑字段
  67. const existingDefaults = allFields.map(field => field.default);
  68. hardcodedTexts.forEach((text, index) => {
  69. // 检查文本是否已存在于字段中,或者是否是常见文本
  70. if (!existingDefaults.includes(text) && !isCommonText(text)) {
  71. let label = '检测到的文本';
  72. if (text.length > 15) {
  73. label = `${text.substring(0, 15)}...`;
  74. } else {
  75. label = text;
  76. }
  77. allFields.push({
  78. name: `detected_text_${index}`,
  79. type: 'text',
  80. label: label,
  81. default: text
  82. });
  83. }
  84. });
  85. // 创建特殊的布局容器用于前三个字段
  86. const specialFormGroup = document.createElement('div');
  87. specialFormGroup.className = 'form-group special-layout';
  88. specialFormGroup.style.display = 'flex';
  89. specialFormGroup.style.gap = '15px';
  90. specialFormGroup.style.marginBottom = '20px';
  91. // 左侧图片容器
  92. const leftColumn = document.createElement('div');
  93. leftColumn.style.flex = '1';
  94. leftColumn.classList.add('left-column');
  95. // 右侧颜色容器
  96. const rightColumn = document.createElement('div');
  97. rightColumn.style.flex = '1';
  98. rightColumn.style.display = 'flex';
  99. rightColumn.style.flexDirection = 'column';
  100. rightColumn.style.gap = '30px';
  101. rightColumn.classList.add('right-column');
  102. // 处理前三个特殊字段
  103. const firstThreeFields = allFields.slice(0, 3);
  104. const remainingFields = allFields.slice(3);
  105. firstThreeFields.forEach((field, index) => {
  106. const label = document.createElement('label');
  107. label.textContent = field.label;
  108. label.setAttribute('for', `field-${field.name}`);
  109. if (index === 0) { // 图片字段
  110. const imageUpload = document.createElement('div');
  111. imageUpload.className = 'image-upload';
  112. const imagePreview = document.createElement('div');
  113. imagePreview.className = 'upload-preview';
  114. const img = document.createElement('img');
  115. img.src = field.default || '';
  116. img.id = `preview-${field.name}`;
  117. imagePreview.appendChild(img);
  118. const uploadButton = document.createElement('label');
  119. uploadButton.className = 'upload-btn';
  120. uploadButton.textContent = '更换图片';
  121. uploadButton.setAttribute('for', `field-${field.name}`);
  122. const input = document.createElement('input');
  123. input.type = 'file';
  124. input.accept = 'image/*';
  125. input.id = `field-${field.name}`;
  126. input.style.display = 'none';
  127. input.addEventListener('change', function(e) {
  128. const file = e.target.files[0];
  129. if (file) {
  130. const reader = new FileReader();
  131. reader.onload = function(event) {
  132. img.src = event.target.result;
  133. window.currentValues[field.name] = event.target.result;
  134. updatePosterPreview();
  135. };
  136. reader.readAsDataURL(file);
  137. }
  138. });
  139. imageUpload.appendChild(imagePreview);
  140. imageUpload.appendChild(uploadButton);
  141. imageUpload.appendChild(input);
  142. leftColumn.appendChild(label);
  143. leftColumn.appendChild(imageUpload);
  144. window.currentValues[field.name] = field.default || '';
  145. } else { // 颜色字段
  146. const colorContainer = document.createElement('div');
  147. colorContainer.style.flex = '1';
  148. const input = document.createElement('input');
  149. input.type = 'color';
  150. input.className = 'color-picker';
  151. input.id = `field-${field.name}`;
  152. input.value = field.default || '#000000';
  153. input.addEventListener('input', function(e) {
  154. window.currentValues[field.name] = e.target.value;
  155. updatePosterPreview();
  156. });
  157. window.currentValues[field.name] = field.default || '';
  158. colorContainer.appendChild(label);
  159. colorContainer.appendChild(input);
  160. rightColumn.appendChild(colorContainer);
  161. }
  162. });
  163. specialFormGroup.appendChild(leftColumn);
  164. specialFormGroup.appendChild(rightColumn);
  165. editForm.appendChild(specialFormGroup);
  166. // 处理剩余字段
  167. remainingFields.forEach(field => {
  168. if (field.default === undefined || field.default === null) {
  169. field.default = '';
  170. }
  171. if (field.name.startsWith('detected_text_') && isCommonText(field.default)) {
  172. return;
  173. }
  174. const formGroup = document.createElement('div');
  175. formGroup.className = 'form-group';
  176. const label = document.createElement('label');
  177. label.textContent = field.label;
  178. label.setAttribute('for', `field-${field.name}`);
  179. let input;
  180. switch (field.type) {
  181. case 'textarea':
  182. input = document.createElement('textarea');
  183. input.className = 'form-control';
  184. input.id = `field-${field.name}`;
  185. input.value = field.default || '';
  186. input.rows = 3;
  187. break;
  188. case 'color':
  189. input = document.createElement('input');
  190. input.type = 'color';
  191. input.className = 'color-picker';
  192. input.id = `field-${field.name}`;
  193. input.value = field.default || '#000000';
  194. break;
  195. case 'image':
  196. const imageUpload = document.createElement('div');
  197. imageUpload.className = 'image-upload';
  198. const imagePreview = document.createElement('div');
  199. imagePreview.className = 'upload-preview';
  200. const img = document.createElement('img');
  201. img.src = field.default || '';
  202. img.id = `preview-${field.name}`;
  203. imagePreview.appendChild(img);
  204. const uploadButton = document.createElement('label');
  205. uploadButton.className = 'upload-btn';
  206. uploadButton.textContent = '选择图片';
  207. uploadButton.setAttribute('for', `field-${field.name}`);
  208. input = document.createElement('input');
  209. input.type = 'file';
  210. input.accept = 'image/*';
  211. input.id = `field-${field.name}`;
  212. input.style.display = 'none';
  213. input.addEventListener('change', function(e) {
  214. const file = e.target.files[0];
  215. if (file) {
  216. const reader = new FileReader();
  217. reader.onload = function(event) {
  218. img.src = event.target.result;
  219. window.currentValues[field.name] = event.target.result;
  220. updatePosterPreview();
  221. };
  222. reader.readAsDataURL(file);
  223. }
  224. });
  225. imageUpload.appendChild(imagePreview);
  226. imageUpload.appendChild(uploadButton);
  227. imageUpload.appendChild(input);
  228. formGroup.appendChild(label);
  229. formGroup.appendChild(imageUpload);
  230. editForm.appendChild(formGroup);
  231. window.currentValues[field.name] = field.default || '';
  232. return;
  233. default:
  234. input = document.createElement('input');
  235. input.type = 'text';
  236. input.className = 'form-control';
  237. input.id = `field-${field.name}`;
  238. input.value = field.default || '';
  239. }
  240. input.addEventListener('input', function(e) {
  241. window.currentValues[field.name] = e.target.value;
  242. updatePosterPreview();
  243. });
  244. window.currentValues[field.name] = field.default || '';
  245. formGroup.appendChild(label);
  246. formGroup.appendChild(input);
  247. editForm.appendChild(formGroup);
  248. });
  249. // 启用下载按钮
  250. if (downloadBtn) downloadBtn.disabled = false;
  251. // 更新预览
  252. updatePosterPreview();
  253. // 自动切换到编辑标签页
  254. const editorTab = document.querySelector('.panel-tab[data-tab="editor-tab"]');
  255. if (editorTab) {
  256. editorTab.click();
  257. }
  258. // 添加一个提示信息,如果检测到了额外文本
  259. const detectedCount = allFields.filter(f => f.name.startsWith('detected_text_')).length;
  260. if (detectedCount > 0 && !document.querySelector('.detection-notice')) {
  261. const notice = document.createElement('div');
  262. notice.className = 'detection-notice';
  263. notice.innerHTML = `
  264. <div class="info-message">
  265. <i class="fas fa-info-circle"></i>
  266. <span>已自动检测到模板中的额外可编辑文本</span>
  267. </div>
  268. `;
  269. editForm.insertBefore(notice, editForm.firstChild);
  270. }
  271. }
  272. // 查找模板中的硬编码文本
  273. function findHardcodedTexts(html) {
  274. const texts = new Set();
  275. // 创建一个临时的DOM元素来解析HTML
  276. const temp = document.createElement('div');
  277. temp.innerHTML = html;
  278. // 递归收集文本节点
  279. const collectTextNodes = (element) => {
  280. Array.from(element.childNodes).forEach(node => {
  281. if (node.nodeType === Node.TEXT_NODE) {
  282. const text = node.textContent.trim();
  283. if (text && !isFontAwesomeClass(text) && !isCssValue(text)) {
  284. texts.add(text);
  285. }
  286. } else if (node.nodeType === Node.ELEMENT_NODE) {
  287. collectTextNodes(node);
  288. }
  289. });
  290. };
  291. collectTextNodes(temp);
  292. return Array.from(texts);
  293. }
  294. // 检查是否是Font Awesome类名
  295. function isFontAwesomeClass(text) {
  296. return text.startsWith('fa-') || text.startsWith('fas ') || text.startsWith('far ') || text.startsWith('fab ');
  297. }
  298. // 检查是否是CSS值
  299. function isCssValue(text) {
  300. // CSS单位和值的正则表达式
  301. const cssValueRegex = /^-?\d+(\.\d+)?(px|em|rem|%|vh|vw|deg|s|ms)?$/;
  302. const colorRegex = /^#[0-9a-f]{3,6}$/i;
  303. const rgbaRegex = /^rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(?:,\s*[\d.]+\s*)?\)$/;
  304. return cssValueRegex.test(text) || colorRegex.test(text) || rgbaRegex.test(text);
  305. }
  306. // 检查是否是常见文本
  307. function isCommonText(text) {
  308. // 如果文本太短,可能不需要编辑
  309. if (text.length < 3) return true;
  310. // 常见的不需要编辑的文本
  311. const commonTexts = [
  312. '选择图片',
  313. '上传',
  314. '下载',
  315. '分享',
  316. '编辑',
  317. '预览',
  318. '确定',
  319. '取消'
  320. ];
  321. return commonTexts.includes(text);
  322. }
  323. // 更新海报预览
  324. function updatePosterPreview() {
  325. const posterPreview = document.getElementById('poster-preview');
  326. if (!posterPreview || !window.currentTemplate) return;
  327. // 生成预览HTML
  328. const previewHTML = window.currentTemplate.template(window.currentValues);
  329. posterPreview.innerHTML = previewHTML;
  330. }
  331. // 下载海报
  332. window.downloadPoster = function(format = 'png', quality = 0.9) {
  333. const posterPreview = document.getElementById('poster-preview');
  334. if (!posterPreview) return;
  335. // 显示加载提示
  336. const loadingTip = document.createElement('div');
  337. loadingTip.className = 'loading-tip';
  338. loadingTip.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 正在生成海报...';
  339. document.body.appendChild(loadingTip);
  340. // 获取所有图片并保存原始样式
  341. const images = posterPreview.getElementsByTagName('img');
  342. const originalStyles = Array.from(images).map(img => ({
  343. width: img.style.width,
  344. height: img.style.height,
  345. maxWidth: img.style.maxWidth,
  346. maxHeight: img.style.maxHeight,
  347. objectFit: img.style.objectFit
  348. }));
  349. // 创建一个函数来清理资源
  350. const cleanup = () => {
  351. try {
  352. // 移除加载提示
  353. if (loadingTip && loadingTip.parentNode) {
  354. loadingTip.remove();
  355. }
  356. // 恢复图片原始样式
  357. Array.from(images).forEach((img, index) => {
  358. if (originalStyles[index]) {
  359. Object.assign(img.style, originalStyles[index]);
  360. }
  361. });
  362. // 恢复水印
  363. if (watermarkData) {
  364. watermarkData.parent.appendChild(watermarkData.element);
  365. }
  366. } catch (error) {
  367. console.error('清理资源时发生错误:', error);
  368. }
  369. };
  370. // 临时移除水印(如果有)以便导出时不包含水印
  371. const watermark = posterPreview.querySelector('.poster-watermark');
  372. let watermarkData = null;
  373. if (watermark) {
  374. watermarkData = {
  375. element: watermark,
  376. parent: watermark.parentNode
  377. };
  378. watermark.remove();
  379. }
  380. try {
  381. // 获取预览区域的实际尺寸
  382. const previewRect = posterPreview.getBoundingClientRect();
  383. const previewWidth = previewRect.width;
  384. const previewHeight = previewRect.height;
  385. // 优化图片尺寸和质量
  386. Array.from(images).forEach(img => {
  387. // 保持图片原始比例
  388. img.style.width = '100%';
  389. img.style.height = '100%';
  390. img.style.maxWidth = 'none'; // 移除最大宽度限制
  391. img.style.objectFit = 'cover';
  392. img.style.imageRendering = 'high-quality';
  393. img.style.webkitFontSmoothing = 'antialiased';
  394. img.style.mozOsxFontSmoothing = 'grayscale';
  395. // 强制浏览器使用高质量缩放
  396. if (img.naturalWidth && img.naturalHeight) {
  397. img.setAttribute('width', img.naturalWidth);
  398. img.setAttribute('height', img.naturalHeight);
  399. }
  400. });
  401. } catch (error) {
  402. console.error('设置图片样式时发生错误:', error);
  403. cleanup();
  404. return;
  405. }
  406. // 优化的html2canvas配置
  407. const canvasOptions = {
  408. scale: format === 'png' ? 3 : 2, // 提高缩放比例以获得更清晰的输出
  409. useCORS: true,
  410. allowTaint: true,
  411. backgroundColor: format === 'png' ? null : 'white',
  412. imageTimeout: 30000, // 30秒超时
  413. logging: false,
  414. onclone: function(clonedDoc) {
  415. try {
  416. const clonedImages = clonedDoc.getElementsByTagName('img');
  417. Array.from(clonedImages).forEach(img => {
  418. img.style.width = '100%';
  419. img.style.height = '100%';
  420. img.style.maxWidth = 'none';
  421. img.style.objectFit = 'cover';
  422. img.style.imageRendering = 'high-quality';
  423. img.style.webkitFontSmoothing = 'antialiased';
  424. img.style.mozOsxFontSmoothing = 'grayscale';
  425. if (img.naturalWidth && img.naturalHeight) {
  426. img.setAttribute('width', img.naturalWidth);
  427. img.setAttribute('height', img.naturalHeight);
  428. }
  429. // 确保图片已加载
  430. if (!img.complete) {
  431. return new Promise((resolve) => {
  432. img.onload = resolve;
  433. });
  434. }
  435. });
  436. } catch (error) {
  437. console.error('克隆文档时发生错误:', error);
  438. }
  439. }
  440. };
  441. // 使用Promise.race来添加超时处理
  442. const timeoutPromise = new Promise((_, reject) => {
  443. setTimeout(() => reject(new Error('生成超时')), 40000); // 40秒总超时
  444. });
  445. Promise.race([
  446. html2canvas(posterPreview, canvasOptions),
  447. timeoutPromise
  448. ]).then(canvas => {
  449. try {
  450. // 根据格式选择导出方式
  451. const exportQuality = format === 'png' ? undefined : 1.0; // 最高质量JPEG
  452. const mimeType = format === 'png' ? 'image/png' : 'image/jpeg';
  453. // 获取原始canvas的尺寸
  454. const originalWidth = canvas.width;
  455. const originalHeight = canvas.height;
  456. // 创建一个新的canvas,保持原始比例
  457. const tempCanvas = document.createElement('canvas');
  458. const ctx = tempCanvas.getContext('2d', {
  459. alpha: format === 'png',
  460. willReadFrequently: false,
  461. desynchronized: true
  462. });
  463. // 设置输出尺寸,保持原始比例
  464. const maxWidth = 1600; // 增加最大宽度以提高清晰度
  465. const scale = maxWidth / originalWidth;
  466. tempCanvas.width = maxWidth;
  467. tempCanvas.height = originalHeight * scale;
  468. // 使用高质量的图像平滑
  469. ctx.imageSmoothingEnabled = true;
  470. ctx.imageSmoothingQuality = 'high';
  471. // 绘制调整后的图像,保持比例
  472. ctx.drawImage(canvas, 0, 0, tempCanvas.width, tempCanvas.height);
  473. // 如果是PNG格式,尝试优化透明度处理
  474. if (format === 'png') {
  475. const imageData = ctx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
  476. ctx.putImageData(imageData, 0, 0);
  477. }
  478. tempCanvas.toBlob(blob => {
  479. const templateName = window.currentTemplate ? window.currentTemplate.name : 'poster';
  480. saveAs(blob, `${templateName}-${Date.now()}.${format}`);
  481. cleanup();
  482. }, mimeType, exportQuality);
  483. } catch (error) {
  484. console.error('导出图片时发生错误:', error);
  485. cleanup();
  486. }
  487. }).catch(error => {
  488. console.error('下载海报失败:', error);
  489. alert('生成海报失败,请重试');
  490. cleanup();
  491. });
  492. }