UNPKG

bytefun

Version:

一个打通了原型设计、UI设计与代码转换、跨平台原生代码开发等的平台

1,444 lines (1,084 loc) 64.7 kB
import * as vscode from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; import { copyImagesBySimulatingUserAction } from './copyData'; import { getNonce } from './utils'; import { WorkspaceUtils } from './workspaceUtils'; export class HtmlPreviewManager { private htmlPreviewPanels: Map<string, vscode.WebviewPanel> = new Map(); private _extensionUri: vscode.Uri; private _onSaveUIConfig?: (configData: string) => Promise<void>; private _onSavePRDConfig?: (configData: string, fromWhere?: string) => Promise<void>; private _onSaveBackendConfig?: (configData: string) => Promise<void>; private panelFileTypes: Map<vscode.WebviewPanel, string> = new Map(); // 记录每个面板对应的文件类型 constructor(extensionUri: vscode.Uri, onSaveUIConfig?: (configData: string) => Promise<void>, onSavePRDConfig?: (configData: string, fromWhere?: string) => Promise<void>, onSaveBackendConfig?: (configData: string) => Promise<void>) { this._extensionUri = extensionUri; this._onSaveUIConfig = onSaveUIConfig; this._onSavePRDConfig = onSavePRDConfig; this._onSaveBackendConfig = onSaveBackendConfig; } /** * 在浏览器中打开HTML文件 */ public async openHtmlFileInBrowser(filePath: string, configData?: string, fromWhere?: string, uniqueKey?: string, displayTitle?: string): Promise<boolean> { try { const workspaceRoot = WorkspaceUtils.getProjectRootPath(); if (!workspaceRoot) { vscode.window.showWarningMessage('无法打开文件:未找到工作区'); return false; // 返回false表示操作失败 } let fullPath = filePath; if (!path.isAbsolute(filePath)) { fullPath = path.join(workspaceRoot, filePath); } // 生成面板的唯一键和显示标题 const panelKey = uniqueKey || fullPath; const panelTitle = displayTitle || path.basename(fullPath); // 如果该预览tab已经存在,则直接显示它 if (this.htmlPreviewPanels.has(panelKey)) { const panel = this.htmlPreviewPanels.get(panelKey); if (panel) { panel.reveal(panel.viewColumn); return false; // 返回false表示没有创建新tab } } // 检查文件是否存在 if (!fs.existsSync(fullPath)) { console.error(`❌ [HtmlPreviewManager] HTML文件不存在: "${fullPath}"`); vscode.window.showWarningMessage(`HTML文件不存在: ${filePath}`); return false; // 返回false表示操作失败 } const panel = vscode.window.createWebviewPanel( 'bytefunHtmlPreview', // viewType panelTitle, // title - 使用自定义标题 vscode.ViewColumn.One, // viewColumn { enableScripts: true, // 授权Webview读取整个工作区的文件 (以防主HTML有其他如img的资源) localResourceRoots: [vscode.Uri.file(workspaceRoot)], // 保持webview状态,避免重新加载 retainContextWhenHidden: true } ); // 保存新创建的panel - 使用唯一键 this.htmlPreviewPanels.set(panelKey, panel); // 记录面板对应的文件类型 const fileName = path.basename(fullPath); this.panelFileTypes.set(panel, fileName); // 当panel关闭时,从map中移除 panel.onDidDispose(() => { this.htmlPreviewPanels.delete(panelKey); this.panelFileTypes.delete(panel); }, null, []); // 添加消息监听器处理按钮点击事件 panel.webview.onDidReceiveMessage( message => { switch (message.command) { case 'refreshPreview': // 重新加载HTML内容 this.refreshHtmlPreview(panel, fullPath, undefined, undefined); break; case 'copyDesign': this.handleCopyDesign(message.data); break; case 'findPageData': this.handleFindPageData(message.pageEnName); break; case 'saveConfig': this.handleSaveConfig(message.data, panel, message.fromWhere); break; } }, undefined, [] ); this.refreshHtmlPreview(panel, fullPath, configData, fromWhere); return true; // 返回true表示成功创建了新tab } catch (error) { console.error('❌ [HtmlPreviewManager] 在webview中打开HTML文件失败:', error); vscode.window.showErrorMessage(`在webview中打开HTML文件失败: ${error}`); return false; // 返回false表示操作失败 } } /** * 处理HTML内容:修改CSS样式、追加CSS和HTML */ private processHtmlContent(html: string): string { try { let processedHtml = html; // 1. 将CSS定义的body的h改为912px // 更精确地匹配screen和body元素的height属性 processedHtml = processedHtml.replace( /body\s*\{[^}]*height:\s*\d+px[^}]*\}/gi, (match) => match.replace(/(height:\s*)\d+px/gi, '$1912px') ); // // 2. 强制修改.page-container的padding为22px 0 22px 0 // processedHtml = processedHtml.replace( // /\.page-container\s*\{[^}]*\}/gi, // (match) => { // // 检查是否已有padding属性 // if (/padding\s*:/i.test(match)) { // // 如果已有padding,则替换 // return match.replace(/padding\s*:\s*[^;]+;?/gi, 'padding: 22px 0;'); // } else { // // 如果没有padding,则在最后一个属性后添加 // return match.replace(/}$/, ' padding: 22px 0;\n}'); // } // } // ); // 3. 强制修改.dialog-overlay的CSS定义 processedHtml = processedHtml.replace( /\.dialog-overlay\s*\{([^}]*)\}/gi, (match, cssContent) => { let modifiedCSS = cssContent; // 强制替换或添加这些属性 const targetProps = { 'position': 'absolute', 'top': '0', 'left': '0', 'width': '100%', 'height': '100%', 'background': 'var(--bg-mask)', 'display': 'flex', 'justify-content': 'center', 'align-items': 'center', 'z-index': '2000' }; // 对每个目标属性进行处理 Object.entries(targetProps).forEach(([prop, value]) => { const propRegex = new RegExp(`\\b${prop}\\s*:\\s*[^;]+;?`, 'gi'); if (propRegex.test(modifiedCSS)) { // 如果属性存在,替换它 modifiedCSS = modifiedCSS.replace(propRegex, `${prop}: ${value};`); } else { // 如果属性不存在,添加它 modifiedCSS += `\n ${prop}: ${value};`; } }); return `.dialog-overlay {${modifiedCSS} }`; } ); // 4. 处理 vtype="dialog" 和 vtype="toast" 节点的 bottom 样式 processedHtml = this.processVtypeBottomStyle(processedHtml); // 5. 将addCss.txt里面的css定义追加到.phone-container的后面 const additionalCss = ` .popup-container { width: 393px; height: 60px; position: absolute; bottom: 0; left: 0; right: 0; background: transparent; border: none; display: flex; align-items: center; /* The following properties enable horizontal scrolling */ justify-content: flex-start; overflow-x: auto; flex-wrap: nowrap; padding: 0 10px; box-sizing: border-box; /* Hide scrollbar for a cleaner look */ -ms-overflow-style: none; /* IE and Edge */ scrollbar-width: none; /* Firefox */ } .popup-container::-webkit-scrollbar { display: none; /* For Chrome, Safari and Opera */ } .popup-button { height: 40px; background: #292929; border-radius: 10px; color: #EEE; font-size: 16px; display: flex; align-items: center; justify-content: center; padding: 0 20px; margin: 0 10px; cursor: pointer; /* Add the following properties to prevent shrinking and text wrapping */ flex-shrink: 0; white-space: nowrap; } .popup-button:hover { background: #424242; } `; // 查找.phone-container相关样式的结束位置,在其后面添加CSS const phoneContainerPattern = /(\.phone-container[^}]*})/; if (phoneContainerPattern.test(processedHtml)) { processedHtml = processedHtml.replace(phoneContainerPattern, `$1\n\n${additionalCss}`); } // 6. 查找HTML中的特定vtype节点,并生成对应的控制按钮 const vtypeTargets = ['dialog', 'toast', 'dropDownMenu', 'sideSlidePanel']; let popupButtons = ''; vtypeTargets.forEach(vtype => { const vtypePattern = new RegExp(`<div[^>]*vtype="${vtype}"[^>]*>`, 'gi'); const matches = processedHtml.match(vtypePattern); if (matches) { matches.forEach((match, index) => { // 提取name属性作为按钮文本,如果没有则使用vtype const nameMatch = match.match(/name="([^"]*)"/); const buttonText = nameMatch ? nameMatch[1] : vtype; // 提取id属性,如果没有则生成一个 const idMatch = match.match(/id="([^"]*)"/); const elementId = idMatch ? idMatch[1] : `${vtype}Element${index}`; // 生成对应的控制按钮 const buttonId = `${elementId}ToggleBtn`; popupButtons += ` <div name="点击切换${buttonText}显隐" id="${buttonId}" class="popup-button" vtype="button">${buttonText}</div>\n`; }); } }); // 7. 查找多状态容器及其子状态节点,并生成对应的控制按钮 popupButtons += this.processMultiStateContainers(processedHtml); // 生成包含动态按钮的HTML const additionalHtml = ` <div name="弹出类元素显示与隐藏切换容器" class="popup-container"> ${popupButtons} </div>`; // 在body节点的排在最后的孩子那里插入HTML内容 if (processedHtml.includes('</body>')) { processedHtml = processedHtml.replace('</body>', `${additionalHtml}\n</body>`); } else { // 如果没有</body>标签,在HTML末尾添加 processedHtml += `\n${additionalHtml}`; } return processedHtml; } catch (error) { console.error('❌ [HtmlPreviewManager] 处理HTML内容失败:', error); return html; // 返回原始HTML } } /** * 处理index.html内容:修改screen和body的高度 */ private processIndexHtmlContent(html: string): string { try { let processedHtml = html; // 将CSS定义的.screen类的height改为912px processedHtml = processedHtml.replace( /\.screen\s*\{[^}]*height:\s*\d+px[^}]*\}/gi, (match) => match.replace(/(height:\s*)\d+px/gi, '$1912px') ); return processedHtml; } catch (error) { console.error('❌ [HtmlPreviewManager] 处理index.html内容失败:', error); return html; // 返回原始HTML } } /** * 处理 vtype="dialog" 和 vtype="toast" 节点的 bottom 样式 */ private processVtypeBottomStyle(html: string): string { try { let processedHtml = html; let hasModification = false; // 处理 vtype="dialog" 节点 const dialogResult = this.processVtypeNodeBottomStyle(processedHtml, 'dialog', (bottomValue) => { // dialog节点:如果有bottom: 0,改为bottom: 60px if (bottomValue === 0) { return 60; } return bottomValue; // 其他值不变 }); if (dialogResult.hasModification) { processedHtml = dialogResult.processedHtml; hasModification = true; } // 处理 vtype="toast" 节点 const toastResult = this.processVtypeNodeBottomStyle(processedHtml, 'toast', (bottomValue) => { // toast节点:bottom值加60px return bottomValue + 60; }); if (toastResult.hasModification) { processedHtml = toastResult.processedHtml; hasModification = true; } if (hasModification) { } else { } return processedHtml; } catch (error) { console.error('❌ [HtmlPreviewManager] 处理vtype节点bottom样式失败:', error); return html; // 返回原始HTML } } /** * 为HTML内容中的所有script标签添加nonce属性以解决CSP安全问题 */ private addNonceToScripts(html: string, nonce: string): string { try { let processedHtml = html; let scriptCount = 0; // 处理所有的<script>标签,为它们添加nonce属性 processedHtml = processedHtml.replace(/<script(\s[^>]*)?>/gi, (match, attributes) => { scriptCount++; // 检查是否已经有nonce属性 if (attributes && /nonce\s*=/i.test(attributes)) { return match; } // 为script标签添加nonce属性 if (attributes) { const newScript = `<script nonce="${nonce}"${attributes}>`; return newScript; } else { const newScript = `<script nonce="${nonce}">`; return newScript; } }); if (scriptCount > 0) { } else { } return processedHtml; } catch (error) { console.error('❌ [HtmlPreviewManager] 为script标签添加nonce失败:', error); return html; // 返回原始HTML } } /** * 为iframe内容添加CSP meta标签 */ private addCSPToIframeContent(html: string, nonce: string): string { try { let processedHtml = html; // 构建CSP标签,允许内联样式和带nonce的脚本 const cspTag = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline' https://cdnjs.cloudflare.com; script-src 'nonce-${nonce}' 'unsafe-eval'; img-src data: https: http:; font-src https://cdnjs.cloudflare.com; connect-src *;">`; // 检查是否已经有CSP标签 if (/Content-Security-Policy/i.test(processedHtml)) { return processedHtml; } // 将CSP标签添加到head中 if (processedHtml.includes('<head>')) { processedHtml = processedHtml.replace('<head>', `<head>\n ${cspTag}`); } else if (processedHtml.includes('<html>')) { processedHtml = processedHtml.replace('<html>', `<html>\n<head>\n ${cspTag}\n</head>`); } else { processedHtml = `<head>\n ${cspTag}\n</head>\n${processedHtml}`; } return processedHtml; } catch (error) { console.error('❌ [HtmlPreviewManager] 为iframe内容添加CSP失败:', error); return html; // 返回原始HTML } } /** * 使用cheerio将onclick等内联事件转换为script中的事件监听器 */ private convertInlineEventsWithCheerio(htmlContent: string): string { try { // 动态导入cheerio const cheerio = require('cheerio'); // 使用cheerio解析HTML const $ = cheerio.load(htmlContent, { xmlMode: false, decodeEntities: false, // 保持实体编码不变 }); // 查找所有带有onclick属性的元素 const onclickElements = $('[onclick]'); if (onclickElements.length === 0) { return htmlContent; } // 收集所有事件监听器代码 const eventListeners: string[] = []; onclickElements.each((index: number, element: any) => { const $element = $(element); const onclickCode = $element.attr('onclick'); if (!onclickCode) return; // 确保元素有ID,如果没有则生成一个 let elementId = $element.attr('id'); if (!elementId) { elementId = `auto_event_${Math.random().toString(36).substr(2, 9)}`; $element.attr('id', elementId); } // 生成事件监听器代码 const listenerCode = ` const element_${elementId.replace(/[^a-zA-Z0-9]/g, '_')} = document.getElementById('${elementId}'); if (element_${elementId.replace(/[^a-zA-Z0-9]/g, '_')}) { element_${elementId.replace(/[^a-zA-Z0-9]/g, '_')}.addEventListener('click', function(event) { ${onclickCode} }); } else { console.warn('⚠️ [Event Converter] 未找到元素:', '${elementId}'); }`; eventListeners.push(listenerCode); // 移除原始的onclick属性 $element.removeAttr('onclick'); }); // 创建完整的事件监听器脚本 const eventListenerScript = ` <script> document.addEventListener('DOMContentLoaded', function() { ${eventListeners.join('')} }); </script>`; // 将事件监听器脚本追加到body结束前 if ($('body').length > 0) { $('body').append(eventListenerScript); } else { // 如果没有body标签,追加到html末尾 $('html').append(eventListenerScript); } const processedHtml = $.html(); return processedHtml; } catch (error) { console.error('❌ [HtmlPreviewManager] 使用cheerio转换内联事件处理器失败:', error); return htmlContent; // 返回原始HTML } } /** * 使用 cheerio 处理多状态容器及其子状态节点,生成对应的控制按钮 */ private processMultiStateContainers(html: string): string { try { // 动态导入cheerio const cheerio = require('cheerio'); // 使用cheerio解析HTML const $ = cheerio.load(html, { xmlMode: false, decodeEntities: false, // 保持实体编码不变 }); let popupButtons = ''; // 查找所有 vtype="multiStateContainer" 的节点 const $multiStateContainers = $('[vtype="multiStateContainer"]'); $multiStateContainers.each((containerIndex: number, containerElement: any) => { const $container = $(containerElement); // 在当前容器内查找所有 vtype="oneStateContentContainer" 的子节点 const $oneStateContainers = $container.find('[vtype="oneStateContentContainer"]'); $oneStateContainers.each((stateIndex: number, stateElement: any) => { const $stateElement = $(stateElement); // 提取name属性作为按钮文本 const nameAttr = $stateElement.attr('name'); const buttonText = nameAttr || `状态${stateIndex + 1}`; // 提取id属性,如果没有则生成一个 const idAttr = $stateElement.attr('id'); const elementId = idAttr || `multiStateContent${containerIndex}_${stateIndex}`; // 如果原来没有id,为元素添加id if (!idAttr) { $stateElement.attr('id', elementId); } // 生成对应的控制按钮 const buttonId = `${elementId}ToggleBtn`; popupButtons += ` <div name="点击切换${buttonText}显隐" id="${buttonId}" class="popup-button" vtype="button">${buttonText}</div>\n`; }); }); return popupButtons; } catch (error) { console.error('❌ [HtmlPreviewManager] 处理多状态容器失败:', error); return ''; } } /** * 处理指定vtype节点的bottom样式 */ private processVtypeNodeBottomStyle(html: string, vtype: string, bottomValueProcessor: (bottomValue: number) => number): { processedHtml: string, hasModification: boolean } { try { let processedHtml = html; let hasModification = false; // 查找所有指定vtype的节点 const vtypeRegex = new RegExp(`<(\\w+)[^>]*vtype=["']${vtype}["'][^>]*>`, 'gi'); let match; // 收集所有节点的class信息 const vtypeClasses: string[] = []; while ((match = vtypeRegex.exec(html)) !== null) { const classMatch = match[0].match(/class=["']([^"']+)["']/); if (classMatch && classMatch[1]) { const classNames = classMatch[1].split(/\s+/); classNames.forEach(className => { if (className.trim() && !vtypeClasses.includes(className.trim())) { vtypeClasses.push(className.trim()); } }); } } // 处理每个CSS类,查找并修改bottom值 for (const className of vtypeClasses) { // 查找该class的CSS定义,检查是否有bottom属性 const classRegex = new RegExp(`\\.${className.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{([^}]*bottom\\s*:\\s*\\d+(?:px)?[^}]*)\\}`, 'gi'); processedHtml = processedHtml.replace(classRegex, (match, cssContent) => { // 提取bottom值 const bottomMatch = cssContent.match(/bottom\s*:\s*(\d+)(?:px)?/i); if (bottomMatch) { const currentBottomValue = parseInt(bottomMatch[1]); const newBottomValue = bottomValueProcessor(currentBottomValue); // 替换bottom值 const modifiedCssContent = cssContent.replace(/bottom\s*:\s*\d+(?:px)?/gi, `bottom: ${newBottomValue}px`); hasModification = true; return `.${className} {${modifiedCssContent}}`; } return match; }); } return { processedHtml, hasModification }; } catch (error) { console.error(`❌ [HtmlPreviewManager] 处理${vtype}节点bottom样式失败:`, error); return { processedHtml: html, hasModification: false }; } } /** * 刷新HTML预览内容 */ private refreshHtmlPreview(panel: vscode.WebviewPanel, fullPath: string, configData?: string, fromWhere?: string): void { try { let mainHtmlContent = fs.readFileSync(fullPath, 'utf8'); const fileDir = path.dirname(fullPath); // 生成nonce用于CSP安全 const nonce = getNonce(); // 检查是否为 uiDesign.html、prdDesign.html 或 backendDesign.html const fileName = path.basename(fullPath); const isUiDesignFile = fileName === 'uiDesign.html'; const isPrdDesignFile = fileName === 'prdDesign.html'; const isBackendDesignFile = fileName === 'backendDesign.html'; // 定义全局缩放样式(uiDesign.html、prdDesign.html 和 backendDesign.html 不需要缩放) const zoomStyle = (isUiDesignFile || isPrdDesignFile || isBackendDesignFile) ? '' : `<style>html { zoom: 0.6; }</style>`; // 最终解决方案:读取iframe的src文件内容,并使用srcdoc直接内联 let finalHtml = mainHtmlContent.replace( /<iframe\s+.*?src="([^"]*)"[^>]*>.*?<\/iframe>/gi, (match, src) => { // 只处理本地相对路径 if (src.startsWith('http') || src.startsWith('data:')) { return match; } const iframeHtmlPath = path.resolve(fileDir, src); if (fs.existsSync(iframeHtmlPath)) { let iframeContent = fs.readFileSync(iframeHtmlPath, 'utf8'); // 1. 为iframe内容添加CSP安全标签 iframeContent = this.addCSPToIframeContent(iframeContent, nonce); // 2. 使用cheerio转换内联事件处理器为script中的事件监听器 iframeContent = this.convertInlineEventsWithCheerio(iframeContent); // 3. 为iframe内容中的所有script标签添加nonce属性 iframeContent = this.addNonceToScripts(iframeContent, nonce); // 4. 对iframe的HTML内容进行处理:修改CSS样式、追加CSS和HTML iframeContent = this.processHtmlContent(iframeContent); const iframeDir = path.dirname(iframeHtmlPath); // 将iframe中的外部CSS内联 iframeContent = iframeContent.replace( /<link\s+.*?rel="stylesheet"\s+.*?href="([^"]*)"[^>]*>/gi, (linkTag, href) => { if (href.startsWith('http') || href.startsWith('//')) { return linkTag; // 不处理外部CSS } const cssPath = path.resolve(iframeDir, href); if (fs.existsSync(cssPath)) { const cssContent = fs.readFileSync(cssPath, 'utf8'); return `<style>\n${cssContent}\n</style>`; } else { console.warn(`⚠️ [HtmlPreviewManager] iframe中的CSS文件未找到: "${cssPath}"`); return `<!-- CSS not found at ${href} -->`; } } ); // 将缩放样式也注入到iframe的head中 if (iframeContent.includes('<head>')) { iframeContent = iframeContent.replace('<head>', `<head>${zoomStyle}`); } else { iframeContent = `<head>${zoomStyle}</head>${iframeContent}`; } // 使用srcdoc属性来嵌入完整的HTML内容。需要对双引号进行转义。 const escapedContent = iframeContent.replace(/"/g, '&quot;'); // 移除原来的src属性,替换为srcdoc const newIframe = match.replace(/src="[^"]*"/, `id="iframe-${path.basename(iframeHtmlPath)}" srcdoc="${escapedContent}"`); return newIframe; } else { console.warn(`⚠️ [HtmlPreviewManager] iframe指向的文件不存在: "${iframeHtmlPath}"`); return '<iframe><p>Error: The source file for this iframe was not found.</p></iframe>'; } } ); // 将缩放样式注入到主文档的head中 if (finalHtml.includes('<head>')) { finalHtml = finalHtml.replace('<head>', `<head>${zoomStyle}`); } else { finalHtml = `<head>${zoomStyle}</head>` + finalHtml; } // 对index.html进行screen和body高度修改 finalHtml = this.processIndexHtmlContent(finalHtml); // 在现有HTML中插入操作栏相关代码 const htmlWithToolbar = this.insertToolbarIntoHtml(finalHtml, panel, nonce, isUiDesignFile || isPrdDesignFile || isBackendDesignFile); panel.webview.html = htmlWithToolbar; // 如果是uiDesign.html文件且有配置数据,延迟调用JavaScript方法 if (isUiDesignFile && configData) { setTimeout(() => { try { panel.webview.postMessage({ command: 'importUiDesignData', data: configData }); } catch (error) { console.error('❌ [HtmlPreviewManager] 发送配置数据失败:', error); } }, 1000); // 延迟1秒确保页面加载完成 } // 如果是prdDesign.html文件且有配置数据,延迟调用JavaScript方法 if (isPrdDesignFile && configData) { setTimeout(() => { try { panel.webview.postMessage({ command: 'importPrdJsonData', data: configData, fromWhere: fromWhere || 'prd' }); } catch (error) { console.error('❌ [HtmlPreviewManager] 发送配置数据失败:', error); } }, 2000); // 延迟1秒确保页面加载完成 } // 如果是backendDesign.html文件且有配置数据,延迟调用JavaScript方法 if (isBackendDesignFile && configData) { setTimeout(() => { try { panel.webview.postMessage({ command: 'importBackendDesignData', data: configData }); } catch (error) { console.error('❌ [HtmlPreviewManager] 发送后端设计配置数据失败:', error); } }, 1000); // 延迟1秒确保页面加载完成 } } catch (error) { console.error('❌ [HtmlPreviewManager] 刷新HTML预览失败:', error); } } /** * 在现有HTML中插入操作栏相关代码 */ private insertToolbarIntoHtml(originalHtml: string, panel: vscode.WebviewPanel, nonce?: string, isUiDesignFile?: boolean): string { // 如果是 uiDesign.html,直接返回原始HTML,不添加任何工具栏 if (isUiDesignFile) { return originalHtml; } let modifiedHtml = originalHtml; // 如果没有传入nonce,则生成一个新的 const actualNonce = nonce || getNonce(); // 1. 添加CSP meta标签到head中 - 允许外部CDN资源和内联脚本 const cspTag = `<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: https: http:; style-src 'self' 'unsafe-inline' ${panel.webview.cspSource} https://cdnjs.cloudflare.com; script-src 'self' 'unsafe-inline' 'unsafe-eval' ${panel.webview.cspSource} https://cdnjs.cloudflare.com; img-src ${panel.webview.cspSource} data: https: http:; frame-src *; connect-src *; font-src https://cdnjs.cloudflare.com data:;">`; if (modifiedHtml.includes('<head>')) { modifiedHtml = modifiedHtml.replace('<head>', `<head>\n${cspTag}`); } else if (modifiedHtml.includes('<html>')) { modifiedHtml = modifiedHtml.replace('<html>', `<html>\n<head>\n${cspTag}\n</head>`); } else { modifiedHtml = `<head>\n${cspTag}\n</head>\n` + modifiedHtml; } // 2. 添加操作栏样式到head中 const toolbarStyles = ` <style id="bytefun-toolbar-styles"> /* ByteFun操作栏样式 */ .bytefun-toolbar { position: fixed; top: 0; left: 0; right: 0; z-index: 10000; display: flex; align-items: center; justify-content: space-between; padding: 8px 16px; background: var(--vscode-sideBar-background, #252526); border-bottom: 1px solid var(--vscode-sideBar-border, #3c3c3c); min-height: 40px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } .bytefun-toolbar-left { display: flex; align-items: center; gap: 8px; color: #c6c6c6; font-size: 16px; } .bytefun-toolbar-right { display: flex; align-items: center; gap: 8px; } .bytefun-toolbar-button { background: #363636; color: #c6c6c6; border: none; padding: 6px 16px; border-radius: 3px; cursor: pointer; font-size: 17px; font-weight: 500; transition: background-color 0.2s ease; white-space: nowrap; } .bytefun-toolbar-button:hover { background: #464646; } .bytefun-toolbar-button:active { transform: translateY(1px); } /* phone-container 中的 fix_input 样式 */ .phone-fix-input-container { width: 350px; margin-top: 10px; padding: 8px; background: var(--vscode-input-background, #3c3c3c); border: 1px solid var(--vscode-input-border, #5a5a5a); border-radius: 14px; display: block; } .phone-fix-input { width: 100%; min-height: 60px; background: transparent; border: none; color: var(--vscode-input-foreground, #cccccc); font-family: monospace; font-size: 17px; resize: vertical; outline: none; font-weight: 400; } .phone-fix-input:focus { outline: none; border: none; box-shadow: none; } .phone-fix-input::placeholder { color: var(--vscode-input-placeholderForeground, #888); } /* 给原有内容添加顶部边距,避免被操作栏遮挡 */ body { padding-top: 70px !important; } </style>`; if (modifiedHtml.includes('</head>')) { modifiedHtml = modifiedHtml.replace('</head>', `${toolbarStyles}\n</head>`); } else { modifiedHtml = toolbarStyles + '\n' + modifiedHtml; } // 3. 添加操作栏HTML到body开始位置 const copyButtons = isUiDesignFile ? '' : ` <button class="bytefun-toolbar-button" id="copyBtn">复制修复</button> <button class="bytefun-toolbar-button" id="copyDesignBtn">复制设计稿</button>`; const toolbarHtml = ` <div class="bytefun-toolbar"> <div class="bytefun-toolbar-left"> <span>🎨 HTML预览</span> </div> <div class="bytefun-toolbar-right"> <button class="bytefun-toolbar-button" id="refreshBtn">刷新</button>${copyButtons} </div> </div>`; if (modifiedHtml.includes('<body>')) { modifiedHtml = modifiedHtml.replace('<body>', `<body>\n${toolbarHtml}`); } else if (modifiedHtml.includes('<body ')) { // 处理带有属性的body标签 modifiedHtml = modifiedHtml.replace(/<body([^>]*)>/, `<body$1>\n${toolbarHtml}`); } else { // 如果没有body标签,在HTML开始处添加 modifiedHtml = `<body>\n${toolbarHtml}\n</body>\n` + modifiedHtml; } // 为每个phone-frame-container的page-title后面添加fix_input输入框 let phoneContainerIndex = 0; modifiedHtml = modifiedHtml.replace(/class="phone-frame-container"/g, (match) => { phoneContainerIndex++; const fixInputHtml = ` <div class="phone-fix-input-container" id="phoneFixInput${phoneContainerIndex}"> <textarea class="phone-fix-input" placeholder="在此输入修改描述..."></textarea> </div>`; return match; // 先返回原匹配,然后在page-title后插入 }); // 重置计数器,然后在每个page-title后插入输入框 phoneContainerIndex = 0; modifiedHtml = modifiedHtml.replace(/<div class="page-title">[\s\S]*?<\/div>/g, (match) => { phoneContainerIndex++; const fixInputHtml = ` <div class="phone-fix-input-container" id="phoneFixInput${phoneContainerIndex}"> <textarea class="phone-fix-input" placeholder="在此输入修改描述..."></textarea> </div>`; return match + fixInputHtml; }); // 4. 添加内联脚本到body结束前 const inlineScript = `<script nonce="${actualNonce}"> const vscode = acquireVsCodeApi(); // 页面加载时初始化 document.addEventListener('DOMContentLoaded', function () { // 初始化事件监听器 initializeEventListeners(); }); // 初始化所有事件监听器 function initializeEventListeners() { // 清理之前的事件监听器(如果存在) cleanupEventListeners(); // 刷新预览按钮 const refreshBtn = document.getElementById('refreshBtn'); if (refreshBtn) { refreshBtn.addEventListener('click', handleRefreshPreview); } else { } // 复制修复输入按钮 const copyBtn = document.getElementById('copyBtn'); if (copyBtn) { copyBtn.addEventListener('click', handleCopyFixInput); } else { } // 复制设计稿按钮 const copyDesignBtn = document.getElementById('copyDesignBtn'); if (copyDesignBtn) { copyDesignBtn.addEventListener('click', clickCopyDesign); } else { } // 编辑按钮事件监听器 const editButtons = document.querySelectorAll('.edit-btn'); editButtons.forEach(button => { button.addEventListener('click', handleEditButtonClick); }); // 监听iframe的load事件 const iframes = document.querySelectorAll('iframe'); iframes.forEach(iframe => { iframe.addEventListener('load', (event) => { const iframeContentWindow = event.target.contentWindow; if (iframeContentWindow) { // 通用的UI元素处理函数 const processUIElements = (vtype, displayStyle, autoHide, autoHideDelay) => { const regex = new RegExp('<div[^>]*vtype="' + vtype + '"[^>]*>', 'g'); const matches = bodyHTML.match(regex); if (matches && matches.length > 0) { matches.forEach(match => { const idMatch = match.match(/id="([^"]+)"/); if (idMatch) { const elementId = idMatch[1]; const toggleBtn = iframeContentWindow.document.getElementById(elementId+'ToggleBtn'); if (toggleBtn) { toggleBtn.addEventListener('click', () => { const element = iframeContentWindow.document.getElementById(elementId); if (element) { if (element.style.display === 'none') { element.style.display = displayStyle; // 如果需要自动隐藏 if (autoHide) { setTimeout(() => { element.style.display = 'none'; }, autoHideDelay); } } else { element.style.display = 'none'; } } }); } } }); } else { } }; // 查找并处理各种UI元素 const bodyHTML = iframeContentWindow.document.body.innerHTML; // 处理各种类型的UI元素 processUIElements('dialog', 'flex', false, 0); // 对话框:flex显示,不自动隐藏 processUIElements('toast', 'flex', true, 3000); // Toast:flex显示,3秒自动隐藏 processUIElements('dropDownMenu', 'block', false, 0); // 下拉菜单:block显示,不自动隐藏 processUIElements('sideSlidePanel', 'block', false, 0); // 侧滑面板:block显示,不自动隐藏 // 处理多状态容器的状态互斥逻辑 processMultiStateContainers(); // 多状态容器处理函数 function processMultiStateContainers() { // 查找所有多状态容器 const multiStateContainerRegex = /<div[^>]*vtype="multiStateContainer"[^>]*>/g; const multiStateMatches = bodyHTML.match(multiStateContainerRegex); if (!multiStateMatches || multiStateMatches.length === 0) { return; } multiStateMatches.forEach((containerMatch, containerIndex) => { // 提取容器的id属性 const containerIdMatch = containerMatch.match(/id="([^"]+)"/); if (!containerIdMatch) { console.warn('⚠️ [HTML Preview] 多状态容器没有id,跳过处理'); return; } const containerId = containerIdMatch[1]; // 在iframe中找到这个容器元素 const containerElement = iframeContentWindow.document.getElementById(containerId); if (!containerElement) { console.warn('⚠️ [HTML Preview] 在iframe中未找到多状态容器元素:', containerId); return; } // 在容器内查找所有状态元素 const stateElements = containerElement.querySelectorAll('[vtype="oneStateContentContainer"]'); if (stateElements.length === 0) { console.warn('⚠️ [HTML Preview] 容器', containerId, '中没有状态元素'); return; } // 为每个状态元素添加按钮点击事件 stateElements.forEach((stateElement, stateIndex) => { const stateElementId = stateElement.id; if (!stateElementId) { console.warn('⚠️ [HTML Preview] 状态元素没有id,跳过处理'); return; } // 查找对应的按钮 const toggleBtnId = stateElementId + 'ToggleBtn'; const toggleBtn = iframeContentWindow.document.getElementById(toggleBtnId); if (!toggleBtn) { console.warn('⚠️ [HTML Preview] 未找到状态按钮:', toggleBtnId); return; } // 添加点击事件监听器 toggleBtn.addEventListener('click', () => { // 执行状态互斥切换 stateElements.forEach((otherStateElement) => { if (otherStateElement.id === stateElementId) { otherStateElement.style.opacity = '1'; } else { otherStateElement.style.opacity = '0'; } }); }); }); // 初始化:根据active类设置opacity状态 if (stateElements.length > 0) { stateElements.forEach((stateElement) => { const hasActiveClass = stateElement.classList.contains('active'); if (hasActiveClass) { stateElement.style.opacity = '1'; } else { stateElement.style.opacity = '0'; } }); } }); } } }); }); // 监听来自VS Code的消息 window.addEventListener('message', handleVSCodeMessage); } // 清理事件监听器 function cleanupEventListeners() { // 移除按钮事件监听器(通过克隆节点的方式移除所有事件监听器) const refreshBtn = document.getElementById('refreshBtn'); if (refreshBtn) { const newRefreshBtn = refreshBtn.cloneNode(true); refreshBtn.parentNode?.replaceChild(newRefreshBtn, refreshBtn); } const copyBtn = document.getElementById('copyBtn'); if (copyBtn) { const newCopyBtn = copyBtn.cloneNode(true); copyBtn.parentNode?.replaceChild(newCopyBtn, copyBtn); } const copyDesignBtn = document.getElementById('copyDesignBtn'); if (copyDesignBtn) { const newCopyDesignBtn = copyDesignBtn.cloneNode(true); copyDesignBtn.parentNode?.replaceChild(newCopyDesignBtn, copyDesignBtn); } } // 处理刷新预览 function handleRefreshPreview() { vscode.postMessage({ command: 'refreshPreview' }); } // 处理复制修复输入 function handleCopyFixInput() { // 收集所有phone-frame-container中的修改描述 const phoneContainers = document.querySelectorAll('.phone-frame-container'); const modifications = []; phoneContainers.forEach((container, index) => { // 获取页面标题 const pageTitleElement = container.querySelector('.page-title'); const pageTitle = pageTitleElement ? pageTitleElement.textContent.trim() : \`页面\${index + 1}\`; // 获取对应的fix-input内容 const fixInputElement = container.querySelector('.phone-fix-input'); const fixInputContent = fixInputElement ? fixInputElement.value.trim() : ''; // 如果有输入内容,则添加到修改列表 if (fixInputContent) { // 提取页面标题,去掉序号和点号,保留主要描述 const cleanTitle = pageTitle.replace(/^\d+\.\s*/, '').trim(); modifications.push(\`\${index + 1}、\${cleanTitle}的修改:\${fixInputContent}\`); } }); if (modifications.length > 0) { // 拼接所有修改描述 const allModifications = modifications.join('。 '); // 复制到剪贴板 navigator.clipboard.writeText(allModifications).then(() => { const copyBtn = document.getElementById('copyBtn'); if (copyBtn) { const originalText = copyBtn.textContent; copyBtn.textContent = '已复制'; setTimeout(() => { copyBtn.textContent = originalText; }, 1500); } }).catch(err => { console.error('❌ [HTML Preview] 复制失败:', err); }); } else { } } // 处理编辑按钮点击事件 function handleEditButtonClick(event) { const pageEnName = event.target.id; try { // 发送消息到VSCode扩展,请求查找页面数据 vscode.postMessage({ command: 'findPageData', pageEnName: pageEnName }); } catch (error) { console.error('❌ [HTML Preview] 发送消息到VSCode扩展失败:', error); } } // 处理来自VS Code的消息 function handleVSCodeMessage(event) { const message = event.data; switch (message.command) { case 'copyToClipboard': // 复制功能已在handleCopyFixInput中处理 break; case 'refreshContent': // 可以在这里处理内容刷新的额外逻辑 break; default: } } // 处理复制设计稿按钮点击事件 function clickCopyDesign() { // 获取所有有输入内容的fix-input对应的页面英文名 const pageEnNames = []; const phoneContainers = document.querySelectorAll('.phone-frame-container'); phoneContainers.forEach((container, index) => { const fixInputElement = container.querySelector('.phone-fix-input'); const pageTitleElement = container.querySelector('.page-title'); if (fixInputElement && pageTitleElement) { const inputValue = fixInputElement.value.trim(); // 只有输入框有内容时才查找button元素 if (inputValue.length > 0) { // 在page-title节点中查找button子节点 const buttonElement = pageTitleElement.querySelector('button'); if (buttonElement && buttonElement.id) { const pageEnName = buttonElement.id; pageEnNames.push(pageEnName); } else { } } else { } } else { } }); if (pageEnNames.length === 0) { vscode.postMessage({ command: 'copyDesign', data: { pageEnNames: [], message: '请先输入修改描述,再点击复制设计稿' } }); return; } vscode.postMessage({ command: 'copyDesign', data: { pageEnNames: pageEnNames } }); } // 暴露给全局的方法,供调试使用 window.reinitializeEventListeners = function () { initializeEventListeners(); }; // 立即尝试初始化(防止DOMContent