fix: 修复 CSRF 缺失 + 重写编辑器为三模式(所见即所得/源码/分屏)
CSRF 修复: - /posts/new 和 /posts/:id/edit 原在独立 postPages group 中,仅含 Required 中间件,缺少 CSRF 下发 - 移动到 pages group(含 CSRF 中间件),用 per-route d.authMdw.Required() 弥补登录校验 - 同时消除 /posts/:id 与 /posts/new 潜在的路由冲突 编辑器重写: - 添加三种编辑模式切换标签:所见即所得 / 源码 / 分屏预览 - 所见即所得(默认):contenteditable 编辑区 + 工具条(execCommand) 支持加粗、斜体、H2/H3、链接、图片、引用、代码、列表、分割线 - 源码:纯 textarea,textarea 作为数据源始终保存 markdown 原文 - 分屏:左 textarea 编辑 + 右实时渲染预览(API 渲染) - 模式切换时自动同步:WYSIWYG → HTML → Markdown → textarea - 工具条在 WYSIWYG 模式用 execCommand,源码模式用文本包裹语法 实现细节: - contenteditable ← textarea 双向同步(WYSIWYG 模式) - 基本 HTML→Markdown 转换器(strong/em/h1-h6/a/pre/code/li/blockquote/img) - 分屏布局用 JS toggled .split-mode class(非 :has(),兼容 Firefox) - 编辑器初始时自动进 WYSIWYG 模式渲染已有内容
This commit is contained in:
@ -36,15 +36,9 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
|
|
||||||
// 帖子页面
|
// 帖子页面
|
||||||
pages.GET("/posts", d.postCtrl.ListPage)
|
pages.GET("/posts", d.postCtrl.ListPage)
|
||||||
|
pages.GET("/posts/new", d.authMdw.Required(), d.postCtrl.NewPage)
|
||||||
pages.GET("/posts/:id", d.postCtrl.ShowPage)
|
pages.GET("/posts/:id", d.postCtrl.ShowPage)
|
||||||
}
|
pages.GET("/posts/:id/edit", d.authMdw.Required(), d.postCtrl.EditPage)
|
||||||
|
|
||||||
// 帖子页面(需登录)
|
|
||||||
postPages := r.Group("/posts")
|
|
||||||
postPages.Use(d.authMdw.Required())
|
|
||||||
{
|
|
||||||
postPages.GET("/new", d.postCtrl.NewPage)
|
|
||||||
postPages.GET("/:id/edit", d.postCtrl.EditPage)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 认证页面(已登录自动跳走)
|
// 认证页面(已登录自动跳走)
|
||||||
|
|||||||
@ -14,21 +14,69 @@
|
|||||||
<input type="text" id="postTitle" class="form-input" value="{{if .Post}}{{.Post.Title}}{{end}}" maxlength="200" required>
|
<input type="text" id="postTitle" class="form-input" value="{{if .Post}}{{.Post.Title}}{{end}}" maxlength="200" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
{{/* 模式切换标签 */}}
|
||||||
<label for="postBody">正文 (Markdown)</label>
|
<div class="editor-modes">
|
||||||
<textarea id="postBody" class="form-textarea" rows="20" required>{{if .Post}}{{.Post.Body}}{{end}}</textarea>
|
<button type="button" class="mode-tab active" data-mode="wysiwyg">所见即所得</button>
|
||||||
|
<button type="button" class="mode-tab" data-mode="source">源码</button>
|
||||||
|
<button type="button" class="mode-tab" data-mode="split">分屏预览</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* 工具条 */}}
|
||||||
|
<div class="editor-toolbar" id="toolbar">
|
||||||
|
<button type="button" class="tb-btn" data-action="bold" title="加粗 (Ctrl+B)">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="tb-btn" data-action="italic" title="斜体 (Ctrl+I)">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z"/></svg>
|
||||||
|
</button>
|
||||||
|
<span class="tb-sep"></span>
|
||||||
|
<button type="button" class="tb-btn" data-action="h2" title="标题">H2</button>
|
||||||
|
<button type="button" class="tb-btn" data-action="h3" title="子标题">H3</button>
|
||||||
|
<span class="tb-sep"></span>
|
||||||
|
<button type="button" class="tb-btn" data-action="link" title="链接">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="tb-btn" data-action="image" title="图片">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
|
||||||
|
</button>
|
||||||
|
<span class="tb-sep"></span>
|
||||||
|
<button type="button" class="tb-btn" data-action="quote" title="引用">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="tb-btn" data-action="code" title="行内代码">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.4 16.6 4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0 4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="tb-btn" data-action="pre" title="代码块">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 3h18v18H3V3zm2 2v14h14V5H5zm4 2h6v2H9V7zm0 4h6v2H9v-2z"/></svg>
|
||||||
|
</button>
|
||||||
|
<span class="tb-sep"></span>
|
||||||
|
<button type="button" class="tb-btn" data-action="ul" title="无序列表">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="tb-btn" data-action="ol" title="有序列表">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="tb-btn" data-action="hr" title="分割线">—</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* 源码编辑器 */}}
|
||||||
|
<div class="editor-body">
|
||||||
|
<textarea id="postBody" class="form-textarea" rows="20" required
|
||||||
|
placeholder="使用 Markdown 编写正文...">{{if .Post}}{{.Post.Body}}{{end}}</textarea>
|
||||||
|
|
||||||
|
{{/* WYSIWYG 编辑器 */}}
|
||||||
|
<div id="wysiwygEditor" class="wysiwyg-editor" contenteditable="true" style="display:none;"></div>
|
||||||
|
|
||||||
|
{{/* 分屏预览 */}}
|
||||||
|
<div id="splitPreview" class="split-preview" style="display:none;">
|
||||||
|
<div class="split-preview-content post-detail-body"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editor-actions">
|
<div class="editor-actions">
|
||||||
<button type="button" id="previewBtn" class="btn btn-secondary">预览</button>
|
|
||||||
<button type="submit" class="btn btn-primary">{{if .Post}}保存修改{{else}}发布帖子{{end}}</button>
|
<button type="submit" class="btn btn-primary">{{if .Post}}保存修改{{else}}发布帖子{{end}}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div id="previewArea" class="post-preview" style="display:none;">
|
|
||||||
<h3>预览</h3>
|
|
||||||
<div id="previewContent" class="post-detail-body"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{template "layout/footer.html" .}}
|
{{template "layout/footer.html" .}}
|
||||||
|
|||||||
@ -164,7 +164,7 @@
|
|||||||
|
|
||||||
/* ========== 编辑器 ========== */
|
/* ========== 编辑器 ========== */
|
||||||
.editor-container {
|
.editor-container {
|
||||||
max-width: 900px;
|
max-width: 1024px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 32px 16px;
|
padding: 32px 16px;
|
||||||
}
|
}
|
||||||
@ -176,7 +176,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.post-form .form-group {
|
.post-form .form-group {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-form label {
|
.post-form label {
|
||||||
@ -196,38 +196,229 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========== 模式标签 ========== */
|
||||||
|
.editor-modes {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-tab {
|
||||||
|
padding: 8px 18px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
background: #f9fafb;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-tab.active {
|
||||||
|
background: #fff;
|
||||||
|
color: #6366f1;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
z-index: 2;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
padding-bottom: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 工具条 ========== */
|
||||||
|
.editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
min-height: 40px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 编辑区上边距 */
|
||||||
|
.editor-toolbar + .editor-body,
|
||||||
|
.editor-modes + .editor-body {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 30px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: #4b5563;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-btn:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-btn svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 编辑区 ========== */
|
||||||
|
.editor-body {
|
||||||
|
position: relative;
|
||||||
|
clear: both;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.form-textarea {
|
.form-textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid #d1d5db;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-family: 'Menlo', 'Consolas', monospace;
|
font-family: 'Menlo', 'Consolas', 'Monaco', monospace;
|
||||||
line-height: 1.6;
|
line-height: 1.7;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
min-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-actions {
|
/* WYSIWYG 编辑器 */
|
||||||
display: flex;
|
.wysiwyg-editor {
|
||||||
gap: 12px;
|
min-height: 400px;
|
||||||
margin-top: 16px;
|
padding: 14px 18px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #1f2937;
|
||||||
|
outline: none;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-preview {
|
.wysiwyg-editor:focus {
|
||||||
margin-top: 32px;
|
border-color: #6366f1;
|
||||||
padding: 24px;
|
box-shadow: 0 0 0 2px rgba(99,102,241,0.15);
|
||||||
background: #f9fafb;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-preview h3 {
|
.wysiwyg-editor h1 { font-size: 2em; margin: 0.5em 0 0.3em; }
|
||||||
margin-top: 0;
|
.wysiwyg-editor h2 { font-size: 1.6em; margin: 0.5em 0 0.3em; }
|
||||||
color: #6b7280;
|
.wysiwyg-editor h3 { font-size: 1.3em; margin: 0.4em 0 0.25em; }
|
||||||
|
.wysiwyg-editor p { margin: 0 0 0.5em; }
|
||||||
|
.wysiwyg-editor pre {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: 'Menlo','Consolas',monospace;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
text-transform: uppercase;
|
overflow-x: auto;
|
||||||
letter-spacing: 0.5px;
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
.wysiwyg-editor code {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: 'Menlo','Consolas',monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.wysiwyg-editor blockquote {
|
||||||
|
border-left: 4px solid #6366f1;
|
||||||
|
padding: 2px 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
.wysiwyg-editor ul, .wysiwyg-editor ol {
|
||||||
|
padding-left: 24px;
|
||||||
|
margin: 0.4em 0;
|
||||||
|
}
|
||||||
|
.wysiwyg-editor li {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.wysiwyg-editor img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.wysiwyg-editor a {
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
.wysiwyg-editor hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 2px solid #e5e7eb;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
.wysiwyg-editor table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
.wysiwyg-editor th, .wysiwyg-editor td {
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
padding: 6px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 分屏预览 ========== */
|
||||||
|
.editor-body.split-mode .form-textarea {
|
||||||
|
width: 50%;
|
||||||
|
float: left;
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-preview {
|
||||||
|
width: 50%;
|
||||||
|
float: right;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-preview .split-preview-content {
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
min-height: 400px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-preview .split-preview-content h1,
|
||||||
|
.split-preview .split-preview-content h2,
|
||||||
|
.split-preview .split-preview-content h3 {
|
||||||
|
margin-top: 18px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-preview .split-preview-content pre {
|
||||||
|
background: #e5e7eb;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-preview .split-preview-content blockquote {
|
||||||
|
border-left: 4px solid #6366f1;
|
||||||
|
padding-left: 14px;
|
||||||
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== 404 ========== */
|
/* ========== 404 ========== */
|
||||||
|
|||||||
@ -4,52 +4,331 @@
|
|||||||
|
|
||||||
var postId = document.getElementById('postId');
|
var postId = document.getElementById('postId');
|
||||||
var titleEl = document.getElementById('postTitle');
|
var titleEl = document.getElementById('postTitle');
|
||||||
var bodyEl = document.getElementById('postBody');
|
var bodyEl = document.getElementById('postBody'); // textarea (source of truth)
|
||||||
var previewBtn = document.getElementById('previewBtn');
|
var wysiwygEl = document.getElementById('wysiwygEditor'); // contenteditable
|
||||||
var previewArea = document.getElementById('previewArea');
|
var splitPrev = document.getElementById('splitPreview');
|
||||||
var previewContent = document.getElementById('previewContent');
|
var splitCont = splitPrev ? splitPrev.querySelector('.split-preview-content') : null;
|
||||||
|
var toolbar = document.getElementById('toolbar');
|
||||||
|
var modeTabs = document.querySelectorAll('.mode-tab');
|
||||||
|
|
||||||
var isEdit = postId && postId.value;
|
var isEdit = postId && postId.value;
|
||||||
|
var currentMode = 'wysiwyg';
|
||||||
|
var syncTimer = null;
|
||||||
|
|
||||||
|
// ===================== HELPERS =====================
|
||||||
|
|
||||||
// 获取 CSRF token
|
|
||||||
function getCSRFToken() {
|
function getCSRFToken() {
|
||||||
var meta = document.querySelector('meta[name="csrf-token"]');
|
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||||
return meta ? meta.getAttribute('content') : '';
|
return meta ? meta.getAttribute('content') : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 预览
|
function getBodyValue() {
|
||||||
if (previewBtn) {
|
return currentMode === 'wysiwyg' ? bodyEl.value : bodyEl.value;
|
||||||
previewBtn.addEventListener('click', function() {
|
}
|
||||||
var body = bodyEl.value.trim();
|
|
||||||
if (!body) { alert('请输入正文'); return; }
|
|
||||||
|
|
||||||
|
// 保存到 textarea 并同步 WYSIWYG
|
||||||
|
function saveToTextarea(md) {
|
||||||
|
bodyEl.value = md;
|
||||||
|
if (currentMode === 'wysiwyg') syncMarkdownToWysiwyg();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================== MARKDOWN ↔ HTML ==================
|
||||||
|
|
||||||
|
// 通过 API 渲染 markdown 为 HTML
|
||||||
|
function renderMarkdown(md, callback) {
|
||||||
fetch('/api/posts/preview', {
|
fetch('/api/posts/preview', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-CSRF-Token': getCSRFToken()
|
'X-CSRF-Token': getCSRFToken()
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ body: body })
|
body: JSON.stringify({ body: md })
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(d) { callback(d.success ? d.data.html : md); })
|
||||||
if (data.success) {
|
.catch(function() { callback(md); });
|
||||||
previewContent.innerHTML = data.data.html;
|
}
|
||||||
previewArea.style.display = 'block';
|
|
||||||
} else {
|
// 将 contenteditable 的 HTML 转回 Markdown(基本转换)
|
||||||
alert(data.message || '预览失败');
|
function htmlToMarkdown(html) {
|
||||||
|
var el = document.createElement('div');
|
||||||
|
el.innerHTML = html;
|
||||||
|
|
||||||
|
function walk(node) {
|
||||||
|
var md = '';
|
||||||
|
if (node.nodeType === 3) { // text
|
||||||
|
return node.textContent;
|
||||||
|
}
|
||||||
|
if (node.nodeType !== 1) return '';
|
||||||
|
var tag = node.tagName.toLowerCase();
|
||||||
|
var children = '';
|
||||||
|
for (var i = 0; i < node.childNodes.length; i++) {
|
||||||
|
children += walk(node.childNodes[i]);
|
||||||
|
}
|
||||||
|
switch (tag) {
|
||||||
|
case 'br': return '\n';
|
||||||
|
case 'p': return children + '\n\n';
|
||||||
|
case 'strong': case 'b': return '**' + children + '**';
|
||||||
|
case 'em': case 'i': return '_' + children + '_';
|
||||||
|
case 'h1': return '# ' + children + '\n\n';
|
||||||
|
case 'h2': return '## ' + children + '\n\n';
|
||||||
|
case 'h3': return '### ' + children + '\n\n';
|
||||||
|
case 'h4': return '#### ' + children + '\n\n';
|
||||||
|
case 'h5': return '##### ' + children + '\n\n';
|
||||||
|
case 'h6': return '###### ' + children + '\n\n';
|
||||||
|
case 'a':
|
||||||
|
var href = node.getAttribute('href') || '';
|
||||||
|
return '[' + children + '](' + href + ')';
|
||||||
|
case 'pre':
|
||||||
|
var code = node.querySelector('code');
|
||||||
|
var lang = code ? (code.className.replace('language-','') || '') : '';
|
||||||
|
var text = node.textContent;
|
||||||
|
return '\n```' + lang + '\n' + text + '\n```\n';
|
||||||
|
case 'code':
|
||||||
|
if (node.parentNode.tagName.toLowerCase() !== 'pre')
|
||||||
|
return '`' + children + '`';
|
||||||
|
return children;
|
||||||
|
case 'li':
|
||||||
|
if (node.parentNode.tagName.toLowerCase() === 'ul') return '- ' + children + '\n';
|
||||||
|
if (node.parentNode.tagName.toLowerCase() === 'ol') return '1. ' + children + '\n';
|
||||||
|
return children + '\n';
|
||||||
|
case 'ul': case 'ol': return children + '\n';
|
||||||
|
case 'blockquote':
|
||||||
|
return '> ' + children.replace(/\n$/, '').replace(/\n/g, '\n> ') + '\n\n';
|
||||||
|
case 'hr': return '\n---\n';
|
||||||
|
case 'img':
|
||||||
|
var src = node.getAttribute('src') || '';
|
||||||
|
var alt = node.getAttribute('alt') || '';
|
||||||
|
return '';
|
||||||
|
case 'div':
|
||||||
|
case 'section':
|
||||||
|
return children;
|
||||||
|
default:
|
||||||
|
return children || node.textContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return walk(el).replace(/\n{3,}/g, '\n\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 markdown 渲染到 WYSIWYG
|
||||||
|
function syncMarkdownToWysiwyg() {
|
||||||
|
if (!bodyEl.value.trim()) {
|
||||||
|
wysiwygEl.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderMarkdown(bodyEl.value, function(html) {
|
||||||
|
var cur = wysiwygEl.innerHTML;
|
||||||
|
// 只在真的变化时更新,避免闪烁
|
||||||
|
if (cur !== html) {
|
||||||
|
wysiwygEl.innerHTML = html;
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.catch(function() { alert('预览请求失败'); });
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提交
|
// WYSIWYG 编辑后延迟同步回 textarea
|
||||||
|
function debounceSync() {
|
||||||
|
clearTimeout(syncTimer);
|
||||||
|
syncTimer = setTimeout(function() {
|
||||||
|
var md = htmlToMarkdown(wysiwygEl.innerHTML);
|
||||||
|
bodyEl.value = md;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MODE SWITCHING ====================
|
||||||
|
|
||||||
|
function switchMode(mode) {
|
||||||
|
// 先同步当前状态
|
||||||
|
if (currentMode === 'wysiwyg') {
|
||||||
|
var md = htmlToMarkdown(wysiwygEl.innerHTML);
|
||||||
|
bodyEl.value = md;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentMode = mode;
|
||||||
|
|
||||||
|
// 更新标签
|
||||||
|
modeTabs.forEach(function(tab) {
|
||||||
|
tab.classList.toggle('active', tab.dataset.mode === mode);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 切换布局 class
|
||||||
|
var editorBody = document.querySelector('.editor-body');
|
||||||
|
if (editorBody) {
|
||||||
|
editorBody.classList.toggle('split-mode', mode === 'split');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换面板
|
||||||
|
if (mode === 'wysiwyg') {
|
||||||
|
bodyEl.style.display = 'none';
|
||||||
|
wysiwygEl.style.display = '';
|
||||||
|
if (splitPrev) splitPrev.style.display = 'none';
|
||||||
|
if (toolbar) toolbar.style.display = '';
|
||||||
|
syncMarkdownToWysiwyg();
|
||||||
|
} else if (mode === 'source') {
|
||||||
|
bodyEl.style.display = '';
|
||||||
|
wysiwygEl.style.display = 'none';
|
||||||
|
if (splitPrev) splitPrev.style.display = 'none';
|
||||||
|
if (toolbar) toolbar.style.display = '';
|
||||||
|
} else if (mode === 'split') {
|
||||||
|
bodyEl.style.display = '';
|
||||||
|
wysiwygEl.style.display = 'none';
|
||||||
|
if (splitPrev) splitPrev.style.display = '';
|
||||||
|
if (toolbar) toolbar.style.display = 'none';
|
||||||
|
updateSplitPreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modeTabs.forEach(function(tab) {
|
||||||
|
tab.addEventListener('click', function() {
|
||||||
|
switchMode(tab.dataset.mode);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 分屏实时预览(防抖)
|
||||||
|
var splitTimer = null;
|
||||||
|
bodyEl.addEventListener('input', function() {
|
||||||
|
if (currentMode === 'split') {
|
||||||
|
clearTimeout(splitTimer);
|
||||||
|
splitTimer = setTimeout(updateSplitPreview, 300);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateSplitPreview() {
|
||||||
|
if (!splitCont || !bodyEl.value.trim()) {
|
||||||
|
if (splitCont) splitCont.innerHTML = '<em style="color:#9ca3af">输入正文后将在此处预览...</em>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderMarkdown(bodyEl.value, function(html) {
|
||||||
|
splitCont.innerHTML = html;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// WYSIWYG 输入实时同步
|
||||||
|
wysiwygEl.addEventListener('input', function() {
|
||||||
|
if (currentMode === 'wysiwyg') debounceSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== TOOLBAR ====================
|
||||||
|
|
||||||
|
if (toolbar) {
|
||||||
|
toolbar.addEventListener('click', function(e) {
|
||||||
|
var btn = e.target.closest('.tb-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
var action = btn.dataset.action;
|
||||||
|
if (currentMode === 'wysiwyg') {
|
||||||
|
execWysiwygAction(action);
|
||||||
|
} else if (currentMode === 'source') {
|
||||||
|
execSourceAction(action);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function execWysiwygAction(action) {
|
||||||
|
wysiwygEl.focus();
|
||||||
|
switch (action) {
|
||||||
|
case 'bold':
|
||||||
|
document.execCommand('bold', false, null);
|
||||||
|
break;
|
||||||
|
case 'italic':
|
||||||
|
document.execCommand('italic', false, null);
|
||||||
|
break;
|
||||||
|
case 'h2':
|
||||||
|
document.execCommand('formatBlock', false, 'h2');
|
||||||
|
break;
|
||||||
|
case 'h3':
|
||||||
|
document.execCommand('formatBlock', false, 'h3');
|
||||||
|
break;
|
||||||
|
case 'link':
|
||||||
|
var url = prompt('输入链接地址:', 'https://');
|
||||||
|
if (url) document.execCommand('createLink', false, url);
|
||||||
|
break;
|
||||||
|
case 'image':
|
||||||
|
var imgUrl = prompt('输入图片链接:', 'https://');
|
||||||
|
if (imgUrl) document.execCommand('insertImage', false, imgUrl);
|
||||||
|
break;
|
||||||
|
case 'quote':
|
||||||
|
document.execCommand('formatBlock', false, 'blockquote');
|
||||||
|
break;
|
||||||
|
case 'code':
|
||||||
|
wrapSelectionWysiwyg('`', '`');
|
||||||
|
break;
|
||||||
|
case 'pre':
|
||||||
|
document.execCommand('formatBlock', false, 'pre');
|
||||||
|
break;
|
||||||
|
case 'ul':
|
||||||
|
document.execCommand('insertUnorderedList', false, null);
|
||||||
|
break;
|
||||||
|
case 'ol':
|
||||||
|
document.execCommand('insertOrderedList', false, null);
|
||||||
|
break;
|
||||||
|
case 'hr':
|
||||||
|
document.execCommand('insertHorizontalRule', false, null);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
debounceSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// WYSIWYG 中包裹选中文本
|
||||||
|
function wrapSelectionWysiwyg(before, after) {
|
||||||
|
var sel = window.getSelection();
|
||||||
|
if (!sel.rangeCount) return;
|
||||||
|
var range = sel.getRangeAt(0);
|
||||||
|
var text = range.toString();
|
||||||
|
if (!text) {
|
||||||
|
text = 'code';
|
||||||
|
range.deleteContents();
|
||||||
|
range.insertNode(document.createTextNode(before + text + after));
|
||||||
|
} else {
|
||||||
|
range.deleteContents();
|
||||||
|
range.insertNode(document.createTextNode(before + text + after));
|
||||||
|
}
|
||||||
|
debounceSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 源码模式下插入 markdown 语法
|
||||||
|
function execSourceAction(action) {
|
||||||
|
var start = bodyEl.selectionStart;
|
||||||
|
var end = bodyEl.selectionEnd;
|
||||||
|
var text = bodyEl.value.substring(start, end);
|
||||||
|
var wrap = {};
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'bold': wrap = { b: '**', a: '**' }; break;
|
||||||
|
case 'italic': wrap = { b: '_', a: '_' }; break;
|
||||||
|
case 'h2': wrap = { b: '## ', a: '' }; break;
|
||||||
|
case 'h3': wrap = { b: '### ',a: '' }; break;
|
||||||
|
case 'link': wrap = { b: '[', a: '](https://)' }; break;
|
||||||
|
case 'image': wrap = { b: '' }; break;
|
||||||
|
case 'quote': wrap = { b: '> ', a: '' }; break;
|
||||||
|
case 'code': wrap = { b: '`', a: '`' }; break;
|
||||||
|
case 'pre': wrap = { b: '\n```\n', a: '\n```\n' }; break;
|
||||||
|
case 'ul': wrap = { b: '- ', a: '' }; break;
|
||||||
|
case 'ol': wrap = { b: '1. ', a: '' }; break;
|
||||||
|
case 'hr': wrap = { b: '\n---\n', a: '' }; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wrap.b !== undefined) {
|
||||||
|
var replacement = wrap.b + (text || (action === 'link' ? 'link' : action === 'image' ? 'image' : 'text')) + wrap.a;
|
||||||
|
bodyEl.value = bodyEl.value.substring(0, start) + replacement + bodyEl.value.substring(end);
|
||||||
|
bodyEl.focus();
|
||||||
|
var cursor = start + wrap.b.length + (text ? text.length : 0);
|
||||||
|
bodyEl.setSelectionRange(cursor, cursor + (text ? wrap.a.length : 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== FORM SUBMISSION ====================
|
||||||
|
|
||||||
form.addEventListener('submit', function(e) {
|
form.addEventListener('submit', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// WYSIWYG 模式下先同步
|
||||||
|
if (currentMode === 'wysiwyg') {
|
||||||
|
var md = htmlToMarkdown(wysiwygEl.innerHTML);
|
||||||
|
bodyEl.value = md;
|
||||||
|
currentMode = 'source';
|
||||||
|
}
|
||||||
|
|
||||||
var title = titleEl.value.trim();
|
var title = titleEl.value.trim();
|
||||||
var body = bodyEl.value.trim();
|
var body = bodyEl.value.trim();
|
||||||
|
|
||||||
if (!title) { alert('请输入标题'); return; }
|
if (!title) { alert('请输入标题'); return; }
|
||||||
if (!body) { alert('请输入正文'); return; }
|
if (!body) { alert('请输入正文'); return; }
|
||||||
|
|
||||||
@ -82,4 +361,9 @@
|
|||||||
})
|
})
|
||||||
.catch(function() { alert('请求失败'); });
|
.catch(function() { alert('请求失败'); });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== INIT ====================
|
||||||
|
if (bodyEl.value.trim()) {
|
||||||
|
switchMode('wysiwyg');
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user