以下给出实现可视化 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

问题在迁移到新结构时集中爆发。


# 二、问题概述

迁移到新的「多文件编辑器」结构后,遭遇多个问题:

  1. 第二个 tab(styles.css)完全不显示。
  2. 其它旧 playground 完全不渲染(无行号、无预览)。
  3. 主题样式干扰 tab 样式,出现 “奇怪的主题 UI 元素”。
  4. 第二个 editor 切换后 gutter(行号栏)出现 “重叠、错位”。
  5. 预览区的图片使用 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 统一流程:

  1. 若存在 .pg-editor-panel → 多文件编辑器
  2. 否则 → 单文件

保证所有 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