feat: Markdown 工具栏编辑器与服务端预览

共用 md_editor 于发帖/改帖/评论,扩展 ComposeBodyToHTML,并提供 /compose/preview。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 04:50:07 +08:00
parent 7f048bd9b5
commit 1f777eebb1
17 changed files with 1056 additions and 232 deletions

View File

@@ -55,8 +55,8 @@
- [x] 发帖:选板块、标题、标签、正文 — SSR `/compose`normal - [x] 发帖:选板块、标题、标签、正文 — SSR `/compose`normal
- [x] 正文图片上传 — `/compose/upload` + Markdown 插入 - [x] 正文图片上传 — `/compose/upload` + Markdown 插入
- [ ] TipTap 富文本能力(见 [06-pages-ux.md](06-pages-ux.md) 编辑器节)— 本分支改用 Markdown textarea 渐进增强 - [ ] TipTap 富文本能力(见 [06-pages-ux.md](06-pages-ux.md) 编辑器节)— **本分支不做 TipTap**改用 Markdown 工具栏渐进增强
- [ ] Markdown 编辑模式(与富文本互转/双模 - [x] Markdown 编辑(工具栏 + `/compose/preview`;发帖/改帖/评论共用 `shared/md_editor`)— 非 TipTap 双模
- [x] 编辑帖子(时限、锁帖约束)— SSR `/post/:id/edit` - [x] 编辑帖子(时限、锁帖约束)— SSR `/post/:id/edit`
- [x] 删除帖子 → 软删进回收站 — SSR Admin帖详情 + `/admin/trash` - [x] 删除帖子 → 软删进回收站 — SSR Admin帖详情 + `/admin/trash`
- [x] 修订历史列表与单条详情(可做 diff— SSR `/post/:id/revisions`(作者/管理员;无 diff - [x] 修订历史列表与单条详情(可做 diff— SSR `/post/:id/revisions`(作者/管理员;无 diff

View File

@@ -28,7 +28,7 @@
| `/board/:id` | 板块 Feed | 已迁 | | `/board/:id` | 板块 Feed | 已迁 |
| `/boards` | 板块索引 | 已迁 | | `/boards` | 板块索引 | 已迁 |
| `/post/:id` | 帖详情 + 评论(回复/赞/私密)/赞/藏 | 已迁 | | `/post/:id` | 帖详情 + 评论(回复/赞/私密)/赞/藏 | 已迁 |
| `/compose` | 发帖(normal / question / poll / bounty / lotterytextarea + 图片 + 门控插入 | 已迁 | | `/compose` | 发帖(类型字段 + Markdown 工具栏/预览/图片/门控 | 已迁 |
| `/post/:id/edit` | 编辑帖 | 已迁 | | `/post/:id/edit` | 编辑帖 | 已迁 |
| `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 | | `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 |
| `/user/:id` | 公开用户页 | 已迁 | | `/user/:id` | 公开用户页 | 已迁 |
@@ -123,22 +123,24 @@
| 悬赏 | 积分输入(显示余额)— SSR compose发帖托管 | | 悬赏 | 积分输入(显示余额)— SSR compose发帖托管 |
| 抽奖 | 中奖人数 120 — SSR compose | | 抽奖 | 中奖人数 120 — SSR compose |
### 3.2 编辑器能力(应对齐 ### 3.2 编辑器能力(本分支Markdown 渐进增强
源:[`ArticleEditor.tsx`]((仅 mainfrontend/src/components/ArticleEditor.tsx)`editor/` 扩展 对照[`ArticleEditor.tsx`]((仅 mainfrontend/src/components/ArticleEditor.tsx)TipTap**不迁**
- 标题 h2h6无 h1避免与帖标题冲突 | 能力 | SSR 状态 |
- 粗体/斜体/删除线等基础标记 |------|----------|
- 链接对话框 | 标题 h2h6`#` 映射为 h2 | Markdown 工具栏 + `ComposeBodyToHTML` |
- 代码块(语言、选项对话框) | 粗体/斜体/删除线/行内代码 | 已迁 |
- 表格插入/编辑 | 链接 | prompt 插入 |
- 图片上传 + 图片组布局 + 浮动/清除浮动 | 围栏代码块 | 已迁(语言 class |
- 表情 / 贴纸选择器多套bilibili/douyin/tieba/weibo 等静态资源) | 列表 / 引用 | 已迁 |
- **登录可见** / **回复可见** / **积分可见**(价格 19999节点 | 图片上传 | `/compose/upload` |
- 富文本 ↔ Markdown 双模(门控块有 markdown 约定,见 [`utils/markdownContent.ts`]((仅 mainfrontend/src/utils/markdownContent.ts) | 登录/回复/积分可见门控 | compose 工具栏 |
- Tab 缩进 | 预览 | `POST /compose/preview`(同消毒管线) |
| Tab 缩进 / 未保存离开 | `beforeunload` |
| 表格 / 图片组 / 表情贴纸 / TipTap 双模 | **未做**(后置) |
未保存离开:`UnsavedChangesDialog` 共用片段:`templates/shared/md_editor.tmpl`(发帖、改帖、评论、改评)
--- ---

View File

@@ -10,15 +10,15 @@
| 项 | 值 | | 项 | 值 |
|----|-----| |----|-----|
| **上一刀** | 嵌套评论树(全局楼层 + 缩进 | | **上一刀** | Markdown 编辑器(工具栏 + 预览,共用 md_editor |
| **下一刀** | 需点名OIDC / S3 / 编辑器 / Gitea / 主题…) | | **下一刀** | 需点名OIDC / S3 / Gitea / 主题 / 表格表情…) |
| **工作区** | 应干净 | | **工作区** | 应干净 |
--- ---
## 一句话状态 ## 一句话状态
核心论坛、Admin、Limits/伪静态、**嵌套评论**已齐。余量多为后置与体验打磨 核心论坛、Admin、Limits/伪静态、嵌套评论、**Markdown 编辑器**已齐。TipTap / 表格 / 表情贴纸仍后置
--- ---
@@ -29,12 +29,14 @@
| 五种帖 + Admin P1 + §H | 已迁 | | 五种帖 + Admin P1 + §H | 已迁 |
| Limits / 伪静态 / 品牌图 | 已迁 | | Limits / 伪静态 / 品牌图 | 已迁 |
| 评论嵌套树 UI | 已迁 | | 评论嵌套树 UI | 已迁 |
| Markdown 工具栏 + preview | 已迁 |
--- ---
## 未完成 ## 未完成
- P3主题、TipTip、游客评论、修订 diff - P3主题、TipTip、游客评论、修订 diff
- 编辑器后置TipTap、表格、图片组、表情贴纸
- 后置OIDC、S3 热切换、Gitea `/projects` - 后置OIDC、S3 热切换、Gitea `/projects`
--- ---

View File

@@ -20,6 +20,20 @@ var (
func funcMap() template.FuncMap { func funcMap() template.FuncMap {
return template.FuncMap{ return template.FuncMap{
"safeHTML": func(s string) template.HTML { return template.HTML(s) }, "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 { "sortURL": func(boardID uint, sort string) string {
q := url.Values{} q := url.Values{}
if sort != "" && sort != "latest" { if sort != "" && sort != "latest" {

View File

@@ -524,6 +524,55 @@ body.j13-body {
} }
.j13-filebtn { display: inline-block; margin: 0; cursor: pointer; } .j13-filebtn { display: inline-block; margin: 0; cursor: pointer; }
.j13-compose { max-width: 720px; margin: 0 auto; padding: 1rem; } .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 { .j13-poll-fields {
margin: 0.75rem 0 1rem; margin: 0.75rem 0 1rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;

View File

@@ -1,17 +1,8 @@
// 姜十三论坛 SSR 渐进增强 // 姜十三论坛 SSR 渐进增强
document.documentElement.dataset.j13Ssr = "1"; document.documentElement.dataset.j13Ssr = "1";
(function composeUploadAndGates() { (function mdEditors() {
const form = document.getElementById("compose-form"); function insertAtCursor(textarea, text) {
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) {
const start = textarea.selectionStart || 0; const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || start; const end = textarea.selectionEnd || start;
textarea.value = textarea.value =
@@ -21,14 +12,44 @@ document.documentElement.dataset.j13Ssr = "1";
textarea.setSelectionRange(pos, pos); textarea.setSelectionRange(pos, pos);
} }
function wrapSelection(openTag, closeTag, fallbackInner) { function wrapSelection(textarea, before, after, fallback) {
const start = textarea.selectionStart || 0; const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || start; const end = textarea.selectionEnd || start;
let selected = textarea.value.slice(start, end); let selected = textarea.value.slice(start, end);
if (!selected.trim()) { if (!selected) selected = fallback || "";
selected = fallbackInner || "在此填写隐藏内容"; 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; let inner = selected;
if (!/^\s*</.test(inner)) { if (!/^\s*</.test(inner)) {
inner = "<p>" + selected.replace(/\n/g, "<br>\n") + "</p>"; inner = "<p>" + selected.replace(/\n/g, "<br>\n") + "</p>";
@@ -37,65 +58,196 @@ document.documentElement.dataset.j13Ssr = "1";
textarea.value = textarea.value =
textarea.value.slice(0, start) + block + textarea.value.slice(end); textarea.value.slice(0, start) + block + textarea.value.slice(end);
textarea.focus(); textarea.focus();
const pos = start + block.length; textarea.setSelectionRange(start + block.length, start + block.length);
textarea.setSelectionRange(pos, pos);
if (statusEl) statusEl.textContent = "已插入门控块"; if (statusEl) statusEl.textContent = "已插入门控块";
} }
if (fileInput) { function initEditor(root) {
fileInput.addEventListener("change", async () => { const textarea = root.querySelector("[data-md-input]");
const file = fileInput.files && fileInput.files[0]; if (!textarea) return;
fileInput.value = ""; const statusEl = root.querySelector("[data-md-status]");
if (!file) return; const previewEl = root.querySelector("[data-md-preview]");
if (statusEl) statusEl.textContent = "上传中…"; const csrf = root.getAttribute("data-csrf") || "";
const fd = new FormData(); const uploadURL = root.getAttribute("data-upload") || "/compose/upload";
fd.append("image", file); const previewURL = root.getAttribute("data-preview") || "/compose/preview";
fd.append("_csrf", csrf); const unsaved = root.getAttribute("data-unsaved") === "1";
try { let dirty = false;
const res = await fetch(uploadURL, { let previewOn = false;
method: "POST",
headers: { "X-CSRF-Token": csrf }, function setStatus(msg) {
body: fd, if (statusEl) statusEl.textContent = msg || "";
credentials: "same-origin", }
});
const data = await res.json().catch(() => ({})); root.querySelectorAll("[data-md-cmd]").forEach((btn) => {
if (!res.ok) throw new Error(data.error || "上传失败"); btn.addEventListener("click", async () => {
const url = data.url; const cmd = btn.getAttribute("data-md-cmd");
if (!url) throw new Error("未返回图片地址"); if (cmd === "preview") {
insertAtCursor(`\n\n![](${url})\n\n`); previewOn = !previewOn;
if (statusEl) statusEl.textContent = "已插入图片"; if (previewOn) {
} catch (e) { setStatus("预览中…");
if (statusEl) statusEl.textContent = e.message || "上传失败"; try {
} const fd = new FormData();
fd.append("content", textarea.value);
fd.append("_csrf", csrf);
const res = await fetch(previewURL, {
method: "POST",
headers: { "X-CSRF-Token": csrf },
body: fd,
credentials: "same-origin",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "预览失败");
previewEl.innerHTML = data.html || "<p class=\"j13-muted\">(空)</p>";
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, '<members-only data-gate="login">', "</members-only>", statusEl);
break;
case "gate-reply":
wrapGate(textarea, '<reply-only data-gate="reply">', "</reply-only>", statusEl);
break;
case "gate-points": {
let cost = window.prompt("积分价格19999", "10");
if (cost == null) return;
const n = parseInt(String(cost).trim(), 10);
if (!n || n < 1 || n > 9999) {
setStatus("价格须为 19999 的整数");
return;
}
wrapGate(
textarea,
'<points-only data-gate="points" data-cost="' + n + '">',
"</points-only>",
statusEl
);
break;
}
}
dirty = true;
});
}); });
const fileInput = root.querySelector("[data-md-upload]");
if (fileInput) {
fileInput.addEventListener("change", async () => {
const file = fileInput.files && fileInput.files[0];
fileInput.value = "";
if (!file || previewOn) return;
setStatus("上传中…");
const fd = new FormData();
fd.append("image", file);
fd.append("_csrf", csrf);
try {
const res = await fetch(uploadURL, {
method: "POST",
headers: { "X-CSRF-Token": csrf },
body: fd,
credentials: "same-origin",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "上传失败");
if (!data.url) throw new Error("未返回图片地址");
insertAtCursor(textarea, "\n\n![](" + data.url + ")\n\n");
dirty = true;
setStatus("已插入图片");
} catch (e) {
setStatus(e.message || "上传失败");
}
});
}
textarea.addEventListener("keydown", (e) => {
if (e.key !== "Tab" || previewOn) return;
e.preventDefault();
const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || start;
if (e.shiftKey) {
const value = textarea.value;
const lineStart = value.lastIndexOf("\n", start - 1) + 1;
const line = value.slice(lineStart, end);
const next = line.replace(/^( {1,2}|\t)/gm, "");
textarea.value = value.slice(0, lineStart) + next + value.slice(end);
textarea.setSelectionRange(lineStart, lineStart + next.length);
} else {
insertAtCursor(textarea, " ");
}
dirty = true;
});
textarea.addEventListener("input", () => {
dirty = true;
});
if (unsaved) {
const form = root.closest("form");
window.addEventListener("beforeunload", (e) => {
if (!dirty) return;
e.preventDefault();
e.returnValue = "";
});
if (form) {
form.addEventListener("submit", () => {
dirty = false;
});
}
}
} }
document.getElementById("compose-gate-login")?.addEventListener("click", () => { document.querySelectorAll("[data-j13-md-editor]").forEach(initEditor);
wrapSelection(
'<members-only data-gate="login">',
"</members-only>"
);
});
document.getElementById("compose-gate-reply")?.addEventListener("click", () => {
wrapSelection(
'<reply-only data-gate="reply">',
"</reply-only>"
);
});
document.getElementById("compose-gate-points")?.addEventListener("click", () => {
let cost = window.prompt("积分价格19999", "10");
if (cost == null) return;
cost = String(cost).trim();
const n = parseInt(cost, 10);
if (!n || n < 1 || n > 9999) {
if (statusEl) statusEl.textContent = "价格须为 19999 的整数";
return;
}
wrapSelection(
`<points-only data-gate="points" data-cost="${n}">`,
"</points-only>"
);
});
})(); })();
(function postContentGates() { (function postContentGates() {

View File

@@ -187,6 +187,29 @@ func (d Deps) ComposeUpload(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"url": url}) c.JSON(http.StatusOK, gin.H{"url": url})
} }
// ComposePreview 正文预览(与发帖同一套 ComposeBodyToHTML + 消毒)
func (d Deps) ComposePreview(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.IsSigned() {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
if !ctx.CheckCSRF() {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效请求"})
return
}
content := c.PostForm("content")
if content == "" {
var body struct {
Content string `json:"content"`
}
_ = c.ShouldBindJSON(&body)
content = body.Content
}
htmlBody := services.SanitizePostHTML(services.ComposeBodyToHTML(content))
c.JSON(http.StatusOK, gin.H{"html": htmlBody})
}
type composeForm struct { type composeForm struct {
BoardID uint BoardID uint
Title string Title string

View File

@@ -58,6 +58,7 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
g.GET("/compose", authMW.RequireAuth(), deps.ComposeGet) g.GET("/compose", authMW.RequireAuth(), deps.ComposeGet)
g.POST("/compose", authMW.RequireAuth(), deps.ComposePost) g.POST("/compose", authMW.RequireAuth(), deps.ComposePost)
g.POST("/compose/upload", authMW.RequireAuth(), deps.ComposeUpload) g.POST("/compose/upload", authMW.RequireAuth(), deps.ComposeUpload)
g.POST("/compose/preview", authMW.RequireAuth(), deps.ComposePreview)
g.GET("/admin/login", func(c *gin.Context) { c.Redirect(http.StatusFound, "/login?redirect=/admin/dashboard") }) g.GET("/admin/login", func(c *gin.Context) { c.Redirect(http.StatusFound, "/login?redirect=/admin/dashboard") })
admin := g.Group("/admin", authMW.RequireAuth(), authMW.RequireAdmin()) admin := g.Group("/admin", authMW.RequireAuth(), authMW.RequireAdmin())

View File

@@ -2,7 +2,6 @@ package web
import ( import (
"fmt" "fmt"
"html"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
@@ -510,7 +509,7 @@ func (d Deps) PostComment(c *gin.Context) {
ctx.Redirect(fmt.Sprintf("/post/%d#comments", id)) ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
return return
} }
safe := "<p>" + html.EscapeString(content) + "</p>" safe := services.ComposeBodyToHTML(content)
cm, err := d.Comment.Create(services.CommentCreateInput{ cm, err := d.Comment.Create(services.CommentCreateInput{
PostID: id, UserID: ctx.UserID(), Content: safe, PostID: id, UserID: ctx.UserID(), Content: safe,
ReplyTo: replyTo, IsPrivate: isPrivate, ReplyTo: replyTo, IsPrivate: isPrivate,
@@ -815,7 +814,7 @@ func (d Deps) CommentEditGet(c *gin.Context) {
PostID: postID, PostID: postID,
CommentID: cm.ID, CommentID: cm.ID,
Floor: cm.Floor, 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) d.renderCommentEdit(ctx, "评论不能为空", postID, cm, plain)
return return
} }
safe := "<p>" + html.EscapeString(plain) + "</p>" safe := services.ComposeBodyToHTML(plain)
_, enteredPending, err := d.Comment.Update(ctx.UserID(), cm.ID, ctx.IsAdmin(), ctx.SkipsModeration(), safe) _, enteredPending, err := d.Comment.Update(ctx.UserID(), cm.ID, ctx.IsAdmin(), ctx.SkipsModeration(), safe)
if err != nil { if err != nil {
d.renderCommentEdit(ctx, err.Error(), postID, cm, plain) d.renderCommentEdit(ctx, err.Error(), postID, cm, plain)
@@ -923,14 +922,6 @@ func (d Deps) renderCommentEdit(ctx *webctx.Context, errMsg string, postID uint,
}) })
} }
// commentHTMLToPlain 发评存的是转义后的 <p>…</p>,编辑时还原为纯文本
func commentHTMLToPlain(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "<p>")
s = strings.TrimSuffix(s, "</p>")
return html.UnescapeString(s)
}
type postRevisionRow struct { type postRevisionRow struct {
ID uint ID uint
EditorName string EditorName string

View File

@@ -7,73 +7,266 @@ import (
) )
var ( var (
mdImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`) mdImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`)
mdLinkRe = 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。 // ComposeBodyToHTML 将发帖/评论表单正文转为可消毒 HTML。
// 若已含块级 HTML / 门控标签则原样交 Sanitize否则按纯文本/轻量 Markdown 转段落 // 若已含块级 HTML / 门控标签则原样交 Sanitize否则按轻量 Markdown 转
func ComposeBodyToHTML(raw string) string { func ComposeBodyToHTML(raw string) string {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
return "" return ""
} }
lower := strings.ToLower(raw) if looksLikeRichHTML(raw) {
if strings.Contains(lower, "<p") || strings.Contains(lower, "<div") ||
strings.Contains(lower, "<h1") || strings.Contains(lower, "<ul") ||
strings.Contains(lower, "<ol") || strings.Contains(lower, "<pre") ||
strings.Contains(lower, "<blockquote") ||
strings.Contains(lower, "<members-only") || strings.Contains(lower, "<reply-only") ||
strings.Contains(lower, "<points-only") {
return raw return raw
} }
return mdDocumentToHTML(raw)
}
// 先转义,再恢复轻量 md 图片/链接 func looksLikeRichHTML(raw string) bool {
esc := html.EscapeString(raw) lower := strings.ToLower(raw)
esc = mdImageRe.ReplaceAllStringFunc(esc, func(m string) string { markers := []string{
sub := mdImageRe.FindStringSubmatch(m) "<p", "<div", "<h1", "<h2", "<h3", "<ul", "<ol", "<pre", "<blockquote",
if len(sub) != 3 { "<table", "<members-only", "<reply-only", "<points-only",
return m }
for _, m := range markers {
if strings.Contains(lower, m) {
return true
} }
alt, src := sub[1], sub[2] }
// 仅允许站内 uploads 或 http(s) return false
if !safeComposeURL(src) { }
return m
}
return `<p><img src="` + html.EscapeString(src) + `" alt="` + alt + `"></p>`
})
esc = mdLinkRe.ReplaceAllStringFunc(esc, func(m string) string {
sub := mdLinkRe.FindStringSubmatch(m)
if len(sub) != 3 {
return m
}
text, href := sub[1], sub[2]
if !safeComposeURL(href) {
return m
}
return `<a href="` + html.EscapeString(href) + `" rel="noopener noreferrer">` + text + `</a>`
})
parts := strings.Split(esc, "\n\n") func mdDocumentToHTML(raw string) string {
lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
var b strings.Builder var b strings.Builder
for _, p := range parts { i := 0
p = strings.TrimSpace(p) for i < len(lines) {
if p == "" { line := lines[i]
trim := strings.TrimSpace(line)
// 围栏代码块
if strings.HasPrefix(trim, "```") {
lang := strings.TrimSpace(strings.TrimPrefix(trim, "```"))
lang = strings.TrimSpace(strings.Split(lang, " ")[0])
i++
var code []string
for i < len(lines) {
if strings.TrimSpace(lines[i]) == "```" {
i++
break
}
code = append(code, lines[i])
i++
}
b.WriteString("<pre><code")
if lang != "" && mdLangRe.MatchString(lang) {
b.WriteString(` class="language-`)
b.WriteString(html.EscapeString(lang))
b.WriteString(`"`)
}
b.WriteString(">")
b.WriteString(html.EscapeString(strings.Join(code, "\n")))
b.WriteString("</code></pre>\n")
continue continue
} }
// 已是独立 img 段
if strings.HasPrefix(p, "<p><img ") { // 空行跳过
b.WriteString(p) if trim == "" {
i++
continue
}
// 标题:####### → h2h6禁止 h1
if level, title, ok := parseMDHeading(trim); ok {
b.WriteString("<h")
b.WriteString(string(rune('0' + level)))
b.WriteString(">")
b.WriteString(mdInlineToHTML(title))
b.WriteString("</h")
b.WriteString(string(rune('0' + level)))
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("<blockquote><p>")
b.WriteString(mdInlineToHTML(strings.Join(quote, "<br>\n")))
b.WriteString("</p></blockquote>\n")
continue
}
// 无序列表
if isMDUL(trim) {
b.WriteString("<ul>\n")
for i < len(lines) {
t := strings.TrimSpace(lines[i])
if !isMDUL(t) {
break
}
item := strings.TrimSpace(t[1:])
if strings.HasPrefix(t, "- ") || strings.HasPrefix(t, "* ") || strings.HasPrefix(t, "+ ") {
item = strings.TrimSpace(t[2:])
}
b.WriteString("<li>")
b.WriteString(mdInlineToHTML(item))
b.WriteString("</li>\n")
i++
}
b.WriteString("</ul>\n")
continue
}
// 有序列表
if isMDOL(trim) {
b.WriteString("<ol>\n")
for i < len(lines) {
t := strings.TrimSpace(lines[i])
if !isMDOL(t) {
break
}
idx := strings.Index(t, ". ")
item := strings.TrimSpace(t[idx+2:])
b.WriteString("<li>")
b.WriteString(mdInlineToHTML(item))
b.WriteString("</li>\n")
i++
}
b.WriteString("</ol>\n")
continue
}
// 普通段落(连续非空、非特殊行)
var para []string
for i < len(lines) {
t := strings.TrimSpace(lines[i])
if t == "" || strings.HasPrefix(t, "```") || isMDUL(t) || isMDOL(t) ||
strings.HasPrefix(t, "> ") || t == ">" {
break
}
if _, _, ok := parseMDHeading(t); ok {
break
}
para = append(para, lines[i])
i++
}
joined := strings.Join(para, "\n")
// 整段已是图片行则单独输出
if m := mdImageRe.FindStringSubmatch(strings.TrimSpace(joined)); len(m) == 3 && strings.TrimSpace(joined) == m[0] && safeComposeURL(m[2]) {
b.WriteString(`<p><img src="`)
b.WriteString(html.EscapeString(m[2]))
b.WriteString(`" alt="`)
b.WriteString(m[1])
b.WriteString(`"></p>` + "\n")
continue continue
} }
p = strings.ReplaceAll(p, "\n", "<br>\n")
b.WriteString("<p>") b.WriteString("<p>")
b.WriteString(p) b.WriteString(mdInlineToHTML(strings.ReplaceAll(joined, "\n", "<br>\n")))
b.WriteString("</p>\n") b.WriteString("</p>\n")
} }
return b.String() return b.String()
} }
func parseMDHeading(trim string) (level int, title string, ok bool) {
n := 0
for n < len(trim) && trim[n] == '#' {
n++
}
if n < 1 || n > 6 {
return 0, "", false
}
if n >= len(trim) || trim[n] != ' ' {
return 0, "", false
}
title = strings.TrimSpace(trim[n+1:])
if title == "" {
return 0, "", false
}
level = n
if level == 1 {
level = 2 // 帖正文禁止 h1
}
return level, title, true
}
func isMDUL(trim string) bool {
return strings.HasPrefix(trim, "- ") || strings.HasPrefix(trim, "* ") || strings.HasPrefix(trim, "+ ")
}
var mdOLRe = regexp.MustCompile(`^\d+\. `)
func isMDOL(trim string) bool {
return mdOLRe.MatchString(trim)
}
func mdInlineToHTML(s string) string {
// 先抽出行内代码,避免其它规则干扰
type ph struct{ key, html string }
var phs []ph
s = mdInlineCodeRe.ReplaceAllStringFunc(s, func(m string) string {
sub := mdInlineCodeRe.FindStringSubmatch(m)
if len(sub) != 2 {
return m
}
key := "\x00CODE" + string(rune('A'+len(phs))) + "\x00"
phs = append(phs, ph{key: key, html: "<code>" + html.EscapeString(sub[1]) + "</code>"})
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 `<img src="` + html.EscapeString(sub[2]) + `" alt="` + sub[1] + `">`
})
s = mdLinkRe.ReplaceAllStringFunc(s, func(m string) string {
sub := mdLinkRe.FindStringSubmatch(m)
if len(sub) != 3 || !safeComposeURL(sub[2]) {
return m
}
return `<a href="` + html.EscapeString(sub[2]) + `" rel="noopener noreferrer">` + sub[1] + `</a>`
})
s = mdBoldRe.ReplaceAllString(s, `<strong>$1</strong>`)
s = mdStrikeRe.ReplaceAllString(s, `<s>$1</s>`)
s = mdItalicRe.ReplaceAllString(s, `<em>$1</em>`)
for _, p := range phs {
s = strings.ReplaceAll(s, p.key, p.html)
}
return s
}
func safeComposeURL(u string) bool { func safeComposeURL(u string) bool {
u = strings.TrimSpace(u) u = strings.TrimSpace(u)
if strings.HasPrefix(u, "/uploads/") || strings.HasPrefix(u, "/media/") { if strings.HasPrefix(u, "/uploads/") || strings.HasPrefix(u, "/media/") {
@@ -82,24 +275,71 @@ func safeComposeURL(u string) bool {
return strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://") return strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://")
} }
// HTMLToComposePlain 编辑页回显:去掉简单标签便于 textarea 编辑(尽力而为) // HTMLToComposePlain 编辑页回显:尽力还原为 Markdown / 纯文本;含门控时保留 HTML。
func HTMLToComposePlain(htmlBody string) string { func HTMLToComposePlain(htmlBody string) string {
s := strings.TrimSpace(htmlBody) s := strings.TrimSpace(htmlBody)
if s == "" { if s == "" {
return "" return ""
} }
lower := strings.ToLower(s) lower := strings.ToLower(s)
// 含门控块时保留 HTML避免编辑丢失
if strings.Contains(lower, "<members-only") || strings.Contains(lower, "<reply-only") || if strings.Contains(lower, "<members-only") || strings.Contains(lower, "<reply-only") ||
strings.Contains(lower, "<points-only") { strings.Contains(lower, "<points-only") {
return s return s
} }
// img → markdown
imgRe := regexp.MustCompile(`(?i)<img[^>]+src="([^"]+)"[^>]*>`) // 代码块
s = imgRe.ReplaceAllString(s, "![]($1)") s = regexp.MustCompile(`(?is)<pre[^>]*>\s*<code(?:\s+class="language-([^"]*)")?[^>]*>([\s\S]*?)</code>\s*</pre>`).ReplaceAllStringFunc(s, func(m string) string {
sub := regexp.MustCompile(`(?is)<pre[^>]*>\s*<code(?:\s+class="language-([^"]*)")?[^>]*>([\s\S]*?)</code>\s*</pre>`).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)<h([2-6])[^>]*>([\s\S]*?)</h[2-6]>`).ReplaceAllStringFunc(s, func(m string) string {
sub := regexp.MustCompile(`(?i)<h([2-6])[^>]*>([\s\S]*?)</h[2-6]>`).FindStringSubmatch(m)
if len(sub) != 3 {
return m
}
level := sub[1][0] - '0'
inner := stripTagsKeepText(sub[2])
return strings.Repeat("#", int(level)) + " " + inner + "\n\n"
})
s = regexp.MustCompile(`(?i)<li[^>]*>([\s\S]*?)</li>`).ReplaceAllString(s, "- $1\n")
s = regexp.MustCompile(`(?i)</?[uo]l[^>]*>`).ReplaceAllString(s, "\n")
s = regexp.MustCompile(`(?i)<blockquote[^>]*>([\s\S]*?)</blockquote>`).ReplaceAllStringFunc(s, func(m string) string {
sub := regexp.MustCompile(`(?i)<blockquote[^>]*>([\s\S]*?)</blockquote>`).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)<strong[^>]*>([\s\S]*?)</strong>`).ReplaceAllString(s, "**$1**")
s = regexp.MustCompile(`(?i)<b[^>]*>([\s\S]*?)</b>`).ReplaceAllString(s, "**$1**")
s = regexp.MustCompile(`(?i)<em[^>]*>([\s\S]*?)</em>`).ReplaceAllString(s, "*$1*")
s = regexp.MustCompile(`(?i)<i[^>]*>([\s\S]*?)</i>`).ReplaceAllString(s, "*$1*")
s = regexp.MustCompile(`(?i)<s[^>]*>([\s\S]*?)</s>`).ReplaceAllString(s, "~~$1~~")
s = regexp.MustCompile(`(?i)<del[^>]*>([\s\S]*?)</del>`).ReplaceAllString(s, "~~$1~~")
s = regexp.MustCompile(`(?i)<code[^>]*>([\s\S]*?)</code>`).ReplaceAllString(s, "`$1`")
s = regexp.MustCompile(`(?i)<img[^>]+src="([^"]+)"[^>]*>`).ReplaceAllString(s, "![]($1)")
s = regexp.MustCompile(`(?i)<a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)</a>`).ReplaceAllString(s, "[$2]($1)")
s = regexp.MustCompile(`(?i)</p>\s*<p>`).ReplaceAllString(s, "\n\n") s = regexp.MustCompile(`(?i)</p>\s*<p>`).ReplaceAllString(s, "\n\n")
s = regexp.MustCompile(`(?i)<br\s*/?>`).ReplaceAllString(s, "\n") s = regexp.MustCompile(`(?i)<br\s*/?>`).ReplaceAllString(s, "\n")
s = regexp.MustCompile(`(?i)</?p[^>]*>`).ReplaceAllString(s, "") s = regexp.MustCompile(`(?i)</?p[^>]*>`).ReplaceAllString(s, "")
s = regexp.MustCompile(`(?i)<[^>]+>`).ReplaceAllString(s, "") s = regexp.MustCompile(`(?i)<[^>]+>`).ReplaceAllString(s, "")
return strings.TrimSpace(html.UnescapeString(s)) return strings.TrimSpace(html.UnescapeString(s))
} }
func stripTagsKeepText(s string) string {
s = regexp.MustCompile(`(?i)<br\s*/?>`).ReplaceAllString(s, "\n")
s = regexp.MustCompile(`(?i)<[^>]+>`).ReplaceAllString(s, "")
return strings.TrimSpace(html.UnescapeString(s))
}

View File

@@ -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)
![图](/uploads/a.png)
`)
out := ComposeBodyToHTML(in)
checks := []string{
"<h2>", "标题", "</h2>",
"<strong>粗体</strong>",
"<em>斜体</em>",
"<s>删除</s>",
"<code>code</code>",
"<ul>", "<li>",
"<ol>",
"<blockquote>",
`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, "<h1>") {
t.Fatal("must not emit h1")
}
}
func TestComposeBodyToHTML_PassThroughGates(t *testing.T) {
in := `<members-only data-gate="login"><p>隐</p></members-only>`
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, "<h2>") || strings.Contains(out, "<h1>") {
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)
}
}

View File

@@ -63,20 +63,21 @@
<label>标签 <label>标签
<input name="tags" maxlength="{{.TagsMax}}" value="{{.Tags}}" placeholder="逗号分隔,可选"/> <input name="tags" maxlength="{{.TagsMax}}" value="{{.Tags}}" placeholder="逗号分隔,可选"/>
</label> </label>
<label>正文 <label>正文</label>
<textarea name="content" id="compose-content" rows="16" required maxlength="{{.ContentMax}}" placeholder="支持纯文本;图片上传后插入 Markdown 图片语法">{{.Content}}</textarea> {{template "shared/md_editor" (dict
</label> "Name" "content"
<div class="j13-compose__tools"> "ID" "compose-content"
<label class="j13-btn-secondary j13-filebtn"> "Content" .Content
插入图片 "Rows" 16
<input type="file" id="compose-image" accept="image/*" hidden/> "MaxLength" .ContentMax
</label> "Placeholder" "支持 Markdown标题/粗斜体/列表/代码块/链接;可用工具栏插入图片与门控块"
<button type="button" class="j13-btn-secondary" id="compose-gate-login" data-gate="login">登录可见</button> "CSRF" .CSRF
<button type="button" class="j13-btn-secondary" id="compose-gate-reply" data-gate="reply">回复可见</button> "ShowGates" 1
<button type="button" class="j13-btn-secondary" id="compose-gate-points" data-gate="points">积分可见</button> "ShowUpload" 1
<span class="j13-muted" id="compose-upload-status"></span> "Unsaved" 1
</div> "Required" 1
<p class="j13-muted">门控选中正文后点按钮包裹积分可见会询问价格19999。含门控时正文以 HTML 提交。</p> )}}
<p class="j13-muted">门控选中正文后点按钮包裹积分可见会询问价格19999。含门控时正文以 HTML 提交。Tab 缩进,可切换预览。</p>
<button type="submit">{{if .IsEdit}}保存{{else}}发布{{end}}</button> <button type="submit">{{if .IsEdit}}保存{{else}}发布{{end}}</button>
</form> </form>
</main> </main>

View File

@@ -218,7 +218,19 @@
<input type="hidden" name="reply_to" id="comment-reply-to" value=""/> <input type="hidden" name="reply_to" id="comment-reply-to" value=""/>
<p class="j13-muted j13-comment-reply-hint" id="comment-reply-hint" hidden></p> <p class="j13-muted j13-comment-reply-hint" id="comment-reply-hint" hidden></p>
<label for="comment-content">发表评论</label> <label for="comment-content">发表评论</label>
<textarea id="comment-content" name="content" rows="4" required maxlength="8000"></textarea> {{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
)}}
<label class="j13-check"> <label class="j13-check">
<input type="checkbox" name="is_private" value="1"/> <input type="checkbox" name="is_private" value="1"/>
私密评论(仅相关可见) 私密评论(仅相关可见)

View File

@@ -8,7 +8,19 @@
<form method="post" action="/post/{{.PostID}}/comments/{{.CommentID}}/edit" class="j13-form j13-comment-form"> <form method="post" action="/post/{{.PostID}}/comments/{{.CommentID}}/edit" class="j13-form j13-comment-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/> <input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<label for="comment-edit-content">内容</label> <label for="comment-edit-content">内容</label>
<textarea id="comment-edit-content" name="content" rows="6" required maxlength="8000">{{.Content}}</textarea> {{template "shared/md_editor" (dict
"Name" "content"
"ID" "comment-edit-content"
"Content" .Content
"Rows" 8
"MaxLength" 8000
"Placeholder" "支持 Markdown"
"CSRF" .CSRF
"ShowGates" 0
"ShowUpload" 1
"Unsaved" 1
"Required" 1
)}}
<div class="j13-form__row"> <div class="j13-form__row">
<button type="submit">保存</button> <button type="submit">保存</button>
<a class="j13-btn-secondary" href="/post/{{.PostID}}#floor-{{.Floor}}">取消</a> <a class="j13-btn-secondary" href="/post/{{.PostID}}#floor-{{.Floor}}">取消</a>

View File

@@ -0,0 +1,52 @@
{{define "shared/md_editor"}}
{{/* 期望:.Name .ID .Content .Rows .MaxLength .Placeholder .CSRF .ShowGates .ShowUpload .Unsaved .Required */}}
<div class="j13-md-editor"
data-j13-md-editor
data-csrf="{{.CSRF}}"
data-upload="/compose/upload"
data-preview="/compose/preview"
data-gates="{{if .ShowGates}}1{{else}}0{{end}}"
data-upload-enabled="{{if .ShowUpload}}1{{else}}0{{end}}"
data-unsaved="{{if .Unsaved}}1{{else}}0{{end}}">
<div class="j13-md-editor__toolbar" role="toolbar" aria-label="Markdown 工具栏">
<button type="button" class="j13-md-editor__btn" data-md-cmd="bold" title="粗体">B</button>
<button type="button" class="j13-md-editor__btn" data-md-cmd="italic" title="斜体"><em>I</em></button>
<button type="button" class="j13-md-editor__btn" data-md-cmd="strike" title="删除线"><s>S</s></button>
<button type="button" class="j13-md-editor__btn" data-md-cmd="code" title="行内代码">&lt;/&gt;</button>
<button type="button" class="j13-md-editor__btn" data-md-cmd="link" title="链接">链接</button>
<span class="j13-md-editor__sep" aria-hidden="true"></span>
<button type="button" class="j13-md-editor__btn" data-md-cmd="h2" title="标题 2">H2</button>
<button type="button" class="j13-md-editor__btn" data-md-cmd="h3" title="标题 3">H3</button>
<button type="button" class="j13-md-editor__btn" data-md-cmd="ul" title="无序列表">• 列表</button>
<button type="button" class="j13-md-editor__btn" data-md-cmd="ol" title="有序列表">1. 列表</button>
<button type="button" class="j13-md-editor__btn" data-md-cmd="quote" title="引用">引用</button>
<button type="button" class="j13-md-editor__btn" data-md-cmd="fence" title="代码块">代码块</button>
{{if .ShowUpload}}
<span class="j13-md-editor__sep" aria-hidden="true"></span>
<label class="j13-md-editor__btn j13-md-editor__filebtn">
图片
<input type="file" accept="image/*" data-md-upload hidden/>
</label>
{{end}}
{{if .ShowGates}}
<span class="j13-md-editor__sep" aria-hidden="true"></span>
<button type="button" class="j13-md-editor__btn" data-md-cmd="gate-login">登录可见</button>
<button type="button" class="j13-md-editor__btn" data-md-cmd="gate-reply">回复可见</button>
<button type="button" class="j13-md-editor__btn" data-md-cmd="gate-points">积分可见</button>
{{end}}
<span class="j13-md-editor__sep" aria-hidden="true"></span>
<button type="button" class="j13-md-editor__btn" data-md-cmd="preview" data-md-preview-toggle>预览</button>
</div>
<textarea
name="{{.Name}}"
id="{{.ID}}"
class="j13-md-editor__input"
rows="{{.Rows}}"
{{if .Required}}required{{end}}
{{if .MaxLength}}maxlength="{{.MaxLength}}"{{end}}
placeholder="{{.Placeholder}}"
data-md-input>{{.Content}}</textarea>
<div class="j13-md-editor__preview j13-post__body" data-md-preview hidden></div>
<p class="j13-muted j13-md-editor__status" data-md-status></p>
</div>
{{end}}

View File

@@ -524,6 +524,55 @@ body.j13-body {
} }
.j13-filebtn { display: inline-block; margin: 0; cursor: pointer; } .j13-filebtn { display: inline-block; margin: 0; cursor: pointer; }
.j13-compose { max-width: 720px; margin: 0 auto; padding: 1rem; } .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 { .j13-poll-fields {
margin: 0.75rem 0 1rem; margin: 0.75rem 0 1rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;

View File

@@ -1,17 +1,8 @@
// 姜十三论坛 SSR 渐进增强 // 姜十三论坛 SSR 渐进增强
document.documentElement.dataset.j13Ssr = "1"; document.documentElement.dataset.j13Ssr = "1";
(function composeUploadAndGates() { (function mdEditors() {
const form = document.getElementById("compose-form"); function insertAtCursor(textarea, text) {
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) {
const start = textarea.selectionStart || 0; const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || start; const end = textarea.selectionEnd || start;
textarea.value = textarea.value =
@@ -21,14 +12,44 @@ document.documentElement.dataset.j13Ssr = "1";
textarea.setSelectionRange(pos, pos); textarea.setSelectionRange(pos, pos);
} }
function wrapSelection(openTag, closeTag, fallbackInner) { function wrapSelection(textarea, before, after, fallback) {
const start = textarea.selectionStart || 0; const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || start; const end = textarea.selectionEnd || start;
let selected = textarea.value.slice(start, end); let selected = textarea.value.slice(start, end);
if (!selected.trim()) { if (!selected) selected = fallback || "";
selected = fallbackInner || "在此填写隐藏内容"; 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; let inner = selected;
if (!/^\s*</.test(inner)) { if (!/^\s*</.test(inner)) {
inner = "<p>" + selected.replace(/\n/g, "<br>\n") + "</p>"; inner = "<p>" + selected.replace(/\n/g, "<br>\n") + "</p>";
@@ -37,65 +58,196 @@ document.documentElement.dataset.j13Ssr = "1";
textarea.value = textarea.value =
textarea.value.slice(0, start) + block + textarea.value.slice(end); textarea.value.slice(0, start) + block + textarea.value.slice(end);
textarea.focus(); textarea.focus();
const pos = start + block.length; textarea.setSelectionRange(start + block.length, start + block.length);
textarea.setSelectionRange(pos, pos);
if (statusEl) statusEl.textContent = "已插入门控块"; if (statusEl) statusEl.textContent = "已插入门控块";
} }
if (fileInput) { function initEditor(root) {
fileInput.addEventListener("change", async () => { const textarea = root.querySelector("[data-md-input]");
const file = fileInput.files && fileInput.files[0]; if (!textarea) return;
fileInput.value = ""; const statusEl = root.querySelector("[data-md-status]");
if (!file) return; const previewEl = root.querySelector("[data-md-preview]");
if (statusEl) statusEl.textContent = "上传中…"; const csrf = root.getAttribute("data-csrf") || "";
const fd = new FormData(); const uploadURL = root.getAttribute("data-upload") || "/compose/upload";
fd.append("image", file); const previewURL = root.getAttribute("data-preview") || "/compose/preview";
fd.append("_csrf", csrf); const unsaved = root.getAttribute("data-unsaved") === "1";
try { let dirty = false;
const res = await fetch(uploadURL, { let previewOn = false;
method: "POST",
headers: { "X-CSRF-Token": csrf }, function setStatus(msg) {
body: fd, if (statusEl) statusEl.textContent = msg || "";
credentials: "same-origin", }
});
const data = await res.json().catch(() => ({})); root.querySelectorAll("[data-md-cmd]").forEach((btn) => {
if (!res.ok) throw new Error(data.error || "上传失败"); btn.addEventListener("click", async () => {
const url = data.url; const cmd = btn.getAttribute("data-md-cmd");
if (!url) throw new Error("未返回图片地址"); if (cmd === "preview") {
insertAtCursor(`\n\n![](${url})\n\n`); previewOn = !previewOn;
if (statusEl) statusEl.textContent = "已插入图片"; if (previewOn) {
} catch (e) { setStatus("预览中…");
if (statusEl) statusEl.textContent = e.message || "上传失败"; try {
} const fd = new FormData();
fd.append("content", textarea.value);
fd.append("_csrf", csrf);
const res = await fetch(previewURL, {
method: "POST",
headers: { "X-CSRF-Token": csrf },
body: fd,
credentials: "same-origin",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "预览失败");
previewEl.innerHTML = data.html || "<p class=\"j13-muted\">(空)</p>";
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, '<members-only data-gate="login">', "</members-only>", statusEl);
break;
case "gate-reply":
wrapGate(textarea, '<reply-only data-gate="reply">', "</reply-only>", statusEl);
break;
case "gate-points": {
let cost = window.prompt("积分价格19999", "10");
if (cost == null) return;
const n = parseInt(String(cost).trim(), 10);
if (!n || n < 1 || n > 9999) {
setStatus("价格须为 19999 的整数");
return;
}
wrapGate(
textarea,
'<points-only data-gate="points" data-cost="' + n + '">',
"</points-only>",
statusEl
);
break;
}
}
dirty = true;
});
}); });
const fileInput = root.querySelector("[data-md-upload]");
if (fileInput) {
fileInput.addEventListener("change", async () => {
const file = fileInput.files && fileInput.files[0];
fileInput.value = "";
if (!file || previewOn) return;
setStatus("上传中…");
const fd = new FormData();
fd.append("image", file);
fd.append("_csrf", csrf);
try {
const res = await fetch(uploadURL, {
method: "POST",
headers: { "X-CSRF-Token": csrf },
body: fd,
credentials: "same-origin",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "上传失败");
if (!data.url) throw new Error("未返回图片地址");
insertAtCursor(textarea, "\n\n![](" + data.url + ")\n\n");
dirty = true;
setStatus("已插入图片");
} catch (e) {
setStatus(e.message || "上传失败");
}
});
}
textarea.addEventListener("keydown", (e) => {
if (e.key !== "Tab" || previewOn) return;
e.preventDefault();
const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || start;
if (e.shiftKey) {
const value = textarea.value;
const lineStart = value.lastIndexOf("\n", start - 1) + 1;
const line = value.slice(lineStart, end);
const next = line.replace(/^( {1,2}|\t)/gm, "");
textarea.value = value.slice(0, lineStart) + next + value.slice(end);
textarea.setSelectionRange(lineStart, lineStart + next.length);
} else {
insertAtCursor(textarea, " ");
}
dirty = true;
});
textarea.addEventListener("input", () => {
dirty = true;
});
if (unsaved) {
const form = root.closest("form");
window.addEventListener("beforeunload", (e) => {
if (!dirty) return;
e.preventDefault();
e.returnValue = "";
});
if (form) {
form.addEventListener("submit", () => {
dirty = false;
});
}
}
} }
document.getElementById("compose-gate-login")?.addEventListener("click", () => { document.querySelectorAll("[data-j13-md-editor]").forEach(initEditor);
wrapSelection(
'<members-only data-gate="login">',
"</members-only>"
);
});
document.getElementById("compose-gate-reply")?.addEventListener("click", () => {
wrapSelection(
'<reply-only data-gate="reply">',
"</reply-only>"
);
});
document.getElementById("compose-gate-points")?.addEventListener("click", () => {
let cost = window.prompt("积分价格19999", "10");
if (cost == null) return;
cost = String(cost).trim();
const n = parseInt(cost, 10);
if (!n || n < 1 || n > 9999) {
if (statusEl) statusEl.textContent = "价格须为 19999 的整数";
return;
}
wrapSelection(
`<points-only data-gate="points" data-cost="${n}">`,
"</points-only>"
);
});
})(); })();
(function postContentGates() { (function postContentGates() {