Spaces:
Sleeping
Sleeping
Commit ·
4167172
1
Parent(s): 6a7dbc0
Enhance: Add Toolbar, Notion Theme, Pangu spacing, and Interview Guide
Browse files- INTERVIEW_GUIDE.md +68 -0
- README.md +22 -10
- static/css/style.css +150 -24
- static/js/app.js +85 -28
- templates/index.html +65 -19
INTERVIEW_GUIDE.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 面试指南:微信公众号 Markdown 编辑器 (WeChat Markdown Editor)
|
| 2 |
+
|
| 3 |
+
> 本文档旨在辅助你理解项目核心架构与技术难点,以便在面试中流利作答。
|
| 4 |
+
|
| 5 |
+
## 1. 项目概述 (Project Overview)
|
| 6 |
+
|
| 7 |
+
**一句话介绍**:
|
| 8 |
+
这是一个基于 Web 的 Markdown 编辑器,专注于解决微信公众号排版繁琐、代码展示不友好以及外链限制等痛点。它能够将 Markdown 实时渲染为适配微信公众号样式的 HTML,支持一键复制发布。
|
| 9 |
+
|
| 10 |
+
**核心价值**:
|
| 11 |
+
- **效率提升**:让技术博主专注于写作,而非排版。
|
| 12 |
+
- **样式定制**:通过 CSS 变量实现多主题切换(如 Notion 风格),满足不同审美。
|
| 13 |
+
- **平台适配**:针对微信环境的特殊限制(如外链、代码块)做了专门处理。
|
| 14 |
+
|
| 15 |
+
## 2. 技术架构 (Technical Architecture)
|
| 16 |
+
|
| 17 |
+
虽然项目看似简单,但其架构设计体现了**轻量化**和**关注点分离**的思想。
|
| 18 |
+
|
| 19 |
+
- **后端 (Backend)**: Python Flask
|
| 20 |
+
- **角色**:主要作为静态资源服务器(Static File Server)。
|
| 21 |
+
- **优势**:轻量、部署简单(配合 Docker/Gunicorn),易于后续扩展(如增加账号系统或云端存储)。
|
| 22 |
+
- **前端 (Frontend)**: Vue 3 + Tailwind CSS
|
| 23 |
+
- **Vue 3**:利用 Composition API (`setup`, `ref`, `computed`) 管理编辑器状态(源码、预览 HTML、主题设置)。
|
| 24 |
+
- **Tailwind CSS**:快速构建现代化的 UI 界面,提高开发效率。
|
| 25 |
+
- **核心引擎**: Markdown-it + Highlight.js
|
| 26 |
+
- **渲染**:在浏览器端实时将 Markdown 解析为 HTML AST,再渲染为 DOM。
|
| 27 |
+
- **高亮**:自动检测代码语言并应用高亮样式。
|
| 28 |
+
|
| 29 |
+
## 3. 核心难点与解决方案 (Key Challenges & Solutions)
|
| 30 |
+
|
| 31 |
+
面试中如果被问到“项目中遇到的最大困难是什么”,可以从以下几点回答:
|
| 32 |
+
|
| 33 |
+
### 难点 1:微信公众号的样式兼容性 (Style Adaptation)
|
| 34 |
+
**问题**:微信公众号后台编辑器过滤掉了许多外部 CSS,且对某些 HTML 标签(如 `h1`, `blockquote`)的默认样式支持不一致。
|
| 35 |
+
**解决方案**:
|
| 36 |
+
- **内联样式模拟**:虽然没有使用内联样式库(如 juice),但我通过精心设计的 CSS 选择器(`#preview-content h1` 等)确保复制到剪贴板的内容带有所需的样式信息。微信编辑器在粘贴富文本时会保留大部分计算后的样式。
|
| 37 |
+
- **自定义 CSS 变量**:使用 `:root` 和 `data-theme` 属性实现主题切换。这样只需修改变量值,无需重写大量 CSS 规则。
|
| 38 |
+
|
| 39 |
+
### 难点 2:外链处理 (External Links)
|
| 40 |
+
**问题**:微信公众号文章除白名单外,不支持普通外部超链接,导致文章中的参考链接无法点击。
|
| 41 |
+
**解决方案**:
|
| 42 |
+
- **自定义 Renderer**:利用 `markdown-it` 的插件机制,重写 `link_open` 和 `link_close` 规则。
|
| 43 |
+
- **脚注转换**:在渲染过程中,将所有外链收集到一个 `footnotes` 数组中。渲染结束后,在文章底部自动追加一个“引用链接”列表,并将正文中的链接转换为上标索引(如 `[1]`)。
|
| 44 |
+
|
| 45 |
+
### 难点 3:富文本一键复制 (Rich Text Copy)
|
| 46 |
+
**问题**:普通的 `navigator.clipboard.writeText` 只能复制纯文本,无法保留颜色和排版。
|
| 47 |
+
**解决方案**:
|
| 48 |
+
- **Range API**: 创建一个 `Range` 对象选中预览区域的 DOM 节点。
|
| 49 |
+
- **Selection API**: 将 `Range` 添加到当前的 `Selection` 中。
|
| 50 |
+
- **execCommand**: 使用 `document.execCommand('copy')`(虽然标准标记为过时,但在处理“富文本复制”场景下,它仍然是兼容性最好、最可靠的方案)。
|
| 51 |
+
|
| 52 |
+
### 难点 4:同步滚动 (Sync Scroll)
|
| 53 |
+
**问题**:左侧 Markdown 源码和右侧渲染后的 HTML 高度不一致,如何实现精准的同步滚动?
|
| 54 |
+
**解决方案**:
|
| 55 |
+
- **百分比映射**:计算当前滚动条位置占总可滚动高度的百分比 (`scrollTop / (scrollHeight - clientHeight)`),并将此百分比应用到另一侧。
|
| 56 |
+
- **防抖动 (Debounce/Lock)**:为了防止“循环触发”滚动事件(左滚带动右,右滚又带动左),设置了一个 `isScrolling` 标志位,在滚动触发时锁定,滚动结束后(通过 `setTimeout`)释放。
|
| 57 |
+
|
| 58 |
+
### 难点 5:中英文自动空格 (Pangu Spacing)
|
| 59 |
+
**问题**:为了提升排版美感,中英文之间应该有空格(如 "Hello你好" -> "Hello 你好")。
|
| 60 |
+
**解决方案**:
|
| 61 |
+
- **正则替换**:前端实现了一个轻量级的替换逻辑。
|
| 62 |
+
- `text.replace(/([\u4e00-\u9fa5])([a-zA-Z0-9])/g, '$1 $2')`:中文后跟英文/数字。
|
| 63 |
+
- `text.replace(/([a-zA-Z0-9])([\u4e00-\u9fa5])/g, '$1 $2')`:英文/数字后跟中文。
|
| 64 |
+
|
| 65 |
+
## 4. 为什么做这个项目?
|
| 66 |
+
|
| 67 |
+
- **自我驱动**:作为一个技术人员,我经常写技术博客。现有的工具要么收费,要么功能太复杂,我希望有一个**轻量、可控、隐私安全(纯本地渲染)**的工具。
|
| 68 |
+
- **技术实践**:我想通过这个项目深入理解前端的**文本处理**和**DOM 操作**,同时实践 Vue 3 的 Composition API。
|
README.md
CHANGED
|
@@ -11,28 +11,40 @@ pinned: false
|
|
| 11 |
|
| 12 |
这是一个专为微信公众号写作设计的 Markdown 编辑器。它可以将 Markdown 实时转换为适合微信公众号粘贴的 HTML 格式,支持多种配色主题和代码高亮。
|
| 13 |
|
| 14 |
-
## 功能
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
- **实时预览**:左侧写作,右侧实时预览效果。
|
| 17 |
- **一键复制**:点击按钮即可复制带有内联样式的 HTML,直接粘贴到微信公众号后台。
|
| 18 |
-
- **多种主题**:内置多种配色方案(默认、极客黑、清新绿等)。
|
| 19 |
- **代码高亮**:完美支持代码块高亮,解决公众号代码排版难题。
|
| 20 |
- **链接转脚注**:自动将 Markdown 中的链接转换为文末脚注(因为公众号不支持外链)。
|
| 21 |
- **本地优先**:纯前端渲染,保护隐私,无数据上传。
|
| 22 |
|
| 23 |
## 技术栈
|
| 24 |
|
| 25 |
-
- Backend: Flask
|
| 26 |
-
- Frontend: Vue 3, Tailwind CSS
|
| 27 |
-
-
|
| 28 |
-
-
|
| 29 |
|
| 30 |
## 如何使用
|
| 31 |
|
| 32 |
-
1. 在左侧输入 Markdown 文本。
|
| 33 |
-
2. 在右
|
| 34 |
-
3. 点击
|
| 35 |
-
4.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
## 作者
|
| 38 |
|
|
|
|
| 11 |
|
| 12 |
这是一个专为微信公众号写作设计的 Markdown 编辑器。它可以将 Markdown 实时转换为适合微信公众号粘贴的 HTML 格式,支持多种配色主题和代码高亮。
|
| 13 |
|
| 14 |
+
## ✨ 新增功能
|
| 15 |
+
|
| 16 |
+
- **🎨 多主题切换**:支持默认、极客黑、粉彩、**Notion** 等多种风格。
|
| 17 |
+
- **🧹 一键格式化**:自动优化排版,实现中英文之间自动加空格(盘古之白)。
|
| 18 |
+
- **🖼️ 图片美化**:支持设置图片圆角和阴影,提升视觉体验。
|
| 19 |
+
- **📊 字数统计**:实时显示文章字数和预计阅读时间。
|
| 20 |
+
- **🛠️ 快捷工具栏**:提供常用的 Markdown 语法快捷键。
|
| 21 |
+
|
| 22 |
+
## 主要特点
|
| 23 |
|
| 24 |
- **实时预览**:左侧写作,右侧实时预览效果。
|
| 25 |
- **一键复制**:点击按钮即可复制带有内联样式的 HTML,直接粘贴到微信公众号后台。
|
|
|
|
| 26 |
- **代码高亮**:完美支持代码块高亮,解决公众号代码排版难题。
|
| 27 |
- **链接转脚注**:自动将 Markdown 中的链接转换为文末脚注(因为公众号不支持外链)。
|
| 28 |
- **本地优先**:纯前端渲染,保护隐私,无数据上传。
|
| 29 |
|
| 30 |
## 技术栈
|
| 31 |
|
| 32 |
+
- **Backend**: Flask (Python)
|
| 33 |
+
- **Frontend**: Vue 3, Tailwind CSS
|
| 34 |
+
- **Core**: markdown-it, highlight.js
|
| 35 |
+
- **Icons**: Phosphor Icons
|
| 36 |
|
| 37 |
## 如何使用
|
| 38 |
|
| 39 |
+
1. 在左侧输入 Markdown 文本,或使用顶部工具栏辅助输入。
|
| 40 |
+
2. 在右上角选择喜欢的主题(如 Notion 风格)。
|
| 41 |
+
3. 点击“魔法棒”图标进行中英文空格优化。
|
| 42 |
+
4. 点击右上角的“复制”按钮。
|
| 43 |
+
5. 在微信公众号后台编辑器中粘贴 (Ctrl+V / Cmd+V)。
|
| 44 |
+
|
| 45 |
+
## 面试辅助
|
| 46 |
+
|
| 47 |
+
如果你对这个项目的架构和实现细节感兴趣,请查看 [INTERVIEW_GUIDE.md](INTERVIEW_GUIDE.md)。
|
| 48 |
|
| 49 |
## 作者
|
| 50 |
|
static/css/style.css
CHANGED
|
@@ -6,27 +6,62 @@
|
|
| 6 |
--code-bg: #f6f8fa;
|
| 7 |
--quote-border: #07c160;
|
| 8 |
--quote-bg: #f9f9f9;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
}
|
| 10 |
|
| 11 |
/* Theme: Geek Black */
|
| 12 |
[data-theme="geek"] {
|
| 13 |
-
--primary-color: #
|
| 14 |
--text-color: #2c3e50;
|
|
|
|
| 15 |
--code-bg: #282c34;
|
| 16 |
--quote-border: #000;
|
|
|
|
|
|
|
| 17 |
}
|
| 18 |
|
| 19 |
-
/* Theme: Pastel */
|
| 20 |
[data-theme="pastel"] {
|
| 21 |
--primary-color: #ff9a9e;
|
| 22 |
--text-color: #555;
|
|
|
|
| 23 |
--code-bg: #fff0f5;
|
| 24 |
--quote-border: #fad0c4;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
}
|
| 26 |
|
| 27 |
body {
|
| 28 |
margin: 0;
|
| 29 |
-
font-family: -
|
| 30 |
height: 100vh;
|
| 31 |
display: flex;
|
| 32 |
flex-direction: column;
|
|
@@ -40,34 +75,76 @@ body {
|
|
| 40 |
color: var(--text-color);
|
| 41 |
word-break: break-all;
|
| 42 |
text-align: justify;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
}
|
| 44 |
|
| 45 |
#preview-content h1,
|
| 46 |
#preview-content h2,
|
| 47 |
#preview-content h3 {
|
| 48 |
margin-top: 1.5em;
|
| 49 |
-
margin-bottom:
|
| 50 |
font-weight: bold;
|
| 51 |
-
color: var(--
|
|
|
|
| 52 |
}
|
| 53 |
|
| 54 |
#preview-content h1 {
|
| 55 |
-
font-size: 1.
|
| 56 |
border-bottom: 2px solid var(--primary-color);
|
| 57 |
padding-bottom: 0.3em;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
}
|
| 59 |
|
| 60 |
#preview-content h2 {
|
| 61 |
-
font-size: 1.
|
| 62 |
-
display:
|
| 63 |
-
border-
|
| 64 |
-
padding-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
}
|
| 66 |
|
| 67 |
#preview-content h3 {
|
| 68 |
font-size: 1.1em;
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
}
|
| 72 |
|
| 73 |
#preview-content p {
|
|
@@ -82,9 +159,10 @@ body {
|
|
| 82 |
}
|
| 83 |
|
| 84 |
#preview-content li {
|
| 85 |
-
margin-bottom: 0.
|
| 86 |
}
|
| 87 |
|
|
|
|
| 88 |
#preview-content blockquote {
|
| 89 |
margin: 1.5em 0;
|
| 90 |
padding: 1em;
|
|
@@ -95,60 +173,108 @@ body {
|
|
| 95 |
border-radius: 4px;
|
| 96 |
}
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
#preview-content code {
|
| 99 |
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
|
| 100 |
font-size: 0.9em;
|
| 101 |
-
padding: 2px
|
| 102 |
background-color: rgba(27, 31, 35, 0.05);
|
| 103 |
color: #d63384;
|
| 104 |
border-radius: 4px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
}
|
| 106 |
|
|
|
|
| 107 |
#preview-content pre {
|
| 108 |
-
background-color: #282c34;
|
| 109 |
padding: 1em;
|
| 110 |
border-radius: 8px;
|
| 111 |
overflow-x: auto;
|
| 112 |
margin: 1.5em 0;
|
| 113 |
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
|
| 114 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
}
|
| 116 |
|
| 117 |
#preview-content pre code {
|
| 118 |
background-color: transparent;
|
| 119 |
color: #abb2bf;
|
| 120 |
padding: 0;
|
| 121 |
-
font-size:
|
| 122 |
border-radius: 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
}
|
| 124 |
|
| 125 |
/* Footnotes */
|
| 126 |
.footnote-ref {
|
| 127 |
-
color: var(--
|
| 128 |
text-decoration: none;
|
| 129 |
font-size: 0.8em;
|
| 130 |
vertical-align: super;
|
|
|
|
| 131 |
}
|
| 132 |
|
| 133 |
.footnotes-sep {
|
| 134 |
border-top: 1px solid #ddd;
|
| 135 |
-
margin-top:
|
| 136 |
-
margin-bottom:
|
|
|
|
| 137 |
}
|
| 138 |
|
| 139 |
.footnotes-list {
|
| 140 |
font-size: 0.9em;
|
| 141 |
-
color: #
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
}
|
| 143 |
|
| 144 |
/* Scrollbar */
|
| 145 |
::-webkit-scrollbar {
|
| 146 |
-
width:
|
| 147 |
-
height:
|
| 148 |
}
|
| 149 |
::-webkit-scrollbar-thumb {
|
| 150 |
background: #ccc;
|
| 151 |
-
border-radius:
|
| 152 |
}
|
| 153 |
::-webkit-scrollbar-track {
|
| 154 |
background: transparent;
|
|
|
|
| 6 |
--code-bg: #f6f8fa;
|
| 7 |
--quote-border: #07c160;
|
| 8 |
--quote-bg: #f9f9f9;
|
| 9 |
+
--link-color: #07c160;
|
| 10 |
+
--heading-color: #333;
|
| 11 |
+
--font-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
| 12 |
+
--img-radius: 0px;
|
| 13 |
+
--img-shadow: none;
|
| 14 |
}
|
| 15 |
|
| 16 |
/* Theme: Geek Black */
|
| 17 |
[data-theme="geek"] {
|
| 18 |
+
--primary-color: #000;
|
| 19 |
--text-color: #2c3e50;
|
| 20 |
+
--heading-color: #000;
|
| 21 |
--code-bg: #282c34;
|
| 22 |
--quote-border: #000;
|
| 23 |
+
--quote-bg: #f8f8f8;
|
| 24 |
+
--link-color: #000;
|
| 25 |
}
|
| 26 |
|
| 27 |
+
/* Theme: Pastel (Pink) */
|
| 28 |
[data-theme="pastel"] {
|
| 29 |
--primary-color: #ff9a9e;
|
| 30 |
--text-color: #555;
|
| 31 |
+
--heading-color: #ff758c;
|
| 32 |
--code-bg: #fff0f5;
|
| 33 |
--quote-border: #fad0c4;
|
| 34 |
+
--quote-bg: #fffbfb;
|
| 35 |
+
--link-color: #ff758c;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/* Theme: Notion (Clean & Serif/Sans mix) */
|
| 39 |
+
[data-theme="notion"] {
|
| 40 |
+
--primary-color: #37352f;
|
| 41 |
+
--text-color: #37352f;
|
| 42 |
+
--heading-color: #37352f;
|
| 43 |
+
--code-bg: #f7f6f3;
|
| 44 |
+
--quote-border: #37352f;
|
| 45 |
+
--quote-bg: transparent;
|
| 46 |
+
--link-color: #37352f;
|
| 47 |
+
--border-color: #e0e0e0;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/* Image Styles (Controlled by JS) */
|
| 51 |
+
[data-img-style="rounded"] {
|
| 52 |
+
--img-radius: 8px;
|
| 53 |
+
}
|
| 54 |
+
[data-img-style="shadow"] {
|
| 55 |
+
--img-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
| 56 |
+
}
|
| 57 |
+
[data-img-style="both"] {
|
| 58 |
+
--img-radius: 8px;
|
| 59 |
+
--img-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
| 60 |
}
|
| 61 |
|
| 62 |
body {
|
| 63 |
margin: 0;
|
| 64 |
+
font-family: var(--font-base);
|
| 65 |
height: 100vh;
|
| 66 |
display: flex;
|
| 67 |
flex-direction: column;
|
|
|
|
| 75 |
color: var(--text-color);
|
| 76 |
word-break: break-all;
|
| 77 |
text-align: justify;
|
| 78 |
+
padding: 20px;
|
| 79 |
+
background: #fff; /* Ensure white background for copy */
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
/* Notion Specific Overrides */
|
| 83 |
+
[data-theme="notion"] #preview-content {
|
| 84 |
+
font-family: "Lyon-Text", Georgia, ui-serif, serif; /* Serif for reading */
|
| 85 |
+
line-height: 1.8;
|
| 86 |
+
}
|
| 87 |
+
[data-theme="notion"] #preview-content h1,
|
| 88 |
+
[data-theme="notion"] #preview-content h2,
|
| 89 |
+
[data-theme="notion"] #preview-content h3 {
|
| 90 |
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, "Apple Color Emoji", Arial, sans-serif, "Segoe UI Emoji", "Segoe UI Symbol"; /* Sans for headings */
|
| 91 |
}
|
| 92 |
|
| 93 |
#preview-content h1,
|
| 94 |
#preview-content h2,
|
| 95 |
#preview-content h3 {
|
| 96 |
margin-top: 1.5em;
|
| 97 |
+
margin-bottom: 0.8em;
|
| 98 |
font-weight: bold;
|
| 99 |
+
color: var(--heading-color);
|
| 100 |
+
line-height: 1.3;
|
| 101 |
}
|
| 102 |
|
| 103 |
#preview-content h1 {
|
| 104 |
+
font-size: 1.6em;
|
| 105 |
border-bottom: 2px solid var(--primary-color);
|
| 106 |
padding-bottom: 0.3em;
|
| 107 |
+
text-align: center;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
/* Geek theme H1 is different */
|
| 111 |
+
[data-theme="geek"] #preview-content h1 {
|
| 112 |
+
border-bottom: 3px solid #000;
|
| 113 |
+
text-align: left;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
/* Notion theme H1 is minimal */
|
| 117 |
+
[data-theme="notion"] #preview-content h1 {
|
| 118 |
+
border-bottom: 1px solid #eee;
|
| 119 |
+
text-align: left;
|
| 120 |
+
font-size: 2em;
|
| 121 |
}
|
| 122 |
|
| 123 |
#preview-content h2 {
|
| 124 |
+
font-size: 1.3em;
|
| 125 |
+
display: block; /* WeChat friendly */
|
| 126 |
+
border-left: 5px solid var(--primary-color);
|
| 127 |
+
padding-left: 10px;
|
| 128 |
+
border-bottom: none;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
/* Notion theme H2 */
|
| 132 |
+
[data-theme="notion"] #preview-content h2 {
|
| 133 |
+
border-left: none;
|
| 134 |
+
padding-left: 0;
|
| 135 |
+
font-size: 1.5em;
|
| 136 |
+
border-bottom: 1px solid #eee;
|
| 137 |
+
padding-bottom: 6px;
|
| 138 |
}
|
| 139 |
|
| 140 |
#preview-content h3 {
|
| 141 |
font-size: 1.1em;
|
| 142 |
+
font-weight: bold;
|
| 143 |
+
margin-top: 1.2em;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
[data-theme="notion"] #preview-content h3 {
|
| 147 |
+
font-size: 1.25em;
|
| 148 |
}
|
| 149 |
|
| 150 |
#preview-content p {
|
|
|
|
| 159 |
}
|
| 160 |
|
| 161 |
#preview-content li {
|
| 162 |
+
margin-bottom: 0.2em;
|
| 163 |
}
|
| 164 |
|
| 165 |
+
/* Blockquote Styles */
|
| 166 |
#preview-content blockquote {
|
| 167 |
margin: 1.5em 0;
|
| 168 |
padding: 1em;
|
|
|
|
| 173 |
border-radius: 4px;
|
| 174 |
}
|
| 175 |
|
| 176 |
+
[data-theme="notion"] #preview-content blockquote {
|
| 177 |
+
border-left: 3px solid currentcolor;
|
| 178 |
+
padding-left: 1em;
|
| 179 |
+
font-style: italic;
|
| 180 |
+
color: inherit;
|
| 181 |
+
opacity: 0.8;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
/* Inline Code */
|
| 185 |
#preview-content code {
|
| 186 |
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
|
| 187 |
font-size: 0.9em;
|
| 188 |
+
padding: 2px 5px;
|
| 189 |
background-color: rgba(27, 31, 35, 0.05);
|
| 190 |
color: #d63384;
|
| 191 |
border-radius: 4px;
|
| 192 |
+
margin: 0 2px;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
[data-theme="notion"] #preview-content code {
|
| 196 |
+
color: #EB5757;
|
| 197 |
+
background: rgba(135,131,120,0.15);
|
| 198 |
}
|
| 199 |
|
| 200 |
+
/* Code Blocks */
|
| 201 |
#preview-content pre {
|
| 202 |
+
background-color: #282c34;
|
| 203 |
padding: 1em;
|
| 204 |
border-radius: 8px;
|
| 205 |
overflow-x: auto;
|
| 206 |
margin: 1.5em 0;
|
| 207 |
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
|
| 208 |
+
line-height: 1.5;
|
| 209 |
+
position: relative;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
[data-theme="notion"] #preview-content pre {
|
| 213 |
+
background-color: #f7f6f3;
|
| 214 |
+
border-radius: 4px;
|
| 215 |
+
}
|
| 216 |
+
[data-theme="notion"] #preview-content pre code {
|
| 217 |
+
color: #37352f;
|
| 218 |
+
text-shadow: none;
|
| 219 |
}
|
| 220 |
|
| 221 |
#preview-content pre code {
|
| 222 |
background-color: transparent;
|
| 223 |
color: #abb2bf;
|
| 224 |
padding: 0;
|
| 225 |
+
font-size: 13px;
|
| 226 |
border-radius: 0;
|
| 227 |
+
margin: 0;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
/* Images */
|
| 231 |
+
#preview-content img {
|
| 232 |
+
max-width: 100%;
|
| 233 |
+
height: auto;
|
| 234 |
+
display: block;
|
| 235 |
+
margin: 1.5em auto;
|
| 236 |
+
border-radius: var(--img-radius);
|
| 237 |
+
box-shadow: var(--img-shadow);
|
| 238 |
}
|
| 239 |
|
| 240 |
/* Footnotes */
|
| 241 |
.footnote-ref {
|
| 242 |
+
color: var(--link-color);
|
| 243 |
text-decoration: none;
|
| 244 |
font-size: 0.8em;
|
| 245 |
vertical-align: super;
|
| 246 |
+
margin-left: 2px;
|
| 247 |
}
|
| 248 |
|
| 249 |
.footnotes-sep {
|
| 250 |
border-top: 1px solid #ddd;
|
| 251 |
+
margin-top: 3em;
|
| 252 |
+
margin-bottom: 1.5em;
|
| 253 |
+
width: 30%;
|
| 254 |
}
|
| 255 |
|
| 256 |
.footnotes-list {
|
| 257 |
font-size: 0.9em;
|
| 258 |
+
color: #888;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
.footnotes-list ol {
|
| 262 |
+
padding-left: 20px;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
.footnotes-list li {
|
| 266 |
+
margin-bottom: 0.5em;
|
| 267 |
+
word-break: break-all;
|
| 268 |
}
|
| 269 |
|
| 270 |
/* Scrollbar */
|
| 271 |
::-webkit-scrollbar {
|
| 272 |
+
width: 6px;
|
| 273 |
+
height: 6px;
|
| 274 |
}
|
| 275 |
::-webkit-scrollbar-thumb {
|
| 276 |
background: #ccc;
|
| 277 |
+
border-radius: 3px;
|
| 278 |
}
|
| 279 |
::-webkit-scrollbar-track {
|
| 280 |
background: transparent;
|
static/js/app.js
CHANGED
|
@@ -6,35 +6,55 @@ createApp({
|
|
| 6 |
|
| 7 |
这是一个专为微信公众号设计的 **Markdown** 编辑器。
|
| 8 |
|
| 9 |
-
##
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
print("Hello, WeChat!")
|
| 16 |
-
\`\`\`
|
| 17 |
-
3. **一键复制**:点击右上角按钮,直接粘贴到公众号后台。
|
| 18 |
-
4. **多主题**:支持极客黑、粉彩等主题。
|
| 19 |
|
| 20 |
-
##
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
| 24 |
|
| 25 |
-
|
| 26 |
|
| 27 |
-
|
|
|
|
| 28 |
|
| 29 |
-
|
| 30 |
`);
|
| 31 |
|
| 32 |
const currentTheme = ref('default');
|
|
|
|
| 33 |
const copied = ref(false);
|
| 34 |
const editorRef = ref(null);
|
| 35 |
const previewRef = ref(null);
|
| 36 |
let isScrolling = false;
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
// Markdown-it setup
|
| 39 |
const md = window.markdownit({
|
| 40 |
html: true,
|
|
@@ -47,30 +67,23 @@ createApp({
|
|
| 47 |
return hljs.highlight(str, { language: lang, ignoreIllegals: true }).value;
|
| 48 |
} catch (__) {}
|
| 49 |
}
|
| 50 |
-
return '';
|
| 51 |
}
|
| 52 |
});
|
| 53 |
|
| 54 |
-
//
|
| 55 |
const defaultRender = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
|
| 56 |
return self.renderToken(tokens, idx, options);
|
| 57 |
};
|
| 58 |
|
| 59 |
-
// We need a way to store footnotes per render.
|
| 60 |
-
// Since markdown-it is sync, we can reset a global list before render.
|
| 61 |
let footnotes = [];
|
| 62 |
|
| 63 |
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
|
| 64 |
const href = tokens[idx].attrGet('href');
|
| 65 |
-
// Ignore internal links or empty
|
| 66 |
if (!href || href.startsWith('#')) {
|
| 67 |
return defaultRender(tokens, idx, options, env, self);
|
| 68 |
}
|
| 69 |
-
|
| 70 |
footnotes.push(href);
|
| 71 |
-
const n = footnotes.length;
|
| 72 |
-
|
| 73 |
-
// Render the link text but add a superscript
|
| 74 |
return `<span class="link-text">`;
|
| 75 |
};
|
| 76 |
|
|
@@ -80,14 +93,13 @@ createApp({
|
|
| 80 |
};
|
| 81 |
|
| 82 |
const htmlContent = computed(() => {
|
| 83 |
-
footnotes = [];
|
| 84 |
let rendered = md.render(markdown.value);
|
| 85 |
|
| 86 |
-
// Append footnotes if any
|
| 87 |
if (footnotes.length > 0) {
|
| 88 |
rendered += `<div class="footnotes-sep"></div><div class="footnotes-list">`;
|
| 89 |
rendered += `<h3>引用链接</h3><ol>`;
|
| 90 |
-
footnotes.forEach((url
|
| 91 |
rendered += `<li>${url}</li>`;
|
| 92 |
});
|
| 93 |
rendered += `</ol></div>`;
|
|
@@ -99,6 +111,10 @@ createApp({
|
|
| 99 |
document.body.setAttribute('data-theme', currentTheme.value);
|
| 100 |
};
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
const copyToWeChat = () => {
|
| 103 |
const range = document.createRange();
|
| 104 |
range.selectNode(document.getElementById('preview-content'));
|
|
@@ -113,6 +129,39 @@ createApp({
|
|
| 113 |
}, 2000);
|
| 114 |
};
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
const handleScroll = (source) => {
|
| 117 |
if (isScrolling) return;
|
| 118 |
isScrolling = true;
|
|
@@ -135,17 +184,25 @@ createApp({
|
|
| 135 |
|
| 136 |
onMounted(() => {
|
| 137 |
updateTheme();
|
|
|
|
| 138 |
});
|
| 139 |
|
|
|
|
|
|
|
|
|
|
| 140 |
return {
|
| 141 |
markdown,
|
| 142 |
htmlContent,
|
| 143 |
currentTheme,
|
|
|
|
| 144 |
copied,
|
|
|
|
|
|
|
| 145 |
editorRef,
|
| 146 |
previewRef,
|
| 147 |
-
|
| 148 |
copyToWeChat,
|
|
|
|
| 149 |
handleScroll
|
| 150 |
};
|
| 151 |
}
|
|
|
|
| 6 |
|
| 7 |
这是一个专为微信公众号设计的 **Markdown** 编辑器。
|
| 8 |
|
| 9 |
+
## 新增功能 ✨
|
| 10 |
|
| 11 |
+
- **一键格式化**:自动在中英文之间添加空格(盘古之白)。
|
| 12 |
+
- **多主题支持**:新增 **Notion** 风格。
|
| 13 |
+
- **图片美化**:支持圆角和阴影。
|
| 14 |
+
- **字数统计**:实时显示字数和阅读时间。
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
+
## 代码示例
|
| 17 |
|
| 18 |
+
\`\`\`python
|
| 19 |
+
def hello_world():
|
| 20 |
+
print("Hello, WeChat!")
|
| 21 |
+
\`\`\`
|
| 22 |
|
| 23 |
+
## 引用链接
|
| 24 |
|
| 25 |
+
微信公众号不支持外部链接,本编辑器会自动将链接转换为脚注。
|
| 26 |
+
例如:[GitHub](https://github.com) 和 [Google](https://google.com)。
|
| 27 |
|
| 28 |
+
> 极致的排版体验。
|
| 29 |
`);
|
| 30 |
|
| 31 |
const currentTheme = ref('default');
|
| 32 |
+
const imgStyle = ref('none'); // none, rounded, shadow, both
|
| 33 |
const copied = ref(false);
|
| 34 |
const editorRef = ref(null);
|
| 35 |
const previewRef = ref(null);
|
| 36 |
let isScrolling = false;
|
| 37 |
|
| 38 |
+
// Statistics
|
| 39 |
+
const wordCount = computed(() => {
|
| 40 |
+
// Simple logic: remove markdown symbols and count
|
| 41 |
+
const text = markdown.value.replace(/[#*>\`\-]/g, '').trim();
|
| 42 |
+
return text.length;
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
const readTime = computed(() => {
|
| 46 |
+
return Math.ceil(wordCount.value / 400); // 400 chars per minute
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
// Pangu (Auto Space) Logic
|
| 50 |
+
const formatContent = () => {
|
| 51 |
+
let text = markdown.value;
|
| 52 |
+
// Add space between CJK and English/Number
|
| 53 |
+
text = text.replace(/([\u4e00-\u9fa5])([a-zA-Z0-9])/g, '$1 $2');
|
| 54 |
+
text = text.replace(/([a-zA-Z0-9])([\u4e00-\u9fa5])/g, '$1 $2');
|
| 55 |
+
markdown.value = text;
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
// Markdown-it setup
|
| 59 |
const md = window.markdownit({
|
| 60 |
html: true,
|
|
|
|
| 67 |
return hljs.highlight(str, { language: lang, ignoreIllegals: true }).value;
|
| 68 |
} catch (__) {}
|
| 69 |
}
|
| 70 |
+
return '';
|
| 71 |
}
|
| 72 |
});
|
| 73 |
|
| 74 |
+
// Footnotes logic
|
| 75 |
const defaultRender = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
|
| 76 |
return self.renderToken(tokens, idx, options);
|
| 77 |
};
|
| 78 |
|
|
|
|
|
|
|
| 79 |
let footnotes = [];
|
| 80 |
|
| 81 |
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
|
| 82 |
const href = tokens[idx].attrGet('href');
|
|
|
|
| 83 |
if (!href || href.startsWith('#')) {
|
| 84 |
return defaultRender(tokens, idx, options, env, self);
|
| 85 |
}
|
|
|
|
| 86 |
footnotes.push(href);
|
|
|
|
|
|
|
|
|
|
| 87 |
return `<span class="link-text">`;
|
| 88 |
};
|
| 89 |
|
|
|
|
| 93 |
};
|
| 94 |
|
| 95 |
const htmlContent = computed(() => {
|
| 96 |
+
footnotes = [];
|
| 97 |
let rendered = md.render(markdown.value);
|
| 98 |
|
|
|
|
| 99 |
if (footnotes.length > 0) {
|
| 100 |
rendered += `<div class="footnotes-sep"></div><div class="footnotes-list">`;
|
| 101 |
rendered += `<h3>引用链接</h3><ol>`;
|
| 102 |
+
footnotes.forEach((url) => {
|
| 103 |
rendered += `<li>${url}</li>`;
|
| 104 |
});
|
| 105 |
rendered += `</ol></div>`;
|
|
|
|
| 111 |
document.body.setAttribute('data-theme', currentTheme.value);
|
| 112 |
};
|
| 113 |
|
| 114 |
+
const updateImgStyle = () => {
|
| 115 |
+
document.body.setAttribute('data-img-style', imgStyle.value);
|
| 116 |
+
};
|
| 117 |
+
|
| 118 |
const copyToWeChat = () => {
|
| 119 |
const range = document.createRange();
|
| 120 |
range.selectNode(document.getElementById('preview-content'));
|
|
|
|
| 129 |
}, 2000);
|
| 130 |
};
|
| 131 |
|
| 132 |
+
// Insert Markdown syntax at cursor
|
| 133 |
+
const insertSyntax = (type) => {
|
| 134 |
+
const textarea = editorRef.value;
|
| 135 |
+
const start = textarea.selectionStart;
|
| 136 |
+
const end = textarea.selectionEnd;
|
| 137 |
+
const text = markdown.value;
|
| 138 |
+
const selected = text.substring(start, end);
|
| 139 |
+
|
| 140 |
+
let before = '';
|
| 141 |
+
let after = '';
|
| 142 |
+
|
| 143 |
+
switch(type) {
|
| 144 |
+
case 'bold': before = '**'; after = '**'; break;
|
| 145 |
+
case 'italic': before = '*'; after = '*'; break;
|
| 146 |
+
case 'code': before = '`'; after = '`'; break;
|
| 147 |
+
case 'quote': before = '> '; after = ''; break;
|
| 148 |
+
case 'link': before = '['; after = '](url)'; break;
|
| 149 |
+
case 'image': before = ''; break;
|
| 150 |
+
case 'h2': before = '## '; after = ''; break;
|
| 151 |
+
case 'h3': before = '### '; after = ''; break;
|
| 152 |
+
case 'hr': before = '\n---\n'; after = ''; break;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
const newText = text.substring(0, start) + before + selected + after + text.substring(end);
|
| 156 |
+
markdown.value = newText;
|
| 157 |
+
|
| 158 |
+
// Restore focus (approximate)
|
| 159 |
+
setTimeout(() => {
|
| 160 |
+
textarea.focus();
|
| 161 |
+
textarea.setSelectionRange(start + before.length, end + before.length);
|
| 162 |
+
}, 0);
|
| 163 |
+
};
|
| 164 |
+
|
| 165 |
const handleScroll = (source) => {
|
| 166 |
if (isScrolling) return;
|
| 167 |
isScrolling = true;
|
|
|
|
| 184 |
|
| 185 |
onMounted(() => {
|
| 186 |
updateTheme();
|
| 187 |
+
updateImgStyle();
|
| 188 |
});
|
| 189 |
|
| 190 |
+
watch(currentTheme, updateTheme);
|
| 191 |
+
watch(imgStyle, updateImgStyle);
|
| 192 |
+
|
| 193 |
return {
|
| 194 |
markdown,
|
| 195 |
htmlContent,
|
| 196 |
currentTheme,
|
| 197 |
+
imgStyle,
|
| 198 |
copied,
|
| 199 |
+
wordCount,
|
| 200 |
+
readTime,
|
| 201 |
editorRef,
|
| 202 |
previewRef,
|
| 203 |
+
formatContent,
|
| 204 |
copyToWeChat,
|
| 205 |
+
insertSyntax,
|
| 206 |
handleScroll
|
| 207 |
};
|
| 208 |
}
|
templates/index.html
CHANGED
|
@@ -10,25 +10,56 @@
|
|
| 10 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/atom-one-dark.min.css">
|
| 11 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
|
| 12 |
<link rel="stylesheet" href="/static/css/style.css">
|
|
|
|
|
|
|
| 13 |
</head>
|
| 14 |
<body class="bg-gray-100 h-screen flex flex-col overflow-hidden">
|
| 15 |
<div id="app" class="h-full flex flex-col">
|
| 16 |
<!-- Header -->
|
| 17 |
-
<header class="bg-white border-b border-gray-200 px-
|
| 18 |
<div class="flex items-center gap-3">
|
| 19 |
-
<div class="text-2xl">📝</div>
|
| 20 |
-
<h1 class="font-bold text-gray-800 text-lg">WeChat
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
</div>
|
| 22 |
|
| 23 |
-
<
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
<
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
<button @click="copyToWeChat" class="bg-green-600 hover:bg-green-700 text-white px-4 py-1.5 rounded text-sm font-medium transition-colors flex items-center gap-2">
|
| 31 |
-
<
|
|
|
|
| 32 |
</button>
|
| 33 |
</div>
|
| 34 |
</header>
|
|
@@ -36,8 +67,7 @@
|
|
| 36 |
<!-- Main Content -->
|
| 37 |
<main class="flex-1 flex overflow-hidden">
|
| 38 |
<!-- Editor -->
|
| 39 |
-
<div class="w-1/2 h-full flex flex-col border-r border-gray-200 bg-gray-50">
|
| 40 |
-
<div class="bg-gray-100 px-4 py-2 text-xs text-gray-500 font-mono border-b border-gray-200">MARKDOWN</div>
|
| 41 |
<textarea
|
| 42 |
v-model="markdown"
|
| 43 |
class="flex-1 w-full p-6 resize-none focus:outline-none font-mono text-sm leading-relaxed bg-gray-50 text-gray-800"
|
|
@@ -45,25 +75,41 @@
|
|
| 45 |
@scroll="handleScroll('editor')"
|
| 46 |
ref="editorRef"
|
| 47 |
></textarea>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
</div>
|
| 49 |
|
| 50 |
<!-- Preview -->
|
| 51 |
-
<div class="w-1/2 h-full flex flex-col bg-white">
|
| 52 |
-
<div class="bg-gray-100 px-4 py-2 text-xs text-gray-500 font-mono border-b border-gray-200 flex justify-between">
|
| 53 |
-
<span>PREVIEW</span>
|
| 54 |
-
<span v-if="copied" class="text-green-600 font-bold animate-pulse">已复制!</span>
|
| 55 |
-
</div>
|
| 56 |
<div
|
| 57 |
id="preview-content"
|
| 58 |
-
class="flex-1 w-full
|
| 59 |
v-html="htmlContent"
|
| 60 |
@scroll="handleScroll('preview')"
|
| 61 |
ref="previewRef"
|
| 62 |
></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
</div>
|
| 64 |
</main>
|
| 65 |
</div>
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
<script src="/static/js/app.js"></script>
|
| 68 |
</body>
|
| 69 |
</html>
|
|
|
|
| 10 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/atom-one-dark.min.css">
|
| 11 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
|
| 12 |
<link rel="stylesheet" href="/static/css/style.css">
|
| 13 |
+
<!-- Phosphor Icons -->
|
| 14 |
+
<script src="https://unpkg.com/@phosphor-icons/web"></script>
|
| 15 |
</head>
|
| 16 |
<body class="bg-gray-100 h-screen flex flex-col overflow-hidden">
|
| 17 |
<div id="app" class="h-full flex flex-col">
|
| 18 |
<!-- Header -->
|
| 19 |
+
<header class="bg-white border-b border-gray-200 px-4 py-3 flex justify-between items-center shadow-sm z-10 shrink-0">
|
| 20 |
<div class="flex items-center gap-3">
|
| 21 |
+
<div class="text-2xl bg-green-100 p-1.5 rounded-lg text-green-600">📝</div>
|
| 22 |
+
<h1 class="font-bold text-gray-800 text-lg hidden sm:block">WeChat Editor</h1>
|
| 23 |
+
</div>
|
| 24 |
+
|
| 25 |
+
<!-- Toolbar (Center) -->
|
| 26 |
+
<div class="flex items-center gap-1 bg-gray-100 p-1 rounded-lg hidden md:flex">
|
| 27 |
+
<button @click="insertSyntax('h2')" class="p-1.5 hover:bg-white rounded text-gray-600 transition-colors" title="二级标题"><i class="ph ph-text-h-two"></i></button>
|
| 28 |
+
<button @click="insertSyntax('h3')" class="p-1.5 hover:bg-white rounded text-gray-600 transition-colors" title="三级标题"><i class="ph ph-text-h-three"></i></button>
|
| 29 |
+
<div class="w-px h-4 bg-gray-300 mx-1"></div>
|
| 30 |
+
<button @click="insertSyntax('bold')" class="p-1.5 hover:bg-white rounded text-gray-600 transition-colors" title="加粗"><i class="ph ph-text-b"></i></button>
|
| 31 |
+
<button @click="insertSyntax('italic')" class="p-1.5 hover:bg-white rounded text-gray-600 transition-colors" title="斜体"><i class="ph ph-text-italic"></i></button>
|
| 32 |
+
<button @click="insertSyntax('quote')" class="p-1.5 hover:bg-white rounded text-gray-600 transition-colors" title="引用"><i class="ph ph-quotes"></i></button>
|
| 33 |
+
<button @click="insertSyntax('code')" class="p-1.5 hover:bg-white rounded text-gray-600 transition-colors" title="代码块"><i class="ph ph-code"></i></button>
|
| 34 |
+
<div class="w-px h-4 bg-gray-300 mx-1"></div>
|
| 35 |
+
<button @click="insertSyntax('link')" class="p-1.5 hover:bg-white rounded text-gray-600 transition-colors" title="链接"><i class="ph ph-link"></i></button>
|
| 36 |
+
<button @click="insertSyntax('image')" class="p-1.5 hover:bg-white rounded text-gray-600 transition-colors" title="图片"><i class="ph ph-image"></i></button>
|
| 37 |
+
<button @click="insertSyntax('hr')" class="p-1.5 hover:bg-white rounded text-gray-600 transition-colors" title="分割线"><i class="ph ph-minus"></i></button>
|
| 38 |
+
<div class="w-px h-4 bg-gray-300 mx-1"></div>
|
| 39 |
+
<button @click="formatContent" class="p-1.5 hover:bg-white rounded text-blue-600 transition-colors" title="中英文自动空格"><i class="ph ph-magic-wand"></i></button>
|
| 40 |
</div>
|
| 41 |
|
| 42 |
+
<!-- Settings (Right) -->
|
| 43 |
+
<div class="flex items-center gap-3">
|
| 44 |
+
<div class="flex flex-col sm:flex-row gap-2">
|
| 45 |
+
<select v-model="currentTheme" class="border border-gray-300 rounded px-2 py-1.5 text-xs sm:text-sm focus:outline-none focus:ring-1 focus:ring-green-500 bg-white">
|
| 46 |
+
<option value="default">🟢 默认主题</option>
|
| 47 |
+
<option value="geek">⚫ 极客黑</option>
|
| 48 |
+
<option value="pastel">🌸 粉彩系</option>
|
| 49 |
+
<option value="notion">⚪ Notion</option>
|
| 50 |
+
</select>
|
| 51 |
+
|
| 52 |
+
<select v-model="imgStyle" class="border border-gray-300 rounded px-2 py-1.5 text-xs sm:text-sm focus:outline-none focus:ring-1 focus:ring-green-500 bg-white hidden sm:block">
|
| 53 |
+
<option value="none">图片: 默认</option>
|
| 54 |
+
<option value="rounded">图片: 圆角</option>
|
| 55 |
+
<option value="shadow">图片: 阴影</option>
|
| 56 |
+
<option value="both">图片: 圆角+阴影</option>
|
| 57 |
+
</select>
|
| 58 |
+
</div>
|
| 59 |
|
| 60 |
+
<button @click="copyToWeChat" class="bg-green-600 hover:bg-green-700 text-white px-4 py-1.5 rounded text-sm font-medium transition-colors flex items-center gap-2 shadow-sm">
|
| 61 |
+
<i class="ph ph-copy"></i>
|
| 62 |
+
<span class="hidden sm:inline">复制</span>
|
| 63 |
</button>
|
| 64 |
</div>
|
| 65 |
</header>
|
|
|
|
| 67 |
<!-- Main Content -->
|
| 68 |
<main class="flex-1 flex overflow-hidden">
|
| 69 |
<!-- Editor -->
|
| 70 |
+
<div class="w-1/2 h-full flex flex-col border-r border-gray-200 bg-gray-50 relative group">
|
|
|
|
| 71 |
<textarea
|
| 72 |
v-model="markdown"
|
| 73 |
class="flex-1 w-full p-6 resize-none focus:outline-none font-mono text-sm leading-relaxed bg-gray-50 text-gray-800"
|
|
|
|
| 75 |
@scroll="handleScroll('editor')"
|
| 76 |
ref="editorRef"
|
| 77 |
></textarea>
|
| 78 |
+
<!-- Word Count Status -->
|
| 79 |
+
<div class="absolute bottom-0 right-0 bg-white/80 backdrop-blur border-t border-l border-gray-200 px-3 py-1 text-xs text-gray-500 rounded-tl-lg">
|
| 80 |
+
{{ wordCount }} 字 | 约 {{ readTime }} 分钟
|
| 81 |
+
</div>
|
| 82 |
</div>
|
| 83 |
|
| 84 |
<!-- Preview -->
|
| 85 |
+
<div class="w-1/2 h-full flex flex-col bg-white relative">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
<div
|
| 87 |
id="preview-content"
|
| 88 |
+
class="flex-1 w-full overflow-y-auto custom-scrollbar"
|
| 89 |
v-html="htmlContent"
|
| 90 |
@scroll="handleScroll('preview')"
|
| 91 |
ref="previewRef"
|
| 92 |
></div>
|
| 93 |
+
|
| 94 |
+
<!-- Copied Toast -->
|
| 95 |
+
<transition name="fade">
|
| 96 |
+
<div v-if="copied" class="absolute top-4 right-4 bg-green-600 text-white px-4 py-2 rounded shadow-lg flex items-center gap-2 z-20">
|
| 97 |
+
<i class="ph ph-check-circle text-lg"></i>
|
| 98 |
+
<span>已复制到剪贴板</span>
|
| 99 |
+
</div>
|
| 100 |
+
</transition>
|
| 101 |
</div>
|
| 102 |
</main>
|
| 103 |
</div>
|
| 104 |
|
| 105 |
+
<style>
|
| 106 |
+
.fade-enter-active, .fade-leave-active {
|
| 107 |
+
transition: opacity 0.5s;
|
| 108 |
+
}
|
| 109 |
+
.fade-enter-from, .fade-leave-to {
|
| 110 |
+
opacity: 0;
|
| 111 |
+
}
|
| 112 |
+
</style>
|
| 113 |
<script src="/static/js/app.js"></script>
|
| 114 |
</body>
|
| 115 |
</html>
|