fix: 修复 WYSIWYG 清空内容 + 分屏工具栏 + XSS 防御(bulemondy消毒)
This commit is contained in:
3
go.mod
3
go.mod
@ -12,6 +12,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
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
|
||||||
@ -26,6 +27,7 @@ require (
|
|||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||||
@ -36,6 +38,7 @@ 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
|
||||||
|
|||||||
6
go.sum
6
go.sum
@ -1,3 +1,5 @@
|
|||||||
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
@ -40,6 +42,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
|
|||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
@ -64,6 +68,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
|||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@ -102,10 +103,11 @@ func (ctrl *PostController) ShowPage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "posts/show.html", common.BuildPageData(c, gin.H{
|
c.HTML(http.StatusOK, "posts/show.html", common.BuildPageData(c, gin.H{
|
||||||
"Title": post.Title,
|
"Title": post.Title,
|
||||||
"Post": post,
|
"Post": post,
|
||||||
"UID": uid,
|
"PostBodyHTML": template.HTML(post.BodyHTML),
|
||||||
"ExtraCSS": "/static/css/posts.css",
|
"UID": uid,
|
||||||
|
"ExtraCSS": "/static/css/posts.css",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,9 +8,14 @@ import (
|
|||||||
"metazone.cc/metalab/internal/config"
|
"metazone.cc/metalab/internal/config"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"github.com/microcosm-cc/bluemonday"
|
||||||
"github.com/yuin/goldmark"
|
"github.com/yuin/goldmark"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ucgPolicy 对 Goldmark 输出的 HTML 做安全消毒
|
||||||
|
// 移除 script / iframe / javscript: / data: 等危险内容
|
||||||
|
var ucgPolicy = bluemonday.UGCPolicy()
|
||||||
|
|
||||||
// postStore 帖子服务所需的最小仓储接口(ISP)
|
// postStore 帖子服务所需的最小仓储接口(ISP)
|
||||||
type postStore interface {
|
type postStore interface {
|
||||||
Create(post *model.Post) error
|
Create(post *model.Post) error
|
||||||
@ -44,13 +49,13 @@ func (s *PostService) auditEnabled() bool {
|
|||||||
return s.ss.IsAuditEnabled()
|
return s.ss.IsAuditEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderMarkdown 将 Markdown 渲染为 HTML
|
// renderMarkdown 将 Markdown 渲染为安全 HTML(Goldmark + bluemonday 消毒)
|
||||||
func (s *PostService) renderMarkdown(body string) (string, error) {
|
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 "", err
|
||||||
}
|
}
|
||||||
return buf.String(), nil
|
return ucgPolicy.Sanitize(buf.String()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create 创建帖子
|
// Create 创建帖子
|
||||||
|
|||||||
@ -17,10 +17,10 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="post-detail-body">
|
<div class="post-detail-body">
|
||||||
{{.Post.BodyHTML}}
|
{{.PostBodyHTML}}
|
||||||
{{/* 如果 BodyHTML 为空,直接显示原文 */}}
|
{{/* 如果 BodyHTML 为空,直接显示原文 */}}
|
||||||
</div>
|
</div>
|
||||||
{{if not .Post.BodyHTML}}
|
{{if not .PostBodyHTML}}
|
||||||
<div class="post-detail-body-raw">{{.Post.Body}}</div>
|
<div class="post-detail-body-raw">{{.Post.Body}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
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'); // textarea (source of truth)
|
var bodyEl = document.getElementById('postBody'); // textarea (唯一数据源)
|
||||||
var wysiwygEl = document.getElementById('wysiwygEditor'); // contenteditable
|
var wysiwygEl = document.getElementById('wysiwygEditor'); // contenteditable
|
||||||
var splitPrev = document.getElementById('splitPreview');
|
var splitPrev = document.getElementById('splitPreview');
|
||||||
var splitCont = splitPrev ? splitPrev.querySelector('.split-preview-content') : null;
|
var splitCont = splitPrev ? splitPrev.querySelector('.split-preview-content') : null;
|
||||||
@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
var isEdit = postId && postId.value;
|
var isEdit = postId && postId.value;
|
||||||
var currentMode = 'wysiwyg';
|
var currentMode = 'wysiwyg';
|
||||||
|
var wysiwygDirty = false; // WYSIWYG 是否已被用户编辑过(首次进入时不覆盖 textarea)
|
||||||
var syncTimer = null;
|
var syncTimer = null;
|
||||||
|
|
||||||
// ===================== HELPERS =====================
|
// ===================== HELPERS =====================
|
||||||
@ -22,19 +23,9 @@
|
|||||||
return meta ? meta.getAttribute('content') : '';
|
return meta ? meta.getAttribute('content') : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBodyValue() {
|
|
||||||
return currentMode === 'wysiwyg' ? bodyEl.value : bodyEl.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存到 textarea 并同步 WYSIWYG
|
|
||||||
function saveToTextarea(md) {
|
|
||||||
bodyEl.value = md;
|
|
||||||
if (currentMode === 'wysiwyg') syncMarkdownToWysiwyg();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================== MARKDOWN ↔ HTML ==================
|
// ================== MARKDOWN ↔ HTML ==================
|
||||||
|
|
||||||
// 通过 API 渲染 markdown 为 HTML
|
// 通过 API 渲染 markdown 为 HTML(服务端 Goldmark + bluemonday 消毒)
|
||||||
function renderMarkdown(md, callback) {
|
function renderMarkdown(md, callback) {
|
||||||
fetch('/api/posts/preview', {
|
fetch('/api/posts/preview', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -55,10 +46,7 @@
|
|||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
|
|
||||||
function walk(node) {
|
function walk(node) {
|
||||||
var md = '';
|
if (node.nodeType === 3) return node.textContent;
|
||||||
if (node.nodeType === 3) { // text
|
|
||||||
return node.textContent;
|
|
||||||
}
|
|
||||||
if (node.nodeType !== 1) return '';
|
if (node.nodeType !== 1) return '';
|
||||||
var tag = node.tagName.toLowerCase();
|
var tag = node.tagName.toLowerCase();
|
||||||
var children = '';
|
var children = '';
|
||||||
@ -82,8 +70,7 @@
|
|||||||
case 'pre':
|
case 'pre':
|
||||||
var code = node.querySelector('code');
|
var code = node.querySelector('code');
|
||||||
var lang = code ? (code.className.replace('language-','') || '') : '';
|
var lang = code ? (code.className.replace('language-','') || '') : '';
|
||||||
var text = node.textContent;
|
return '\n```' + lang + '\n' + node.textContent + '\n```\n';
|
||||||
return '\n```' + lang + '\n' + text + '\n```\n';
|
|
||||||
case 'code':
|
case 'code':
|
||||||
if (node.parentNode.tagName.toLowerCase() !== 'pre')
|
if (node.parentNode.tagName.toLowerCase() !== 'pre')
|
||||||
return '`' + children + '`';
|
return '`' + children + '`';
|
||||||
@ -97,9 +84,7 @@
|
|||||||
return '> ' + children.replace(/\n$/, '').replace(/\n/g, '\n> ') + '\n\n';
|
return '> ' + children.replace(/\n$/, '').replace(/\n/g, '\n> ') + '\n\n';
|
||||||
case 'hr': return '\n---\n';
|
case 'hr': return '\n---\n';
|
||||||
case 'img':
|
case 'img':
|
||||||
var src = node.getAttribute('src') || '';
|
return ' || '') + ')';
|
||||||
var alt = node.getAttribute('alt') || '';
|
|
||||||
return '';
|
|
||||||
case 'div':
|
case 'div':
|
||||||
case 'section':
|
case 'section':
|
||||||
return children;
|
return children;
|
||||||
@ -110,87 +95,36 @@
|
|||||||
return walk(el).replace(/\n{3,}/g, '\n\n').trim();
|
return walk(el).replace(/\n{3,}/g, '\n\n').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将 markdown 渲染到 WYSIWYG
|
// 将 markdown 从 textarea 渲染到 WYSIWYG
|
||||||
function syncMarkdownToWysiwyg() {
|
function syncMarkdownToWysiwyg() {
|
||||||
if (!bodyEl.value.trim()) {
|
if (!bodyEl.value.trim()) {
|
||||||
wysiwygEl.innerHTML = '';
|
wysiwygEl.innerHTML = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderMarkdown(bodyEl.value, function(html) {
|
renderMarkdown(bodyEl.value, function(html) {
|
||||||
var cur = wysiwygEl.innerHTML;
|
wysiwygEl.innerHTML = html;
|
||||||
// 只在真的变化时更新,避免闪烁
|
|
||||||
if (cur !== html) {
|
|
||||||
wysiwygEl.innerHTML = html;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// WYSIWYG 编辑后延迟同步回 textarea
|
// WYSIWYG 编辑后延迟将 HTML 转为 markdown 保存回 textarea
|
||||||
function debounceSync() {
|
function debounceSync() {
|
||||||
clearTimeout(syncTimer);
|
clearTimeout(syncTimer);
|
||||||
syncTimer = setTimeout(function() {
|
syncTimer = setTimeout(function() {
|
||||||
var md = htmlToMarkdown(wysiwygEl.innerHTML);
|
var md = htmlToMarkdown(wysiwygEl.innerHTML);
|
||||||
bodyEl.value = md;
|
if (md !== bodyEl.value) {
|
||||||
}, 500);
|
bodyEl.value = md;
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== MODE SWITCHING ====================
|
// markdown 保存回 textarea 后触发分屏预览更新
|
||||||
|
|
||||||
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() {
|
bodyEl.addEventListener('input', function() {
|
||||||
if (currentMode === 'split') {
|
if (currentMode === 'split') {
|
||||||
clearTimeout(splitTimer);
|
clearTimeout(splitTimer);
|
||||||
splitTimer = setTimeout(updateSplitPreview, 300);
|
splitTimer = setTimeout(updateSplitPreview, 300);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
var splitTimer = null;
|
||||||
|
|
||||||
function updateSplitPreview() {
|
function updateSplitPreview() {
|
||||||
if (!splitCont || !bodyEl.value.trim()) {
|
if (!splitCont || !bodyEl.value.trim()) {
|
||||||
@ -204,7 +138,65 @@
|
|||||||
|
|
||||||
// WYSIWYG 输入实时同步
|
// WYSIWYG 输入实时同步
|
||||||
wysiwygEl.addEventListener('input', function() {
|
wysiwygEl.addEventListener('input', function() {
|
||||||
if (currentMode === 'wysiwyg') debounceSync();
|
if (currentMode === 'wysiwyg') {
|
||||||
|
wysiwygDirty = true;
|
||||||
|
debounceSync();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== MODE SWITCHING ====================
|
||||||
|
|
||||||
|
function switchMode(mode) {
|
||||||
|
// 仅在离开 WYSIWYG 时同步 WYSIWYG → textarea
|
||||||
|
if (currentMode === 'wysiwyg' && mode !== 'wysiwyg' && wysiwygDirty) {
|
||||||
|
var md = htmlToMarkdown(wysiwygEl.innerHTML);
|
||||||
|
bodyEl.value = md;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentMode = mode;
|
||||||
|
wysiwygDirty = false; // 切换到新模式下重置 dirty 标记
|
||||||
|
|
||||||
|
// 更新标签
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具栏在 split 模式下也显示(源码风格操作)
|
||||||
|
if (toolbar) {
|
||||||
|
toolbar.style.display = (mode === 'source' || mode === 'split') ? '' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换面板
|
||||||
|
if (mode === 'wysiwyg') {
|
||||||
|
bodyEl.style.display = 'none';
|
||||||
|
wysiwygEl.style.display = '';
|
||||||
|
if (splitPrev) splitPrev.style.display = 'none';
|
||||||
|
syncMarkdownToWysiwyg();
|
||||||
|
wysiwygEl.focus();
|
||||||
|
} else if (mode === 'source') {
|
||||||
|
bodyEl.style.display = '';
|
||||||
|
wysiwygEl.style.display = 'none';
|
||||||
|
if (splitPrev) splitPrev.style.display = 'none';
|
||||||
|
bodyEl.focus();
|
||||||
|
} else if (mode === 'split') {
|
||||||
|
bodyEl.style.display = '';
|
||||||
|
wysiwygEl.style.display = 'none';
|
||||||
|
if (splitPrev) splitPrev.style.display = '';
|
||||||
|
bodyEl.focus();
|
||||||
|
updateSplitPreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modeTabs.forEach(function(tab) {
|
||||||
|
tab.addEventListener('click', function() {
|
||||||
|
switchMode(tab.dataset.mode);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==================== TOOLBAR ====================
|
// ==================== TOOLBAR ====================
|
||||||
@ -216,7 +208,8 @@
|
|||||||
var action = btn.dataset.action;
|
var action = btn.dataset.action;
|
||||||
if (currentMode === 'wysiwyg') {
|
if (currentMode === 'wysiwyg') {
|
||||||
execWysiwygAction(action);
|
execWysiwygAction(action);
|
||||||
} else if (currentMode === 'source') {
|
} else {
|
||||||
|
// source 和 split 模式都用源码工具栏(操作 textarea 中的 markdown)
|
||||||
execSourceAction(action);
|
execSourceAction(action);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -264,7 +257,7 @@
|
|||||||
document.execCommand('insertHorizontalRule', false, null);
|
document.execCommand('insertHorizontalRule', false, null);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
debounceSync();
|
wysiwygDirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// WYSIWYG 中包裹选中文本
|
// WYSIWYG 中包裹选中文本
|
||||||
@ -273,18 +266,13 @@
|
|||||||
if (!sel.rangeCount) return;
|
if (!sel.rangeCount) return;
|
||||||
var range = sel.getRangeAt(0);
|
var range = sel.getRangeAt(0);
|
||||||
var text = range.toString();
|
var text = range.toString();
|
||||||
if (!text) {
|
if (!text) text = 'code';
|
||||||
text = 'code';
|
range.deleteContents();
|
||||||
range.deleteContents();
|
range.insertNode(document.createTextNode(before + text + after));
|
||||||
range.insertNode(document.createTextNode(before + text + after));
|
wysiwygDirty = true;
|
||||||
} else {
|
|
||||||
range.deleteContents();
|
|
||||||
range.insertNode(document.createTextNode(before + text + after));
|
|
||||||
}
|
|
||||||
debounceSync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 源码模式下插入 markdown 语法
|
// 源码/分屏模式下插入 markdown 语法
|
||||||
function execSourceAction(action) {
|
function execSourceAction(action) {
|
||||||
var start = bodyEl.selectionStart;
|
var start = bodyEl.selectionStart;
|
||||||
var end = bodyEl.selectionEnd;
|
var end = bodyEl.selectionEnd;
|
||||||
@ -311,7 +299,10 @@
|
|||||||
bodyEl.value = bodyEl.value.substring(0, start) + replacement + bodyEl.value.substring(end);
|
bodyEl.value = bodyEl.value.substring(0, start) + replacement + bodyEl.value.substring(end);
|
||||||
bodyEl.focus();
|
bodyEl.focus();
|
||||||
var cursor = start + wrap.b.length + (text ? text.length : 0);
|
var cursor = start + wrap.b.length + (text ? text.length : 0);
|
||||||
bodyEl.setSelectionRange(cursor, cursor + (text ? wrap.a.length : 0));
|
bodyEl.setSelectionRange(cursor, cursor + (text ? 0 : replacement.length - wrap.b.length - wrap.a.length));
|
||||||
|
|
||||||
|
// 分屏模式下立刻更新预览
|
||||||
|
if (currentMode === 'split') updateSplitPreview();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -320,11 +311,10 @@
|
|||||||
form.addEventListener('submit', function(e) {
|
form.addEventListener('submit', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// WYSIWYG 模式下先同步
|
// WYSIWYG 模式下先同步到 textarea
|
||||||
if (currentMode === 'wysiwyg') {
|
if (currentMode === 'wysiwyg') {
|
||||||
var md = htmlToMarkdown(wysiwygEl.innerHTML);
|
var md = htmlToMarkdown(wysiwygEl.innerHTML);
|
||||||
bodyEl.value = md;
|
bodyEl.value = md;
|
||||||
currentMode = 'source';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var title = titleEl.value.trim();
|
var title = titleEl.value.trim();
|
||||||
@ -364,6 +354,7 @@
|
|||||||
|
|
||||||
// ==================== INIT ====================
|
// ==================== INIT ====================
|
||||||
if (bodyEl.value.trim()) {
|
if (bodyEl.value.trim()) {
|
||||||
switchMode('wysiwyg');
|
// 如果已有内容(编辑帖子),渲染到 WYSIWYG 但不写入 textarea
|
||||||
|
syncMarkdownToWysiwyg();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user