HTML 实时预览工具

我开源了一个 HTML 实时预览工具 ------ 粘贴代码即刻预览,支持设备模拟和拖拽分栏

一个基于 React + CodeMirror 的轻量级 HTML 预览器,无需后端,打开即用。支持语法高亮、实时预览、设备模拟、拖放加载文件等实用功能。大家可以用这个来预览任何东西,比如说我之前用html演示的动画都可以整过来自用,网址已经放在下面了,然后如果要学习具体代码逻辑的话可以往下看。(其实没啥看的)

在线体验https://loavebstyqay6.ok.kimi.link


目录

  • 效果展示
  • 功能特性
  • 技术栈
  • 项目结构
  • 核心实现详解
    • [1. 整体布局架构](#1. 整体布局架构)
    • [2. CodeMirror 编辑器集成](#2. CodeMirror 编辑器集成)
    • [3. iframe 实时预览机制](#3. iframe 实时预览机制)
    • [4. 响应式 SplitPane 分栏](#4. 响应式 SplitPane 分栏)
    • [5. 设备模拟切换](#5. 设备模拟切换)
    • [6. 拖放文件加载](#6. 拖放文件加载)
    • [7. 极暗主题 UI 设计](#7. 极暗主题 UI 设计)
  • 完整代码
  • 本地运行
  • 总结

效果展示

打开工具后,页面分为左右两栏:左侧是代码编辑器,右侧是预览窗口。在左侧粘贴 HTML 代码,右侧会即时渲染出对应的网页效果。


功能特性

功能 说明
实时预览 编辑器输入即刻同步到预览窗口,无需手动刷新
语法高亮 基于 CodeMirror 6 的 HTML 语法高亮、行号、活动行高亮
可拖拽分栏 自由调整编辑器与预览区的比例,支持横向/纵向布局切换
设备模拟 一键切换桌面(100%)、平板(768px)、手机(375px)三种视口
拖放加载 直接将本地 .html 文件拖入编辑器区域即可加载
示例模板 内置 3 个示例:HTML5 骨架、响应式卡片、表单布局
代码格式化 自动对 HTML 进行缩进格式化
一键复制 快速复制编辑器中的全部代码到剪贴板
光标状态 底部状态栏实时显示行号、列号、字符计数
纯前端 无需后端服务,所有逻辑在浏览器端完成

技术栈

  • React 19 + TypeScript ------ UI 框架与类型安全
  • Vite ------ 构建工具,极速 HMR
  • Tailwind CSS ------ 原子化 CSS 样式
  • CodeMirror 6 (@uiw/react-codemirror) ------ 代码编辑器,支持 HTML 语法高亮
  • react-resizable-panels ------ 可拖拽调整大小的分栏面板
  • Framer Motion ------ 布局切换、按钮反馈等微动画
  • Lucide React ------ 轻量级图标库

项目结构

复制代码
html-previewer/
├── index.html
├── package.json
├── vite.config.ts
├── tsconfig.json
├── tailwind.config.js
├── src/
│   ├── main.tsx              # 应用入口
│   ├── App.tsx               # 根组件,管理全局状态
│   ├── index.css             # 全局样式 + CodeMirror 主题定制
│   ├── data/
│   │   └── examples.ts       # 3 个示例代码数据
│   └── sections/
│       ├── Header.tsx        # 顶部工具栏
│       ├── EditorPanel.tsx   # 代码编辑器面板
│       ├── PreviewPanel.tsx  # 预览面板
│       └── StatusBar.tsx     # 底部状态栏
└── dist/                     # 构建输出

核心实现详解

1. 整体布局架构

整个应用采用三栏式固定布局:Header(顶部工具栏) + Main Workspace(主工作区) + StatusBar(底部状态栏) 。主工作区使用 react-resizable-panels 实现 Editor 与 Preview 的分栏。

状态管理 非常简单,全部使用 React 的 useState

tsx 复制代码
function App() {
  const [code, setCode] = useState(defaultCode);           // 编辑器代码
  const [layout, setLayout] = useState<'horizontal' | 'vertical'>('horizontal');
  const [cursorPos, setCursorPos] = useState({ line: 1, col: 1 });
  // ...
}

2. CodeMirror 编辑器集成

编辑器基于 @uiw/react-codemirror,加载了 @codemirror/lang-html 扩展实现 HTML 语法高亮,使用 oneDark 主题作为基础并进行深度定制:

tsx 复制代码
import CodeMirror from '@uiw/react-codemirror';
import { html } from '@codemirror/lang-html';
import { oneDark } from '@codemirror/theme-one-dark';

<CodeMirror
  value={code}
  height="100%"
  extensions={[html()]}
  theme={oneDark}
  onChange={onChange}
  onUpdate={handleCursorActivity}
  basicSetup={{
    lineNumbers: true,
    highlightActiveLineGutter: true,
    highlightActiveLine: true,
    bracketMatching: true,
    closeBrackets: true,
    highlightSelectionMatches: true,
  }}
/>

通过自定义 CSS 覆盖了 CodeMirror 的默认配色,使其与整体 #0a0a0a 极暗风格保持一致:

css 复制代码
.cm-editor { background: #0d1117 !important; }
.cm-gutters { background: #0d1117 !important; border-right: 1px solid rgba(255,255,255,0.06) !important; }
.cm-lineNumbers .cm-gutterElement { color: #484f58 !important; }
.cm-activeLineGutter { background: rgba(255,255,255,0.04) !important; }
.cm-activeLine { background: rgba(255,255,255,0.03) !important; }
.cm-cursor { border-left: 2px solid #ffffff !important; }
.cm-selectionBackground { background: rgba(255,255,255,0.12) !important; }

3. iframe 实时预览机制

预览功能通过一个 sandbox 属性的 iframe 实现。核心原理是:将编辑器中的 HTML 代码通过 srcdoc 属性直接注入 iframe:

tsx 复制代码
<iframe
  key={refreshKey}      // 改变 key 强制刷新
  srcDoc={code}          // 直接注入 HTML 代码
  sandbox="allow-scripts allow-same-origin allow-popups allow-forms allow-modals"
  style={{ width: '100%', height: '100%', border: 'none' }}
/>

为什么用 srcdoc 而不是 src

  • srcdoc 允许直接传入 HTML 字符串,无需服务器托管
  • 配合 sandbox 属性,预览代码在隔离环境中运行,无法访问父页面数据
  • 修改代码后 iframe 自动重新渲染,实现"实时"效果

刷新机制 :通过改变 iframe 的 key 属性强制 React 重新挂载组件,实现预览刷新。

4. 响应式 SplitPane 分栏

使用 react-resizable-panelsGroupPanelSeparator 三个组件实现分栏:

tsx 复制代码
import { Panel, Group, Separator } from 'react-resizable-panels';

<Group orientation={layout}>  {/* "horizontal" 或 "vertical" */}
  <Panel defaultSize={50} minSize={20}>
    <EditorPanel />
  </Panel>
  <Separator />
  <Panel defaultSize={50} minSize={20}>
    <PreviewPanel />
  </Panel>
</Group>

分隔线(Separator)添加了 hover 效果:鼠标悬停时变宽并变亮,拖拽时更明显。通过 onMouseEnter/onMouseLeave 动态修改样式实现。

布局切换使用 Framer Motion 的 AnimatePresence 包裹,实现淡入淡出过渡动画。

5. 设备模拟切换

预览面板顶部有一组设备切换按钮(桌面/平板/手机),通过改变包裹 iframe 的外部容器的 width 来模拟不同设备宽度:

tsx 复制代码
const deviceWidths = {
  desktop: '100%',
  tablet: '768px',
  mobile: '375px',
};

<div style={{ width: deviceWidths[device], margin: '0 auto', transition: 'width 0.3s' }}>
  <iframe srcDoc={code} ... />
</div>

CSS transition 让宽度变化有平滑动画效果。

6. 拖放文件加载

在编辑器面板区域监听 dragoverdragleavedrop 三个事件:

tsx 复制代码
const handleDrop = (e: React.DragEvent) => {
  e.preventDefault();
  const file = e.dataTransfer.files[0];
  
  // 仅接受 .html / .htm 文件
  if (!file.name.endsWith('.html') && !file.name.endsWith('.htm')) {
    setDragError('请拖放 .html 文件');
    return;
  }
  
  const reader = new FileReader();
  reader.onload = (event) => {
    onChange(event.target?.result as string);
  };
  reader.readAsText(file);
};

拖入文件时显示半透明遮罩层提示,非法文件格式会显示错误提示。

7. 极暗主题 UI 设计

整个界面采用极暗风格(#0a0a0a),核心设计原则:

  • 无阴影层级 :所有面板不依赖 box-shadow,而是通过半透明填充 rgba(255,255,255,0.02~0.04) 和极淡边框 rgba(255,255,255,0.06~0.08) 区分层级
  • 液态玻璃材质 :Header 和 Status Bar 使用 backdrop-filter: blur(16px) 营造透光感
  • 克制的强调色 :不使用高饱和色彩,交互反馈使用白色半透明 rgba(255,255,255,0.75),错误状态使用淡红色 rgba(255,100,100,0.8)
  • 纤细图标 :所有 Lucide 图标统一 strokeWidth={1.5},保持轻盈感
  • 按钮标签 :操作按钮使用 text-transform: uppercase + 宽字距 letter-spacing: 0.08em,营造精致感

完整代码

以下是项目的全部源代码,复制即可运行。

package.json

json 复制代码
{
  "name": "html-previewer",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@codemirror/commands": "^6.8.1",
    "@codemirror/lang-html": "^6.4.9",
    "@codemirror/search": "^6.5.10",
    "@codemirror/state": "^6.5.2",
    "@codemirror/theme-one-dark": "^6.1.2",
    "@codemirror/view": "^6.36.5",
    "@uiw/react-codemirror": "^4.23.10",
    "framer-motion": "^12.7.4",
    "lucide-react": "^0.511.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "react-resizable-panels": "^3.0.1"
  },
  "devDependencies": {
    "@tailwindcss/vite": "^4.1.4",
    "@types/react": "^19.0.0",
    "@types/react-dom": "^19.0.0",
    "@vitejs/plugin-react": "^4.4.1",
    "tailwindcss": "^3.4.19",
    "typescript": "^5.7.0",
    "vite": "^7.2.4"
  }
}

vite.config.ts

ts 复制代码
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'

export default defineConfig({
  plugins: [react(), tailwindcss()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
})

tsconfig.json

json 复制代码
{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ],
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

tsconfig.app.json

json 复制代码
{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "target": "ES2022",
    "useDefineForClassFields": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "types": ["vite/client"],
    "skipLibCheck": true,
    "baseUrl": ".",
    "paths": { "@/*": ["./src/*"] },
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src"]
}

tailwind.config.js

js 复制代码
/** @type {import('tailwindcss').Config} */
export default {
  darkMode: ["class"],
  content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"],
  theme: {
    extend: {
      borderRadius: {
        lg: "var(--radius)",
        md: "calc(var(--radius) - 2px)",
        sm: "calc(var(--radius) - 4px)",
      },
      colors: {
        background: "hsl(var(--background))",
        foreground: "hsl(var(--foreground))",
        card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))" },
        popover: { DEFAULT: "hsl(var(--popover))", foreground: "hsl(var(--popover-foreground))" },
        primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))" },
        secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))" },
        muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))" },
        accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))" },
        destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))" },
        border: "hsl(var(--border))",
        input: "hsl(var(--input))",
        ring: "hsl(var(--ring))",
      },
    },
  },
  plugins: [require("tailwindcss-animate")],
}

