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

@@ -524,6 +524,55 @@ body.j13-body {
}
.j13-filebtn { display: inline-block; margin: 0; cursor: pointer; }
.j13-compose { max-width: 720px; margin: 0 auto; padding: 1rem; }
.j13-md-editor { margin: 0.35rem 0 0.75rem; }
.j13-md-editor__toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
margin-bottom: 0.45rem;
}
.j13-md-editor__btn {
appearance: none;
border: 1px solid var(--j13-border);
background: var(--j13-surface, #fff);
color: inherit;
border-radius: 4px;
padding: 0.25rem 0.5rem;
font: inherit;
font-size: 0.8rem;
line-height: 1.3;
cursor: pointer;
}
.j13-md-editor__btn:hover { border-color: var(--j13-accent, #3366cc); }
.j13-md-editor__btn.is-active {
border-color: var(--j13-accent, #3366cc);
color: var(--j13-accent, #3366cc);
}
.j13-md-editor__filebtn { margin: 0; cursor: pointer; }
.j13-md-editor__sep {
width: 1px;
height: 1.1rem;
background: var(--j13-border);
margin: 0 0.15rem;
}
.j13-md-editor__input {
width: 100%;
min-height: 8rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.9rem;
line-height: 1.5;
}
.j13-md-editor__preview {
min-height: 8rem;
padding: 0.75rem 0.85rem;
border: 1px solid var(--j13-border);
border-radius: 4px;
background: var(--j13-surface, #fff);
}
.j13-md-editor__status { min-height: 1.2em; margin: 0.35rem 0 0; }
.j13-poll-fields {
margin: 0.75rem 0 1rem;
padding: 0.75rem 1rem;

View File

@@ -1,17 +1,8 @@
// 姜十三论坛 SSR 渐进增强
document.documentElement.dataset.j13Ssr = "1";
(function composeUploadAndGates() {
const form = document.getElementById("compose-form");
const textarea = document.getElementById("compose-content");
if (!form || !textarea) return;
const csrf = form.getAttribute("data-csrf") || "";
const uploadURL = form.getAttribute("data-upload") || "/compose/upload";
const fileInput = document.getElementById("compose-image");
const statusEl = document.getElementById("compose-upload-status");
function insertAtCursor(text) {
(function mdEditors() {
function insertAtCursor(textarea, text) {
const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || start;
textarea.value =
@@ -21,14 +12,44 @@ document.documentElement.dataset.j13Ssr = "1";
textarea.setSelectionRange(pos, pos);
}
function wrapSelection(openTag, closeTag, fallbackInner) {
function wrapSelection(textarea, before, after, fallback) {
const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || start;
let selected = textarea.value.slice(start, end);
if (!selected.trim()) {
selected = fallbackInner || "在此填写隐藏内容";
if (!selected) selected = fallback || "";
const block = before + selected + after;
textarea.value =
textarea.value.slice(0, start) + block + textarea.value.slice(end);
textarea.focus();
if (fallback && !textarea.value.slice(start, end)) {
textarea.setSelectionRange(start + before.length, start + before.length + selected.length);
} else {
textarea.setSelectionRange(start + block.length, start + block.length);
}
// 若选区尚非段落,包一层 p 便于消毒后结构稳定
}
function prefixLines(textarea, prefix) {
const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || start;
const value = textarea.value;
let lineStart = value.lastIndexOf("\n", start - 1) + 1;
let lineEnd = value.indexOf("\n", end);
if (lineEnd < 0) lineEnd = value.length;
const chunk = value.slice(lineStart, lineEnd);
const next = chunk
.split("\n")
.map((ln) => (ln.trim() ? prefix + ln.replace(/^\s+/, "") : prefix.trimEnd()))
.join("\n");
textarea.value = value.slice(0, lineStart) + next + value.slice(lineEnd);
textarea.focus();
textarea.setSelectionRange(lineStart, lineStart + next.length);
}
function wrapGate(textarea, openTag, closeTag, statusEl) {
const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || start;
let selected = textarea.value.slice(start, end);
if (!selected.trim()) selected = "在此填写隐藏内容";
let inner = selected;
if (!/^\s*</.test(inner)) {
inner = "<p>" + selected.replace(/\n/g, "<br>\n") + "</p>";
@@ -37,65 +58,196 @@ document.documentElement.dataset.j13Ssr = "1";
textarea.value =
textarea.value.slice(0, start) + block + textarea.value.slice(end);
textarea.focus();
const pos = start + block.length;
textarea.setSelectionRange(pos, pos);
textarea.setSelectionRange(start + block.length, start + block.length);
if (statusEl) statusEl.textContent = "已插入门控块";
}
if (fileInput) {
fileInput.addEventListener("change", async () => {
const file = fileInput.files && fileInput.files[0];
fileInput.value = "";
if (!file) return;
if (statusEl) statusEl.textContent = "上传中…";
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 || "上传失败");
const url = data.url;
if (!url) throw new Error("未返回图片地址");
insertAtCursor(`\n\n![](${url})\n\n`);
if (statusEl) statusEl.textContent = "已插入图片";
} catch (e) {
if (statusEl) statusEl.textContent = e.message || "上传失败";
}
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("积分价格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", () => {
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>"
);
});
document.querySelectorAll("[data-j13-md-editor]").forEach(initEditor);
})();
(function postContentGates() {