feat: Markdown 工具栏编辑器与服务端预览
共用 md_editor 于发帖/改帖/评论,扩展 ComposeBodyToHTML,并提供 /compose/preview。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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\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("积分价格(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) {
|
||||
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\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("积分价格(1–9999)", "10");
|
||||
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(
|
||||
`<points-only data-gate="points" data-cost="${n}">`,
|
||||
"</points-only>"
|
||||
);
|
||||
});
|
||||
document.querySelectorAll("[data-j13-md-editor]").forEach(initEditor);
|
||||
})();
|
||||
|
||||
(function postContentGates() {
|
||||
|
||||
Reference in New Issue
Block a user