index.html

html 复制代码
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>&lt;/&gt;</text></svg>" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>HTML Preview - 实时预览工具</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

src/main.tsx

tsx 复制代码
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

src/index.css

css 复制代码
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    --background: 0 0% 4%;
    --foreground: 0 0% 100%;
    --card: 0 0% 4%;
    --card-foreground: 0 0% 100%;
    --popover: 0 0% 6%;
    --popover-foreground: 0 0% 100%;
    --primary: 0 0% 100%;
    --primary-foreground: 0 0% 4%;
    --secondary: 0 0% 10%;
    --secondary-foreground: 0 0% 100%;
    --muted: 0 0% 12%;
    --muted-foreground: 0 0% 55%;
    --accent: 0 0% 14%;
    --accent-foreground: 0 0% 100%;
    --destructive: 0 70% 50%;
    --destructive-foreground: 0 0% 100%;
    --border: 0 0% 12%;
    --input: 0 0% 14%;
    --ring: 0 0% 80%;
    --radius: 0.5rem;
  }

  * {
    @apply border-border;
  }

  body {
    background: #0a0a0a;
    color: #ffffff;
    font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
    overflow: hidden;
  }
}

/* CodeMirror Custom Theme - Dark Clarity */
.cm-editor {
  background: #0d1117 !important;
  font-family: 'Menlo', 'Monaco', 'Courier New', monospace !important;
  font-size: 14px !important;
  line-height: 1.6 !important;
}

