174 lines
5.5 KiB
React
174 lines
5.5 KiB
React
|
|
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>
|
|||
|
|
);
|
|||
|
|
}
|