feat: 增强编辑器链接对话框并统一图片插入选择器

链接支持网址/文字与站内搜索,新标签由全站设置控制;文章与评论共用已上传图片选择器。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-01 21:22:03 +08:00
parent 2159280af5
commit d5aa36416c
10 changed files with 1195 additions and 128 deletions

View File

@@ -419,6 +419,14 @@ export const api = {
fd.append('image', file);
return request<{ url: string }>('/api/uploads/image', { method: 'POST', body: fd, headers: {} });
},
/** 当前用户历史上传的帖子图片 */
myPostImages: (params?: { page?: number; size?: number }) => {
const q = new URLSearchParams();
if (params?.page) q.set('page', String(params.page));
if (params?.size) q.set('size', String(params.size));
const qs = q.toString();
return request<MediaListResult>(`/api/uploads/images${qs ? `?${qs}` : ''}`);
},
createPost: (data: {
board_id: string; title: string; content: string; tags?: string; post_type?: string;
poll_options?: string; bounty_points?: number; lottery_winner_count?: number;

View File

@@ -31,7 +31,6 @@ import {
insertMarkdownLink,
} from '../utils/markdownFormat';
import { countWords } from '../utils/text';
import { api } from '../api/client';
import { notify } from '@/lib/notify';
import { MembersOnly } from './editor/MembersOnlyExtension';
import { ReplyOnly } from './editor/ReplyOnlyExtension';
@@ -40,7 +39,8 @@ import { TabIndent } from './editor/TabIndentExtension';
import { ArticleImage, type ImageDisplay } from './editor/ArticleImageExtension';
import { ImageGroup, suggestImageGroupLayout } from './editor/ImageGroupExtension';
import { ClearFloatParagraph, ClearFloatSync } from './editor/ClearFloatParagraph';
import { ArticleLinkDialog } from './editor/ArticleLinkDialog';
import { ArticleLinkDialog, type ArticleLinkConfirm } from './editor/ArticleLinkDialog';
import { ArticleImagePickerDialog } from './editor/ArticleImagePickerDialog';
import { ArticleCodeBlockDialog } from './editor/ArticleCodeBlockDialog';
import { ArticleCodeBlock } from './editor/ArticleCodeBlockExtension';
import {
@@ -73,6 +73,7 @@ interface Props {
type EditorMode = 'rich' | 'markdown';
type LinkTarget = 'rich' | 'markdown';
type ImagePickerTarget = 'rich' | 'markdown';
type CodeBlockTarget = 'rich' | 'markdown';
type TableTarget = 'rich' | 'markdown';
@@ -167,34 +168,6 @@ function cycleHeading(editor: Editor) {
editor.chain().focus().toggleHeading({ level: 2 }).run();
}
/** 触发图片文件选择并上传(支持多选) */
async function uploadPostImageFiles(multiple = true): Promise<string[]> {
return new Promise(resolve => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
input.multiple = multiple;
input.onchange = async () => {
const files = [...(input.files ?? [])];
if (!files.length) {
resolve([]);
return;
}
const urls: string[] = [];
for (const file of files) {
try {
const { url } = await api.uploadPostImage(file);
urls.push(url);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '图片上传失败');
}
}
resolve(urls);
};
input.click();
});
}
/** 渲染工具栏按钮列表 */
function renderToolButtons(tools: ToolBtn[]) {
return tools.map((t, i) => (
@@ -228,7 +201,11 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
const [markdownSource, setMarkdownSource] = useState('');
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [linkDialogUrl, setLinkDialogUrl] = useState('');
const [linkDialogText, setLinkDialogText] = useState('');
const [linkDialogEditing, setLinkDialogEditing] = useState(false);
const [linkTarget, setLinkTarget] = useState<LinkTarget>('rich');
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const [imagePickerTarget, setImagePickerTarget] = useState<ImagePickerTarget>('rich');
const [codeBlockDialogOpen, setCodeBlockDialogOpen] = useState(false);
const [codeBlockTarget, setCodeBlockTarget] = useState<CodeBlockTarget>('rich');
const [codeBlockEditing, setCodeBlockEditing] = useState(false);
@@ -262,6 +239,11 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
openOnClick: false,
autolink: true,
defaultProtocol: 'https',
// 新标签由全站设置在展示层处理,编辑器不写 target/rel
HTMLAttributes: {
target: null,
rel: null,
},
}),
ArticleImage.configure({ inline: false, allowBase64: false }),
ImageGroup,
@@ -393,14 +375,33 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
const openLinkDialog = useCallback((target: LinkTarget) => {
if (target === 'rich') {
if (!editor) return;
const prev = editor.getAttributes('link').href as string | undefined;
setLinkDialogUrl(prev ?? '');
const { from, to, empty } = editor.state.selection;
let text = empty ? '' : editor.state.doc.textBetween(from, to, '');
let href = '';
let editing = false;
if (editor.isActive('link')) {
const attrs = editor.getAttributes('link');
href = (attrs.href as string) || '';
editing = Boolean(href);
editor.chain().focus().extendMarkRange('link').run();
const sel = editor.state.selection;
text = editor.state.doc.textBetween(sel.from, sel.to, '') || text;
}
setLinkDialogUrl(href);
setLinkDialogText(text);
setLinkDialogEditing(editing);
} else {
const textarea = markdownRef.current;
const selected = textarea
? markdownSource.slice(textarea.selectionStart, textarea.selectionEnd)
: '';
setLinkDialogUrl('');
setLinkDialogText(selected);
setLinkDialogEditing(false);
}
setLinkTarget(target);
setLinkDialogOpen(true);
}, [editor]);
}, [editor, markdownSource]);
const openCodeBlockDialog = useCallback((target: CodeBlockTarget) => {
setCodeBlockTarget(target);
@@ -477,11 +478,12 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
}
}, [editor]);
const applyLink = useCallback((url: string) => {
const applyLink = useCallback((payload: ArticleLinkConfirm) => {
const { url, text } = payload;
if (linkTarget === 'markdown') {
const textarea = markdownRef.current;
if (!textarea || !url) return;
insertMarkdownLink(textarea, markdownSource, url, handleMarkdownChange);
insertMarkdownLink(textarea, markdownSource, url, handleMarkdownChange, { text });
return;
}
if (!editor) return;
@@ -489,7 +491,31 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
// 新标签行为由全站 open_content_links_in_new_tab 在展示层处理
const linkAttrs = { href: url };
const { empty } = editor.state.selection;
const hasLink = editor.isActive('link');
if (!empty || hasLink) {
editor.chain().focus().extendMarkRange('link').setLink(linkAttrs).run();
const { from, to } = editor.state.selection;
const current = editor.state.doc.textBetween(from, to, '');
if (text && current !== text) {
editor.chain().focus().insertContentAt(
{ from, to },
{
type: 'text',
text,
marks: [{ type: 'link', attrs: linkAttrs }],
},
).run();
}
return;
}
editor.chain().focus().insertContent({
type: 'text',
text: text || '链接文字',
marks: [{ type: 'link', attrs: linkAttrs }],
}).run();
}, [editor, linkTarget, markdownSource, handleMarkdownChange]);
const removeLink = useCallback(() => {
@@ -497,16 +523,33 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
editor.chain().focus().extendMarkRange('link').unsetLink().run();
}, [editor]);
const setImage = useCallback(async () => {
if (!editor) return;
const urls = await uploadPostImageFiles(true);
const openImagePicker = useCallback((target: ImagePickerTarget) => {
setImagePickerTarget(target);
setImagePickerOpen(true);
}, []);
const applyImageUrls = useCallback((urls: string[]) => {
if (!urls.length) return;
if (imagePickerTarget === 'markdown') {
const textarea = markdownRef.current;
if (!textarea) return;
if (urls.length === 1) {
insertAtCursor(textarea, markdownSource, `\n\n![图片](${urls[0]})\n\n`, handleMarkdownChange);
return;
}
const layout = suggestImageGroupLayout(urls.length);
const imgs = urls.map(u => `<img src="${u}" alt="">`).join('');
const block = `\n\n<div data-image-group data-layout="${layout}" class="image-group image-group--${layout}">${imgs}</div>\n\n`;
insertAtCursor(textarea, markdownSource, block, handleMarkdownChange);
return;
}
if (!editor) return;
if (urls.length === 1) {
editor.chain().focus().setImage({ src: urls[0] }).run();
return;
}
editor.chain().focus().insertImageGroup(urls, suggestImageGroupLayout(urls.length)).run();
}, [editor]);
}, [editor, imagePickerTarget, markdownSource, handleMarkdownChange]);
const setImageDisplay = useCallback((display: ImageDisplay) => {
if (!editor) return;
@@ -596,21 +639,6 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
fn(textarea, markdownSource, handleMarkdownChange);
}, [markdownSource, handleMarkdownChange]);
const insertMarkdownImage = useCallback(async () => {
const textarea = markdownRef.current;
if (!textarea) return;
const urls = await uploadPostImageFiles(true);
if (!urls.length) return;
if (urls.length === 1) {
insertAtCursor(textarea, markdownSource, `\n\n![图片](${urls[0]})\n\n`, handleMarkdownChange);
return;
}
const layout = suggestImageGroupLayout(urls.length);
const imgs = urls.map(u => `<img src="${u}" alt="">`).join('');
const block = `\n\n<div data-image-group data-layout="${layout}" class="image-group image-group--${layout}">${imgs}</div>\n\n`;
insertAtCursor(textarea, markdownSource, block, handleMarkdownChange);
}, [markdownSource, handleMarkdownChange]);
const markdownPreviewHtml = useMemo(
() => sanitizeHtml(markdownToHtml(markdownSource)),
[markdownSource],
@@ -637,9 +665,9 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
{ icon: <LinkIcon size={15} />, title: '链接', active: editor.isActive('link'), action: () => openLinkDialog('rich') },
{
icon: <ImageIcon size={15} />,
title: '上传图片',
hint: '可多选;多张自动并排成图组',
action: setImage,
title: '图片',
hint: '上传、链接或从已上传中选择',
action: () => openImagePicker('rich'),
},
{
icon: <Columns2 size={15} />,
@@ -732,7 +760,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
}
return tools;
}, [editor, enableContentGates, openLinkDialog, openCodeBlockDialog, openTableDialog, setImage, wrapMembersOnly, wrapReplyOnly, wrapPointsOnly, wrapSelectedAsGroup, setImageDisplay]);
}, [editor, enableContentGates, openLinkDialog, openCodeBlockDialog, openTableDialog, openImagePicker, wrapMembersOnly, wrapReplyOnly, wrapPointsOnly, wrapSelectedAsGroup, setImageDisplay]);
const buildMarkdownTools = useCallback((): ToolBtn[] => {
const tools: ToolBtn[] = [
@@ -748,7 +776,12 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
{ icon: <Code size={15} />, title: '代码块', hint: '语言、行号与折叠', action: () => openCodeBlockDialog('markdown') },
{ icon: <TableIcon size={15} />, title: '表格', hint: '插入 GFM 管道表', action: () => openTableDialog('markdown') },
{ icon: <LinkIcon size={15} />, title: '链接', action: () => openLinkDialog('markdown') },
{ icon: <ImageIcon size={15} />, title: '上传图片', action: insertMarkdownImage },
{
icon: <ImageIcon size={15} />,
title: '图片',
hint: '上传、链接或从已上传中选择',
action: () => openImagePicker('markdown'),
},
];
if (enableContentGates) {
tools.push(
@@ -776,7 +809,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
);
}
return tools;
}, [enableContentGates, withMarkdown, openLinkDialog, openCodeBlockDialog, openTableDialog, insertMarkdownImage]);
}, [enableContentGates, withMarkdown, openLinkDialog, openCodeBlockDialog, openTableDialog, openImagePicker]);
const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools();
const words = mode === 'markdown'
@@ -897,8 +930,15 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
open={linkDialogOpen}
onOpenChange={setLinkDialogOpen}
initialUrl={linkDialogUrl}
initialText={linkDialogText}
editing={linkDialogEditing}
onConfirm={applyLink}
onRemove={linkTarget === 'rich' && linkDialogUrl ? removeLink : undefined}
onRemove={linkTarget === 'rich' && linkDialogEditing ? removeLink : undefined}
/>
<ArticleImagePickerDialog
open={imagePickerOpen}
onOpenChange={setImagePickerOpen}
onInsert={applyImageUrls}
/>
<ArticleCodeBlockDialog
open={codeBlockDialogOpen}