.cm-editor.cm-focused {
  outline: none !important;
}

.cm-gutters {
  background: #0d1117 !important;
  border-right: 1px solid rgba(255, 255, 255, 0.06) !important;
}

.cm-lineNumbers .cm-gutterElement {
  color: #484f58 !important;
  font-size: 12px !important;
  padding: 0 12px 0 8px !important;
}

.cm-activeLineGutter {
  background: rgba(255, 255, 255, 0.04) !important;
}

.cm-activeLineGutter .cm-gutterElement {
  color: #8b949e !important;
}

.cm-activeLine {
  background: rgba(255, 255, 255, 0.03) !important;
}

.cm-cursor {
  border-left: 2px solid #ffffff !important;
}

.cm-selectionBackground {
  background: rgba(255, 255, 255, 0.12) !important;
}

.cm-focused .cm-selectionBackground {
  background: rgba(255, 255, 255, 0.16) !important;
}

.cm-scroller {
  overflow: auto !important;
}

.cm-content {
  padding: 16px 0 !important;
  caret-color: #ffffff !important;
}

/* Scrollbar Styling */
::-webkit-scrollbar {
  width: 6px;
  height: 6px;
}

::-webkit-scrollbar-track {
  background: transparent;
}

::-webkit-scrollbar-thumb {
  background: rgba(255, 255, 255, 0.1);
  border-radius: 3px;
}

::-webkit-scrollbar-thumb:hover {
  background: rgba(255, 255, 255, 0.2);
}

/* Resizable Panel Gutter */
[data-panel-resize-handle-id] {
  background: rgba(255, 255, 255, 0.06) !important;
  transition: all 0.2s ease !important;
}

[data-panel-resize-handle-id]:hover {
  background: rgba(255, 255, 255, 0.2) !important;
}

[data-panel-resize-handle-id][data-resize-handle-active] {
  background: rgba(255, 255, 255, 0.3) !important;
}

/* Liquid Glass Button Base */
.btn-glass {
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 6px;
  color: rgba(255, 255, 255, 0.7);
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
}

.btn-glass:hover {
  background: rgba(255, 255, 255, 0.08);
  border-color: rgba(255, 255, 255, 0.15);
  color: rgba(255, 255, 255, 0.85);
}

.btn-glass:active {
  background: rgba(255, 255, 255, 0.06);
}

/* Liquid Glass Panel */
.panel-glass {
  background: rgba(255, 255, 255, 0.02);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  border: 1px solid rgba(255, 255, 255, 0.06);
}

/* Dropdown Panel */
.dropdown-glass {
  background: rgba(15, 15, 15, 0.95);
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 8px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}

/* Device Button */
.btn-device {
  width: 28px;
  height: 28px;
  border-radius: 4px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: transparent;
  color: rgba(255, 255, 255, 0.35);
  transition: all 0.2s ease;
  cursor: pointer;
  border: none;
}

.btn-device:hover {
  background: rgba(255, 255, 255, 0.05);
  color: rgba(255, 255, 255, 0.6);
}

.btn-device.active {
  background: rgba(255, 255, 255, 0.08);
  color: rgba(255, 255, 255, 0.85);
}

/* Drag Overlay */
.drag-overlay {
  background: rgba(255, 255, 255, 0.02);
  border: 1px dashed rgba(255, 255, 255, 0.15);
}

/* Error Bar */
.error-bar {
  background: rgba(255, 50, 50, 0.08);
  border-top: 1px solid rgba(255, 100, 100, 0.15);
  color: rgba(255, 120, 120, 0.85);
}

/* Mobile adjustments */
@media (max-width: 768px) {
  .cm-editor {
    font-size: 12px !important;
  }
}

src/data/examples.ts

ts 复制代码
export interface Example {
  id: string;
  name: string;
  code: string;
}

