feat(widget): 为评论编辑器添加Markdown预览功能
- 引入 marked 和 DOMPurify 依赖以支持 Markdown 解析与安全渲染 - 在评论表单和回复编辑器中添加预览按钮,支持实时预览 - 新增预览区域样式,优化操作按钮布局间距 - 修复表单状态初始化问题,确保预览功能稳定
This commit is contained in:
@@ -11,5 +11,9 @@
|
||||
"terser": "^5.44.1",
|
||||
"vite": "^6.0.11",
|
||||
"vite-plugin-css-injected-by-js": "^3.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.3.1",
|
||||
"marked": "^17.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { Component } from './Component.js';
|
||||
import { AdminAuthModal } from './AdminAuthModal.js';
|
||||
import { auth } from '../utils/auth.js';
|
||||
import { renderMarkdown } from '../utils/markdown.js';
|
||||
|
||||
export class CommentForm extends Component {
|
||||
/**
|
||||
@@ -20,8 +21,17 @@ export class CommentForm extends Component {
|
||||
*/
|
||||
constructor(container, props = {}) {
|
||||
super(container, props);
|
||||
// 确保 localForm 的各个字段都有初始值
|
||||
const initialForm = props.form || {};
|
||||
this.state = {
|
||||
localForm: { ...props.form },
|
||||
localForm: {
|
||||
name: initialForm.name || '',
|
||||
email: initialForm.email || '',
|
||||
url: initialForm.url || '',
|
||||
content: initialForm.content || '',
|
||||
},
|
||||
activeTab: 'write', // 'write' | 'preview'
|
||||
showPreview: false,
|
||||
};
|
||||
this.modal = null;
|
||||
}
|
||||
@@ -93,7 +103,6 @@ export class CommentForm extends Component {
|
||||
this.createElement('textarea', {
|
||||
className: `cwd-form-textarea ${formErrors.content ? 'cwd-input-error' : ''}`,
|
||||
attributes: {
|
||||
placeholder: '',
|
||||
rows: 4,
|
||||
disabled: submitting,
|
||||
onInput: (e) => this.handleFieldChange('content', e.target.value),
|
||||
@@ -109,6 +118,16 @@ export class CommentForm extends Component {
|
||||
this.createElement('div', {
|
||||
className: 'cwd-form-actions',
|
||||
children: [
|
||||
this.createElement('button', {
|
||||
className: `cwd-btn cwd-btn-secondary cwd-btn-preview ${this.state.showPreview ? 'cwd-btn-active' : ''}`,
|
||||
attributes: {
|
||||
type: 'button',
|
||||
disabled: submitting || !localForm.content?.trim(),
|
||||
style: localForm.content?.trim() ? '' : 'display:none;',
|
||||
onClick: () => this.togglePreview(),
|
||||
},
|
||||
text: this.state.showPreview ? '关闭' : '预览',
|
||||
}),
|
||||
this.createElement('button', {
|
||||
className: 'cwd-btn cwd-btn-primary',
|
||||
attributes: {
|
||||
@@ -119,6 +138,23 @@ export class CommentForm extends Component {
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
// 预览区域
|
||||
...(this.state.showPreview && localForm.content
|
||||
? [
|
||||
this.createElement('div', {
|
||||
className: 'cwd-preview-container',
|
||||
children: [
|
||||
this.createTextElement('div', '预览效果:', 'cwd-preview-title'),
|
||||
this.createElement('div', {
|
||||
className: 'cwd-preview-content cwd-comment-content',
|
||||
// 直接设置 innerHTML
|
||||
html: renderMarkdown(localForm.content),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -174,6 +210,24 @@ export class CommentForm extends Component {
|
||||
submitBtn.textContent = submitting ? '提交中...' : '提交评论';
|
||||
}
|
||||
|
||||
// 更新预览按钮状态
|
||||
const previewBtn = this.elements.root.querySelector('.cwd-btn-preview');
|
||||
if (previewBtn) {
|
||||
const hasContent = !!localForm.content?.trim();
|
||||
previewBtn.disabled = submitting || !hasContent;
|
||||
previewBtn.style.display = hasContent ? '' : 'none';
|
||||
if (!hasContent) {
|
||||
this.state.showPreview = false;
|
||||
const previewContainer = this.elements.root.querySelector('.cwd-preview-container');
|
||||
if (previewContainer) {
|
||||
previewContainer.remove();
|
||||
}
|
||||
previewBtn.textContent = '预览';
|
||||
} else {
|
||||
previewBtn.textContent = this.state.showPreview ? '关闭' : '预览';
|
||||
}
|
||||
}
|
||||
|
||||
// 更新输入框禁用状态
|
||||
const inputs = this.elements.root.querySelectorAll('input, textarea');
|
||||
inputs.forEach((input) => {
|
||||
@@ -272,6 +326,11 @@ export class CommentForm extends Component {
|
||||
if (contentTextarea) contentTextarea.value = form.content || '';
|
||||
}
|
||||
|
||||
togglePreview() {
|
||||
this.state.showPreview = !this.state.showPreview;
|
||||
this.render();
|
||||
}
|
||||
|
||||
handleFieldChange(field, value) {
|
||||
this.state.localForm[field] = value;
|
||||
if (this.props.onFieldChange) {
|
||||
@@ -280,13 +339,29 @@ export class CommentForm extends Component {
|
||||
// 实时更新按钮状态
|
||||
if (this.elements.root) {
|
||||
this.updateFormState();
|
||||
// 实时更新预览内容
|
||||
if (field === 'content' && this.state.showPreview) {
|
||||
this.updatePreviewContent(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatePreviewContent(content) {
|
||||
const previewContent = this.elements.root.querySelector('.cwd-preview-content');
|
||||
if (previewContent) {
|
||||
previewContent.innerHTML = renderMarkdown(content);
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
const email = this.state.localForm.email?.trim();
|
||||
const adminEmail = this.props.adminEmail;
|
||||
if (adminEmail && email && email === adminEmail && !auth.hasToken()) {
|
||||
this.showAuthModal();
|
||||
return;
|
||||
}
|
||||
if (this.props.onSubmit) {
|
||||
// 提交当前表单数据
|
||||
this.props.onSubmit(this.state.localForm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { Component } from './Component.js';
|
||||
import { renderMarkdown } from '../utils/markdown.js';
|
||||
|
||||
export class ReplyEditor extends Component {
|
||||
/**
|
||||
@@ -23,7 +24,8 @@ export class ReplyEditor extends Component {
|
||||
this.state = {
|
||||
content: props.content || '',
|
||||
// 如果没有昵称或邮箱,显示用户信息输入框。且一旦显示,在当前编辑器生命周期内保持显示,避免输入过程中消失
|
||||
showUserInfo: !currentUser || !currentUser.name || !currentUser.email
|
||||
showUserInfo: !currentUser || !currentUser.name || !currentUser.email,
|
||||
showPreview: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,10 +72,10 @@ export class ReplyEditor extends Component {
|
||||
className: 'cwd-reply-textarea',
|
||||
attributes: {
|
||||
rows: 3,
|
||||
placeholder: '写下你的回复...',
|
||||
placeholder: '支持 Markdown 格式',
|
||||
disabled: this.props.submitting,
|
||||
onInput: (e) => this.handleInput(e)
|
||||
}
|
||||
onInput: (e) => this.handleInput(e),
|
||||
},
|
||||
}),
|
||||
|
||||
// 错误提示
|
||||
@@ -98,6 +100,15 @@ export class ReplyEditor extends Component {
|
||||
this.createElement('div', {
|
||||
className: 'cwd-reply-actions',
|
||||
children: [
|
||||
this.createElement('button', {
|
||||
className: `cwd-btn cwd-btn-secondary cwd-btn-small cwd-btn-preview ${this.state.showPreview ? 'cwd-btn-active' : ''}`,
|
||||
attributes: {
|
||||
type: 'button',
|
||||
disabled: this.props.submitting || !this.state.content.trim(),
|
||||
onClick: () => this.togglePreview(),
|
||||
},
|
||||
text: this.state.showPreview ? '关闭' : '预览',
|
||||
}),
|
||||
this.createElement('button', {
|
||||
className: 'cwd-btn cwd-btn-primary cwd-btn-small',
|
||||
attributes: {
|
||||
@@ -117,7 +128,24 @@ export class ReplyEditor extends Component {
|
||||
text: '取消'
|
||||
})
|
||||
]
|
||||
})
|
||||
}),
|
||||
|
||||
// 预览区域
|
||||
...(this.state.showPreview && this.state.content
|
||||
? [
|
||||
this.createElement('div', {
|
||||
className: 'cwd-preview-container',
|
||||
children: [
|
||||
this.createTextElement('div', '预览效果:', 'cwd-preview-title'),
|
||||
this.createElement('div', {
|
||||
className: 'cwd-preview-content cwd-comment-content',
|
||||
// 直接设置 innerHTML
|
||||
html: renderMarkdown(this.state.content),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]
|
||||
});
|
||||
|
||||
@@ -159,16 +187,41 @@ export class ReplyEditor extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
togglePreview() {
|
||||
this.state.showPreview = !this.state.showPreview;
|
||||
this.render();
|
||||
}
|
||||
|
||||
handleInput(e) {
|
||||
this.state.content = e.target.value;
|
||||
|
||||
// 更新提交按钮的禁用状态
|
||||
const submitBtn = this.elements.root?.querySelector('.cwd-btn-primary');
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = this.props.submitting || !this.state.content.trim();
|
||||
}
|
||||
|
||||
// 更新预览按钮的禁用状态
|
||||
const previewBtn = this.elements.root?.querySelector('.cwd-btn-preview');
|
||||
if (previewBtn) {
|
||||
previewBtn.disabled = this.props.submitting || !this.state.content.trim();
|
||||
}
|
||||
|
||||
if (this.props.onUpdate) {
|
||||
this.props.onUpdate(this.state.content);
|
||||
}
|
||||
|
||||
// 实时更新预览内容
|
||||
if (this.state.showPreview) {
|
||||
this.updatePreviewContent(this.state.content);
|
||||
}
|
||||
}
|
||||
|
||||
updatePreviewContent(content) {
|
||||
const previewContent = this.elements.root?.querySelector('.cwd-preview-content');
|
||||
if (previewContent) {
|
||||
previewContent.innerHTML = renderMarkdown(content);
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit() {
|
||||
|
||||
@@ -509,6 +509,8 @@ export class CWDComments {
|
||||
// 更新表单组件
|
||||
if (this.commentForm) {
|
||||
this.commentForm.state.localForm = { ...this.store.store.getState().form };
|
||||
this.commentForm.state.showPreview = false;
|
||||
this.commentForm.render();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,33 @@
|
||||
color: var(--cwd-text, #24292f);
|
||||
}
|
||||
|
||||
.cwd-preview-container {
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid var(--cwd-border, #d0d7de);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.cwd-preview-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--cwd-text-secondary, #6e7781);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.cwd-preview-content {
|
||||
padding: 12px;
|
||||
min-height: 80px;
|
||||
background: var(--cwd-bg, #ffffff);
|
||||
border: 1px solid var(--cwd-border, #d0d7de);
|
||||
border-radius: var(--cwd-radius, 6px);
|
||||
}
|
||||
|
||||
.cwd-btn-active {
|
||||
background: var(--cwd-bg-hover, #eaeef2);
|
||||
color: var(--cwd-text, #24292f);
|
||||
border-color: var(--cwd-border-active, #8c959f);
|
||||
}
|
||||
|
||||
.cwd-form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -170,6 +197,7 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cwd-btn {
|
||||
|
||||
34
docs/widget/src/utils/markdown.js
Normal file
34
docs/widget/src/utils/markdown.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
// 配置 marked
|
||||
try {
|
||||
marked.setOptions({
|
||||
gfm: true, // 启用 GitHub Flavored Markdown
|
||||
breaks: true, // 启用换行符转 <br>
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to configure marked:', e);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Markdown 为 HTML,并进行净化
|
||||
* @param {string} content Markdown 内容
|
||||
* @returns {string} 净化后的 HTML
|
||||
*/
|
||||
export function renderMarkdown(content) {
|
||||
if (!content) return '';
|
||||
try {
|
||||
const html = marked.parse(content);
|
||||
// marked.parse can return a Promise if async is enabled, but we are using sync mode
|
||||
// Just in case, handle potential Promise (though unlikely with current config)
|
||||
if (html instanceof Promise) {
|
||||
console.warn('marked.parse returned a Promise. Async markdown rendering is not fully supported in this sync flow.');
|
||||
return '';
|
||||
}
|
||||
return DOMPurify.sanitize(html);
|
||||
} catch (error) {
|
||||
console.error('Markdown rendering error:', error);
|
||||
return DOMPurify.sanitize(content); // Fallback to plain text (sanitized)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user