duqing2026 commited on
Commit
4167172
·
1 Parent(s): 6a7dbc0

Enhance: Add Toolbar, Notion Theme, Pangu spacing, and Interview Guide

Browse files
Files changed (5) hide show
  1. INTERVIEW_GUIDE.md +68 -0
  2. README.md +22 -10
  3. static/css/style.css +150 -24
  4. static/js/app.js +85 -28
  5. 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
- - Markdown Engine: markdown-it
28
- - Syntax Highlighting: highlight.js
29
 
30
  ## 如何使用
31
 
32
- 1. 在左侧输入 Markdown 文本。
33
- 2. 在右侧预览效果
34
- 3. 点击右上角的复制到公众号按钮
35
- 4. 在微信公众号后台编辑器中粘贴 (Ctrl+V / Cmd+V)
 
 
 
 
 
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: #333;
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: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
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: 1em;
50
  font-weight: bold;
51
- color: var(--primary-color);
 
52
  }
53
 
54
  #preview-content h1 {
55
- font-size: 1.4em;
56
  border-bottom: 2px solid var(--primary-color);
57
  padding-bottom: 0.3em;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  }
59
 
60
  #preview-content h2 {
61
- font-size: 1.2em;
62
- display: inline-block;
63
- border-bottom: 2px solid var(--primary-color);
64
- padding-bottom: 5px;
 
 
 
 
 
 
 
 
 
 
65
  }
66
 
67
  #preview-content h3 {
68
  font-size: 1.1em;
69
- padding-left: 10px;
70
- border-left: 4px solid var(--primary-color);
 
 
 
 
71
  }
72
 
73
  #preview-content p {
@@ -82,9 +159,10 @@ body {
82
  }
83
 
84
  #preview-content li {
85
- margin-bottom: 0.5em;
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 4px;
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; /* Dark theme for code blocks mostly looks better */
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
- -webkit-overflow-scrolling: touch;
 
 
 
 
 
 
 
 
 
 
115
  }
116
 
117
  #preview-content pre code {
118
  background-color: transparent;
119
  color: #abb2bf;
120
  padding: 0;
121
- font-size: 14px;
122
  border-radius: 0;
 
 
 
 
 
 
 
 
 
 
 
123
  }
124
 
125
  /* Footnotes */
126
  .footnote-ref {
127
- color: var(--primary-color);
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: 2em;
136
- margin-bottom: 1em;
 
137
  }
138
 
139
  .footnotes-list {
140
  font-size: 0.9em;
141
- color: #666;
 
 
 
 
 
 
 
 
 
142
  }
143
 
144
  /* Scrollbar */
145
  ::-webkit-scrollbar {
146
- width: 8px;
147
- height: 8px;
148
  }
149
  ::-webkit-scrollbar-thumb {
150
  background: #ccc;
151
- border-radius: 4px;
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
- 1. **实时预览**:左侧输入,右侧实时预览
12
- 2. **代码高亮**:
13
- \`\`\`python
14
- def hello():
15
- print("Hello, WeChat!")
16
- \`\`\`
17
- 3. **一键复制**:点击右上角按钮,直接粘贴到公众号后台。
18
- 4. **多主题**:支持极客黑、粉彩等主题。
19
 
20
- ## 关于链接
21
 
22
- 微信公众号不支持外部链接,本编辑器会自动将链接转换为脚注。
23
- 例如:[GitHub](https://github.com) 和 [Google](https://google.com)
 
 
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 ''; // use external default escaping
51
  }
52
  });
53
 
54
- // Custom Link Renderer for Footnotes
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 = []; // Reset 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, index) => {
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
- updateTheme,
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 = '!['; after = '](https://)'; 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-6 py-3 flex justify-between items-center shadow-sm z-10">
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 Markdown Editor</h1>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  </div>
22
 
23
- <div class="flex items-center gap-4">
24
- <select v-model="currentTheme" @change="updateTheme" class="border border-gray-300 rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-500">
25
- <option value="default">默认主题 (WeChat Green)</option>
26
- <option value="geek">极客黑 (Geek Black)</option>
27
- <option value="pastel">粉彩 (Pastel)</option>
28
- </select>
 
 
 
 
 
 
 
 
 
 
 
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
- <span>📋</span> 复制到公众号
 
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 p-8 overflow-y-auto"
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>