View File

@@ -12,11 +12,11 @@ import {
List, ListOrdered, Code, Link as LinkIcon, Image as ImageIcon,
} from 'lucide-react';
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
import { api } from '../api/client';
import { notify } from '@/lib/notify';
import { ArticleCodeBlock } from './editor/ArticleCodeBlockExtension';
import { ArticleCodeBlockDialog } from './editor/ArticleCodeBlockDialog';
import { ArticleImage } from './editor/ArticleImageExtension';
import { ArticleImagePickerDialog } from './editor/ArticleImagePickerDialog';
import { ArticleLinkDialog, type ArticleLinkConfirm } from './editor/ArticleLinkDialog';
import { TabIndent } from './editor/TabIndentExtension';
import type { CodeBlockInsertOptions } from '../utils/codeBlockOptions';
import { Tooltip } from './ui/Tooltip';
@@ -49,28 +49,6 @@ function isEditorEmpty(editor: Editor): boolean {
return !hasImage;
}
/** 触发图片文件选择并上传 */
async function uploadImageFiles(): Promise<string[]> {
return new Promise(resolve => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
input.multiple = false;
input.onchange = async () => {
const files = [...(input.files ?? [])];
if (!files.length) { resolve([]); return; }
try {
const { url } = await api.uploadPostImage(files[0]);
resolve([url]);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '图片上传失败');
resolve([]);
}
};
input.click();
});
}
const CommentEditor = forwardRef<CommentEditorHandle, Props>(function CommentEditor(
{ value, onChange, placeholder = '说点什么吧…' },
ref,
@@ -79,6 +57,11 @@ const CommentEditor = forwardRef<CommentEditorHandle, Props>(function CommentEdi
const lastValueRef = useRef(value);
const [, setTick] = useState(0);
const [showSticker, setShowSticker] = useState(false);
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [linkDialogUrl, setLinkDialogUrl] = useState('');
const [linkDialogText, setLinkDialogText] = useState('');
const [linkDialogEditing, setLinkDialogEditing] = useState(false);
const [codeBlockDialogOpen, setCodeBlockDialogOpen] = useState(false);
const [codeBlockEditing, setCodeBlockEditing] = useState(false);
const [codeBlockInitial, setCodeBlockInitial] = useState<CodeBlockInsertOptions | null>(null);
@@ -99,6 +82,11 @@ const CommentEditor = forwardRef<CommentEditorHandle, Props>(function CommentEdi
openOnClick: false,
autolink: true,
defaultProtocol: 'https',
// 新标签由全站设置在展示层处理,编辑器不写 target/rel
HTMLAttributes: {
target: null,
rel: null,
},
}),
ArticleImage.configure({ inline: true, allowBase64: true }),
Placeholder.configure({ placeholder }),
@@ -179,11 +167,14 @@ const CommentEditor = forwardRef<CommentEditorHandle, Props>(function CommentEdi
setShowSticker(false);
}, [editor]);
const setImage = useCallback(async () => {
if (!editor) return;
const urls = await uploadImageFiles();
if (!urls.length) return;
editor.chain().focus().setImage({ src: urls[0] }).run();
const applyImageUrls = useCallback((urls: string[]) => {
if (!editor || !urls.length) return;
// 评论为 inline 图,无图组:逐张插入并跟空格,便于光标落在右侧
const nodes = urls.flatMap(src => [
{ type: 'image' as const, attrs: { src } },
{ type: 'text' as const, text: ' ' },
]);
editor.chain().focus().insertContent(nodes).run();
}, [editor]);
const openCodeBlockDialog = useCallback(() => {
@@ -213,23 +204,70 @@ const CommentEditor = forwardRef<CommentEditorHandle, Props>(function CommentEdi
}).run();
}, [editor]);
const setLink = useCallback(() => {
const openLinkDialog = useCallback(() => {
if (!editor) return;
const prev = editor.getAttributes('link').href as string | undefined;
const url = window.prompt('链接地址', prev ?? 'https://');
if (url === null) return;
const { from, to, empty } = editor.state.selection;
let text = empty ? '' : editor.state.doc.textBetween(from, to, '');
let href = '';
let editing = false;
if (editor.isActive('link')) {
const attrs = editor.getAttributes('link');
href = (attrs.href as string) || '';
editing = Boolean(href);
editor.chain().focus().extendMarkRange('link').run();
const sel = editor.state.selection;
text = editor.state.doc.textBetween(sel.from, sel.to, '') || text;
}
setLinkDialogUrl(href);
setLinkDialogText(text);
setLinkDialogEditing(editing);
setLinkDialogOpen(true);
}, [editor]);
const applyLink = useCallback((payload: ArticleLinkConfirm) => {
if (!editor) return;
const { url, text } = payload;
if (!url) {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
// 新标签行为由全站 open_content_links_in_new_tab 在展示层处理
const linkAttrs = { href: url };
const { empty } = editor.state.selection;
const hasLink = editor.isActive('link');
if (!empty || hasLink) {
editor.chain().focus().extendMarkRange('link').setLink(linkAttrs).run();
const { from, to } = editor.state.selection;
const current = editor.state.doc.textBetween(from, to, '');
if (text && current !== text) {
editor.chain().focus().insertContentAt(
{ from, to },
{
type: 'text',
text,
marks: [{ type: 'link', attrs: linkAttrs }],
},
).run();
}
return;
}
editor.chain().focus().insertContent({
type: 'text',
text: text || '链接文字',
marks: [{ type: 'link', attrs: linkAttrs }],
}).run();
}, [editor]);
const removeLink = useCallback(() => {
if (!editor) return;
editor.chain().focus().extendMarkRange('link').unsetLink().run();
}, [editor]);
if (!editor) {
return <div className="comment-editor"><div className="article-editor-bar" /><div className="article-editor-body" /></div>;
}
const tools: { icon: React.ReactNode; title: string; active?: boolean; action: () => void; className?: string }[] = [
const tools: { icon: React.ReactNode; title: string; hint?: string; active?: boolean; action: () => void; className?: string }[] = [
{ icon: <Bold size={15} />, title: '加粗', active: editor.isActive('bold'), action: () => editor.chain().focus().toggleBold().run() },
{ icon: <Italic size={15} />, title: '斜体', active: editor.isActive('italic'), action: () => editor.chain().focus().toggleItalic().run() },
{ icon: <UnderlineIcon size={15} />, title: '下划线', active: editor.isActive('underline'), action: () => editor.chain().focus().toggleUnderline().run() },
@@ -237,8 +275,13 @@ const CommentEditor = forwardRef<CommentEditorHandle, Props>(function CommentEdi
{ icon: <List size={15} />, title: '无序列表', active: editor.isActive('bulletList'), action: () => editor.chain().focus().toggleBulletList().run() },
{ icon: <ListOrdered size={15} />, title: '有序列表', active: editor.isActive('orderedList'), action: () => editor.chain().focus().toggleOrderedList().run() },
{ icon: <Code size={15} />, title: '代码块', active: editor.isActive('codeBlock'), action: openCodeBlockDialog },
{ icon: <LinkIcon size={15} />, title: '链接', active: editor.isActive('link'), action: setLink },
{ icon: <ImageIcon size={15} />, title: '上传图片', action: setImage },
{ icon: <LinkIcon size={15} />, title: '链接', active: editor.isActive('link'), action: openLinkDialog },
{
icon: <ImageIcon size={15} />,
title: '图片',
hint: '上传、链接或从已上传中选择',
action: () => setImagePickerOpen(true),
},
{ icon: <span className="article-tool-btn__owo">OwO</span>, title: '表情 OwO', active: showSticker, action: () => setShowSticker(v => !v), className: 'article-tool-btn--owo' },
];
@@ -247,14 +290,14 @@ const CommentEditor = forwardRef<CommentEditorHandle, Props>(function CommentEdi
<div className="article-editor-bar">
<div className="article-editor-tools">
{tools.map((t, i) => (
<Tooltip key={i} content={t.title} side="bottom">
<Tooltip key={i} content={t.title} hint={t.hint} side="bottom">
<button
ref={i === tools.length - 1 ? stickerBtnRef : undefined}
type="button"
className={`article-tool-btn${t.active ? ' active' : ''}${t.className ? ` ${t.className}` : ''}`}
onMouseDown={e => e.preventDefault()}
onClick={t.action}
aria-label={t.title}
aria-label={t.hint ? `${t.title}${t.hint}` : t.title}
>
{t.icon}
</button>
@@ -268,6 +311,20 @@ const CommentEditor = forwardRef<CommentEditorHandle, Props>(function CommentEdi
</div>
</div>
{showSticker && <StickerPicker onSelect={insertSticker} />}
<ArticleLinkDialog
open={linkDialogOpen}
onOpenChange={setLinkDialogOpen}
initialUrl={linkDialogUrl}
initialText={linkDialogText}
editing={linkDialogEditing}
onConfirm={applyLink}
onRemove={linkDialogEditing ? removeLink : undefined}
/>
<ArticleImagePickerDialog
open={imagePickerOpen}
onOpenChange={setImagePickerOpen}
onInsert={applyImageUrls}
/>
<ArticleCodeBlockDialog
open={codeBlockDialogOpen}
onOpenChange={setCodeBlockDialogOpen}

View File

@@ -0,0 +1,351 @@
import { useCallback, useEffect, useRef, useState, type DragEvent } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { notify } from '@/lib/notify';
import { api } from '@/api/client';
import type { MediaItem } from '@/api/types';
import { toPostImageThumbSrc } from '@/utils/postContent';
import { Upload, Link2, Images, Loader2 } from 'lucide-react';
export type ImagePickerTarget = 'rich' | 'markdown';
type PickerTab = 'upload' | 'link' | 'gallery';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 上传或选中后插入一张或多张图片 URL */
onInsert: (urls: string[]) => void;
}
/** 校验可插入的图片地址:外链或站内绝对路径 */
export function isValidImageSrc(url: string): boolean {
const u = url.trim();
if (!u) return false;
if (u.startsWith('/') && !u.startsWith('//')) return true;
try {
const parsed = new URL(u);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
const ACCEPT = 'image/jpeg,image/png,image/gif,image/webp';
const PAGE_SIZE = 24;
/** 文章编辑器:统一图片插入(上传 / 链接 / 我的图片) */
export function ArticleImagePickerDialog({
open,
onOpenChange,
onInsert,
}: Props) {
const [tab, setTab] = useState<PickerTab>('upload');
const [url, setUrl] = useState('');
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [gallery, setGallery] = useState<MediaItem[]>([]);
const [galleryPage, setGalleryPage] = useState(1);
const [galleryTotalPages, setGalleryTotalPages] = useState(1);
const [galleryLoading, setGalleryLoading] = useState(false);
const [galleryLoaded, setGalleryLoaded] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const resetState = useCallback(() => {
setTab('upload');
setUrl('');
setUploading(false);
setDragOver(false);
setGallery([]);
setGalleryPage(1);
setGalleryTotalPages(1);
setGalleryLoading(false);
setGalleryLoaded(false);
setSelected(new Set());
}, []);
useEffect(() => {
if (open) resetState();
}, [open, resetState]);
const handleOpenChange = (next: boolean) => {
if (!next) resetState();
onOpenChange(next);
};
const finishInsert = (urls: string[]) => {
if (!urls.length) return;
onInsert(urls);
handleOpenChange(false);
};
const uploadFiles = async (files: File[]) => {
const images = files.filter(f => f.type.startsWith('image/'));
if (!images.length) {
notify.warning('请选择图片文件jpeg / png / gif / webp');
return;
}
setUploading(true);
const urls: string[] = [];
try {
for (const file of images) {
try {
const { url: uploaded } = await api.uploadPostImage(file);
urls.push(uploaded);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '图片上传失败');
}
}
if (urls.length) finishInsert(urls);
} finally {
setUploading(false);
}
};
const onFileChange = (list: FileList | null) => {
if (!list?.length) return;
void uploadFiles([...list]);
};
const onDrop = (e: DragEvent) => {
e.preventDefault();
setDragOver(false);
if (uploading) return;
void uploadFiles([...e.dataTransfer.files]);
};
const handleLinkInsert = () => {
const next = url.trim();
if (!next) {
notify.warning('请输入图片地址');
return;
}
if (!isValidImageSrc(next)) {
notify.warning('请使用 http(s) 外链或本站以 / 开头的路径');
return;
}
finishInsert([next]);
};
const loadGallery = useCallback(async (page: number, append: boolean) => {
setGalleryLoading(true);
try {
const res = await api.myPostImages({ page, size: PAGE_SIZE });
setGallery(prev => (append ? [...prev, ...res.files] : res.files));
setGalleryPage(res.page);
setGalleryTotalPages(res.total_pages);
setGalleryLoaded(true);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '加载图片失败');
setGalleryLoaded(true);
} finally {
setGalleryLoading(false);
}
}, []);
useEffect(() => {
if (open && tab === 'gallery' && !galleryLoaded && !galleryLoading) {
void loadGallery(1, false);
}
}, [open, tab, galleryLoaded, galleryLoading, loadGallery]);
const toggleSelect = (itemUrl: string) => {
setSelected(prev => {
const next = new Set(prev);
if (next.has(itemUrl)) next.delete(itemUrl);
else next.add(itemUrl);
return next;
});
};
const handleGalleryInsert = () => {
if (!selected.size) {
notify.warning('请先选择图片');
return;
}
// 保持网格出现顺序
const urls = gallery.filter(f => selected.has(f.url)).map(f => f.url);
finishInsert(urls);
};
const tabs: { id: PickerTab; label: string; icon: typeof Upload }[] = [
{ id: 'upload', label: '上传', icon: Upload },
{ id: 'link', label: '链接', icon: Link2 },
{ id: 'gallery', label: '我的图片', icon: Images },
];
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="article-image-picker">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="article-image-picker__tabs" role="tablist">
{tabs.map(t => {
const Icon = t.icon;
return (
<button
key={t.id}
type="button"
role="tab"
aria-selected={tab === t.id}
className={`article-image-picker__tab${tab === t.id ? ' is-active' : ''}`}
onClick={() => setTab(t.id)}
disabled={uploading}
>
<Icon size={14} aria-hidden />
{t.label}
</button>
);
})}
</div>
{tab === 'upload' && (
<div className="article-image-picker__panel">
<div
className={`article-image-picker__drop${dragOver ? ' is-dragover' : ''}${uploading ? ' is-busy' : ''}`}
onDragOver={e => {
e.preventDefault();
if (!uploading) setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={onDrop}
onClick={() => !uploading && fileInputRef.current?.click()}
role="button"
tabIndex={0}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
fileInputRef.current?.click();
}
}}
>
{uploading ? (
<>
<Loader2 size={28} className="article-image-picker__spin" />
<p></p>
</>
) : (
<>
<Upload size={28} />
<p></p>
<span> jpeg / png / gif / webp</span>
</>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept={ACCEPT}
multiple
className="sr-only"
onChange={e => {
onFileChange(e.target.files);
e.target.value = '';
}}
/>
</div>
)}
{tab === 'link' && (
<div className="article-image-picker__panel">
<Input
type="url"
value={url}
placeholder="https://… 或 /uploads/posts/…"
onChange={e => setUrl(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
handleLinkInsert();
}
}}
autoFocus
/>
<p className="article-image-picker__hint">
</p>
<DialogFooter className="article-image-picker__footer">
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
</Button>
<Button type="button" onClick={handleLinkInsert}>
</Button>
</DialogFooter>
</div>
)}
{tab === 'gallery' && (
<div className="article-image-picker__panel">
{galleryLoading && !gallery.length ? (
<div className="article-image-picker__empty">
<Loader2 size={22} className="article-image-picker__spin" />
<span></span>
</div>
) : !gallery.length ? (
<div className="article-image-picker__empty"></div>
) : (
<>
<div className="article-image-picker__grid">
{gallery.map(item => {
const thumb = toPostImageThumbSrc(item.url) || item.url;
const isSel = selected.has(item.url);
return (
<button
key={item.url}
type="button"
className={`article-image-picker__thumb${isSel ? ' is-selected' : ''}`}
title={item.name}
onClick={() => toggleSelect(item.url)}
>
<img src={thumb} alt={item.name} loading="lazy" />
</button>
);
})}
</div>
{galleryPage < galleryTotalPages && (
<div className="article-image-picker__more">
<Button
type="button"
variant="outline"
size="sm"
disabled={galleryLoading}
onClick={() => void loadGallery(galleryPage + 1, true)}
>
{galleryLoading ? '加载中…' : '加载更多'}
</Button>
</div>
)}
</>
)}
<DialogFooter className="article-image-picker__footer">
<span className="article-image-picker__selected-count">
{selected.size}
</span>
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
</Button>
<Button type="button" disabled={!selected.size} onClick={handleGalleryInsert}>
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -1,39 +1,165 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { notify } from '@/lib/notify';
import { api } from '@/api/client';
import type { PostItem, SitePageSummary } from '@/api/types';
import { pagePath, postPath } from '@/utils/permalink';
import { useForumLimits } from '@/hooks/useForumLimits';
import { Loader2 } from 'lucide-react';
export interface ArticleLinkConfirm {
url: string;
text: string;
}
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
initialUrl?: string;
onConfirm: (url: string) => void;
initialText?: string;
/** 是否正在编辑已有链接 */
editing?: boolean;
onConfirm: (payload: ArticleLinkConfirm) => void;
onRemove?: () => void;
}
/** 文章编辑器链接输入弹窗 */
type SiteHit = {
key: string;
title: string;
url: string;
kind: 'post' | 'page';
};
/** 校验可插入的链接地址:外链或站内绝对路径 */
export function isValidLinkHref(url: string): boolean {
const u = url.trim();
if (!u) return false;
if (u.startsWith('/') && !u.startsWith('//')) return true;
if (u.startsWith('#') && u.length > 1) return true;
try {
const parsed = new URL(u);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
const SEARCH_DEBOUNCE_MS = 300;
const POST_PAGE_SIZE = 12;
/** 文章/评论编辑器:插入或编辑链接(含站内内容搜索) */
export function ArticleLinkDialog({
open,
onOpenChange,
initialUrl = '',
initialText = '',
editing = false,
onConfirm,
onRemove,
}: Props) {
const [url, setUrl] = useState(initialUrl);
const { limits } = useForumLimits();
const [url, setUrl] = useState('');
const [text, setText] = useState('');
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [posts, setPosts] = useState<PostItem[]>([]);
const [pages, setPages] = useState<SitePageSummary[]>([]);
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (open) setUrl(initialUrl || 'https://');
}, [open, initialUrl]);
if (!open) return;
setUrl(initialUrl || '');
setText(initialText || '');
setQuery('');
setDebouncedQuery('');
}, [open, initialUrl, initialText]);
useEffect(() => {
if (!open) return;
const t = window.setTimeout(() => setDebouncedQuery(query.trim()), SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(t);
}, [query, open]);
const loadSiteHits = useCallback(async (keyword: string) => {
setLoading(true);
try {
const [postsRes, pagesRes] = await Promise.all([
api.posts({
page: 1,
size: POST_PAGE_SIZE,
sort: 'new',
...(keyword ? { keyword, title_only: '1' } : {}),
}),
api.pages(),
]);
setPosts(postsRes.posts || []);
let nextPages = pagesRes.pages || [];
if (keyword) {
const q = keyword.toLowerCase();
nextPages = nextPages.filter(
p => p.title.toLowerCase().includes(q) || p.slug.toLowerCase().includes(q),
);
}
setPages(nextPages.slice(0, POST_PAGE_SIZE));
setLoaded(true);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '加载站内内容失败');
setLoaded(true);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!open) return;
void loadSiteHits(debouncedQuery);
}, [open, debouncedQuery, loadSiteHits]);
const hits: SiteHit[] = useMemo(() => {
const postHits: SiteHit[] = posts.map(p => ({
key: `post-${p.id}`,
title: p.title,
url: postPath(p.id, limits),
kind: 'post',
}));
const pageHits: SiteHit[] = pages.map(p => ({
key: `page-${p.slug}`,
title: p.title,
url: pagePath(p.slug, limits),
kind: 'page',
}));
return [...postHits, ...pageHits];
}, [posts, pages, limits]);
const pickHit = (hit: SiteHit) => {
setUrl(hit.url);
setText(prev => (prev.trim() ? prev : hit.title));
};
const handleConfirm = () => {
onConfirm(url.trim());
const nextUrl = url.trim();
if (!nextUrl) {
notify.warning('请输入网址');
return;
}
if (!isValidLinkHref(nextUrl)) {
notify.warning('请使用 http(s) 外链或本站以 / 开头的路径');
return;
}
onConfirm({
url: nextUrl,
text: text.trim() || '链接文字',
});
onOpenChange(false);
};
@@ -41,25 +167,107 @@ export function ArticleLinkDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="article-link-dialog">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription> URL</DialogDescription>
<DialogTitle></DialogTitle>
</DialogHeader>
<Input
type="url"
value={url}
placeholder="https://"
onChange={e => setUrl(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
handleConfirm();
}
}}
autoFocus
/>
<section className="article-link-dialog__section">
<h3 className="article-link-dialog__section-title"> URL</h3>
<div className="article-link-dialog__field">
<Label htmlFor="article-link-url"></Label>
<Input
id="article-link-url"
type="url"
value={url}
placeholder="https://… 或 /post/123"
onChange={e => setUrl(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
handleConfirm();
}
}}
autoFocus
/>
</div>
<div className="article-link-dialog__field">
<Label htmlFor="article-link-text"></Label>
<Input
id="article-link-text"
value={text}
placeholder="显示在正文中的文字"
onChange={e => setText(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
handleConfirm();
}
}}
/>
</div>
</section>
<section className="article-link-dialog__section">
<h3 className="article-link-dialog__section-title"></h3>
<div className="article-link-dialog__field">
<Label htmlFor="article-link-search"></Label>
<Input
id="article-link-search"
type="search"
value={query}
placeholder="搜索帖子或单页…"
onChange={e => setQuery(e.target.value)}
/>
</div>
<div className="article-link-dialog__hits">
<p className="article-link-dialog__hits-hint">
{debouncedQuery
? `搜索「${debouncedQuery}`
: '未指定搜索条件。自动显示最近发布条目。'}
</p>
{loading && !loaded ? (
<div className="article-link-dialog__hits-empty">
<Loader2 size={18} className="article-link-dialog__spin" />
<span></span>
</div>
) : !hits.length ? (
<div className="article-link-dialog__hits-empty"></div>
) : (
<ul className="article-link-dialog__hit-list">
{hits.map(hit => (
<li key={hit.key}>
<button
type="button"
className={`article-link-dialog__hit${url === hit.url ? ' is-selected' : ''}`}
onClick={() => pickHit(hit)}
>
<span className="article-link-dialog__hit-title">{hit.title}</span>
<span className="article-link-dialog__hit-kind">
{hit.kind === 'post' ? '帖子' : '单页'}
</span>
</button>
</li>
))}
</ul>
)}
{loading && loaded ? (
<div className="article-link-dialog__hits-loading">
<Loader2 size={14} className="article-link-dialog__spin" />
</div>
) : null}
</div>
</section>
<DialogFooter className="article-link-dialog__footer">
{onRemove && initialUrl ? (
<Button type="button" variant="outline" onClick={() => { onRemove(); onOpenChange(false); }}>
{editing && onRemove ? (
<Button
type="button"
variant="outline"
className="article-link-dialog__remove"
onClick={() => {
onRemove();
onOpenChange(false);
}}
>
</Button>
) : null}
@@ -67,7 +275,7 @@ export function ArticleLinkDialog({
</Button>
<Button type="button" onClick={handleConfirm}>
{editing ? '更新链接' : '添加链接'}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -10956,8 +10956,270 @@ button.profile-stat:hover strong {
color: var(--color-text-3);
}
.article-link-dialog {
width: min(520px, 92vw);
max-width: min(520px, 92vw);
max-height: min(85vh, 720px);
display: flex;
flex-direction: column;
gap: 0;
overflow-y: auto;
}
.article-link-dialog__section {
display: flex;
flex-direction: column;
gap: 12px;
padding: 4px 0 16px;
}
.article-link-dialog__section + .article-link-dialog__section {
border-top: 1px solid var(--j13-border-light);
padding-top: 16px;
}
.article-link-dialog__section-title {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
}
.article-link-dialog__field {
display: flex;
flex-direction: column;
gap: 6px;
}
.article-link-dialog__field label {
font-size: 12px;
color: var(--color-text-2);
}
.article-link-dialog__hits {
position: relative;
border: 1px solid var(--j13-border);
border-radius: 10px;
background: var(--j13-bg-block-muted, var(--color-fill-3));
overflow: hidden;
}
.article-link-dialog__hits-hint {
margin: 0;
padding: 10px 12px 6px;
font-size: 12px;
color: var(--color-text-3);
}
.article-link-dialog__hit-list {
list-style: none;
margin: 0;
padding: 0 0 6px;
max-height: 220px;
overflow-y: auto;
}
.article-link-dialog__hit {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 9px 12px;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
color: var(--color-text-1);
transition: background 0.12s;
}
.article-link-dialog__hit:hover {
background: var(--j13-bg-surface, #fff);
}
.article-link-dialog__hit.is-selected {
background: var(--j13-green-bg);
}
.article-link-dialog__hit-title {
flex: 1;
min-width: 0;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.article-link-dialog__hit-kind {
flex-shrink: 0;
font-size: 11px;
color: var(--color-text-3);
padding: 2px 7px;
border-radius: 999px;
background: color-mix(in srgb, var(--j13-border) 55%, transparent);
}
.article-link-dialog__hits-empty {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 88px;
font-size: 13px;
color: var(--color-text-3);
}
.article-link-dialog__hits-loading {
position: absolute;
top: 10px;
right: 12px;
color: var(--color-text-3);
}
.article-link-dialog__spin {
animation: article-link-dialog-spin 0.8s linear infinite;
}
@keyframes article-link-dialog-spin {
to { transform: rotate(360deg); }
}
.article-link-dialog__footer {
gap: 8px;
margin-top: 4px;
}
.article-link-dialog__remove {
margin-right: auto;
}
.article-image-picker {
width: min(520px, 92vw);
max-width: min(520px, 92vw);
}
.article-image-picker__tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
margin: 4px 0 12px;
padding: 4px;
border: 1px solid var(--j13-border);
border-radius: 10px;
background: var(--j13-bg-block-muted, var(--color-fill-3));
}
.article-image-picker__tab {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
padding: 8px 10px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--color-text-2);
font-size: 13px;
font-weight: 500;
line-height: 1.2;
cursor: pointer;
transition: background 0.12s, border-color 0.12s, color 0.12s, box-shadow 0.12s;
}
.article-image-picker__tab:hover:not(:disabled) {
background: var(--j13-bg-surface, #fff);
border-color: color-mix(in srgb, var(--j13-green) 22%, var(--j13-border));
color: var(--color-text-1);
}
.article-image-picker__tab.is-active {
background: var(--j13-bg-surface, #fff);
border-color: color-mix(in srgb, var(--j13-green) 40%, transparent);
color: var(--j13-green-hover, var(--j13-green));
font-weight: 600;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06), 0 0 0 1px color-mix(in srgb, var(--j13-green) 18%, transparent);
}
.article-image-picker__tab:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.article-image-picker__panel {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 160px;
}
.article-image-picker__drop {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 180px;
padding: 24px 16px;
border: 1.5px dashed var(--j13-border);
border-radius: 10px;
background: var(--j13-bg-block-muted, var(--color-fill-3));
color: var(--color-text-2);
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.article-image-picker__drop p {
margin: 0;
font-size: 14px;
color: var(--color-text-1);
}
.article-image-picker__drop span {
font-size: 12px;
color: var(--color-text-3);
}
.article-image-picker__drop.is-dragover {
border-color: var(--j13-green);
background: var(--j13-green-bg);
}
.article-image-picker__drop.is-busy {
cursor: wait;
pointer-events: none;
}
.article-image-picker__hint {
margin: 0;
font-size: 12px;
color: var(--color-text-3);
}
.article-image-picker__footer {
gap: 8px;
align-items: center;
}
.article-image-picker__selected-count {
margin-right: auto;
font-size: 13px;
color: var(--color-text-2);
}
.article-image-picker__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
max-height: 280px;
overflow-y: auto;
padding: 2px;
}
.article-image-picker__thumb {
aspect-ratio: 1;
padding: 0;
border: 2px solid var(--j13-border);
border-radius: 8px;
overflow: hidden;
background: var(--j13-bg-block-muted, var(--color-fill-3));
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.article-image-picker__thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.article-image-picker__thumb.is-selected {
border-color: var(--j13-green);
box-shadow: 0 0 0 1px var(--j13-green);
}
.article-image-picker__empty {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 140px;
color: var(--color-text-3);
font-size: 13px;
}
.article-image-picker__more {
display: flex;
justify-content: center;
}
.article-image-picker__spin {
animation: article-image-picker-spin 0.8s linear infinite;
}
@keyframes article-image-picker-spin {
to { transform: rotate(360deg); }
}
/* 代码块插入弹窗 */

View File

@@ -122,9 +122,12 @@ export function insertMarkdownLink(
value: string,
url: string,
onChange: ChangeHandler,
opts?: { text?: string },
) {
const { selectionStart, selectionEnd } = textarea;
const selected = value.slice(selectionStart, selectionEnd) || '链接文字';
const selected = opts?.text?.trim()
|| value.slice(selectionStart, selectionEnd)
|| '链接文字';
const insert = `[${selected}](${url})`;
const next = value.slice(0, selectionStart) + insert + value.slice(selectionEnd);
applyTextareaChange(