feat: wangEditor 替换为 ByteMD Markdown 编辑器
- 移除 wangEditor 5 库文件(1.27MB JS + 14KB CSS,含手动 hack) - 集成 ByteMD 1.22.0(基于 CodeMirror 5,不依赖 contentEditable) - 编辑器输出 Markdown 原文,后端 goldmark + bluemonday 双重防护渲染 HTML - 删除 editor.js 中 scrollIntoView hack、code-lang-label DOM 注入、PreviewAPI 调用 - 清理 posts.css 中 ~200 行 wangEditor 样式覆盖(w-e-* 类名) - 清理 show.html 中 code-lang-label JS 注入(goldmark 自带 language-xxx class) - 更新 CSP 安全策略:移除 CodeMirror 注释,保留 esm.sh CDN - go.mod:bluemonday 和 goldmark 均保留为直接依赖(纵深防御)
This commit is contained in:
8
go.mod
8
go.mod
@ -3,10 +3,14 @@ module metazone.cc/metalab
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/chai2010/webp v1.4.0
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
|
github.com/yuin/goldmark v1.8.2
|
||||||
golang.org/x/crypto v0.52.0
|
golang.org/x/crypto v0.52.0
|
||||||
|
golang.org/x/image v0.41.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
@ -16,7 +20,6 @@ require (
|
|||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
github.com/chai2010/webp v1.4.0 // indirect
|
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
@ -38,7 +41,6 @@ require (
|
|||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
@ -52,11 +54,9 @@ require (
|
|||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
github.com/yuin/goldmark v1.8.2 // indirect
|
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
golang.org/x/image v0.41.0 // indirect
|
|
||||||
golang.org/x/net v0.54.0 // indirect
|
golang.org/x/net v0.54.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
|
|||||||
@ -9,13 +9,14 @@ import (
|
|||||||
func SecurityHeaders() gin.HandlerFunc {
|
func SecurityHeaders() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// Content-Security-Policy
|
// Content-Security-Policy
|
||||||
// script-src: 本站 + esm.sh CDN (CodeMirror 6 ESM 模块)
|
// script-src: 本站 + esm.sh CDN (ByteMD ESM 模块)
|
||||||
|
// style-src: 本站 + esm.sh (ByteMD/highlight.js CSS)
|
||||||
// connect-src: 本站 + esm.sh (source map 请求)
|
// connect-src: 本站 + esm.sh (source map 请求)
|
||||||
// img-src: 本站 + data: URI (头像裁切) + blob: (粘贴图片)
|
// img-src: 本站 + data: URI (头像裁切) + blob: (粘贴图片)
|
||||||
c.Header("Content-Security-Policy",
|
c.Header("Content-Security-Policy",
|
||||||
"default-src 'self'; "+
|
"default-src 'self'; "+
|
||||||
"script-src 'self' 'unsafe-inline' https://esm.sh; "+
|
"script-src 'self' 'unsafe-inline' https://esm.sh; "+
|
||||||
"style-src 'self' 'unsafe-inline'; "+
|
"style-src 'self' 'unsafe-inline' https://esm.sh; "+
|
||||||
"img-src 'self' data: blob:; "+
|
"img-src 'self' data: blob:; "+
|
||||||
"connect-src 'self' https://esm.sh")
|
"connect-src 'self' https://esm.sh")
|
||||||
|
|
||||||
|
|||||||
@ -12,9 +12,10 @@ import (
|
|||||||
"github.com/yuin/goldmark"
|
"github.com/yuin/goldmark"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ucgPolicy 对 Goldmark 输出的 HTML 做安全消毒
|
// sanitizePolicy 纵深防御:对 goldmark 输出的 HTML 再做一层消毒
|
||||||
// UGC 默认策略会剥离 code/pre 的 class 属性,需要显式放行以保留 language-xxx
|
// goldmark 默认已过滤原始 HTML 和危险链接,但 bluemonday 作为第二道防线
|
||||||
var ucgPolicy = func() *bluemonday.Policy {
|
// UGC 策略会剥离 code/pre 的 class 属性,需要显式放行以保留 language-xxx
|
||||||
|
var sanitizePolicy = func() *bluemonday.Policy {
|
||||||
p := bluemonday.UGCPolicy()
|
p := bluemonday.UGCPolicy()
|
||||||
p.AllowAttrs("class").OnElements("code", "pre")
|
p.AllowAttrs("class").OnElements("code", "pre")
|
||||||
return p
|
return p
|
||||||
@ -48,29 +49,32 @@ func NewPostService(repo postStore, ss *config.SiteSettings) *PostService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// auditEnabled 审核是否开启(文案审核与全局审核开关一致)
|
// auditEnabled 审核是否开启
|
||||||
func (s *PostService) auditEnabled() bool {
|
func (s *PostService) auditEnabled() bool {
|
||||||
return s.ss.IsAuditEnabled()
|
return s.ss.IsAuditEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
// sanitizeHTML 对编辑器输出的 HTML 做安全消毒(bluemonday)
|
// renderMarkdown 将 Markdown 文本渲染为安全 HTML
|
||||||
// 编辑器提交的是富文本 HTML,不再经过 Markdown 渲染
|
// goldmark(默认安全模式)→ bluemonday(纵深防御)
|
||||||
func (s *PostService) sanitizeHTML(body string) string {
|
func (s *PostService) renderMarkdown(body string) (string, error) {
|
||||||
return ucgPolicy.Sanitize(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenderMarkdown 对外暴露,用于预览(从纯文本 Markdown 渲染为 HTML)
|
|
||||||
func (s *PostService) RenderMarkdown(body string) (string, error) {
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := s.md.Convert([]byte(body), &buf); err != nil {
|
if err := s.md.Convert([]byte(body), &buf); err != nil {
|
||||||
return "", err
|
return "", fmt.Errorf("markdown render: %w", err)
|
||||||
}
|
}
|
||||||
return ucgPolicy.Sanitize(buf.String()), nil
|
return sanitizePolicy.Sanitize(buf.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderMarkdown 对外暴露,用于预览 API
|
||||||
|
func (s *PostService) RenderMarkdown(body string) (string, error) {
|
||||||
|
return s.renderMarkdown(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create 创建帖子
|
// Create 创建帖子
|
||||||
func (s *PostService) Create(userID uint, title, body string) (*model.Post, error) {
|
func (s *PostService) Create(userID uint, title, body string) (*model.Post, error) {
|
||||||
bodyHTML := s.sanitizeHTML(body)
|
bodyHTML, err := s.renderMarkdown(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
status := model.PostStatusApproved
|
status := model.PostStatusApproved
|
||||||
if s.auditEnabled() {
|
if s.auditEnabled() {
|
||||||
@ -78,11 +82,11 @@ func (s *PostService) Create(userID uint, title, body string) (*model.Post, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
post := &model.Post{
|
post := &model.Post{
|
||||||
Title: title,
|
Title: title,
|
||||||
Body: body,
|
Body: body,
|
||||||
BodyHTML: bodyHTML,
|
BodyHTML: bodyHTML,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Status: status,
|
Status: status,
|
||||||
AllowComment: true,
|
AllowComment: true,
|
||||||
}
|
}
|
||||||
if err := s.repo.Create(post); err != nil {
|
if err := s.repo.Create(post); err != nil {
|
||||||
@ -121,14 +125,18 @@ func (s *PostService) Update(postID uint, title, body string) error {
|
|||||||
return common.ErrUserNotFound
|
return common.ErrUserNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// pending / locked 禁止编辑
|
|
||||||
if post.Status == model.PostStatusPending || post.Status == model.PostStatusLocked {
|
if post.Status == model.PostStatusPending || post.Status == model.PostStatusLocked {
|
||||||
return fmt.Errorf("当前状态不允许编辑")
|
return fmt.Errorf("当前状态不允许编辑")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bodyHTML, err := s.renderMarkdown(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
post.Title = title
|
post.Title = title
|
||||||
post.Body = body
|
post.Body = body
|
||||||
post.BodyHTML = s.sanitizeHTML(body)
|
post.BodyHTML = bodyHTML
|
||||||
|
|
||||||
// rejected 状态编辑后自动重置为 draft
|
// rejected 状态编辑后自动重置为 draft
|
||||||
if post.Status == model.PostStatusRejected {
|
if post.Status == model.PostStatusRejected {
|
||||||
|
|||||||
@ -1,35 +1,16 @@
|
|||||||
{{template "layout/header.html" .}}
|
{{template "layout/header.html" .}}
|
||||||
<link rel="stylesheet" href="/static/lib/wangeditor.css">
|
<link rel="stylesheet" href="https://esm.sh/bytemd@1.22.0/dist/index.css">
|
||||||
|
<link rel="stylesheet" href="https://esm.sh/highlight.js@11.9.0/styles/github.css">
|
||||||
<style>
|
<style>
|
||||||
/* wangEditor 容器适配全高布局 */
|
/* ByteMD 编辑器容器适配全高布局 */
|
||||||
#editor-wrapper {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 0;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
#toolbar-container {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
#editor-container {
|
#editor-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
/* .w-e-scroll 是 wangEditor 内部唯一的滚动容器 */
|
#editor-container .bytemd {
|
||||||
.editor-pane .w-e-text-container {
|
|
||||||
height: 100% !important;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.editor-pane .w-e-scroll {
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.editor-pane .w-e-text-container [data-slate-editor] {
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<body>
|
<body>
|
||||||
@ -48,30 +29,18 @@
|
|||||||
maxlength="200" required autofocus>
|
maxlength="200" required autofocus>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{/* 编辑器主体:编辑区 + 预览区 */}}
|
{{/* 编辑器主体 */}}
|
||||||
<div class="editor-main">
|
<div class="editor-main">
|
||||||
<div class="editor-pane" id="editorPane">
|
<div class="editor-pane" id="editorPane">
|
||||||
<div id="editor-wrapper">
|
<div id="editor-container" data-value="{{if .Post}}{{.Post.Body}}{{end}}"></div>
|
||||||
<div id="toolbar-container"></div>
|
|
||||||
<div id="editor-container"></div>
|
|
||||||
</div>
|
|
||||||
<textarea id="postBody" style="display:none;">{{if .Post}}{{.Post.Body}}{{end}}</textarea>
|
|
||||||
</div>
|
|
||||||
<div class="preview-pane" id="previewPane">
|
|
||||||
<div class="preview-content post-detail-body" id="previewContent">
|
|
||||||
<em style="color:#9ca3af">预览将在此处显示...</em>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{/* 状态栏 */}}
|
{{/* 状态栏 */}}
|
||||||
<div class="editor-statusbar">
|
<div class="editor-statusbar">
|
||||||
<span class="status-item" id="wordCount">0 字</span>
|
<span class="status-item" id="wordCount">0 字</span>
|
||||||
<span class="status-item" id="lineCount">0 行</span>
|
|
||||||
<span class="status-item" id="readTime">约 1 分钟</span>
|
|
||||||
<span class="status-spacer"></span>
|
|
||||||
<span class="status-item status-draft" id="draftStatus" style="display:none">草稿已保存</span>
|
<span class="status-item status-draft" id="draftStatus" style="display:none">草稿已保存</span>
|
||||||
<span class="status-item" id="btnTogglePreview" style="cursor:pointer" title="切换预览 (Ctrl+Shift+P)">预览</span>
|
<span class="status-spacer"></span>
|
||||||
<span class="status-item" id="btnFullscreen" style="cursor:pointer" title="全屏编辑 (F11)">全屏</span>
|
<span class="status-item" id="btnFullscreen" style="cursor:pointer" title="全屏编辑 (F11)">全屏</span>
|
||||||
<button type="submit" class="btn btn-primary btn-submit">{{if .Post}}保存修改{{else}}发布帖子{{end}}</button>
|
<button type="submit" class="btn btn-primary btn-submit">{{if .Post}}保存修改{{else}}发布帖子{{end}}</button>
|
||||||
</div>
|
</div>
|
||||||
@ -80,6 +49,4 @@
|
|||||||
|
|
||||||
{{template "layout/footer.html" .}}
|
{{template "layout/footer.html" .}}
|
||||||
|
|
||||||
<script src="/static/lib/wangeditor.js"></script>
|
<script type="module" src="/static/js/editor.js"></script>
|
||||||
<script src="/static/js/common.js"></script>
|
|
||||||
<script src="/static/js/editor.js"></script>
|
|
||||||
|
|||||||
@ -48,18 +48,6 @@
|
|||||||
<script src="/static/js/common.js"></script>
|
<script src="/static/js/common.js"></script>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
// 代码块语言标签注入
|
|
||||||
document.querySelectorAll('.post-detail-body pre').forEach(function(pre) {
|
|
||||||
var code = pre.querySelector('code[class*="language-"]');
|
|
||||||
if (!code) return;
|
|
||||||
var match = code.className.match(/language-(\w+)/);
|
|
||||||
if (!match) return;
|
|
||||||
var label = document.createElement('span');
|
|
||||||
label.className = 'code-lang-label';
|
|
||||||
label.textContent = match[1];
|
|
||||||
pre.insertBefore(label, pre.firstChild);
|
|
||||||
});
|
|
||||||
|
|
||||||
var btn = document.getElementById('submitAuditBtn');
|
var btn = document.getElementById('submitAuditBtn');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
btn.addEventListener('click', function() {
|
btn.addEventListener('click', function() {
|
||||||
|
|||||||
@ -125,9 +125,8 @@
|
|||||||
color: #2980b9;
|
color: #2980b9;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 代码块基础样式(详情页预览 + 编辑器内通用) */
|
/* 代码块基础样式 */
|
||||||
.post-detail-body pre,
|
.post-detail-body pre {
|
||||||
#editor-container [data-slate-editor] pre {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
@ -154,8 +153,7 @@
|
|||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-detail-body pre code,
|
.post-detail-body pre code {
|
||||||
#editor-container [data-slate-editor] pre code {
|
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
@ -265,232 +263,14 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================================================================
|
/* ByteMD 编辑器容器 */
|
||||||
wangEditor 风格覆盖 — 匹配 MetaLab 靛蓝色系
|
|
||||||
================================================================ */
|
|
||||||
|
|
||||||
/* 编辑器整体容器 */
|
|
||||||
#editor-container {
|
#editor-container {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* wangEditor 内部文本容器撑满 */
|
#editor-container .bytemd {
|
||||||
#editor-container .w-e-text-container {
|
|
||||||
height: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 编辑区域 */
|
|
||||||
#editor-container [data-slate-editor] {
|
|
||||||
padding: 16px 24px;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.75;
|
|
||||||
color: #1f2937;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 工具栏容器 */
|
|
||||||
#toolbar-container .w-e-toolbar {
|
|
||||||
background: #fafafa !important;
|
|
||||||
border: none !important;
|
|
||||||
border-bottom: 1px solid #e5e7eb !important;
|
|
||||||
padding: 4px 12px !important;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 工具栏按钮 */
|
|
||||||
#toolbar-container .w-e-bar-item button {
|
|
||||||
color: #4b5563 !important;
|
|
||||||
padding: 4px 6px !important;
|
|
||||||
border-radius: 4px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#toolbar-container .w-e-bar-item button:hover {
|
|
||||||
background: #e5e7eb !important;
|
|
||||||
color: #111 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 工具栏按钮激活态 */
|
|
||||||
#toolbar-container .w-e-bar-item button.active {
|
|
||||||
background: #e0e7ff !important;
|
|
||||||
color: #6366f1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 工具栏分隔线 */
|
|
||||||
#toolbar-container .w-e-bar-divider {
|
|
||||||
background: #e5e7eb !important;
|
|
||||||
margin: 4px 6px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 下拉菜单面板 */
|
|
||||||
.w-e-panel-container {
|
|
||||||
background: #fff !important;
|
|
||||||
border: 1px solid #d1d5db !important;
|
|
||||||
border-radius: 8px !important;
|
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,0.12) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-e-panel-container .w-e-panel-content {
|
|
||||||
color: #374151 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 下拉菜单项 */
|
|
||||||
.w-e-dropdown-item {
|
|
||||||
color: #374151 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-e-dropdown-item:hover {
|
|
||||||
background: #f3f4f6 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-e-dropdown-item.active {
|
|
||||||
background: #e0e7ff !important;
|
|
||||||
color: #6366f1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 下拉菜单 */
|
|
||||||
.w-e-bar-item .w-e-bar-item-menus-container {
|
|
||||||
background: #fff !important;
|
|
||||||
border: 1px solid #d1d5db !important;
|
|
||||||
border-radius: 8px !important;
|
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,0.12) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 链接/图片输入弹窗 */
|
|
||||||
.w-e-modal {
|
|
||||||
background: #fff !important;
|
|
||||||
border: 1px solid #d1d5db !important;
|
|
||||||
border-radius: 8px !important;
|
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,0.12) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-e-modal input,
|
|
||||||
.w-e-modal textarea {
|
|
||||||
border: 1px solid #d1d5db !important;
|
|
||||||
border-radius: 6px !important;
|
|
||||||
padding: 8px 12px !important;
|
|
||||||
font-size: 14px !important;
|
|
||||||
color: #374151 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-e-modal input:focus,
|
|
||||||
.w-e-modal textarea:focus {
|
|
||||||
border-color: #6366f1 !important;
|
|
||||||
box-shadow: 0 0 0 3px rgba(99,102,241,0.1) !important;
|
|
||||||
outline: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-e-modal button {
|
|
||||||
border-radius: 6px !important;
|
|
||||||
font-size: 13px !important;
|
|
||||||
padding: 6px 16px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-e-modal .w-e-modal-header {
|
|
||||||
border-bottom: 1px solid #e5e7eb !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-e-modal .w-e-modal-footer {
|
|
||||||
border-top: 1px solid #e5e7eb !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 确认按钮颜色覆盖为靛蓝 */
|
|
||||||
.w-e-modal .w-e-modal-footer button:last-child {
|
|
||||||
background: #6366f1 !important;
|
|
||||||
color: #fff !important;
|
|
||||||
border: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-e-modal .w-e-modal-footer button:last-child:hover {
|
|
||||||
background: #4f46e5 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 表格创建选择器 */
|
|
||||||
.w-e-table-panel {
|
|
||||||
background: #fff !important;
|
|
||||||
border: 1px solid #d1d5db !important;
|
|
||||||
border-radius: 8px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-e-table-panel .w-e-table-panel-item {
|
|
||||||
border-color: #e5e7eb !important;
|
|
||||||
background: #f9fafb !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-e-table-panel .w-e-table-panel-item.active {
|
|
||||||
background: #e0e7ff !important;
|
|
||||||
border-color: #6366f1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 选中文本背景色 */
|
|
||||||
#editor-container [data-slate-editor] ::selection {
|
|
||||||
background: #c7d2fe !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* placeholder */
|
|
||||||
#editor-container [data-slate-editor] [data-slate-placeholder] {
|
|
||||||
color: #c4c4c4 !important;
|
|
||||||
font-style: normal !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 编辑器内代码块样式 — 以预览区样式为准 */
|
|
||||||
#editor-container [data-slate-editor] pre {
|
|
||||||
position: relative;
|
|
||||||
background: #f3f4f6 !important;
|
|
||||||
padding: 12px 16px !important;
|
|
||||||
padding-top: 36px !important;
|
|
||||||
border-radius: 8px !important;
|
|
||||||
font-family: 'Menlo', 'Consolas', 'Monaco', 'Liberation Mono', monospace !important;
|
|
||||||
font-size: 14px !important;
|
|
||||||
line-height: 1.5 !important;
|
|
||||||
overflow-x: auto !important;
|
|
||||||
margin: 12px 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 语言标签由 JS 动态注入 .code-lang-label,不再使用 ::before 伪元素 */
|
|
||||||
/* 若 wangEditor 的 pre 上有 data-language 属性,则作为降级方案 */
|
|
||||||
#editor-container [data-slate-editor] pre[data-language]::before {
|
|
||||||
content: attr(data-language);
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: #e5e7eb;
|
|
||||||
color: #6b7280;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: 8px 8px 0 0;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
#editor-container [data-slate-editor] pre code {
|
|
||||||
background: transparent !important;
|
|
||||||
border: none !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
font-size: inherit !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 预览区 */
|
|
||||||
.preview-pane {
|
|
||||||
width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
border-left: 1px solid #e5e7eb;
|
|
||||||
background: #fff;
|
|
||||||
transition: width 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-pane.visible {
|
|
||||||
width: 50%;
|
|
||||||
min-width: 320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-content {
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
|
||||||
padding: 20px 28px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 状态栏 ---- */
|
/* ---- 状态栏 ---- */
|
||||||
|
|||||||
@ -1,486 +1,214 @@
|
|||||||
/**
|
/**
|
||||||
* MetaLab Markdown Editor — wangEditor 5 版
|
* MetaLab Editor — ByteMD Markdown 编辑器
|
||||||
*
|
*
|
||||||
* 使用 wangEditor 所见即所得编辑器,通过 Markdown 语法输入实现快捷编辑。
|
* ByteMD 基于 CodeMirror 5(纯 textarea 兜底,不依赖 contentEditable),
|
||||||
* 提交时将内容转为 Markdown 字符串发送到后端。
|
* 输出 Markdown 原文,由后端 goldmark 统一渲染 HTML。
|
||||||
|
*
|
||||||
|
* 安全模型:Markdown 文本 → goldmark → HTML(AST 生成,天然安全)
|
||||||
|
* 前端不再做 sanitize,XSS 风险由 goldmark 的 AST 输出消除。
|
||||||
*/
|
*/
|
||||||
|
import { Editor } from 'https://esm.sh/bytemd@1.22.0'
|
||||||
|
import gfm from 'https://esm.sh/@bytemd/plugin-gfm@1.22.0'
|
||||||
|
import highlight from 'https://esm.sh/@bytemd/plugin-highlight@1.22.0'
|
||||||
|
|
||||||
(function () {
|
// ---- 状态 ----
|
||||||
'use strict';
|
let editor = null
|
||||||
|
let currentValue = ''
|
||||||
|
let draftTimer = null
|
||||||
|
|
||||||
/* ================================================================
|
// ---- 初始化 ----
|
||||||
DOM 引用
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
================================================================ */
|
const container = document.getElementById('editor-container')
|
||||||
const form = document.getElementById('postForm');
|
if (!container) return
|
||||||
const postIdEl = document.getElementById('postId');
|
|
||||||
const titleEl = document.getElementById('postTitle');
|
|
||||||
const bodyEl = document.getElementById('postBody');
|
|
||||||
const editorPane = document.getElementById('editorPane');
|
|
||||||
const previewPane = document.getElementById('previewPane');
|
|
||||||
const previewCont = document.getElementById('previewContent');
|
|
||||||
const btnToggle = document.getElementById('btnTogglePreview');
|
|
||||||
const btnFull = document.getElementById('btnFullscreen');
|
|
||||||
const wordCountEl = document.getElementById('wordCount');
|
|
||||||
const lineCountEl = document.getElementById('lineCount');
|
|
||||||
const readTimeEl = document.getElementById('readTime');
|
|
||||||
const draftStatus = document.getElementById('draftStatus');
|
|
||||||
|
|
||||||
const isEdit = !!(postIdEl && postIdEl.value);
|
const postId = document.getElementById('postId')?.value
|
||||||
|
const isEdit = !!postId
|
||||||
|
|
||||||
let previewVisible = false;
|
// 从容器 data-value 属性获取初始内容(由后端 SSR 渲染)
|
||||||
let syncTimer = null;
|
const initialValue = container.dataset.value || ''
|
||||||
let saveTimer = null;
|
currentValue = initialValue
|
||||||
let splitTimer = null;
|
|
||||||
let draftCharCount = 0;
|
|
||||||
let editor = null;
|
|
||||||
|
|
||||||
/* ================================================================
|
const plugins = [
|
||||||
CSRF Token
|
gfm(),
|
||||||
================================================================ */
|
highlight(),
|
||||||
function getCSRFToken() {
|
]
|
||||||
// 优先从 cookie 读取(与 CSRF Double Submit Cookie 一致)
|
|
||||||
const match = document.cookie.match(/(?:^|;\s*)mlb_csrf=([^;]*)/);
|
|
||||||
if (match) return match[1];
|
|
||||||
// fallback: 从 meta 标签读取
|
|
||||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
|
||||||
return meta ? meta.getAttribute('content') : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================================================================
|
editor = new Editor({
|
||||||
创建 wangEditor 编辑器
|
target: container,
|
||||||
================================================================ */
|
props: {
|
||||||
function initEditor() {
|
value: initialValue,
|
||||||
if (!window.wangEditor) {
|
plugins,
|
||||||
console.error('wangEditor not loaded, retrying...');
|
mode: 'split',
|
||||||
setTimeout(initEditor, 200);
|
placeholder: '在此输入正文... (Markdown)',
|
||||||
return;
|
uploadImages: uploadImage,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听内容变化,同步 currentValue
|
||||||
|
editor.$on('change', (e) => {
|
||||||
|
currentValue = e.detail.value
|
||||||
|
updateWordCount(currentValue)
|
||||||
|
scheduleDraft(currentValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 初始字数统计
|
||||||
|
updateWordCount(initialValue)
|
||||||
|
|
||||||
|
// 恢复草稿(延迟确保 ByteMD 完全初始化)
|
||||||
|
if (!isEdit) {
|
||||||
|
setTimeout(() => restoreDraft(), 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { createEditor, createToolbar } = window.wangEditor;
|
// 全屏切换
|
||||||
|
document.getElementById('btnFullscreen')?.addEventListener('click', toggleFullscreen)
|
||||||
|
|
||||||
const editorConfig = {
|
// 表单提交
|
||||||
placeholder: '在此输入正文...',
|
document.getElementById('postForm')?.addEventListener('submit', handleSubmit)
|
||||||
onChange() {
|
|
||||||
// 延迟更新代码块语言标签(同步操作 DOM 会干扰 wangEditor 内部同步,导致滚动跳动)
|
|
||||||
updateCodeLabels();
|
|
||||||
|
|
||||||
// 替代 wangEditor 内置 scrollIntoView(已禁用),仅在光标不可见时微调滚动
|
// Ctrl+S 快捷键
|
||||||
ensureCursorVisible();
|
document.addEventListener('keydown', handleKeyboard)
|
||||||
|
})
|
||||||
|
|
||||||
clearTimeout(syncTimer);
|
// ---- 图片上传 ----
|
||||||
syncTimer = setTimeout(() => {
|
async function uploadImage(files) {
|
||||||
updateStats();
|
const csrfMeta = document.querySelector('meta[name="csrf-token"]')
|
||||||
if (previewVisible) updatePreview();
|
const csrf = csrfMeta ? csrfMeta.getAttribute('content') : ''
|
||||||
}, 200);
|
|
||||||
|
|
||||||
// 字数触发:每新增 300 字符保存
|
const results = []
|
||||||
// 时间触发:停止输入 10 秒后自动保存(防止末尾内容丢失)
|
for (const file of files) {
|
||||||
const currentLen = (editor ? editor.getText().length : 0);
|
const formData = new FormData()
|
||||||
if (currentLen - draftCharCount >= 300) {
|
formData.append('file', file)
|
||||||
draftCharCount = currentLen;
|
|
||||||
saveDraft();
|
|
||||||
}
|
|
||||||
clearTimeout(saveTimer);
|
|
||||||
saveTimer = setTimeout(saveDraft, 10000);
|
|
||||||
},
|
|
||||||
MENU_CONF: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
// 配置上传图片
|
const resp = await fetch('/api/posts/upload-image', {
|
||||||
editorConfig.MENU_CONF['uploadImage'] = {
|
method: 'POST',
|
||||||
maxFileSize: 5 * 1024 * 1024,
|
headers: { 'X-CSRF-Token': csrf },
|
||||||
allowedFileTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
body: formData,
|
||||||
// 自定义上传逻辑,适配后端 API
|
})
|
||||||
customUpload(file, insertFn) {
|
const data = await resp.json()
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('file', file);
|
|
||||||
|
|
||||||
const csrf = getCSRFToken();
|
if (data.success || data.code === 200) {
|
||||||
|
const url = data.data?.url || data.url
|
||||||
fetch('/api/posts/upload-image', {
|
results.push({ url, alt: file.name, title: file.name })
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-Token': csrf,
|
|
||||||
},
|
|
||||||
body: fd,
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(res => {
|
|
||||||
if (res.success && res.data && res.data.url) {
|
|
||||||
insertFn(res.data.url, res.data.url, res.data.url);
|
|
||||||
} else {
|
|
||||||
alert(res.message || '上传失败');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
alert('上传请求失败,请检查网络');
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// 创建编辑器
|
|
||||||
editor = createEditor({
|
|
||||||
selector: '#editor-container',
|
|
||||||
html: bodyEl ? bodyEl.value : '<p><br></p>',
|
|
||||||
config: editorConfig,
|
|
||||||
mode: 'default',
|
|
||||||
});
|
|
||||||
|
|
||||||
// 创建工具栏
|
|
||||||
createToolbar({
|
|
||||||
editor,
|
|
||||||
selector: '#toolbar-container',
|
|
||||||
config: {
|
|
||||||
excludeKeys: [
|
|
||||||
// 移除视频相关按钮:不允许视频上传,不在前端提供视频按钮
|
|
||||||
'group-video', 'insertVideo', 'uploadVideo',
|
|
||||||
// 移除"网络图片"功能,仅保留上传图片
|
|
||||||
'insertImage',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
mode: 'default',
|
|
||||||
});
|
|
||||||
|
|
||||||
// 移除原始 textarea
|
|
||||||
if (bodyEl) bodyEl.remove();
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
updateStats();
|
|
||||||
updateCodeLabels();
|
|
||||||
loadDraft();
|
|
||||||
|
|
||||||
titleEl.addEventListener('input', () => {
|
|
||||||
saveDraft();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 滚动同步(.w-e-scroll 是编辑区唯一滚动容器)
|
|
||||||
previewCont.addEventListener('scroll', syncScrollToEditor);
|
|
||||||
setTimeout(() => {
|
|
||||||
const scroller = (document.getElementById('editor-container') || editorPane).querySelector('.w-e-scroll');
|
|
||||||
if (scroller) scroller.addEventListener('scroll', syncScrollToPreview);
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
console.log('%c📝 MetaLab Editor (wangEditor) ready', 'color:#6366f1;font-weight:bold');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================================================================
|
|
||||||
确保光标可见 — 替代 wangEditor 内置 scrollIntoView
|
|
||||||
wangEditor 默认使用 block:"end" 会在代码块换行时导致大幅滚动,
|
|
||||||
已在 wangeditor.js 中禁用该调用,此处用最小偏移量保证光标可见
|
|
||||||
================================================================ */
|
|
||||||
function ensureCursorVisible() {
|
|
||||||
if (!editor) return;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
try {
|
|
||||||
const sel = window.getSelection();
|
|
||||||
if (!sel || !sel.rangeCount) return;
|
|
||||||
const range = sel.getRangeAt(0);
|
|
||||||
|
|
||||||
// .w-e-scroll 是编辑区滚动容器
|
|
||||||
const scroller = document.querySelector('#editor-container .w-e-scroll');
|
|
||||||
if (!scroller) return;
|
|
||||||
|
|
||||||
const cursorRect = range.getBoundingClientRect();
|
|
||||||
const scrollRect = scroller.getBoundingClientRect();
|
|
||||||
|
|
||||||
// 光标低于可见区 → 向下滚到光标可见
|
|
||||||
if (cursorRect.bottom > scrollRect.bottom) {
|
|
||||||
scroller.scrollTop += cursorRect.bottom - scrollRect.bottom + 8;
|
|
||||||
}
|
|
||||||
// 光标高于可见区 → 向上滚到光标可见
|
|
||||||
else if (cursorRect.top < scrollRect.top) {
|
|
||||||
scroller.scrollTop -= scrollRect.top - cursorRect.top + 8;
|
|
||||||
}
|
|
||||||
} catch (_) { /* 忽略 */ }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================================================================
|
|
||||||
字数统计
|
|
||||||
================================================================ */
|
|
||||||
function getEditorText() {
|
|
||||||
return editor ? editor.getText() : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEditorHtml() {
|
|
||||||
return editor ? editor.getHtml() : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================================================================
|
|
||||||
代码块语言标签 — 从 <code class="language-xxx"> 提取并注入标签
|
|
||||||
================================================================ */
|
|
||||||
// 代码块语言标签更新节流定时器
|
|
||||||
let codeLabelTimer = null;
|
|
||||||
|
|
||||||
function updateCodeLabels() {
|
|
||||||
if (!editor) return;
|
|
||||||
|
|
||||||
// 节流:避免 onChange 中频繁调用导致滚动跳动
|
|
||||||
clearTimeout(codeLabelTimer);
|
|
||||||
codeLabelTimer = setTimeout(_updateCodeLabelsImpl, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _updateCodeLabelsImpl() {
|
|
||||||
if (!editor) return;
|
|
||||||
|
|
||||||
// wangEditor 编辑区 <code> 不带 class="language-xxx",
|
|
||||||
// 但 getHtml() 输出带 class="language-xxx"。
|
|
||||||
// 用 DOMParser 解析 HTML 获取语言,再注入到编辑区对应 pre。
|
|
||||||
try {
|
|
||||||
const html = editor.getHtml();
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const doc = parser.parseFromString(html, 'text/html');
|
|
||||||
const htmlPres = doc.querySelectorAll('pre');
|
|
||||||
|
|
||||||
const editorEl = document.getElementById('editor-container');
|
|
||||||
if (!editorEl) return;
|
|
||||||
const domPres = editorEl.querySelectorAll('pre');
|
|
||||||
|
|
||||||
// 保存当前滚动位置,防止 DOM 操作引起跳动
|
|
||||||
// .w-e-scroll 是 wangEditor 内部的滚动容器
|
|
||||||
const scroller = editorEl.querySelector('.w-e-scroll');
|
|
||||||
const savedScrollTop = scroller ? scroller.scrollTop : 0;
|
|
||||||
|
|
||||||
htmlPres.forEach((htmlPre, idx) => {
|
|
||||||
const domPre = domPres[idx];
|
|
||||||
if (!domPre || domPre.querySelector('.code-lang-label')) return;
|
|
||||||
|
|
||||||
const code = htmlPre.querySelector('code[class*="language-"]');
|
|
||||||
if (code) {
|
|
||||||
const match = code.className.match(/language-(\w+)/);
|
|
||||||
if (match && match[1]) {
|
|
||||||
const label = document.createElement('span');
|
|
||||||
label.className = 'code-lang-label';
|
|
||||||
label.textContent = match[1];
|
|
||||||
domPre.style.position = 'relative';
|
|
||||||
domPre.insertBefore(label, domPre.firstChild);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 恢复滚动位置
|
|
||||||
if (scroller && scroller.scrollTop !== savedScrollTop) {
|
|
||||||
scroller.scrollTop = savedScrollTop;
|
|
||||||
}
|
}
|
||||||
} catch (_) { /* 忽略 */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStats() {
|
|
||||||
const text = getEditorText();
|
|
||||||
const chars = text.length;
|
|
||||||
const lines = text.split('\n').length;
|
|
||||||
const words = text.trim() ? text.trim().split(/\s+/).length : 0;
|
|
||||||
const minutes = Math.max(1, Math.ceil(words / 200));
|
|
||||||
|
|
||||||
wordCountEl.textContent = chars + ' 字';
|
|
||||||
lineCountEl.textContent = lines + ' 行';
|
|
||||||
readTimeEl.textContent = '约 ' + minutes + ' 分钟';
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================================================================
|
|
||||||
预览(wangEditor 直接展示 HTML,无需服务端渲染)
|
|
||||||
================================================================ */
|
|
||||||
function updatePreview() {
|
|
||||||
if (!previewVisible) return;
|
|
||||||
const html = editor ? editor.getHtml() : '';
|
|
||||||
if (html === '<p><br></p>' || !html.trim()) {
|
|
||||||
previewCont.innerHTML = '<em style="color:#9ca3af">预览将在此处显示...</em>';
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
clearTimeout(splitTimer);
|
return results
|
||||||
splitTimer = setTimeout(() => {
|
|
||||||
previewCont.innerHTML = html;
|
|
||||||
// 预览区也注入语言标签
|
|
||||||
previewCont.querySelectorAll('pre').forEach(pre => {
|
|
||||||
if (pre.querySelector('.code-lang-label')) return;
|
|
||||||
const code = pre.querySelector('code[class*="language-"]');
|
|
||||||
if (code) {
|
|
||||||
const match = code.className.match(/language-(\w+)/);
|
|
||||||
if (match) {
|
|
||||||
const label = document.createElement('span');
|
|
||||||
label.className = 'code-lang-label';
|
|
||||||
label.textContent = match[1];
|
|
||||||
pre.insertBefore(label, pre.firstChild);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 300);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePreview() {
|
// ---- 字数统计 ----
|
||||||
previewVisible = !previewVisible;
|
function updateWordCount(text) {
|
||||||
previewPane.classList.toggle('visible', previewVisible);
|
const el = document.getElementById('wordCount')
|
||||||
if (previewVisible) updatePreview();
|
if (!el) return
|
||||||
|
const len = (text || '').replace(/\s/g, '').length
|
||||||
|
el.textContent = len + ' 字'
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================================================================
|
// ---- 草稿保存/恢复 ----
|
||||||
滚动同步
|
function scheduleDraft(text) {
|
||||||
================================================================ */
|
clearTimeout(draftTimer)
|
||||||
let scrollSyncing = false;
|
draftTimer = setTimeout(() => saveDraft(text), 2000)
|
||||||
|
|
||||||
function getEditorScroller() {
|
|
||||||
const el = document.getElementById('editor-container') || editorPane;
|
|
||||||
return el ? (el.querySelector('.w-e-scroll') || el) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncScrollToPreview() {
|
function saveDraft(text) {
|
||||||
if (!previewVisible || scrollSyncing) return;
|
const title = document.getElementById('postTitle')?.value || ''
|
||||||
scrollSyncing = true;
|
if (!text.trim() && !title.trim()) return
|
||||||
const scroller = getEditorScroller();
|
|
||||||
if (!scroller) { scrollSyncing = false; return; }
|
|
||||||
const ratio = scroller.scrollTop / Math.max(1, scroller.scrollHeight - scroller.clientHeight);
|
|
||||||
previewCont.scrollTop = ratio * Math.max(1, previewCont.scrollHeight - previewCont.clientHeight);
|
|
||||||
requestAnimationFrame(() => { scrollSyncing = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncScrollToEditor() {
|
|
||||||
if (!previewVisible || scrollSyncing) return;
|
|
||||||
scrollSyncing = true;
|
|
||||||
const scroller = getEditorScroller();
|
|
||||||
if (!scroller) { scrollSyncing = false; return; }
|
|
||||||
const ratio = previewCont.scrollTop / Math.max(1, previewCont.scrollHeight - previewCont.clientHeight);
|
|
||||||
scroller.scrollTop = ratio * Math.max(1, scroller.scrollHeight - scroller.clientHeight);
|
|
||||||
requestAnimationFrame(() => { scrollSyncing = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================================================================
|
|
||||||
自动保存草稿
|
|
||||||
================================================================ */
|
|
||||||
function saveDraft() {
|
|
||||||
const title = titleEl.value.trim();
|
|
||||||
const bodyHtml = getEditorHtml();
|
|
||||||
const bodyText = getEditorText().trim();
|
|
||||||
|
|
||||||
// 空编辑区不保存(wangEditor 空内容 HTML 是 <p><br></p>)
|
|
||||||
if (!bodyText && !title) return;
|
|
||||||
|
|
||||||
const draft = { title, body: bodyHtml, updatedAt: Date.now() };
|
|
||||||
localStorage.setItem('mlb_draft_' + (isEdit ? postIdEl.value : 'new'), JSON.stringify(draft));
|
|
||||||
|
|
||||||
draftStatus.style.display = '';
|
|
||||||
draftStatus.textContent = '草稿已保存 ' + new Date().toLocaleTimeString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadDraft() {
|
|
||||||
const key = 'mlb_draft_' + (isEdit ? postIdEl.value : 'new');
|
|
||||||
const raw = localStorage.getItem(key);
|
|
||||||
if (!raw) return;
|
|
||||||
try {
|
try {
|
||||||
const draft = JSON.parse(raw);
|
localStorage.setItem('draft_post_title', title)
|
||||||
if (draft.body || draft.title) {
|
localStorage.setItem('draft_post_body', text)
|
||||||
// 如果编辑器已有内容(编辑模式从 DB 加载),跳过草稿恢复
|
const statusEl = document.getElementById('draftStatus')
|
||||||
if (isEdit && getEditorText().trim()) return;
|
if (statusEl) {
|
||||||
|
statusEl.style.display = ''
|
||||||
if (draft.body && confirm('检测到未保存的草稿(' + new Date(draft.updatedAt).toLocaleString() + '),是否恢复?')) {
|
setTimeout(() => { statusEl.style.display = 'none' }, 2000)
|
||||||
if (editor) editor.setHtml(draft.body);
|
|
||||||
if (draft.title && !titleEl.value) titleEl.value = draft.title;
|
|
||||||
} else {
|
|
||||||
// 用户拒绝恢复,清除旧草稿
|
|
||||||
clearDraft();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (_) { localStorage.removeItem(key); }
|
} catch (e) {
|
||||||
|
// localStorage 不可用(隐私模式等),静默忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreDraft() {
|
||||||
|
try {
|
||||||
|
const savedId = localStorage.getItem('draft_post_id')
|
||||||
|
if (savedId) return // 编辑模式不恢复草稿
|
||||||
|
|
||||||
|
const title = localStorage.getItem('draft_post_title')
|
||||||
|
const body = localStorage.getItem('draft_post_body')
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
document.getElementById('postTitle').value = title
|
||||||
|
}
|
||||||
|
if (body && editor) {
|
||||||
|
editor.$set({ value: body })
|
||||||
|
currentValue = body
|
||||||
|
updateWordCount(body)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 静默忽略
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearDraft() {
|
function clearDraft() {
|
||||||
const key = 'mlb_draft_' + (isEdit ? postIdEl.value : 'new');
|
try {
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem('draft_post_title')
|
||||||
|
localStorage.removeItem('draft_post_body')
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================================================================
|
// ---- 全屏 ----
|
||||||
全屏编辑
|
|
||||||
================================================================ */
|
|
||||||
function toggleFullscreen() {
|
function toggleFullscreen() {
|
||||||
document.querySelector('.editor-layout').classList.toggle('fullscreen');
|
const layout = document.querySelector('.editor-layout')
|
||||||
if (editor) editor.focus(true);
|
if (!layout) return
|
||||||
|
layout.classList.toggle('fullscreen')
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================================================================
|
// ---- 键盘快捷键 ----
|
||||||
事件绑定
|
function handleKeyboard(e) {
|
||||||
================================================================ */
|
|
||||||
if (btnToggle) btnToggle.addEventListener('click', togglePreview);
|
|
||||||
if (btnFull) btnFull.addEventListener('click', toggleFullscreen);
|
|
||||||
|
|
||||||
/* ================================================================
|
|
||||||
键盘快捷键
|
|
||||||
================================================================ */
|
|
||||||
document.addEventListener('keydown', e => {
|
|
||||||
const mod = e.ctrlKey || e.metaKey;
|
|
||||||
|
|
||||||
if (mod && e.key === 's') {
|
|
||||||
e.preventDefault();
|
|
||||||
saveDraft();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mod && e.shiftKey && e.key === 'P') {
|
|
||||||
e.preventDefault();
|
|
||||||
togglePreview();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.key === 'F11') {
|
if (e.key === 'F11') {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
toggleFullscreen();
|
toggleFullscreen()
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
const layout = document.querySelector('.editor-layout');
|
e.preventDefault()
|
||||||
if (layout && layout.classList.contains('fullscreen')) {
|
document.getElementById('postForm')?.dispatchEvent(new Event('submit'))
|
||||||
layout.classList.remove('fullscreen');
|
|
||||||
if (editor) editor.focus(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
/* ================================================================
|
|
||||||
表单提交
|
|
||||||
================================================================ */
|
|
||||||
form.addEventListener('submit', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const title = titleEl.value.trim();
|
|
||||||
const body = getEditorHtml();
|
|
||||||
const bodyText = getEditorText();
|
|
||||||
|
|
||||||
if (!title) { alert('请输入标题'); return; }
|
|
||||||
if (!bodyText) { alert('请输入正文'); return; }
|
|
||||||
|
|
||||||
let url = '/api/posts';
|
|
||||||
let method = 'POST';
|
|
||||||
if (isEdit && postIdEl.value) {
|
|
||||||
url = '/api/posts/' + postIdEl.value;
|
|
||||||
method = 'PUT';
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch(url, {
|
|
||||||
method,
|
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCSRFToken() },
|
|
||||||
body: JSON.stringify({ title, body })
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
clearDraft();
|
|
||||||
if (data.data && data.data.id) {
|
|
||||||
window.location.href = '/posts/' + data.data.id;
|
|
||||||
} else {
|
|
||||||
window.location.href = '/posts';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
alert(data.message || '操作失败');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => alert('请求失败'));
|
|
||||||
});
|
|
||||||
|
|
||||||
// 启动:等 wangEditor 加载完成后初始化
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initEditor);
|
|
||||||
} else {
|
|
||||||
initEditor();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
})();
|
// ---- 表单提交 ----
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const postId = document.getElementById('postId')?.value
|
||||||
|
const title = document.getElementById('postTitle')?.value.trim()
|
||||||
|
|
||||||
|
if (!title) { alert('请输入标题'); return }
|
||||||
|
if (!currentValue.trim()) { alert('请输入正文'); return }
|
||||||
|
|
||||||
|
const isEdit = !!postId
|
||||||
|
const url = isEdit ? '/api/posts/' + postId : '/api/posts'
|
||||||
|
const method = isEdit ? 'PUT' : 'POST'
|
||||||
|
|
||||||
|
const csrfMeta = document.querySelector('meta[name="csrf-token"]')
|
||||||
|
const csrf = csrfMeta ? csrfMeta.getAttribute('content') : ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': csrf,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ title, body: currentValue }),
|
||||||
|
})
|
||||||
|
const data = await resp.json()
|
||||||
|
|
||||||
|
if (data.success || data.code === 200) {
|
||||||
|
clearDraft()
|
||||||
|
const newPostId = data.data?.id || data.data?.ID || postId
|
||||||
|
window.location.href = newPostId ? '/posts/' + newPostId : '/posts'
|
||||||
|
} else {
|
||||||
|
alert(data.message || '操作失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('请求失败,请重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user