diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md
index 686f57a..35c7f50 100644
--- a/docs/rebuild-spec/02-features.md
+++ b/docs/rebuild-spec/02-features.md
@@ -55,8 +55,8 @@
- [x] 发帖:选板块、标题、标签、正文 — SSR `/compose`(normal)
- [x] 正文图片上传 — `/compose/upload` + Markdown 插入
-- [ ] TipTap 富文本能力(见 [06-pages-ux.md](06-pages-ux.md) 编辑器节)— 本分支改用 Markdown textarea 渐进增强
-- [ ] Markdown 编辑模式(与富文本互转/双模)
+- [ ] TipTap 富文本能力(见 [06-pages-ux.md](06-pages-ux.md) 编辑器节)— **本分支不做 TipTap**;改用 Markdown 工具栏渐进增强
+- [x] Markdown 编辑(工具栏 + `/compose/preview`;发帖/改帖/评论共用 `shared/md_editor`)— 非 TipTap 双模
- [x] 编辑帖子(时限、锁帖约束)— SSR `/post/:id/edit`
- [x] 删除帖子 → 软删进回收站 — SSR Admin(帖详情 + `/admin/trash`)
- [x] 修订历史列表与单条详情(可做 diff)— SSR `/post/:id/revisions`(作者/管理员;无 diff)
diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md
index 4fab168..e8d2dff 100644
--- a/docs/rebuild-spec/06-pages-ux.md
+++ b/docs/rebuild-spec/06-pages-ux.md
@@ -28,7 +28,7 @@
| `/board/:id` | 板块 Feed | 已迁 |
| `/boards` | 板块索引 | 已迁 |
| `/post/:id` | 帖详情 + 评论(回复/赞/私密)/赞/藏 | 已迁 |
-| `/compose` | 发帖(normal / question / poll / bounty / lottery;textarea + 图片 + 门控插入) | 已迁 |
+| `/compose` | 发帖(类型字段 + Markdown 工具栏/预览/图片/门控) | 已迁 |
| `/post/:id/edit` | 编辑帖 | 已迁 |
| `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 |
| `/user/:id` | 公开用户页 | 已迁 |
@@ -123,22 +123,24 @@
| 悬赏 | 积分输入(显示余额)— SSR compose;发帖托管 |
| 抽奖 | 中奖人数 1–20 — SSR compose |
-### 3.2 编辑器能力(应对齐)
+### 3.2 编辑器能力(本分支:Markdown 渐进增强)
-源:[`ArticleEditor.tsx`]((仅 main)frontend/src/components/ArticleEditor.tsx) 与 `editor/` 扩展
+源对照:[`ArticleEditor.tsx`]((仅 main)frontend/src/components/ArticleEditor.tsx)(TipTap,**不迁**)
-- 标题 h2–h6(无 h1,避免与帖标题冲突)
-- 粗体/斜体/删除线等基础标记
-- 链接对话框
-- 代码块(语言、选项对话框)
-- 表格插入/编辑
-- 图片上传 + 图片组布局 + 浮动/清除浮动
-- 表情 / 贴纸选择器(多套:bilibili/douyin/tieba/weibo 等静态资源)
-- **登录可见** / **回复可见** / **积分可见**(价格 1–9999)节点
-- 富文本 ↔ Markdown 双模(门控块有 markdown 约定,见 [`utils/markdownContent.ts`]((仅 main)frontend/src/utils/markdownContent.ts))
-- Tab 缩进
+| 能力 | SSR 状态 |
+|------|----------|
+| 标题 h2–h6(`#` 映射为 h2) | Markdown 工具栏 + `ComposeBodyToHTML` |
+| 粗体/斜体/删除线/行内代码 | 已迁 |
+| 链接 | prompt 插入 |
+| 围栏代码块 | 已迁(语言 class) |
+| 列表 / 引用 | 已迁 |
+| 图片上传 | `/compose/upload` |
+| 登录/回复/积分可见门控 | compose 工具栏 |
+| 预览 | `POST /compose/preview`(同消毒管线) |
+| Tab 缩进 / 未保存离开 | `beforeunload` |
+| 表格 / 图片组 / 表情贴纸 / TipTap 双模 | **未做**(后置) |
-未保存离开:`UnsavedChangesDialog`。
+共用片段:`templates/shared/md_editor.tmpl`(发帖、改帖、评论、改评)。
---
diff --git a/docs/rebuild-spec/09-ssr-progress.md b/docs/rebuild-spec/09-ssr-progress.md
index 46a3475..de7fa03 100644
--- a/docs/rebuild-spec/09-ssr-progress.md
+++ b/docs/rebuild-spec/09-ssr-progress.md
@@ -10,15 +10,15 @@
| 项 | 值 |
|----|-----|
-| **上一刀** | 嵌套评论树(全局楼层 + 缩进) |
-| **下一刀** | 需点名(OIDC / S3 / 编辑器 / Gitea / 主题…) |
+| **上一刀** | Markdown 编辑器(工具栏 + 预览,共用 md_editor) |
+| **下一刀** | 需点名(OIDC / S3 / Gitea / 主题 / 表格表情…) |
| **工作区** | 应干净 |
---
## 一句话状态
-核心论坛、Admin、Limits/伪静态、**嵌套评论**已齐。余量多为后置与体验打磨。
+核心论坛、Admin、Limits/伪静态、嵌套评论、**Markdown 编辑器**已齐。TipTap / 表格 / 表情贴纸仍后置。
---
@@ -29,12 +29,14 @@
| 五种帖 + Admin P1 + §H | 已迁 |
| Limits / 伪静态 / 品牌图 | 已迁 |
| 评论嵌套树 UI | 已迁 |
+| Markdown 工具栏 + preview | 已迁 |
---
## 未完成
- P3:主题、TipTip、游客评论、修订 diff
+- 编辑器后置:TipTap、表格、图片组、表情贴纸
- 后置:OIDC、S3 热切换、Gitea `/projects`
---
diff --git a/modules/webrender/render.go b/modules/webrender/render.go
index f4b3385..57629ff 100644
--- a/modules/webrender/render.go
+++ b/modules/webrender/render.go
@@ -20,6 +20,20 @@ var (
func funcMap() template.FuncMap {
return template.FuncMap{
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
+ "dict": func(values ...any) (map[string]any, error) {
+ if len(values)%2 != 0 {
+ return nil, fmt.Errorf("dict 需要偶数个参数")
+ }
+ m := make(map[string]any, len(values)/2)
+ for i := 0; i < len(values); i += 2 {
+ k, ok := values[i].(string)
+ if !ok {
+ return nil, fmt.Errorf("dict 键必须是字符串")
+ }
+ m[k] = values[i+1]
+ }
+ return m, nil
+ },
"sortURL": func(boardID uint, sort string) string {
q := url.Values{}
if sort != "" && sort != "latest" {
diff --git a/public/assets/site.css b/public/assets/site.css
index 6e27034..5945bc4 100644
--- a/public/assets/site.css
+++ b/public/assets/site.css
@@ -524,6 +524,55 @@ body.j13-body {
}
.j13-filebtn { display: inline-block; margin: 0; cursor: pointer; }
.j13-compose { max-width: 720px; margin: 0 auto; padding: 1rem; }
+
+.j13-md-editor { margin: 0.35rem 0 0.75rem; }
+.j13-md-editor__toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ align-items: center;
+ margin-bottom: 0.45rem;
+}
+.j13-md-editor__btn {
+ appearance: none;
+ border: 1px solid var(--j13-border);
+ background: var(--j13-surface, #fff);
+ color: inherit;
+ border-radius: 4px;
+ padding: 0.25rem 0.5rem;
+ font: inherit;
+ font-size: 0.8rem;
+ line-height: 1.3;
+ cursor: pointer;
+}
+.j13-md-editor__btn:hover { border-color: var(--j13-accent, #3366cc); }
+.j13-md-editor__btn.is-active {
+ border-color: var(--j13-accent, #3366cc);
+ color: var(--j13-accent, #3366cc);
+}
+.j13-md-editor__filebtn { margin: 0; cursor: pointer; }
+.j13-md-editor__sep {
+ width: 1px;
+ height: 1.1rem;
+ background: var(--j13-border);
+ margin: 0 0.15rem;
+}
+.j13-md-editor__input {
+ width: 100%;
+ min-height: 8rem;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 0.9rem;
+ line-height: 1.5;
+}
+.j13-md-editor__preview {
+ min-height: 8rem;
+ padding: 0.75rem 0.85rem;
+ border: 1px solid var(--j13-border);
+ border-radius: 4px;
+ background: var(--j13-surface, #fff);
+}
+.j13-md-editor__status { min-height: 1.2em; margin: 0.35rem 0 0; }
+
.j13-poll-fields {
margin: 0.75rem 0 1rem;
padding: 0.75rem 1rem;
diff --git a/public/assets/site.js b/public/assets/site.js
index 16e5206..89acb95 100644
--- a/public/assets/site.js
+++ b/public/assets/site.js
@@ -1,17 +1,8 @@
// 姜十三论坛 SSR 渐进增强
document.documentElement.dataset.j13Ssr = "1";
-(function composeUploadAndGates() {
- const form = document.getElementById("compose-form");
- const textarea = document.getElementById("compose-content");
- if (!form || !textarea) return;
-
- const csrf = form.getAttribute("data-csrf") || "";
- const uploadURL = form.getAttribute("data-upload") || "/compose/upload";
- const fileInput = document.getElementById("compose-image");
- const statusEl = document.getElementById("compose-upload-status");
-
- function insertAtCursor(text) {
+(function mdEditors() {
+ function insertAtCursor(textarea, text) {
const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || start;
textarea.value =
@@ -21,14 +12,44 @@ document.documentElement.dataset.j13Ssr = "1";
textarea.setSelectionRange(pos, pos);
}
- function wrapSelection(openTag, closeTag, fallbackInner) {
+ function wrapSelection(textarea, before, after, fallback) {
const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || start;
let selected = textarea.value.slice(start, end);
- if (!selected.trim()) {
- selected = fallbackInner || "在此填写隐藏内容";
+ if (!selected) selected = fallback || "";
+ const block = before + selected + after;
+ textarea.value =
+ textarea.value.slice(0, start) + block + textarea.value.slice(end);
+ textarea.focus();
+ if (fallback && !textarea.value.slice(start, end)) {
+ textarea.setSelectionRange(start + before.length, start + before.length + selected.length);
+ } else {
+ textarea.setSelectionRange(start + block.length, start + block.length);
}
- // 若选区尚非段落,包一层 p 便于消毒后结构稳定
+ }
+
+ function prefixLines(textarea, prefix) {
+ const start = textarea.selectionStart || 0;
+ const end = textarea.selectionEnd || start;
+ const value = textarea.value;
+ let lineStart = value.lastIndexOf("\n", start - 1) + 1;
+ let lineEnd = value.indexOf("\n", end);
+ if (lineEnd < 0) lineEnd = value.length;
+ const chunk = value.slice(lineStart, lineEnd);
+ const next = chunk
+ .split("\n")
+ .map((ln) => (ln.trim() ? prefix + ln.replace(/^\s+/, "") : prefix.trimEnd()))
+ .join("\n");
+ textarea.value = value.slice(0, lineStart) + next + value.slice(lineEnd);
+ textarea.focus();
+ textarea.setSelectionRange(lineStart, lineStart + next.length);
+ }
+
+ function wrapGate(textarea, openTag, closeTag, statusEl) {
+ const start = textarea.selectionStart || 0;
+ const end = textarea.selectionEnd || start;
+ let selected = textarea.value.slice(start, end);
+ if (!selected.trim()) selected = "在此填写隐藏内容";
let inner = selected;
if (!/^\s*" + selected.replace(/\n/g, "
\n") + "
(空)
"; + previewEl.hidden = false; + textarea.hidden = true; + btn.classList.add("is-active"); + setStatus("预览模式(再点返回编辑)"); + } catch (e) { + previewOn = false; + setStatus(e.message || "预览失败"); + } + } else { + previewEl.hidden = true; + previewEl.innerHTML = ""; + textarea.hidden = false; + btn.classList.remove("is-active"); + textarea.focus(); + setStatus(""); + } + return; + } + if (previewOn) return; + switch (cmd) { + case "bold": + wrapSelection(textarea, "**", "**", "粗体"); + break; + case "italic": + wrapSelection(textarea, "*", "*", "斜体"); + break; + case "strike": + wrapSelection(textarea, "~~", "~~", "删除线"); + break; + case "code": + wrapSelection(textarea, "`", "`", "code"); + break; + case "link": { + const url = window.prompt("链接地址", "https://"); + if (!url) return; + wrapSelection(textarea, "[", "](" + url + ")", "链接文字"); + break; + } + case "h2": + prefixLines(textarea, "## "); + break; + case "h3": + prefixLines(textarea, "### "); + break; + case "ul": + prefixLines(textarea, "- "); + break; + case "ol": + prefixLines(textarea, "1. "); + break; + case "quote": + prefixLines(textarea, "> "); + break; + case "fence": + wrapSelection(textarea, "```\n", "\n```", "code"); + break; + case "gate-login": + wrapGate(textarea, '" + html.EscapeString(content) + "
" + safe := services.ComposeBodyToHTML(content) cm, err := d.Comment.Create(services.CommentCreateInput{ PostID: id, UserID: ctx.UserID(), Content: safe, ReplyTo: replyTo, IsPrivate: isPrivate, @@ -815,7 +814,7 @@ func (d Deps) CommentEditGet(c *gin.Context) { PostID: postID, CommentID: cm.ID, Floor: cm.Floor, - Content: commentHTMLToPlain(cm.Content), + Content: services.HTMLToComposePlain(cm.Content), }) } @@ -841,7 +840,7 @@ func (d Deps) CommentEditPost(c *gin.Context) { d.renderCommentEdit(ctx, "评论不能为空", postID, cm, plain) return } - safe := "" + html.EscapeString(plain) + "
" + safe := services.ComposeBodyToHTML(plain) _, enteredPending, err := d.Comment.Update(ctx.UserID(), cm.ID, ctx.IsAdmin(), ctx.SkipsModeration(), safe) if err != nil { d.renderCommentEdit(ctx, err.Error(), postID, cm, plain) @@ -923,14 +922,6 @@ func (d Deps) renderCommentEdit(ctx *webctx.Context, errMsg string, postID uint, }) } -// commentHTMLToPlain 发评存的是转义后的…
,编辑时还原为纯文本 -func commentHTMLToPlain(s string) string { - s = strings.TrimSpace(s) - s = strings.TrimPrefix(s, "") - s = strings.TrimSuffix(s, "
") - return html.UnescapeString(s) -} - type postRevisionRow struct { ID uint EditorName string diff --git a/services/compose_body.go b/services/compose_body.go index 2d899f9..793f354 100644 --- a/services/compose_body.go +++ b/services/compose_body.go @@ -7,73 +7,266 @@ import ( ) var ( - mdImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`) - mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) + mdImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`) + mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) + mdBoldRe = regexp.MustCompile(`\*\*([^*]+)\*\*`) + mdStrikeRe = regexp.MustCompile(`~~([^~]+)~~`) + mdItalicRe = regexp.MustCompile(`\*([^*\n]+)\*`) + mdInlineCodeRe = regexp.MustCompile("`([^`\n]+)`") + mdLangRe = regexp.MustCompile(`^[a-zA-Z0-9_+-]+$`) ) -// ComposeBodyToHTML 将发帖表单正文转为可消毒 HTML。 -// 若已含块级 HTML / 门控标签则原样交 Sanitize;否则按纯文本/轻量 Markdown 转段落。 +// ComposeBodyToHTML 将发帖/评论表单正文转为可消毒 HTML。 +// 若已含块级 HTML / 门控标签则原样交 Sanitize;否则按轻量 Markdown 转换。 func ComposeBodyToHTML(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } - lower := strings.ToLower(raw) - if strings.Contains(lower, "")
+ b.WriteString(html.EscapeString(strings.Join(code, "\n")))
+ b.WriteString("\n")
continue
}
- // 已是独立 img 段
- if strings.HasPrefix(p, "")
+ b.WriteString(mdInlineToHTML(title))
+ b.WriteString("\n")
+ i++
+ continue
+ }
+
+ // 引用
+ if strings.HasPrefix(trim, "> ") || trim == ">" {
+ var quote []string
+ for i < len(lines) {
+ t := strings.TrimSpace(lines[i])
+ if t == ">" {
+ quote = append(quote, "")
+ i++
+ continue
+ }
+ if strings.HasPrefix(t, "> ") {
+ quote = append(quote, strings.TrimPrefix(t, "> "))
+ i++
+ continue
+ }
+ break
+ }
+ b.WriteString("
\n") + continue + } + + // 无序列表 + if isMDUL(trim) { + b.WriteString("") + b.WriteString(mdInlineToHTML(strings.Join(quote, "
\n"))) + b.WriteString("
")
- b.WriteString(p)
+ b.WriteString(mdInlineToHTML(strings.ReplaceAll(joined, "\n", "
\n")))
b.WriteString("
" + html.EscapeString(sub[1]) + ""})
+ return key
+ })
+
+ s = html.EscapeString(s)
+ // 占位符被 escape 了,还原 key
+ for i := range phs {
+ escKey := html.EscapeString(phs[i].key)
+ s = strings.ReplaceAll(s, escKey, phs[i].key)
+ }
+
+ s = mdImageRe.ReplaceAllStringFunc(s, func(m string) string {
+ sub := mdImageRe.FindStringSubmatch(m)
+ if len(sub) != 3 || !safeComposeURL(sub[2]) {
+ return m
+ }
+ return `]*>\s*]*>([\s\S]*?)\s*`).ReplaceAllStringFunc(s, func(m string) string {
+ sub := regexp.MustCompile(`(?is)]*>\s*]*>([\s\S]*?)\s*`).FindStringSubmatch(m)
+ if len(sub) != 3 {
+ return m
+ }
+ lang, body := sub[1], html.UnescapeString(sub[2])
+ return "```" + lang + "\n" + body + "\n```\n\n"
+ })
+
+ s = regexp.MustCompile(`(?i)]*>([\s\S]*?)`).ReplaceAllStringFunc(s, func(m string) string { + sub := regexp.MustCompile(`(?i)
]*>([\s\S]*?)`).FindStringSubmatch(m) + if len(sub) != 2 { + return m + } + inner := stripTagsKeepText(sub[1]) + lines := strings.Split(inner, "\n") + for i := range lines { + lines[i] = "> " + strings.TrimSpace(lines[i]) + } + return strings.Join(lines, "\n") + "\n\n" + }) + + s = regexp.MustCompile(`(?i)]*>([\s\S]*?)`).ReplaceAllString(s, "**$1**") + s = regexp.MustCompile(`(?i)]*>([\s\S]*?)`).ReplaceAllString(s, "**$1**") + s = regexp.MustCompile(`(?i)]*>([\s\S]*?)`).ReplaceAllString(s, "*$1*") + s = regexp.MustCompile(`(?i)]*>([\s\S]*?)`).ReplaceAllString(s, "*$1*") + s = regexp.MustCompile(`(?i)
]*>([\s\S]*?)`).ReplaceAllString(s, "`$1`")
+ s = regexp.MustCompile(`(?i)`).ReplaceAllString(s, "\n\n")
s = regexp.MustCompile(`(?i)
`).ReplaceAllString(s, "\n")
s = regexp.MustCompile(`(?i)?p[^>]*>`).ReplaceAllString(s, "")
s = regexp.MustCompile(`(?i)<[^>]+>`).ReplaceAllString(s, "")
return strings.TrimSpace(html.UnescapeString(s))
}
+
+func stripTagsKeepText(s string) string {
+ s = regexp.MustCompile(`(?i)
`).ReplaceAllString(s, "\n")
+ s = regexp.MustCompile(`(?i)<[^>]+>`).ReplaceAllString(s, "")
+ return strings.TrimSpace(html.UnescapeString(s))
+}
diff --git a/services/compose_body_test.go b/services/compose_body_test.go
new file mode 100644
index 0000000..7844a5c
--- /dev/null
+++ b/services/compose_body_test.go
@@ -0,0 +1,72 @@
+package services
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestComposeBodyToHTML_MarkdownBasics(t *testing.T) {
+ in := strings.TrimSpace(`
+## 标题
+
+段落 **粗体** 与 *斜体* 和 ~~删除~~ 与 ` + "`code`" + `。
+
+- 甲
+- 乙
+
+1. 一
+2. 二
+
+> 引用行
+
+` + "```go\nfmt.Println(1)\n```" + `
+
+[链接](https://example.com)
+
+`)
+ out := ComposeBodyToHTML(in)
+ checks := []string{
+ "
code",
+ "", + `class="language-go"`, + "fmt.Println(1)", + `href="https://example.com"`, + `src="/uploads/a.png"`, + } + for _, c := range checks { + if !strings.Contains(out, c) { + t.Fatalf("missing %q in:\n%s", c, out) + } + } + if strings.Contains(out, "") { + t.Fatal("must not emit h1") + } +} + +func TestComposeBodyToHTML_PassThroughGates(t *testing.T) { + in := `
` + if got := ComposeBodyToHTML(in); got != in { + t.Fatalf("gate html should pass through, got %q", got) + } +} + +func TestComposeBodyToHTML_H1MapsToH2(t *testing.T) { + out := ComposeBodyToHTML("# 顶标题") + if !strings.Contains(out, " 隐
") || strings.Contains(out, "
") { + t.Fatalf("got %s", out) + } +} + +func TestHTMLToComposePlain_RoundTripLite(t *testing.T) { + html := ComposeBodyToHTML("## Hi\n\n**x**") + plain := HTMLToComposePlain(html) + if !strings.Contains(plain, "## Hi") || !strings.Contains(plain, "**x**") { + t.Fatalf("plain=%q", plain) + } +} diff --git a/templates/compose.tmpl b/templates/compose.tmpl index 0755dff..9c907fa 100644 --- a/templates/compose.tmpl +++ b/templates/compose.tmpl @@ -63,20 +63,21 @@ - -
- - - - - --门控:选中正文后点按钮包裹;积分可见会询问价格(1–9999)。含门控时正文以 HTML 提交。
+ + {{template "shared/md_editor" (dict + "Name" "content" + "ID" "compose-content" + "Content" .Content + "Rows" 16 + "MaxLength" .ContentMax + "Placeholder" "支持 Markdown:标题/粗斜体/列表/代码块/链接;可用工具栏插入图片与门控块" + "CSRF" .CSRF + "ShowGates" 1 + "ShowUpload" 1 + "Unsaved" 1 + "Required" 1 + )}} +门控:选中正文后点按钮包裹;积分可见会询问价格(1–9999)。含门控时正文以 HTML 提交。Tab 缩进,可切换预览。
diff --git a/templates/post/body.tmpl b/templates/post/body.tmpl index a26d479..ccaa7be 100644 --- a/templates/post/body.tmpl +++ b/templates/post/body.tmpl @@ -218,7 +218,19 @@ - + {{template "shared/md_editor" (dict + "Name" "content" + "ID" "comment-content" + "Content" "" + "Rows" 5 + "MaxLength" 8000 + "Placeholder" "支持 Markdown;可回复楼上" + "CSRF" .CSRF + "ShowGates" 0 + "ShowUpload" 1 + "Unsaved" 0 + "Required" 1 + )}}