Initial commit: anime-minimalist personal blog
- React 18 + Vite + React Router v6 - Glass morphism cards with rounded corners - Dark/light theme toggle - Article CRUD with localStorage persistence - Search, admin panel (PIN: 2501), sakura petal canvas animation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
173
src/components/admin/MarkdownToolbar.jsx
Normal file
173
src/components/admin/MarkdownToolbar.jsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import styles from './MarkdownToolbar.module.css';
|
||||
|
||||
const TOOLS = [
|
||||
{ label: 'B', title: '加粗', wrap: (s) => `<strong>${s}</strong>` },
|
||||
{ label: 'I', title: '斜体', wrap: (s) => `<em>${s}</em>` },
|
||||
{ label: 'H2', title: '二级标题', wrap: (s) => `<h2>${s}</h2>` },
|
||||
{ label: '❝', title: '引用', wrap: (s) => `<blockquote>${s}</blockquote>` },
|
||||
{ label: '</>', title: '代码', wrap: (s) => `<code>${s}</code>` },
|
||||
];
|
||||
|
||||
export default function MarkdownToolbar({ textareaRef }) {
|
||||
const [imgPopover, setImgPopover] = useState(false);
|
||||
const [imgUrl, setImgUrl] = useState('');
|
||||
const [imgAlt, setImgAlt] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const insertAt = (html) => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
const pos = ta.selectionEnd;
|
||||
const before = ta.value.slice(0, pos);
|
||||
const after = ta.value.slice(pos);
|
||||
const next = before + '\n' + html + '\n' + after;
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
|
||||
setter.call(ta, next);
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
ta.focus();
|
||||
};
|
||||
|
||||
const apply = (wrapFn) => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const selected = ta.value.slice(start, end) || '内容';
|
||||
const wrapped = wrapFn(selected);
|
||||
const before = ta.value.slice(0, start);
|
||||
const after = ta.value.slice(end);
|
||||
const next = before + wrapped + after;
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
|
||||
setter.call(ta, next);
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
ta.focus();
|
||||
ta.setSelectionRange(start + wrapped.length, start + wrapped.length);
|
||||
};
|
||||
|
||||
const insertImgUrl = () => {
|
||||
if (!imgUrl.trim()) return;
|
||||
const alt = imgAlt.trim() || '图片';
|
||||
insertAt(`<img src="${imgUrl.trim()}" alt="${alt}" />`);
|
||||
setImgUrl('');
|
||||
setImgAlt('');
|
||||
setImgPopover(false);
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
alert('图片文件不能超过 2MB(base64 会使文章体积变大)');
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const src = ev.target.result;
|
||||
const alt = file.name.replace(/\.[^.]+$/, '') || '图片';
|
||||
insertAt(`<img src="${src}" alt="${alt}" />`);
|
||||
setUploading(false);
|
||||
setImgPopover(false);
|
||||
e.target.value = '';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.toolbarWrap}>
|
||||
<div className={styles.toolbar}>
|
||||
{TOOLS.map(t => (
|
||||
<button
|
||||
key={t.label}
|
||||
type="button"
|
||||
className={styles.tool}
|
||||
title={t.title}
|
||||
onMouseDown={(e) => { e.preventDefault(); apply(t.wrap); }}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className={styles.separator} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tool}
|
||||
title="插入图片"
|
||||
onMouseDown={(e) => { e.preventDefault(); setImgPopover(v => !v); }}
|
||||
>
|
||||
🖼
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{imgPopover && (
|
||||
<div className={`${styles.popover} glass-card`}>
|
||||
<p className={styles.popoverTitle}>插入图片</p>
|
||||
|
||||
<div className={styles.popoverSection}>
|
||||
<label className={styles.popoverLabel}>图片 URL</label>
|
||||
<div className={styles.urlRow}>
|
||||
<input
|
||||
className={styles.popoverInput}
|
||||
value={imgUrl}
|
||||
onChange={e => setImgUrl(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && insertImgUrl()}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
onClick={insertImgUrl}
|
||||
disabled={!imgUrl.trim()}
|
||||
>
|
||||
插入
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className={styles.popoverInput}
|
||||
value={imgAlt}
|
||||
onChange={e => setImgAlt(e.target.value)}
|
||||
placeholder="图片描述(alt,可选)"
|
||||
style={{ marginTop: 6 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.divider}>
|
||||
<span>或</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.popoverSection}>
|
||||
<label className={styles.popoverLabel}>上传本地图片(≤ 2MB,转 base64)</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
style={{ width: '100%', justifyContent: 'center' }}
|
||||
>
|
||||
{uploading ? '处理中…' : '选择图片文件'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.popoverClose}
|
||||
onClick={() => setImgPopover(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user