export const examples: Example[] = [
  {
    id: 'skeleton',
    name: 'HTML5 骨架',
    code: `<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>我的第一个网页</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    }
    .card {
      background: white;
      padding: 40px;
      border-radius: 16px;
      box-shadow: 0 20px 60px rgba(0,0,0,0.3);
      text-align: center;
      max-width: 400px;
    }
    h1 { color: #333; margin-bottom: 16px; }
    p { color: #666; line-height: 1.6; }
  </style>
</head>
<body>
  <div class="card">
    <h1>Hello World</h1>
    <p>这是一个简单的 HTML 示例。你可以修改代码并实时预览效果。</p>
  </div>
</body>
</html>`,
  },
  {
    id: 'cards',
    name: '响应式卡片',
    code: `<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>响应式卡片</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: system-ui, -apple-system, sans-serif;
      background: #f5f5f5;
      padding: 40px 20px;
    }
    .container {
      max-width: 1200px;
      margin: 0 auto;
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
      gap: 24px;
    }
    .card {
      background: white;
      border-radius: 12px;
      padding: 32px;
      box-shadow: 0 2px 8px rgba(0,0,0,0.08);
      transition: transform 0.2s, box-shadow 0.2s;
    }
    .card:hover {
      transform: translateY(-4px);
      box-shadow: 0 8px 24px rgba(0,0,0,0.12);
    }
    .card h2 { color: #1a1a1a; margin-bottom: 12px; font-size: 20px; }
    .card p { color: #666; line-height: 1.6; font-size: 14px; }
    .icon {
      width: 48px; height: 48px;
      background: #4f46e5;
      border-radius: 12px;
      margin-bottom: 16px;
      display: flex;
      align-items: center;
      justify-content: center;
      color: white;
      font-size: 24px;
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="card">
      <div class="icon">&#9889;</div>
      <h2>快速开始</h2>
      <p>无需配置,粘贴代码即可预览。支持拖放加载本地 HTML 文件。</p>
    </div>
    <div class="card">
      <div class="icon">&#128241;</div>
      <h2>响应式预览</h2>
      <p>一键切换桌面、平板、手机视图,验证你的响应式布局。</p>
    </div>
    <div class="card">
      <div class="icon">&#128274;</div>
      <h2>安全沙箱</h2>
      <p>预览在 iframe 沙箱中运行,代码不会访问你的本地数据。</p>
    </div>
  </div>
</body>
</html>`,
  },
  {
    id: 'form',
    name: '表单布局',
    code: `<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>表单示例</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: system-ui, -apple-system, sans-serif;
      background: #fafafa;
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
      padding: 20px;
    }
    .form-container {
      background: white;
      padding: 48px;
      border-radius: 16px;
      box-shadow: 0 4px 20px rgba(0,0,0,0.08);
      width: 100%;
      max-width: 440px;
    }
    h1 { font-size: 24px; margin-bottom: 8px; color: #111; }
    .subtitle { color: #888; margin-bottom: 32px; font-size: 14px; }
    .form-group { margin-bottom: 20px; }
    label {
      display: block;
      margin-bottom: 6px;
      font-size: 14px;
      font-weight: 500;
      color: #444;
    }
    input, textarea {
      width: 100%;
      padding: 12px 16px;
      border: 1px solid #e0e0e0;
      border-radius: 8px;
      font-size: 15px;
      font-family: inherit;
      transition: border-color 0.2s;
    }
    input:focus, textarea:focus {
      outline: none;
      border-color: #4f46e5;
    }
    textarea { resize: vertical; min-height: 100px; }
    button {
      width: 100%;
      padding: 14px;
      background: #4f46e5;
      color: white;
      border: none;
      border-radius: 8px;
      font-size: 16px;
      font-weight: 500;
      cursor: pointer;
      transition: background 0.2s;
    }
    button:hover { background: #4338ca; }
  </style>
</head>
<body>
  <div class="form-container">
    <h1>联系我们</h1>
    <p class="subtitle">填写以下信息,我们会尽快回复。</p>
    <form>
      <div class="form-group">
        <label>姓名</label>
        <input type="text" placeholder="请输入你的姓名">
      </div>
      <div class="form-group">
        <label>邮箱</label>
        <input type="email" placeholder="your@email.com">
      </div>
      <div class="form-group">
        <label>留言</label>
        <textarea placeholder="请输入你的留言..."></textarea>
      </div>
      <button type="submit">提交</button>
    </form>
  </div>
</body>
</html>`,
  },
];

