diff --git a/docs/widget/package.json b/docs/widget/package.json index 44b580c..f0b8aa5 100644 --- a/docs/widget/package.json +++ b/docs/widget/package.json @@ -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" } } diff --git a/docs/widget/src/components/CommentForm.js b/docs/widget/src/components/CommentForm.js index 275f2e6..9d86e08 100644 --- a/docs/widget/src/components/CommentForm.js +++ b/docs/widget/src/components/CommentForm.js @@ -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); } } diff --git a/docs/widget/src/components/ReplyEditor.js b/docs/widget/src/components/ReplyEditor.js index 8d02cdf..e4b4c9a 100644 --- a/docs/widget/src/components/ReplyEditor.js +++ b/docs/widget/src/components/ReplyEditor.js @@ -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() { diff --git a/docs/widget/src/core/CWDComments.js b/docs/widget/src/core/CWDComments.js index 1e56114..0cfe2cc 100644 --- a/docs/widget/src/core/CWDComments.js +++ b/docs/widget/src/core/CWDComments.js @@ -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(); } } } diff --git a/docs/widget/src/styles/main.css b/docs/widget/src/styles/main.css index bd1f92e..5777b18 100644 --- a/docs/widget/src/styles/main.css +++ b/docs/widget/src/styles/main.css @@ -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 { diff --git a/docs/widget/src/utils/markdown.js b/docs/widget/src/utils/markdown.js new file mode 100644 index 0000000..e166d00 --- /dev/null +++ b/docs/widget/src/utils/markdown.js @@ -0,0 +1,34 @@ +import { marked } from 'marked'; +import DOMPurify from 'dompurify'; + +// 配置 marked +try { + marked.setOptions({ + gfm: true, // 启用 GitHub Flavored Markdown + breaks: true, // 启用换行符转
+ }); +} 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) + } +}