以下给出实现可视化 html+css 编辑器的流程
待办事项
# 结构示例
<div class="playground">
<div class="editor-wrapper">
<textarea>
<!-- 单文件 HTML 代码 --></textarea>
</div>
<div class="preview-wrapper">
<iframe sandbox="allow-scripts allow-same-origin allow-modals"></iframe>
</div>
</div>
# 多文件
<div class="playground">
<div class="editor-wrapper">
<!-- 顶部标签栏 -->
<div class="pg-tabs">
<div class="pg-tab active" data-file="index.html">index.html</div>
<div class="pg-tab" data-file="styles.css">styles.css</div>
</div>
<!-- index.html 文件 -->
<div class="pg-editor-panel active" data-file="index.html">
<textarea class="editor">
<!-- index.html 文件内容 --></textarea>
</div>
<!-- styles.css 文件 -->
<div class="pg-editor-panel" data-file="styles.css">
<textarea class="editor">
<!-- styles.css 文件内容 --></textarea>
</div>
</div>
<div class="preview-wrapper">
<iframe class="preview" sandbox="allow-scripts allow-same-origin allow-modals"></iframe>
</div>
</div>
# css
/* 整个 playground:左编辑右预览 */ | |
.playground { | |
display: grid; | |
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); | |
grid-template-rows: auto minmax(260px, auto); | |
grid-template-areas: | |
"editor preview" | |
"editor preview"; | |
column-gap: 1.2rem; | |
row-gap: 0; | |
margin: 1.5rem 0; | |
} | |
/* 左侧整体框 */ | |
.playground .editor-wrapper { | |
grid-area: editor; | |
min-width: 0; | |
min-height: 260px; | |
background: #ffffff; | |
border: 1px solid #d0d0d0; | |
border-radius: 6px; | |
box-shadow: 0 1px 3px rgba(0,0,0,0.08); | |
overflow: hidden; | |
display: flex; | |
flex-direction: column; | |
} | |
/* 顶部自定义 tabs(避免使用主题的 .tabs/.tab) */ | |
.playground .pg-tabs { | |
display: flex; | |
flex-wrap: nowrap; | |
gap: 8px; | |
padding: 6px 8px 0; | |
border-bottom: 1px solid #d0d0d0; | |
background: #f5f5f5; | |
flex-shrink: 0; | |
} | |
.playground .pg-tab { | |
box-sizing: border-box; | |
display: inline-flex; | |
align-items: center; | |
justify-content: center; | |
padding: 4px 10px; | |
border: 1px solid #444; | |
border-bottom: none; | |
background: #222; | |
color: #ccc; | |
cursor: pointer; | |
border-radius: 4px 4px 0 0; | |
font-size: 0.8rem; | |
line-height: 1.4; | |
flex: 0 0 auto; | |
white-space: nowrap; /* 防止文件名折行 */ | |
} | |
.playground .pg-tab.active { | |
background: #111; | |
color: #fff; | |
} | |
/* 多文件编辑器容器:只让当前激活的那一个占满高度,其余彻底隐藏 */ | |
.playground .pg-editor-panel { | |
display: none; | |
flex: 1 1 auto; | |
min-height: 220px; | |
} | |
.playground .pg-editor-panel.active { | |
display: block; | |
} | |
/* CodeMirror 实例 */ | |
.playground .editor-wrapper .CodeMirror { | |
height: 100%; | |
width: 100%; | |
font-family: SFMono-Regular, Menlo, Consolas, monospace; | |
font-size: 0.9rem; | |
background: #ffffff; | |
color: #000000; | |
} | |
/* 没启用 CodeMirror 时的降级 textarea 样式 */ | |
.playground textarea.editor { | |
height: 100%; | |
width: 100%; | |
border: none; | |
resize: none; | |
outline: none; | |
padding: 0.9rem 1rem; | |
box-sizing: border-box; | |
font-family: SFMono-Regular, Menlo, Consolas, monospace; | |
font-size: 0.9rem; | |
color: #000000; | |
background: #ffffff; | |
} | |
/* 右侧预览 */ | |
.playground .preview-wrapper { | |
grid-area: preview; | |
min-width: 0; | |
min-height: 260px; | |
background: #ffffff; | |
border: 1px solid #d0d0d0; | |
border-radius: 6px; | |
box-shadow: 0 1px 3px rgba(0,0,0,0.08); | |
overflow: hidden; | |
display: flex; | |
} | |
.playground .preview-wrapper iframe.preview { | |
width: 100%; | |
height: 100%; | |
border: none; | |
background: #ffffff; | |
color: #000000; | |
} | |
/* 手机端上下堆叠 */ | |
@media (max-width: 768px) { | |
.playground { | |
grid-template-columns: minmax(0, 1fr); | |
grid-template-rows: auto minmax(220px, auto) minmax(220px, auto); | |
grid-template-areas: | |
"editor" | |
"editor" | |
"preview"; | |
row-gap: 0.8rem; | |
} | |
} |
# js
// Playground 代码编辑与预览功能 | |
// /source/js/personal.js | |
document.addEventListener("DOMContentLoaded", function () { | |
const playgrounds = document.querySelectorAll(".playground"); | |
playgrounds.forEach(function (container) { | |
const previewFrame = container.querySelector("iframe.preview"); | |
if (!previewFrame) return; | |
if (typeof CodeMirror === "undefined") return; | |
const editorWrapper = container.querySelector(".editor-wrapper") || container; | |
const panels = editorWrapper.querySelectorAll(".pg-editor-panel"); | |
// 统一:只在 playground 代码字符串里修正 data-src -> src | |
function normalizeHtml(code) { | |
return code.replace(/\bdata-src(\s*=\s*)/g, "src$1"); | |
} | |
/* ========= 情况 A:没有 pg-editor-panel -> 单文件模式 ========= */ | |
if (panels.length === 0) { | |
const textarea = editorWrapper.querySelector("textarea.editor"); | |
if (!textarea) return; | |
// 在传给 CodeMirror 之前,把 \/\/ 转换成 // | |
textarea.value = textarea.value.replace(/\\\/\\\//g, "//"); | |
const editor = CodeMirror.fromTextArea(textarea, { | |
mode: "htmlmixed", | |
lineNumbers: true, | |
tabSize: 2, | |
indentUnit: 2, | |
lineWrapping: true | |
}); | |
function updatePreviewSingle() { | |
const code = normalizeHtml(editor.getValue()); | |
previewFrame.srcdoc = code; | |
} | |
editor.on("change", updatePreviewSingle); | |
updatePreviewSingle(); | |
return; // 单文件模式结束 | |
} | |
/* ========= 情况 B:有 pg-editor-panel -> 多文件 + tabs 模式 ========= */ | |
const tabs = editorWrapper.querySelectorAll(".pg-tab"); | |
const editors = new Map(); // fileName -> { editor, panel } | |
panels.forEach(function (panel) { | |
const file = panel.dataset.file || "index.html"; | |
const textarea = panel.querySelector("textarea.editor"); | |
if (!textarea) return; | |
// 在传给 CodeMirror 之前,把 \/\/ 转换成 // | |
textarea.value = textarea.value.replace(/\\\/\\\//g, "//"); | |
const editor = CodeMirror.fromTextArea(textarea, { | |
mode: "htmlmixed", | |
lineNumbers: true, | |
tabSize: 2, | |
indentUnit: 2, | |
lineWrapping: true | |
}); | |
editors.set(file, { editor, panel }); | |
}); | |
if (editors.size === 0) return; | |
function buildSrcDoc() { | |
const allEntries = Array.from(editors.entries()); | |
// 1. 选 HTML 主文件:优先 index.html,否则第一个 | |
let htmlKey = editors.has("index.html") | |
? "index.html" | |
: allEntries[0][0]; | |
let htmlCode = normalizeHtml(editors.get(htmlKey).editor.getValue()); | |
// 2. 有 styles.css 的话,把 CSS 注入 <head> | |
const cssEntry = editors.get("styles.css"); | |
if (cssEntry) { | |
const cssCode = cssEntry.editor.getValue(); | |
if (cssCode.trim()) { | |
const styleTag = `<style>\n${cssCode}\n</style>`; | |
if (htmlCode.includes("</head>")) { | |
htmlCode = htmlCode.replace("</head>", styleTag + "\n</head>"); | |
} else { | |
htmlCode = styleTag + "\n" + htmlCode; | |
} | |
} | |
} | |
return htmlCode; | |
} | |
function updatePreviewMulti() { | |
const doc = buildSrcDoc(); | |
previewFrame.srcdoc = doc; | |
} | |
// 所有文件变动时更新预览 | |
editors.forEach(({ editor }) => { | |
editor.on("change", updatePreviewMulti); | |
}); | |
//tab 显示切换 | |
function setActiveFile(file) { | |
if (tabs.length) { | |
tabs.forEach(tab => { | |
tab.classList.toggle("active", tab.dataset.file === file); | |
}); | |
} | |
editors.forEach(({ panel }, name) => { | |
panel.classList.toggle("active", name === file); | |
}); | |
// 关键:当前文件对应的 CodeMirror 在隐藏状态初始化,需要 refresh | |
const current = editors.get(file); | |
if (current) { | |
// 等一帧,确保 display 切换已经生效 | |
setTimeout(() => { | |
current.editor.refresh(); | |
}, 0); | |
} | |
} | |
// 绑定 tab 点击事件(如果有 tab) | |
if (tabs.length) { | |
tabs.forEach(tab => { | |
tab.addEventListener("click", function () { | |
const file = tab.dataset.file; | |
if (!file || !editors.has(file)) return; | |
setActiveFile(file); | |
}); | |
}); | |
} | |
// 默认激活 index.html 或第一个文件 | |
let defaultFile = "index.html"; | |
if (!editors.has(defaultFile)) { | |
defaultFile = editors.keys().next().value; | |
} | |
setActiveFile(defaultFile); | |
// 初次预览 | |
updatePreviewMulti(); | |
}); | |
}); |
# 异常行为调试报告
# 一、背景说明
组件结构如下:
- 左侧:HTML/CSS/JS 多文件编辑器(基于 CodeMirror)
- 顶部:标签栏(tab)用于切换文件
- 右侧:iframe 实时渲染 preview
该交互组件需要支持:
- 单文件 playground(只有 HTML 一份)
- 多文件 playground(index.html + styles.css 等)
- 每个 playground 独立运行互不干扰
- Hexo 模板渲染不破坏其中的 HTML
问题在迁移到新结构时集中爆发。
# 二、问题概述
迁移到新的「多文件编辑器」结构后,遭遇多个问题:
- 第二个 tab(styles.css)完全不显示。
- 其它旧 playground 完全不渲染(无行号、无预览)。
- 主题样式干扰 tab 样式,出现 “奇怪的主题 UI 元素”。
- 第二个 editor 切换后 gutter(行号栏)出现 “重叠、错位”。
- 预览区的图片使用 data-src(懒加载)导致 iframe 内图片不显示。
整个交互组件不可用。
# 三、排查过程(按时间线)
# 1. tab 不显示
HTML 中确实有第二个 tab,但是界面只看到一个。
初步判断是样式问题,但进一步检查发现:
- ShokaX 主题对
.tab.tabs有自己的 UI 组件(卡片式选项卡) - 用户使用
<button class="tab">正好触发主题内置 tab 组件,导致直接被主题接管
→ 原 tab UI 被主题覆盖,导致混乱。
第一次修复:
避免使用 .tab / .tabs 类名,统一改为 .pg-tab 、 .pg-tabs 。
# 2. 旧 playground 不渲染
新 JS 查找结构:
- 如果有
.editor-container→ 多文件模式 - 否则 → 单文件模式
旧 playground 没有 .editor-wrapper 也没有 .editor-container ,导致 JS 找不到 textarea,CodeMirror 不初始化 → 不显示行号。
第二次修复:
- 单文件模式改为直接查找 container 内的
textarea.editor,增强兼容性。
# 3. tab 切换后行号栏重叠
此问题一度误判为 CSS 布局问题,但经事实验证:
根本原因:CodeMirror 在 “display:none” 时初始化,尺寸不正确。
CodeMirror 的已知缺陷:
如果编辑器初始化时容器不可见(如 display:none),之后变为可见时必须调用
editor.refresh()。
否则 gutter(行号栏)宽度不刷新,看起来像 “叠在一起”。
第三次修复:
setTimeout(() => { | |
current.editor.refresh(); | |
}, 0); |
确保切换 tab 后刷新 CodeMirror。
# 4. data-src 导致图片不显示
因为主题使用懒加载自动把所有 <img src> 改为 <img data-src> ,但 iframe 内并不加载懒加载脚本。
第四次修复:
预览写入 iframe 前执行:
html = html.replace(/\bdata-src(\s*=\s*)/g, "src$1"); |
强制还原为 src 。
# 四、根因分析(最终定论)
本次事件由三个互相关联的根因触发:
# 根因 1:主题样式与内部组件类名冲突(高影响)
用户使用了 .tab / .tabs (ShokaX 原生 UI class),主题会自动注入卡片动画、布局等,导致 tab 样式严重混乱。
# 根因 2:CodeMirror 在隐藏容器中初始化(中影响)
多文件结构切换不调用 editor.refresh() → gutter 错位。
# 根因 3:单 / 多文件结构不一致(中影响)
新 JS 只认识 .editor-container ,旧 playground 因结构不同被忽略,导致 CodeMirror 不初始化 → 无行号、无预览。
# 根因 4:懒加载脚本导致 iframe 内图像无法识别(低影响)
主题自动把 src 改成 data-src ,iframe 不会执行懒加载 JS → 图片无法加载。
# 五、最终解决方案
本次最终解决方案包括 5 个关键点:
# 解决方案 1:命名隔离(pg- 命名空间)
避免与主题 UI 冲突,把所有 playground 内部类名统一改为:
.pg-tabs.pg-tab.pg-editor-panel.pg-*
彻底避开主题组件。
# 解决方案 2:统一结构
多文件结构固定为:
<div class="pg-tabs">...</div> | |
<div class="pg-editor-panel" data-file="xxx"></div> |
单文件结构:
<div class="editor-wrapper"> | |
<textarea class="editor"></textarea> | |
</div> |
# 解决方案 3:CodeMirror 刷新机制
在 tab 切换时注入:
editor.refresh(); |
彻底解决行号错位。
# 解决方案 4:兼容单文件与多文件模式
JS 统一流程:
- 若存在
.pg-editor-panel→ 多文件编辑器 - 否则 → 单文件
保证所有 playground 工作一致。
# 解决方案 5:data-src → src 的 iframe 修复
统一修复图片路径:
html = html.replace(/\bdata-src(\s*=\s*)/g, "src$1"); |
保证 preview 内图片正常显示。
# 六、最终架构图(简化版)
playground
├── editor-wrapper
│ ├── pg-tabs
│ │ ├── pg-tab(data-file=index.html)
│ │ └── pg-tab(data-file=styles.css)
│ ├── pg-editor-panel(data-file=index.html)
│ │ └── CodeMirror instance 1
│ └── pg-editor-panel(data-file=styles.css)
│ └── CodeMirror instance 2
└── preview-wrapper
└── iframe.preview
JS 控制:
- tabs → 切换 active class
- editor-panel → 切换 active
- active editor →
refresh() - 所有编辑器变动 → update preview
- 在 preview 中重写
srcdoc