feat: Markdown 工具栏编辑器与服务端预览
共用 md_editor 于发帖/改帖/评论,扩展 ComposeBodyToHTML,并提供 /compose/preview。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
| `/board/:id` | 板块 Feed | 已迁 |
|
| `/board/:id` | 板块 Feed | 已迁 |
|
||||||
| `/boards` | 板块索引 | 已迁 |
|
| `/boards` | 板块索引 | 已迁 |
|
||||||
| `/post/:id` | 帖详情 + 评论(回复/赞/私密)/赞/藏 | 已迁 |
|
| `/post/:id` | 帖详情 + 评论(回复/赞/私密)/赞/藏 | 已迁 |
|
||||||
| `/compose` | 发帖(normal / question / poll / bounty / lottery;textarea + 图片 + 门控插入) | 已迁 |
|
| `/compose` | 发帖(类型字段 + Markdown 工具栏/预览/图片/门控) | 已迁 |
|
||||||
| `/post/:id/edit` | 编辑帖 | 已迁 |
|
| `/post/:id/edit` | 编辑帖 | 已迁 |
|
||||||
| `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 |
|
| `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 |
|
||||||
| `/user/:id` | 公开用户页 | 已迁 |
|
| `/user/:id` | 公开用户页 | 已迁 |
|
||||||
@@ -123,22 +123,24 @@
|
|||||||
| 悬赏 | 积分输入(显示余额)— SSR compose;发帖托管 |
|
| 悬赏 | 积分输入(显示余额)— SSR compose;发帖托管 |
|
||||||
| 抽奖 | 中奖人数 1–20 — 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,避免与帖标题冲突)
|
| 能力 | SSR 状态 |
|
||||||
- 粗体/斜体/删除线等基础标记
|
|------|----------|
|
||||||
- 链接对话框
|
| 标题 h2–h6(`#` 映射为 h2) | Markdown 工具栏 + `ComposeBodyToHTML` |
|
||||||
- 代码块(语言、选项对话框)
|
| 粗体/斜体/删除线/行内代码 | 已迁 |
|
||||||
- 表格插入/编辑
|
| 链接 | prompt 插入 |
|
||||||
- 图片上传 + 图片组布局 + 浮动/清除浮动
|
| 围栏代码块 | 已迁(语言 class) |
|
||||||
- 表情 / 贴纸选择器(多套:bilibili/douyin/tieba/weibo 等静态资源)
|
| 列表 / 引用 | 已迁 |
|
||||||
- **登录可见** / **回复可见** / **积分可见**(价格 1–9999)节点
|
| 图片上传 | `/compose/upload` |
|
||||||
- 富文本 ↔ Markdown 双模(门控块有 markdown 约定,见 [`utils/markdownContent.ts`]((仅 main)frontend/src/utils/markdownContent.ts))
|
| 登录/回复/积分可见门控 | compose 工具栏 |
|
||||||
- Tab 缩进
|
| 预览 | `POST /compose/preview`(同消毒管线) |
|
||||||
|
| Tab 缩进 / 未保存离开 | `beforeunload` |
|
||||||
|
| 表格 / 图片组 / 表情贴纸 / TipTap 双模 | **未做**(后置) |
|
||||||
|
|
||||||
未保存离开:`UnsavedChangesDialog`。
|
共用片段:`templates/shared/md_editor.tmpl`(发帖、改帖、评论、改评)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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" {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,17 +58,136 @@ 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 = "已插入门控块";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initEditor(root) {
|
||||||
|
const textarea = root.querySelector("[data-md-input]");
|
||||||
|
if (!textarea) return;
|
||||||
|
const statusEl = root.querySelector("[data-md-status]");
|
||||||
|
const previewEl = root.querySelector("[data-md-preview]");
|
||||||
|
const csrf = root.getAttribute("data-csrf") || "";
|
||||||
|
const uploadURL = root.getAttribute("data-upload") || "/compose/upload";
|
||||||
|
const previewURL = root.getAttribute("data-preview") || "/compose/preview";
|
||||||
|
const unsaved = root.getAttribute("data-unsaved") === "1";
|
||||||
|
let dirty = false;
|
||||||
|
let previewOn = false;
|
||||||
|
|
||||||
|
function setStatus(msg) {
|
||||||
|
if (statusEl) statusEl.textContent = msg || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
root.querySelectorAll("[data-md-cmd]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
const cmd = btn.getAttribute("data-md-cmd");
|
||||||
|
if (cmd === "preview") {
|
||||||
|
previewOn = !previewOn;
|
||||||
|
if (previewOn) {
|
||||||
|
setStatus("预览中…");
|
||||||
|
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("积分价格(1–9999)", "10");
|
||||||
|
if (cost == null) return;
|
||||||
|
const n = parseInt(String(cost).trim(), 10);
|
||||||
|
if (!n || n < 1 || n > 9999) {
|
||||||
|
setStatus("价格须为 1–9999 的整数");
|
||||||
|
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) {
|
if (fileInput) {
|
||||||
fileInput.addEventListener("change", async () => {
|
fileInput.addEventListener("change", async () => {
|
||||||
const file = fileInput.files && fileInput.files[0];
|
const file = fileInput.files && fileInput.files[0];
|
||||||
fileInput.value = "";
|
fileInput.value = "";
|
||||||
if (!file) return;
|
if (!file || previewOn) return;
|
||||||
if (statusEl) statusEl.textContent = "上传中…";
|
setStatus("上传中…");
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("image", file);
|
fd.append("image", file);
|
||||||
fd.append("_csrf", csrf);
|
fd.append("_csrf", csrf);
|
||||||
@@ -60,42 +200,54 @@ document.documentElement.dataset.j13Ssr = "1";
|
|||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(data.error || "上传失败");
|
if (!res.ok) throw new Error(data.error || "上传失败");
|
||||||
const url = data.url;
|
if (!data.url) throw new Error("未返回图片地址");
|
||||||
if (!url) throw new Error("未返回图片地址");
|
insertAtCursor(textarea, "\n\n\n\n");
|
||||||
insertAtCursor(`\n\n\n\n`);
|
dirty = true;
|
||||||
if (statusEl) statusEl.textContent = "已插入图片";
|
setStatus("已插入图片");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (statusEl) statusEl.textContent = e.message || "上传失败";
|
setStatus(e.message || "上传失败");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("compose-gate-login")?.addEventListener("click", () => {
|
textarea.addEventListener("keydown", (e) => {
|
||||||
wrapSelection(
|
if (e.key !== "Tab" || previewOn) return;
|
||||||
'<members-only data-gate="login">',
|
e.preventDefault();
|
||||||
"</members-only>"
|
const start = textarea.selectionStart || 0;
|
||||||
);
|
const end = textarea.selectionEnd || start;
|
||||||
});
|
if (e.shiftKey) {
|
||||||
document.getElementById("compose-gate-reply")?.addEventListener("click", () => {
|
const value = textarea.value;
|
||||||
wrapSelection(
|
const lineStart = value.lastIndexOf("\n", start - 1) + 1;
|
||||||
'<reply-only data-gate="reply">',
|
const line = value.slice(lineStart, end);
|
||||||
"</reply-only>"
|
const next = line.replace(/^( {1,2}|\t)/gm, "");
|
||||||
);
|
textarea.value = value.slice(0, lineStart) + next + value.slice(end);
|
||||||
});
|
textarea.setSelectionRange(lineStart, lineStart + next.length);
|
||||||
document.getElementById("compose-gate-points")?.addEventListener("click", () => {
|
} else {
|
||||||
let cost = window.prompt("积分价格(1–9999)", "10");
|
insertAtCursor(textarea, " ");
|
||||||
if (cost == null) return;
|
|
||||||
cost = String(cost).trim();
|
|
||||||
const n = parseInt(cost, 10);
|
|
||||||
if (!n || n < 1 || n > 9999) {
|
|
||||||
if (statusEl) statusEl.textContent = "价格须为 1–9999 的整数";
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
wrapSelection(
|
dirty = true;
|
||||||
`<points-only data-gate="points" data-cost="${n}">`,
|
|
||||||
"</points-only>"
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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.querySelectorAll("[data-j13-md-editor]").forEach(initEditor);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(function postContentGates() {
|
(function postContentGates() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -9,71 +9,264 @@ 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
|
|
||||||
}
|
}
|
||||||
alt, src := sub[1], sub[2]
|
for _, m := range markers {
|
||||||
// 仅允许站内 uploads 或 http(s)
|
if strings.Contains(lower, m) {
|
||||||
if !safeComposeURL(src) {
|
return true
|
||||||
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]
|
return false
|
||||||
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标题:#–###### → h2–h6(禁止 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, "")
|
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, "")
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|||||||
72
services/compose_body_test.go
Normal file
72
services/compose_body_test.go
Normal 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)
|
||||||
|

|
||||||
|
`)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">门控:选中正文后点按钮包裹;积分可见会询问价格(1–9999)。含门控时正文以 HTML 提交。</p>
|
)}}
|
||||||
|
<p class="j13-muted">门控:选中正文后点按钮包裹;积分可见会询问价格(1–9999)。含门控时正文以 HTML 提交。Tab 缩进,可切换预览。</p>
|
||||||
<button type="submit">{{if .IsEdit}}保存{{else}}发布{{end}}</button>
|
<button type="submit">{{if .IsEdit}}保存{{else}}发布{{end}}</button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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"/>
|
||||||
私密评论(仅相关可见)
|
私密评论(仅相关可见)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
52
templates/shared/md_editor.tmpl
Normal file
52
templates/shared/md_editor.tmpl
Normal 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="行内代码"></></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}}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,17 +58,136 @@ 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 = "已插入门控块";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initEditor(root) {
|
||||||
|
const textarea = root.querySelector("[data-md-input]");
|
||||||
|
if (!textarea) return;
|
||||||
|
const statusEl = root.querySelector("[data-md-status]");
|
||||||
|
const previewEl = root.querySelector("[data-md-preview]");
|
||||||
|
const csrf = root.getAttribute("data-csrf") || "";
|
||||||
|
const uploadURL = root.getAttribute("data-upload") || "/compose/upload";
|
||||||
|
const previewURL = root.getAttribute("data-preview") || "/compose/preview";
|
||||||
|
const unsaved = root.getAttribute("data-unsaved") === "1";
|
||||||
|
let dirty = false;
|
||||||
|
let previewOn = false;
|
||||||
|
|
||||||
|
function setStatus(msg) {
|
||||||
|
if (statusEl) statusEl.textContent = msg || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
root.querySelectorAll("[data-md-cmd]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
const cmd = btn.getAttribute("data-md-cmd");
|
||||||
|
if (cmd === "preview") {
|
||||||
|
previewOn = !previewOn;
|
||||||
|
if (previewOn) {
|
||||||
|
setStatus("预览中…");
|
||||||
|
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("积分价格(1–9999)", "10");
|
||||||
|
if (cost == null) return;
|
||||||
|
const n = parseInt(String(cost).trim(), 10);
|
||||||
|
if (!n || n < 1 || n > 9999) {
|
||||||
|
setStatus("价格须为 1–9999 的整数");
|
||||||
|
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) {
|
if (fileInput) {
|
||||||
fileInput.addEventListener("change", async () => {
|
fileInput.addEventListener("change", async () => {
|
||||||
const file = fileInput.files && fileInput.files[0];
|
const file = fileInput.files && fileInput.files[0];
|
||||||
fileInput.value = "";
|
fileInput.value = "";
|
||||||
if (!file) return;
|
if (!file || previewOn) return;
|
||||||
if (statusEl) statusEl.textContent = "上传中…";
|
setStatus("上传中…");
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("image", file);
|
fd.append("image", file);
|
||||||
fd.append("_csrf", csrf);
|
fd.append("_csrf", csrf);
|
||||||
@@ -60,42 +200,54 @@ document.documentElement.dataset.j13Ssr = "1";
|
|||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(data.error || "上传失败");
|
if (!res.ok) throw new Error(data.error || "上传失败");
|
||||||
const url = data.url;
|
if (!data.url) throw new Error("未返回图片地址");
|
||||||
if (!url) throw new Error("未返回图片地址");
|
insertAtCursor(textarea, "\n\n\n\n");
|
||||||
insertAtCursor(`\n\n\n\n`);
|
dirty = true;
|
||||||
if (statusEl) statusEl.textContent = "已插入图片";
|
setStatus("已插入图片");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (statusEl) statusEl.textContent = e.message || "上传失败";
|
setStatus(e.message || "上传失败");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("compose-gate-login")?.addEventListener("click", () => {
|
textarea.addEventListener("keydown", (e) => {
|
||||||
wrapSelection(
|
if (e.key !== "Tab" || previewOn) return;
|
||||||
'<members-only data-gate="login">',
|
e.preventDefault();
|
||||||
"</members-only>"
|
const start = textarea.selectionStart || 0;
|
||||||
);
|
const end = textarea.selectionEnd || start;
|
||||||
});
|
if (e.shiftKey) {
|
||||||
document.getElementById("compose-gate-reply")?.addEventListener("click", () => {
|
const value = textarea.value;
|
||||||
wrapSelection(
|
const lineStart = value.lastIndexOf("\n", start - 1) + 1;
|
||||||
'<reply-only data-gate="reply">',
|
const line = value.slice(lineStart, end);
|
||||||
"</reply-only>"
|
const next = line.replace(/^( {1,2}|\t)/gm, "");
|
||||||
);
|
textarea.value = value.slice(0, lineStart) + next + value.slice(end);
|
||||||
});
|
textarea.setSelectionRange(lineStart, lineStart + next.length);
|
||||||
document.getElementById("compose-gate-points")?.addEventListener("click", () => {
|
} else {
|
||||||
let cost = window.prompt("积分价格(1–9999)", "10");
|
insertAtCursor(textarea, " ");
|
||||||
if (cost == null) return;
|
|
||||||
cost = String(cost).trim();
|
|
||||||
const n = parseInt(cost, 10);
|
|
||||||
if (!n || n < 1 || n > 9999) {
|
|
||||||
if (statusEl) statusEl.textContent = "价格须为 1–9999 的整数";
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
wrapSelection(
|
dirty = true;
|
||||||
`<points-only data-gate="points" data-cost="${n}">`,
|
|
||||||
"</points-only>"
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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.querySelectorAll("[data-j13-md-editor]").forEach(initEditor);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(function postContentGates() {
|
(function postContentGates() {
|
||||||
|
|||||||
Reference in New Issue
Block a user