export const defaultCode = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>实时预览</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
      background: #f0f0f0;
    }
    .welcome {
      text-align: center;
      padding: 40px;
    }
    h1 { color: #333; margin-bottom: 12px; font-size: 28px; }
    p { color: #888; font-size: 15px; line-height: 1.6; }
  </style>
</head>
<body>
  <div class="welcome">
    <h1>HTML 预览器</h1>
    <p>在左侧编辑器中粘贴 HTML 代码<br>即可在此处实时预览效果</p>
  </div>
</body>
</html>`;

src/sections/Header.tsx

tsx 复制代码
import { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
  Code2,
  FileCode,
  Copy,
  Check,
  Trash2,
  AlignLeft,
  Layout,
  LayoutTemplate,
  ChevronDown,
} from 'lucide-react';
import { examples } from '@/data/examples';

interface HeaderProps {
  code: string;
  onCodeChange: (code: string) => void;
  layout: 'horizontal' | 'vertical';
  onLayoutToggle: () => void;
  onFormat: () => void;
}

export default function Header({
  code,
  onCodeChange,
  layout,
  onLayoutToggle,
  onFormat,
}: HeaderProps) {
  const [showExamples, setShowExamples] = useState(false);
  const [copyState, setCopyState] = useState<'idle' | 'success' | 'error'>('idle');
  const dropdownRef = useRef<HTMLDivElement>(null);

  // Close dropdown on outside click
  useEffect(() => {
    function handleClickOutside(e: MouseEvent) {
      if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
        setShowExamples(false);
      }
    }
    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, []);

  const handleCopy = async () => {
    try {
      await navigator.clipboard.writeText(code);
      setCopyState('success');
      setTimeout(() => setCopyState('idle'), 1500);
    } catch {
      setCopyState('error');
      setTimeout(() => setCopyState('idle'), 2000);
    }
  };

  const handleClear = () => {
    onCodeChange('');
  };

  const handleLoadExample = (exampleCode: string) => {
    onCodeChange(exampleCode);
    setShowExamples(false);
  };

  const copyIcon = copyState === 'success'
    ? <Check size={15} strokeWidth={1.5} style={{ color: 'rgba(150,255,150,0.8)' }} />
    : copyState === 'error'
    ? <AlignLeft size={15} strokeWidth={1.5} style={{ color: 'rgba(255,100,100,0.8)' }} />
    : <Copy size={15} strokeWidth={1.5} />;

  return (
    <header
      className="panel-glass flex items-center justify-between select-none"
      style={{
        height: 48,
        padding: '0 28px',
        borderBottom: '1px solid rgba(255,255,255,0.06)',
        borderTop: 'none',
        borderLeft: 'none',
        borderRight: 'none',
      }}
    >
      {/* Left: Logo + Title */}
      <div className="flex items-center gap-2.5">
        <Code2 size={18} strokeWidth={1.5} style={{ color: 'rgba(255,255,255,0.55)' }} />
        <h1
          style={{
            fontSize: 'clamp(1.25rem, 2.5vw, 1.5rem)',
            fontWeight: 400,
            letterSpacing: '-0.03em',
            color: '#ffffff',
            lineHeight: 1,
          }}
        >
          HTML Preview
        </h1>
      </div>

      {/* Right: Action Buttons */}
      <div className="flex items-center" style={{ gap: 8 }}>
        {/* Load Example Button */}
        <div className="relative" ref={dropdownRef}>
          <button
            className="btn-glass"
            style={{ padding: '7px 12px' }}
            onClick={() => setShowExamples(!showExamples)}
          >
            <FileCode size={15} strokeWidth={1.5} />
            <span
              className="hidden sm:inline"
              style={{
                fontSize: 11,
                letterSpacing: '0.08em',
                textTransform: 'uppercase',
              }}
            >
              示例
            </span>
            <ChevronDown
              size={12}
              strokeWidth={1.5}
              style={{
                transform: showExamples ? 'rotate(180deg)' : 'rotate(0deg)',
                transition: 'transform 0.2s ease',
              }}
            />
          </button>

          <AnimatePresence>
            {showExamples && (
              <motion.div
                initial={{ opacity: 0, y: -4 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: -4 }}
                transition={{ duration: 0.15, ease: [0.4, 0, 0.2, 1] }}
                className="dropdown-glass absolute right-0 top-full mt-1 overflow-hidden"
                style={{ zIndex: 50, minWidth: 160 }}
              >
                {examples.map((ex) => (
                  <button
                    key={ex.id}
                    className="w-full text-left"
                    style={{
                      padding: '8px 16px',
                      fontSize: 13,
                      color: 'rgba(255,255,255,0.7)',
                      transition: 'all 0.15s ease',
                      background: 'transparent',
                      border: 'none',
                      cursor: 'pointer',
                      display: 'block',
                    }}
                    onMouseEnter={(e) => {
                      e.currentTarget.style.background = 'rgba(255,255,255,0.06)';
                      e.currentTarget.style.color = '#ffffff';
                    }}
                    onMouseLeave={(e) => {
                      e.currentTarget.style.background = 'transparent';
                      e.currentTarget.style.color = 'rgba(255,255,255,0.7)';
                    }}
                    onClick={() => handleLoadExample(ex.code)}
                  >
                    {ex.name}
                  </button>
                ))}
              </motion.div>
            )}
          </AnimatePresence>
        </div>

        {/* Copy Button */}
        <button className="btn-glass" style={{ padding: '7px 12px' }} onClick={handleCopy}>
          <motion.div
            key={copyState}
            initial={{ scale: 0.8, opacity: 0 }}
            animate={{ scale: 1, opacity: 1 }}
            transition={{ duration: 0.2 }}
          >
            {copyIcon}
          </motion.div>
          <span
            className="hidden sm:inline"
            style={{
              fontSize: 11,
              letterSpacing: '0.08em',
              textTransform: 'uppercase',
            }}
          >
            {copyState === 'success' ? '已复制' : copyState === 'error' ? '失败' : '复制'}
          </span>
        </button>

        {/* Clear Button */}
        <button
          className="btn-glass group"
          style={{ padding: '7px 12px' }}
          onClick={handleClear}
          onMouseEnter={(e) => {
            const icon = e.currentTarget.querySelector('.trash-icon') as HTMLElement;
            const text = e.currentTarget.querySelector('.trash-text') as HTMLElement;
            if (icon) icon.style.color = 'rgba(255,100,100,0.8)';
            if (text) text.style.color = 'rgba(255,100,100,0.8)';
          }}
          onMouseLeave={(e) => {
            const icon = e.currentTarget.querySelector('.trash-icon') as HTMLElement;
            const text = e.currentTarget.querySelector('.trash-text') as HTMLElement;
            if (icon) icon.style.color = '';
            if (text) text.style.color = '';
          }}
        >
          <Trash2 size={15} strokeWidth={1.5} className="trash-icon transition-colors duration-300" />
          <span
            className="trash-text hidden sm:inline transition-colors duration-300"
            style={{
              fontSize: 11,
              letterSpacing: '0.08em',
              textTransform: 'uppercase',
            }}
          >
            清空
          </span>
        </button>

        {/* Format Button */}
        <button className="btn-glass" style={{ width: 32, height: 32 }} onClick={onFormat} title="格式化">
          <AlignLeft size={15} strokeWidth={1.5} />
        </button>

        {/* Layout Toggle Button */}
        <button
          className="btn-glass"
          style={{ width: 32, height: 32 }}
          onClick={onLayoutToggle}
          title={layout === 'horizontal' ? '切换上下布局' : '切换左右布局'}
        >
          <motion.div
            animate={{ rotate: layout === 'horizontal' ? 0 : 90 }}
            transition={{ duration: 0.2 }}
          >
            {layout === 'horizontal' ? (
              <Layout size={15} strokeWidth={1.5} />
            ) : (
              <LayoutTemplate size={15} strokeWidth={1.5} />
            )}
          </motion.div>
        </button>
      </div>
    </header>
  );
}

src/sections/EditorPanel.tsx

tsx 复制代码
import { useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import CodeMirror from '@uiw/react-codemirror';
import { html } from '@codemirror/lang-html';
import { oneDark } from '@codemirror/theme-one-dark';
import { Upload, AlertCircle } from 'lucide-react';

interface EditorPanelProps {
  code: string;
  onChange: (value: string) => void;
  onCursorChange?: (line: number, col: number) => void;
}

export default function EditorPanel({ code, onChange, onCursorChange }: EditorPanelProps) {
  const [isDragOver, setIsDragOver] = useState(false);
  const [dragError, setDragError] = useState<string | null>(null);

  const handleDragOver = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragOver(true);
  }, []);

  const handleDragLeave = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragOver(false);
    setDragError(null);
  }, []);

  const handleDrop = useCallback(
    (e: React.DragEvent) => {
      e.preventDefault();
      e.stopPropagation();
      setIsDragOver(false);

      const file = e.dataTransfer.files[0];
      if (!file) return;

      if (!file.name.endsWith('.html') && !file.name.endsWith('.htm')) {
        setDragError('请拖放 .html 文件');
        setTimeout(() => setDragError(null), 2000);
        return;
      }

      const reader = new FileReader();
      reader.onload = (event) => {
        const text = event.target?.result as string;
        if (text) onChange(text);
      };
      reader.readAsText(file);
    },
    [onChange]
  );

  const handleCursorActivity = useCallback(
    (viewUpdate: any) => {
      if (viewUpdate.state && onCursorChange) {
        const { head } = viewUpdate.state.selection.main;
        const line = viewUpdate.state.doc.lineAt(head);
        const col = head - line.from;
        onCursorChange(line.number, col + 1);
      }
    },
    [onCursorChange]
  );

  return (
    <div
      className="relative flex flex-col h-full"
      style={{ borderRight: '1px solid rgba(255,255,255,0.06)' }}
      onDragOver={handleDragOver}
      onDragLeave={handleDragLeave}
      onDrop={handleDrop}
    >
      {/* Tab Bar */}
      <div
        className="flex items-end"
        style={{
          height: 36,
          background: 'rgba(255,255,255,0.02)',
          borderBottom: '1px solid rgba(255,255,255,0.06)',
        }}
      >
        <div
          className="flex items-center gap-2"
          style={{
            padding: '0 16px',
            height: 35,
            background: 'rgba(255,255,255,0.04)',
            borderTop: '1px solid rgba(255,255,255,0.06)',
            borderLeft: '1px solid rgba(255,255,255,0.06)',
            borderRight: '1px solid rgba(255,255,255,0.06)',
            borderRadius: '6px 6px 0 0',
          }}
        >
          <span style={{ color: 'rgba(255,255,255,0.4)', fontSize: 8 }}>&#9679;</span>
          <span
            style={{
              fontSize: 12,
              fontWeight: 500,
              color: '#ffffff',
              fontFamily: 'Menlo, Monaco, monospace',
            }}
          >
            index.html
          </span>
        </div>
      </div>

      {/* CodeMirror Editor */}
      <div className="flex-1 relative overflow-hidden">
        <CodeMirror
          value={code}
          height="100%"
          extensions={[html()]}
          theme={oneDark}
          onChange={onChange}
          onUpdate={handleCursorActivity}
          basicSetup={{
            lineNumbers: true,
            highlightActiveLineGutter: true,
            highlightActiveLine: true,
            foldGutter: false,
            dropCursor: false,
            allowMultipleSelections: false,
            indentOnInput: true,
            bracketMatching: true,
            closeBrackets: true,
            autocompletion: false,
            highlightSelectionMatches: true,
            searchKeymap: true,
          }}
          style={{
            height: '100%',
            fontSize: 14,
            fontFamily: "'Menlo', 'Monaco', 'Courier New', monospace",
          }}
        />

        {/* Drag Overlay */}
        <AnimatePresence>
          {isDragOver && (
            <motion.div
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.15 }}
              className="drag-overlay absolute inset-0 flex flex-col items-center justify-center"
              style={{ zIndex: 10 }}
            >
              <Upload size={24} strokeWidth={1.5} style={{ color: 'rgba(255,255,255,0.25)', marginBottom: 12 }} />
              <span style={{ fontSize: 14, color: 'rgba(255,255,255,0.4)', letterSpacing: '0.04em' }}>
                拖放 HTML 文件到此处
              </span>
            </motion.div>
          )}
        </AnimatePresence>

        {/* Drag Error */}
        <AnimatePresence>
          {dragError && (
            <motion.div
              initial={{ opacity: 0, y: 10 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.2 }}
              className="absolute inset-0 flex items-center justify-center"
              style={{ zIndex: 10, background: 'rgba(10,10,10,0.7)' }}
            >
              <div className="flex items-center gap-2" style={{ color: 'rgba(255,100,100,0.8)' }}>
                <AlertCircle size={18} strokeWidth={1.5} />
                <span style={{ fontSize: 14 }}>{dragError}</span>
              </div>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </div>
  );
}

src/sections/PreviewPanel.tsx

tsx 复制代码
import { useState, useCallback, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Monitor, Tablet, Smartphone, RotateCcw, AlertCircle } from 'lucide-react';

type DeviceType = 'desktop' | 'tablet' | 'mobile';

interface PreviewPanelProps {
  code: string;
}

const deviceWidths: Record<DeviceType, string> = {
  desktop: '100%',
  tablet: '768px',
  mobile: '375px',
};

export default function PreviewPanel({ code }: PreviewPanelProps) {
  const [device, setDevice] = useState<DeviceType>('desktop');
  const [refreshKey, setRefreshKey] = useState(0);
  const [isRefreshing, setIsRefreshing] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleRefresh = useCallback(() => {
    setIsRefreshing(true);
    setRefreshKey((prev) => prev + 1);
    setTimeout(() => setIsRefreshing(false), 400);
  }, []);

  useEffect(() => {
    const handleMessage = (event: MessageEvent) => {
      if (event.data?.type === 'iframe-error') {
        setError(event.data.message);
      }
    };
    window.addEventListener('message', handleMessage);
    return () => window.removeEventListener('message', handleMessage);
  }, []);

  useEffect(() => {
    setError(null);
  }, [code]);

  const devices: { type: DeviceType; icon: typeof Monitor; label: string }[] = [
    { type: 'desktop', icon: Monitor, label: '桌面' },
    { type: 'tablet', icon: Tablet, label: '平板' },
    { type: 'mobile', icon: Smartphone, label: '手机' },
  ];

  return (
    <div className="flex flex-col h-full">
      {/* Toolbar */}
      <div
        className="flex items-center justify-between"
        style={{
          height: 36,
          padding: '0 12px',
          background: 'rgba(255,255,255,0.02)',
          borderBottom: '1px solid rgba(255,255,255,0.06)',
        }}
      >
        <div className="flex items-center" style={{ gap: 4 }}>
          {devices.map(({ type, icon: Icon, label }) => (
            <button
              key={type}
              className={`btn-device ${device === type ? 'active' : ''}`}
              onClick={() => setDevice(type)}
              title={label}
            >
              <Icon size={15} strokeWidth={1.5} />
            </button>
          ))}
        </div>
        <button className="btn-device" onClick={handleRefresh} title="刷新预览">
          <motion.div
            animate={isRefreshing ? { rotate: -360 } : { rotate: 0 }}
            transition={{ duration: 0.4, ease: [0.4, 0, 0.2, 1] }}
          >
            <RotateCcw size={15} strokeWidth={1.5} />
          </motion.div>
        </button>
      </div>

      {/* Preview Viewport */}
      <div className="flex-1 relative" style={{ background: '#0a0a0a', overflow: 'auto' }}>
        <div
          style={{
            width: deviceWidths[device],
            maxWidth: '100%',
            height: '100%',
            margin: '0 auto',
            transition: 'width 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
            background: '#ffffff',
          }}
        >
          <iframe
            key={refreshKey}
            srcDoc={code}
            sandbox="allow-scripts allow-same-origin allow-popups allow-forms allow-modals"
            style={{ width: '100%', height: '100%', border: 'none', display: 'block' }}
            title="HTML Preview"
          />
        </div>

        {error && (
          <motion.div
            initial={{ y: '100%' }}
            animate={{ y: 0 }}
            transition={{ duration: 0.2 }}
            className="error-bar absolute bottom-0 left-0 right-0 flex items-center gap-2"
            style={{ height: 32, padding: '0 12px', fontSize: 12 }}
          >
            <AlertCircle size={14} strokeWidth={1.5} />
            <span>JavaScript Error: {error}</span>
          </motion.div>
        )}
      </div>
    </div>
  );
}

src/sections/StatusBar.tsx

tsx 复制代码
interface StatusBarProps {
  line: number;
  col: number;
  charCount: number;
}

export default function StatusBar({ line, col, charCount }: StatusBarProps) {
  return (
    <footer
      className="panel-glass flex items-center justify-between"
      style={{
        height: 32,
        padding: '0 28px',
        borderTop: '1px solid rgba(255,255,255,0.06)',
        borderBottom: 'none',
        borderLeft: 'none',
        borderRight: 'none',
      }}
    >
      <div
        className="flex items-center gap-1"
        style={{
          fontSize: 12,
          color: 'rgba(255,255,255,0.4)',
          fontFamily: "'Menlo', 'Monaco', 'Courier New', monospace",
        }}
      >
        <span>Ln {line}</span>
        <span style={{ color: 'rgba(255,255,255,0.15)' }}>&#65372;</span>
        <span>Col {col}</span>
      </div>
      <div
        style={{
          fontSize: 12,
          color: 'rgba(255,255,255,0.4)',
          fontFamily: "'Menlo', 'Monaco', 'Courier New', monospace",
        }}
      >
        {charCount.toLocaleString()} characters
      </div>
    </footer>
  );
}

src/App.tsx

tsx 复制代码
import { useState, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Panel, Group, Separator } from 'react-resizable-panels';
import Header from '@/sections/Header';
import EditorPanel from '@/sections/EditorPanel';
import PreviewPanel from '@/sections/PreviewPanel';
import StatusBar from '@/sections/StatusBar';
import { defaultCode } from '@/data/examples';

function App() {
  const [code, setCode] = useState(defaultCode);
  const [layout, setLayout] = useState<'horizontal' | 'vertical'>('horizontal');
  const [cursorPos, setCursorPos] = useState({ line: 1, col: 1 });

  const handleCodeChange = useCallback((value: string) => {
    setCode(value);
  }, []);

  const handleLayoutToggle = useCallback(() => {
    setLayout((prev) => (prev === 'horizontal' ? 'vertical' : 'horizontal'));
  }, []);

  const handleCursorChange = useCallback((line: number, col: number) => {
    setCursorPos({ line, col });
  }, []);

  const handleFormat = useCallback(() => {
    let formatted = '';
    let indent = 0;
    const lines = code.split('\n');
    const trimmedLines = lines.map((l) => l.trim());

    for (let i = 0; i < trimmedLines.length; i++) {
      const line = trimmedLines[i];
      if (!line) {
        formatted += '\n';
        continue;
      }
      if (line.startsWith('</')) {
        indent = Math.max(0, indent - 1);
      }
      formatted += '  '.repeat(indent) + line + '\n';
      if (
        line.startsWith('<') &&
        !line.startsWith('</') &&
        !line.endsWith('/>') &&
        !line.startsWith('<!') &&
        !line.startsWith('<?') &&
        !line.startsWith('<meta') &&
        !line.startsWith('<link') &&
        !line.startsWith('<img') &&
        !line.startsWith('<input') &&
        !line.startsWith('<br') &&
        !line.startsWith('<hr')
      ) {
        const tagMatch = line.match(/<(\w+)/);
        if (tagMatch) {
          const tagName = tagMatch[1];
          const hasClosingTag = line.includes(`</${tagName}>`);
          if (!hasClosingTag) indent++;
        }
      }
    }
    setCode(formatted.trim() + '\n');
  }, [code]);

  const charCount = useMemo(() => code.length, [code]);

  return (
    <div
      className="flex flex-col"
      style={{ width: '100vw', height: '100vh', background: '#0a0a0a', overflow: 'hidden' }}
    >
      <Header
        code={code}
        onCodeChange={handleCodeChange}
        layout={layout}
        onLayoutToggle={handleLayoutToggle}
        onFormat={handleFormat}
      />
      <div className="flex-1" style={{ minHeight: 0 }}>
        <AnimatePresence mode="wait">
          <motion.div
            key={layout}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.2 }}
            className="h-full"
          >
            <Group orientation={layout} style={{ height: '100%' }}>
              <Panel defaultSize={50} minSize={20} style={{ minHeight: 0 }}>
                <EditorPanel
                  code={code}
                  onChange={handleCodeChange}
                  onCursorChange={handleCursorChange}
                />
              </Panel>
              <Separator
                style={{
                  ...(layout === 'horizontal' ? { width: 4 } : { height: 4 }),
                  background: 'rgba(255,255,255,0.06)',
                  transition: 'all 0.2s ease',
                  flexShrink: 0,
                }}
                onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => {
                  const el = e.currentTarget;
                  el.style.background = 'rgba(255,255,255,0.2)';
                  if (layout === 'horizontal') el.style.width = '6px';
                  else el.style.height = '6px';
                }}
                onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => {
                  const el = e.currentTarget;
                  el.style.background = 'rgba(255,255,255,0.06)';
                  if (layout === 'horizontal') el.style.width = '4px';
                  else el.style.height = '4px';
                }}
              />
              <Panel defaultSize={50} minSize={20} style={{ minHeight: 0 }}>
                <PreviewPanel code={code} />
              </Panel>
            </Group>
          </motion.div>
        </AnimatePresence>
      </div>
      <StatusBar line={cursorPos.line} col={cursorPos.col} charCount={charCount} />
    </div>
  );
}

export default App;

本地运行

bash 复制代码
# 克隆项目后
cd html-previewer
npm install
npm run dev

浏览器打开 http://localhost:5173 即可使用。


总结

这个 HTML 预览器是一个纯前端工具,无需任何后端服务,适合嵌入到各种开发工具链中。核心实现思路非常简单:CodeMirror 负责编辑,iframe + srcdoc 负责预览,react-resizable-panels 负责布局。整个项目代码量不到 1000 行,但覆盖了代码编辑、实时预览、文件拖放、设备模拟、布局切换等完整功能。

如果你有类似的需求(在线代码演示、教学工具、快速原型验证),可以直接基于这个项目进行二次开发。比如添加 CSS/JS 编辑面板、支持多文件、集成本地存储保存代码历史等。

在线体验https://loavebstyqay6.ok.kimi.link


如果这篇文章对你有帮助,欢迎点赞收藏,也欢迎在评论区交流讨论!

相关推荐
广州智维科技1 小时前
Kvaser Edge WL400S:工业级边缘计算与 CAN‑FD 数据采集平台解析73-30130-01688-0
前端·edge·边缘计算
aichitang20241 小时前
椭圆的光学性质
html·html5·几何学
吃好睡好便好1 小时前
在Matlab中用sphere( )函数绘制球面图
开发语言·前端·javascript·学习·算法·matlab·信息可视化
黑贝是条狗1 小时前
注册表破解chrome,edge阻止浏览器连接本地websocket
前端·javascript·数据库
UXbot1 小时前
AI 原型工具对比(2026):从文字描述到完整 App 界面的 5 款主流平台评测
android·前端·ios·交互·软件构建
ZC跨境爬虫1 小时前
跟着 MDN 学 HTML day_53:(深入理解 XPathResult 接口)
前端·javascript·ui·html·音视频
摸鱼仙人~1 小时前
html-anything 仓库全面介绍
前端·html
之歆2 小时前
DAY_24JavaScript 面向对象深度全解:Object、构造函数与 this 系统指南(上)
开发语言·前端·javascript·原型模式
梦梦代码精2 小时前
开源智能体平台 BuildingAI 深度解析:Monorepo 架构、MCP 集成及 GPT-Image-2 接入实测
前端·人工智能·后端·gpt·开源·github