支持公开用户主页、帖子图缩略图与编辑器图组排版。
新增用户签名与活动统计、图片灯箱;正文按需生成缩略图;TipTap 支持多图分组与环绕排版,并注入站点标题避免刷新闪烁。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,7 +5,6 @@ import { useEditor, EditorContent, type Editor } from '@tiptap/react';
|
||||
import { TextSelection } from '@tiptap/pm/state';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import DOMPurify from 'dompurify';
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIcon, Code, Quote,
|
||||
List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
|
||||
FileCode, PenLine, Maximize2, Minimize2,
|
||||
Columns2, PanelLeft, PanelRight, StretchHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
|
||||
import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent';
|
||||
@@ -30,6 +30,9 @@ import { api } from '../api/client';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { MembersOnly } from './editor/MembersOnlyExtension';
|
||||
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 { Tooltip } from './ui/Tooltip';
|
||||
|
||||
@@ -85,25 +88,29 @@ function cycleHeading(editor: Editor) {
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run();
|
||||
}
|
||||
|
||||
/** 触发图片文件选择并上传 */
|
||||
async function uploadPostImageFile(): Promise<string | null> {
|
||||
/** 触发图片文件选择并上传(支持多选) */
|
||||
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 file = input.files?.[0];
|
||||
if (!file) {
|
||||
resolve(null);
|
||||
const files = [...(input.files ?? [])];
|
||||
if (!files.length) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { url } = await api.uploadPostImage(file);
|
||||
resolve(url);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '图片上传失败');
|
||||
resolve(null);
|
||||
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();
|
||||
});
|
||||
@@ -149,14 +156,18 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [2, 3, 4, 5, 6] },
|
||||
paragraph: false,
|
||||
}),
|
||||
ClearFloatParagraph,
|
||||
ClearFloatSync,
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
defaultProtocol: 'https',
|
||||
}),
|
||||
Image.configure({ inline: false, allowBase64: false }),
|
||||
ArticleImage.configure({ inline: false, allowBase64: false }),
|
||||
ImageGroup,
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === 'paragraph' && node.parent?.type.name === 'membersOnly') {
|
||||
@@ -302,9 +313,24 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
|
||||
const setImage = useCallback(async () => {
|
||||
if (!editor) return;
|
||||
const url = await uploadPostImageFile();
|
||||
if (url) {
|
||||
editor.chain().focus().setImage({ src: url }).run();
|
||||
const urls = await uploadPostImageFiles(true);
|
||||
if (!urls.length) return;
|
||||
if (urls.length === 1) {
|
||||
editor.chain().focus().setImage({ src: urls[0] }).run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().insertImageGroup(urls, suggestImageGroupLayout(urls.length)).run();
|
||||
}, [editor]);
|
||||
|
||||
const setImageDisplay = useCallback((display: ImageDisplay) => {
|
||||
if (!editor) return;
|
||||
editor.chain().focus().setImageDisplay(display).run();
|
||||
}, [editor]);
|
||||
|
||||
const wrapSelectedAsGroup = useCallback(() => {
|
||||
if (!editor) return;
|
||||
if (!editor.commands.wrapImagesInGroup()) {
|
||||
notify.warning('请先点击或靠近至少两张连续图片,再合并为图组');
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
@@ -356,9 +382,16 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
const insertMarkdownImage = useCallback(async () => {
|
||||
const textarea = markdownRef.current;
|
||||
if (!textarea) return;
|
||||
const url = await uploadPostImageFile();
|
||||
if (!url) return;
|
||||
insertAtCursor(textarea, markdownSource, `\n\n\n\n`, handleMarkdownChange);
|
||||
const urls = await uploadPostImageFiles(true);
|
||||
if (!urls.length) return;
|
||||
if (urls.length === 1) {
|
||||
insertAtCursor(textarea, markdownSource, `\n\n\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(
|
||||
@@ -368,7 +401,11 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
|
||||
const buildRichTools = useCallback((): ToolBtn[] => {
|
||||
if (!editor) return [];
|
||||
return [
|
||||
const imageActive = editor.isActive('image');
|
||||
const groupActive = editor.isActive('imageGroup');
|
||||
const currentDisplay = (editor.getAttributes('image').display as ImageDisplay) || 'default';
|
||||
|
||||
const tools: ToolBtn[] = [
|
||||
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', active: editor.isActive('heading'), action: () => cycleHeading(editor) },
|
||||
{ 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() },
|
||||
@@ -380,17 +417,57 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
{ 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: () => editor.chain().focus().toggleCodeBlock().run() },
|
||||
{ icon: <LinkIcon size={15} />, title: '链接', active: editor.isActive('link'), action: () => openLinkDialog('rich') },
|
||||
{ icon: <ImageIcon size={15} />, title: '上传图片', action: setImage },
|
||||
{
|
||||
icon: <LockKeyhole size={15} />,
|
||||
title: '登录可见',
|
||||
hint: '插入或包裹;区块内 Ctrl+Enter 退出',
|
||||
active: editor.isActive('membersOnly'),
|
||||
className: 'article-tool-btn--members',
|
||||
action: wrapMembersOnly,
|
||||
icon: <ImageIcon size={15} />,
|
||||
title: '上传图片',
|
||||
hint: '可多选;多张自动并排成图组',
|
||||
action: setImage,
|
||||
},
|
||||
{
|
||||
icon: <Columns2 size={15} />,
|
||||
title: '合并为图组',
|
||||
hint: '点击一张图后合并其附近连续图片(无需框选多张)',
|
||||
active: groupActive,
|
||||
action: wrapSelectedAsGroup,
|
||||
},
|
||||
];
|
||||
}, [editor, openLinkDialog, setImage, wrapMembersOnly]);
|
||||
|
||||
if (imageActive && !groupActive) {
|
||||
tools.push(
|
||||
{
|
||||
icon: <StretchHorizontal size={15} />,
|
||||
title: '通栏大图',
|
||||
active: currentDisplay === 'wide',
|
||||
action: () => setImageDisplay(currentDisplay === 'wide' ? 'default' : 'wide'),
|
||||
},
|
||||
{
|
||||
icon: <PanelLeft size={15} />,
|
||||
title: '左绕排',
|
||||
hint: '图片居左,文字环绕',
|
||||
active: currentDisplay === 'float-left',
|
||||
action: () => setImageDisplay(currentDisplay === 'float-left' ? 'default' : 'float-left'),
|
||||
},
|
||||
{
|
||||
icon: <PanelRight size={15} />,
|
||||
title: '右绕排',
|
||||
hint: '图片居右,文字环绕',
|
||||
active: currentDisplay === 'float-right',
|
||||
action: () => setImageDisplay(currentDisplay === 'float-right' ? 'default' : 'float-right'),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
tools.push({
|
||||
icon: <LockKeyhole size={15} />,
|
||||
title: '登录可见',
|
||||
hint: '插入或包裹;区块内 Ctrl+Enter 退出',
|
||||
active: editor.isActive('membersOnly'),
|
||||
className: 'article-tool-btn--members',
|
||||
action: wrapMembersOnly,
|
||||
});
|
||||
|
||||
return tools;
|
||||
}, [editor, openLinkDialog, setImage, wrapMembersOnly, wrapSelectedAsGroup, setImageDisplay]);
|
||||
|
||||
const buildMarkdownTools = useCallback((): ToolBtn[] => [
|
||||
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) },
|
||||
@@ -463,12 +540,10 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
|
||||
<div className="article-editor-status">
|
||||
<div className="article-editor-status-meta">
|
||||
<span>{words} 字</span>
|
||||
<span className="article-editor-status-sep">·</span>
|
||||
<span>
|
||||
{mode === 'rich' ? '富文本' : 'Markdown 源码'}
|
||||
{' · Tab 缩进 / Shift+Tab 回退'}
|
||||
{mode === 'rich' ? ' · 登录可见内 Ctrl+Enter 退出' : ''}
|
||||
<span className="article-editor-wordcount">{words} 字</span>
|
||||
<span className="article-editor-status-sep" aria-hidden>·</span>
|
||||
<span className="article-editor-mode-label">
|
||||
{mode === 'rich' ? '所见即所得' : 'Markdown'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -498,12 +573,12 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="article-editor-view-btn"
|
||||
className={`article-editor-view-btn${fullscreen ? ' active' : ''}`}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => setFullscreen(v => !v)}
|
||||
>
|
||||
{fullscreen ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
|
||||
<span>{fullscreen ? '退出全屏' : '全屏'}</span>
|
||||
<span>{fullscreen ? '退出' : '全屏'}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { getCroppedAvatarFile } from '../utils/avatarCrop';
|
||||
import { getCroppedAvatarFile, validateAvatarOutput } from '../utils/avatarCrop';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
imageSrc: string | null;
|
||||
fileName?: string;
|
||||
/** 裁剪后文件体积上限(MB) */
|
||||
maxMb: number;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (file: File) => void;
|
||||
}
|
||||
@@ -25,6 +27,7 @@ export default function AvatarCropDialog({
|
||||
open,
|
||||
imageSrc,
|
||||
fileName,
|
||||
maxMb,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: Props) {
|
||||
@@ -50,6 +53,11 @@ export default function AvatarCropDialog({
|
||||
setConfirming(true);
|
||||
try {
|
||||
const file = await getCroppedAvatarFile(imageSrc, croppedAreaPixels, fileName);
|
||||
const sizeErr = validateAvatarOutput(file, maxMb);
|
||||
if (sizeErr) {
|
||||
notify.error(sizeErr);
|
||||
return;
|
||||
}
|
||||
onConfirm(file);
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '../utils/comment';
|
||||
import { isTimeDiffSignificant } from '../utils/content';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import UserLink from './UserLink';
|
||||
|
||||
function canManageComment(c: Comment, user?: User | null): boolean {
|
||||
if (!user) return false;
|
||||
@@ -97,20 +98,40 @@ function CommentItem({
|
||||
id={`floor-${c.floor}`}
|
||||
className={`waline-comment ${nested ? 'nested' : ''} ${isHighlighted ? 'highlight' : ''}`}
|
||||
>
|
||||
<div className={`waline-comment-avatar ${guest && !c.user?.avatar ? 'guest' : ''}`}>
|
||||
{c.user?.avatar ? (
|
||||
<img src={c.user.avatar} alt="" loading="lazy" decoding="async" />
|
||||
) : (
|
||||
commentInitial(c)
|
||||
)}
|
||||
</div>
|
||||
{!guest && c.user_id ? (
|
||||
<UserLink
|
||||
user={c.user ?? { id: c.user_id, nickname: nick }}
|
||||
showAvatar={false}
|
||||
showName={false}
|
||||
className={`waline-comment-avatar user-link--avatar-only${!c.user?.avatar ? ' guest' : ''}`}
|
||||
>
|
||||
{c.user?.avatar ? (
|
||||
<img src={c.user.avatar} alt="" loading="lazy" decoding="async" />
|
||||
) : (
|
||||
commentInitial(c)
|
||||
)}
|
||||
</UserLink>
|
||||
) : (
|
||||
<div className={`waline-comment-avatar ${!c.user?.avatar ? 'guest' : ''}`}>
|
||||
{c.user?.avatar ? (
|
||||
<img src={c.user.avatar} alt="" loading="lazy" decoding="async" />
|
||||
) : (
|
||||
commentInitial(c)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="waline-comment-main">
|
||||
<div className="waline-comment-head">
|
||||
{c.guest_url ? (
|
||||
{guest && c.guest_url ? (
|
||||
<a href={c.guest_url} target="_blank" rel="noopener noreferrer" className="waline-comment-author">
|
||||
{nick}
|
||||
</a>
|
||||
) : !guest && c.user_id ? (
|
||||
<UserLink
|
||||
user={c.user ?? { id: c.user_id, nickname: nick }}
|
||||
className="waline-comment-author"
|
||||
/>
|
||||
) : (
|
||||
<span className="waline-comment-author">{nick}</span>
|
||||
)}
|
||||
|
||||
58
frontend/src/components/ImageLightbox.tsx
Normal file
58
frontend/src/components/ImageLightbox.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface Props {
|
||||
src: string | null;
|
||||
alt?: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** 帖子正文图片灯箱:展示原图,点击遮罩 / Esc / 关闭按钮退出 */
|
||||
export default function ImageLightbox({ src, alt = '', open, onClose }: Props) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
window.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open || !src) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="image-lightbox" role="dialog" aria-modal="true" aria-label="查看原图">
|
||||
<button
|
||||
type="button"
|
||||
className="image-lightbox-backdrop"
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="image-lightbox-close"
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={20} aria-hidden />
|
||||
</button>
|
||||
<div className="image-lightbox-stage">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || '原图'}
|
||||
className="image-lightbox-img"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<p className="image-lightbox-hint">点击空白处关闭</p>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMemo, useCallback, useEffect } from 'react';
|
||||
import { useMemo, useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { renderPostContentHtml } from '../utils/postContent';
|
||||
import { extractHeadingsFromHtml, type PostHeading } from '../utils/postHeadings';
|
||||
import { loginPath, registerPath } from '../utils/authRedirect';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { notify } from '@/lib/notify';
|
||||
import ImageLightbox from './ImageLightbox';
|
||||
|
||||
interface Props {
|
||||
html: string;
|
||||
@@ -14,7 +15,7 @@ interface Props {
|
||||
onHeadingsChange?: (headings: PostHeading[]) => void;
|
||||
}
|
||||
|
||||
/** 帖子正文渲染(含会员专属区块、代码块美化) */
|
||||
/** 帖子正文渲染(含会员专属区块、代码块美化、图片灯箱) */
|
||||
export default function PostContent({
|
||||
html,
|
||||
isLoggedIn,
|
||||
@@ -23,6 +24,8 @@ export default function PostContent({
|
||||
}: Props) {
|
||||
const nav = useNavigate();
|
||||
const { limits } = useForumLimits();
|
||||
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
||||
const [lightboxAlt, setLightboxAlt] = useState('');
|
||||
|
||||
const prepared = useMemo(() => {
|
||||
const rendered = renderPostContentHtml(html, isLoggedIn, {
|
||||
@@ -38,6 +41,13 @@ export default function PostContent({
|
||||
onHeadingsChange?.(prepared.headings);
|
||||
}, [prepared.headings, onHeadingsChange]);
|
||||
|
||||
const openLightbox = useCallback((img: HTMLImageElement) => {
|
||||
const full = img.getAttribute('data-full') || img.currentSrc || img.src;
|
||||
if (!full) return;
|
||||
setLightboxSrc(full);
|
||||
setLightboxAlt(img.getAttribute('alt') || '');
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback(async (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-members-login]')) {
|
||||
@@ -50,6 +60,12 @@ export default function PostContent({
|
||||
nav(registerPath());
|
||||
return;
|
||||
}
|
||||
const zoomImg = target.closest<HTMLImageElement>('img.post-content-img--zoomable');
|
||||
if (zoomImg) {
|
||||
e.preventDefault();
|
||||
openLightbox(zoomImg);
|
||||
return;
|
||||
}
|
||||
const copyBtn = target.closest<HTMLElement>('[data-code-copy]');
|
||||
if (copyBtn) {
|
||||
e.preventDefault();
|
||||
@@ -68,13 +84,30 @@ export default function PostContent({
|
||||
notify.error('复制失败');
|
||||
}
|
||||
}
|
||||
}, [nav]);
|
||||
}, [nav, openLightbox]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
const zoomImg = (e.target as HTMLElement).closest?.('img.post-content-img--zoomable');
|
||||
if (!zoomImg || !(zoomImg instanceof HTMLImageElement)) return;
|
||||
e.preventDefault();
|
||||
openLightbox(zoomImg);
|
||||
}, [openLightbox]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
dangerouslySetInnerHTML={{ __html: prepared.html }}
|
||||
/>
|
||||
<>
|
||||
<div
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
dangerouslySetInnerHTML={{ __html: prepared.html }}
|
||||
/>
|
||||
<ImageLightbox
|
||||
src={lightboxSrc}
|
||||
alt={lightboxAlt}
|
||||
open={!!lightboxSrc}
|
||||
onClose={() => setLightboxSrc(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo } from 'react';
|
||||
import { MessageCircle, ThumbsUp } from 'lucide-react';
|
||||
import BoardBadge from '@/components/BoardBadge';
|
||||
import PinnedIcon from '@/components/PinnedIcon';
|
||||
import UserLink from '@/components/UserLink';
|
||||
import type { PostItem } from '../api/types';
|
||||
import type { FeedSort } from './FeedSortBar';
|
||||
import { formatTime } from '../utils/content';
|
||||
@@ -22,13 +23,33 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
|
||||
const commentCount = post.comment_count ?? 0;
|
||||
const likeCount = post.like_count ?? 0;
|
||||
|
||||
const openPost = () => onSelect(post.id);
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openPost();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button type="button" className="post-row" onClick={() => onSelect(post.id)}>
|
||||
<div className="post-avatar">
|
||||
<div
|
||||
className="post-row"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={openPost}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<UserLink
|
||||
user={post.user}
|
||||
showAvatar={false}
|
||||
showName={false}
|
||||
stopPropagation
|
||||
className="post-avatar user-link--avatar-only"
|
||||
>
|
||||
{post.user?.avatar
|
||||
? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" />
|
||||
: initial}
|
||||
</div>
|
||||
</UserLink>
|
||||
<div className="post-body">
|
||||
<div className="post-title">
|
||||
{post.pinned && <PinnedIcon className="mr-1.5" />}
|
||||
@@ -36,7 +57,7 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
|
||||
</div>
|
||||
<div className="post-meta">
|
||||
{post.board && <BoardBadge board={post.board} />}
|
||||
<span>{post.user?.nickname || '匿名'}</span>
|
||||
<UserLink user={post.user} stopPropagation className="post-meta-user" />
|
||||
<span>{timeLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,7 +71,7 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
|
||||
{likeCount}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { PostRevision } from '../api/types';
|
||||
import PostContent from './PostContent';
|
||||
import { formatDateTime } from '../utils/content';
|
||||
import { moveTabIndex, useOverlayA11y } from '../hooks/useOverlayA11y';
|
||||
import UserLink from './UserLink';
|
||||
import {
|
||||
type PostSnapshot,
|
||||
htmlToDiffText,
|
||||
@@ -273,7 +274,14 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
|
||||
</div>
|
||||
<span className="post-revision-item-title">{entry.rev.title}</span>
|
||||
<span className="post-revision-item-meta">
|
||||
{entry.rev.editor?.nickname ?? '未知'} · {formatDateTime(entry.rev.created_at)}
|
||||
<UserLink
|
||||
user={entry.rev.editor
|
||||
? entry.rev.editor
|
||||
: { nickname: '未知' }}
|
||||
stopPropagation
|
||||
className="post-revision-editor-link"
|
||||
/>
|
||||
{' · '}{formatDateTime(entry.rev.created_at)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
@@ -291,7 +299,12 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
|
||||
<div className="post-revision-main-head">
|
||||
<div>
|
||||
<span className="post-revision-main-editor">
|
||||
{selected.rev.editor?.nickname ?? '未知'}
|
||||
<UserLink
|
||||
user={selected.rev.editor
|
||||
? selected.rev.editor
|
||||
: { nickname: '未知' }}
|
||||
className="post-revision-editor-link"
|
||||
/>
|
||||
</span>
|
||||
<span className="post-revision-main-time">
|
||||
{formatDateTime(selected.rev.created_at)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { PostItem, RecentComment, TagCount } from '../api/types';
|
||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import TagCloud from './TagCloud';
|
||||
import UserLink from './UserLink';
|
||||
|
||||
interface Props {
|
||||
hot: PostItem[];
|
||||
@@ -110,21 +111,39 @@ export default function RightPanel({
|
||||
) : commentList.length === 0 ? (
|
||||
<div className="widget-empty">暂无评论</div>
|
||||
) : commentList.map(item => (
|
||||
<button
|
||||
<div
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="widget-item widget-item--comment"
|
||||
title={item.post_title ? `${item.author} · ${item.post_title}` : item.author}
|
||||
onClick={() => onPostClick(item.post_id)}
|
||||
>
|
||||
<span className="widget-item-avatar" aria-hidden>
|
||||
{item.avatar
|
||||
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
|
||||
: (item.author?.[0] || '?')}
|
||||
</span>
|
||||
<span className="widget-item-title">{item.excerpt}</span>
|
||||
<span className="widget-item-time">{item.created_at}</span>
|
||||
</button>
|
||||
{item.user_id ? (
|
||||
<UserLink
|
||||
user={{ id: item.user_id, nickname: item.author, avatar: item.avatar }}
|
||||
showAvatar={false}
|
||||
showName={false}
|
||||
stopPropagation
|
||||
className="widget-item-avatar user-link--avatar-only"
|
||||
>
|
||||
{item.avatar
|
||||
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
|
||||
: (item.author?.[0] || '?')}
|
||||
</UserLink>
|
||||
) : (
|
||||
<span className="widget-item-avatar" aria-hidden>
|
||||
{item.avatar
|
||||
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
|
||||
: (item.author?.[0] || '?')}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="widget-item-comment-main"
|
||||
onClick={() => onPostClick(item.post_id)}
|
||||
>
|
||||
<span className="widget-item-title">{item.excerpt}</span>
|
||||
<span className="widget-item-time">{item.created_at}</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { getBoardThemeIndex } from '../utils/boardTheme';
|
||||
import ArticleOutline from './ArticleOutline';
|
||||
|
||||
// 内容页不参与左侧栏高亮(非 feed 浏览上下文)
|
||||
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile'];
|
||||
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile', '/user/'];
|
||||
|
||||
export function isNeutralSidebarRoute(pathname: string): boolean {
|
||||
return NEUTRAL_SIDEBAR_PREFIXES.some(prefix => pathname.startsWith(prefix));
|
||||
|
||||
77
frontend/src/components/UserLink.tsx
Normal file
77
frontend/src/components/UserLink.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { MouseEvent, ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { userPath } from '../utils/userPath';
|
||||
|
||||
export type UserLinkUser = {
|
||||
id?: number;
|
||||
nickname?: string;
|
||||
avatar?: string;
|
||||
} | null | undefined;
|
||||
|
||||
interface Props {
|
||||
user: UserLinkUser;
|
||||
className?: string;
|
||||
avatarClassName?: string;
|
||||
nameClassName?: string;
|
||||
showAvatar?: boolean;
|
||||
showName?: boolean;
|
||||
/** 嵌在可点击父级内时阻止冒泡(如帖子列表行) */
|
||||
stopPropagation?: boolean;
|
||||
children?: ReactNode;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/** 统一用户入口:点击进入 /user/:id 公开主页 */
|
||||
export default function UserLink({
|
||||
user,
|
||||
className,
|
||||
avatarClassName,
|
||||
nameClassName,
|
||||
showAvatar = false,
|
||||
showName = true,
|
||||
stopPropagation = false,
|
||||
children,
|
||||
title,
|
||||
}: Props) {
|
||||
const id = user?.id && user.id > 0 ? user.id : 0;
|
||||
const nick = user?.nickname?.trim() || '匿名';
|
||||
const initial = nick[0] || '?';
|
||||
const tip = title || nick;
|
||||
|
||||
const onClick = stopPropagation
|
||||
? (e: MouseEvent) => { e.stopPropagation(); }
|
||||
: undefined;
|
||||
|
||||
const body = children ?? (
|
||||
<>
|
||||
{showAvatar && (
|
||||
<span className={cn('user-link-avatar', avatarClassName)} aria-hidden>
|
||||
{user?.avatar
|
||||
? <img src={user.avatar} alt="" loading="lazy" decoding="async" />
|
||||
: initial}
|
||||
</span>
|
||||
)}
|
||||
{showName && <span className={cn('user-link-name', nameClassName)}>{nick}</span>}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<span className={cn('user-link user-link--static', className)} title={tip}>
|
||||
{body}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={userPath(id)}
|
||||
className={cn('user-link', className)}
|
||||
title={tip}
|
||||
onClick={onClick}
|
||||
>
|
||||
{body}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
67
frontend/src/components/editor/ArticleImageExtension.tsx
Normal file
67
frontend/src/components/editor/ArticleImageExtension.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import Image from '@tiptap/extension-image';
|
||||
import { mergeAttributes } from '@tiptap/core';
|
||||
|
||||
/** 单图展示形态(对齐 Notion / Medium 常见选项) */
|
||||
export type ImageDisplay = 'default' | 'wide' | 'float-left' | 'float-right';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
articleImage: {
|
||||
setImageDisplay: (display: ImageDisplay) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文章图片:在 TipTap Image 上增加 data-display,
|
||||
* 支持通栏 / 左绕排 / 右绕排。
|
||||
*/
|
||||
export const ArticleImage = Image.extend({
|
||||
name: 'image',
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
display: {
|
||||
default: 'default' satisfies ImageDisplay,
|
||||
parseHTML: (el) =>
|
||||
(el.getAttribute('data-display') as ImageDisplay) || 'default',
|
||||
renderHTML: (attrs) => {
|
||||
const display = (attrs.display as ImageDisplay) || 'default';
|
||||
if (display === 'default') return {};
|
||||
return {
|
||||
'data-display': display,
|
||||
class: `article-img article-img--${display}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const display = (HTMLAttributes['data-display'] as ImageDisplay) || 'default';
|
||||
const cls = [
|
||||
HTMLAttributes.class,
|
||||
'article-img',
|
||||
display !== 'default' ? `article-img--${display}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return [
|
||||
'img',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
class: cls || undefined,
|
||||
draggable: false,
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
setImageDisplay: (display) => ({ commands }) =>
|
||||
commands.updateAttributes(this.name, { display }),
|
||||
};
|
||||
},
|
||||
});
|
||||
144
frontend/src/components/editor/ClearFloatParagraph.ts
Normal file
144
frontend/src/components/editor/ClearFloatParagraph.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import Paragraph from '@tiptap/extension-paragraph';
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import type { Node as ProseMirrorNode, NodeType } from '@tiptap/pm/model';
|
||||
import type { Transaction } from '@tiptap/pm/state';
|
||||
|
||||
function isFloatImage(node: ProseMirrorNode): boolean {
|
||||
if (node.type.name !== 'image') return false;
|
||||
const display = node.attrs.display as string | undefined;
|
||||
return display === 'float-left' || display === 'float-right';
|
||||
}
|
||||
|
||||
function isBlankParagraph(node: ProseMirrorNode): boolean {
|
||||
if (node.type.name !== 'paragraph') return false;
|
||||
if (node.content.size === 0) return true;
|
||||
let blank = true;
|
||||
node.forEach(child => {
|
||||
if (child.type.name === 'hardBreak') return;
|
||||
if (child.isText && !(child.text || '').replace(/\u00a0/g, ' ').trim()) return;
|
||||
blank = false;
|
||||
});
|
||||
return blank;
|
||||
}
|
||||
|
||||
function isHardClearBlock(node: ProseMirrorNode): boolean {
|
||||
const name = node.type.name;
|
||||
if (name === 'image') return !isFloatImage(node);
|
||||
return name === 'imageGroup'
|
||||
|| name === 'heading'
|
||||
|| name === 'horizontalRule'
|
||||
|| name === 'codeBlock'
|
||||
|| name === 'blockquote'
|
||||
|| name === 'table'
|
||||
|| name === 'bulletList'
|
||||
|| name === 'orderedList';
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算顶层块是否应带 clearFloat。
|
||||
* - 双空行后自动打开
|
||||
* - 一旦打开,只要仍在绕排图之后就保持(避免源码往返丢空段后失效)
|
||||
*/
|
||||
function computeClearFloatFlags(doc: ProseMirrorNode): boolean[] {
|
||||
const flags: boolean[] = [];
|
||||
let seenFloat = false;
|
||||
let blankRun = 0;
|
||||
|
||||
doc.forEach(node => {
|
||||
if (isFloatImage(node)) {
|
||||
seenFloat = true;
|
||||
blankRun = 0;
|
||||
flags.push(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHardClearBlock(node)) {
|
||||
seenFloat = false;
|
||||
blankRun = 0;
|
||||
flags.push(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!seenFloat) {
|
||||
flags.push(false);
|
||||
blankRun = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBlankParagraph(node)) {
|
||||
blankRun += 1;
|
||||
flags.push(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const already = Boolean(node.attrs.clearFloat);
|
||||
flags.push(blankRun >= 2 || already);
|
||||
blankRun = 0;
|
||||
});
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
function syncClearFloatAttrs(doc: ProseMirrorNode, paragraphType: NodeType, tr: Transaction): boolean {
|
||||
const flags = computeClearFloatFlags(doc);
|
||||
let modified = false;
|
||||
let index = 0;
|
||||
|
||||
doc.forEach((node, offset) => {
|
||||
const should = flags[index] ?? false;
|
||||
index += 1;
|
||||
if (node.type !== paragraphType) return;
|
||||
const current = Boolean(node.attrs.clearFloat);
|
||||
if (current === should) return;
|
||||
tr.setNodeMarkup(offset, undefined, { ...node.attrs, clearFloat: should });
|
||||
modified = true;
|
||||
});
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
/** 段落:支持 data-clear-float,源码往返可保留「写到绕排图下方」 */
|
||||
export const ClearFloatParagraph = Paragraph.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
clearFloat: {
|
||||
default: false,
|
||||
parseHTML: (el) => el.hasAttribute('data-clear-float'),
|
||||
renderHTML: (attrs) => (
|
||||
attrs.clearFloat
|
||||
? { 'data-clear-float': '', class: 'article-clear-float' }
|
||||
: {}
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const syncKey = new PluginKey('clearFloatSync');
|
||||
|
||||
/** 根据双空行自动写入/保持段落 clearFloat 属性 */
|
||||
export const ClearFloatSync = Extension.create({
|
||||
name: 'clearFloatSync',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: syncKey,
|
||||
appendTransaction(transactions, _oldState, newState) {
|
||||
if (!transactions.some(tr => tr.docChanged)) return null;
|
||||
if (transactions.some(tr => tr.getMeta(syncKey))) return null;
|
||||
|
||||
const paragraphType = newState.schema.nodes.paragraph;
|
||||
if (!paragraphType) return null;
|
||||
|
||||
const tr = newState.tr;
|
||||
if (!syncClearFloatAttrs(newState.doc, paragraphType, tr)) return null;
|
||||
tr.setMeta(syncKey, true);
|
||||
return tr;
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
371
frontend/src/components/editor/ImageGroupExtension.tsx
Normal file
371
frontend/src/components/editor/ImageGroupExtension.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
import { NodeSelection } from '@tiptap/pm/state';
|
||||
import type { EditorState } from '@tiptap/pm/state';
|
||||
import {
|
||||
ReactNodeViewRenderer,
|
||||
NodeViewWrapper,
|
||||
NodeViewContent,
|
||||
type NodeViewProps,
|
||||
} from '@tiptap/react';
|
||||
import { Columns2, Columns3, LayoutGrid, Plus, Ungroup } from 'lucide-react';
|
||||
import { api } from '../../api/client';
|
||||
import { notify } from '@/lib/notify';
|
||||
|
||||
export type ImageGroupLayout = 'cols-2' | 'cols-3' | 'cols-4';
|
||||
|
||||
const LAYOUTS: { key: ImageGroupLayout; label: string; icon: typeof Columns2; hint: string }[] = [
|
||||
{ key: 'cols-2', label: '两列', icon: Columns2, hint: '并排两张' },
|
||||
{ key: 'cols-3', label: '三列', icon: Columns3, hint: '并排三张' },
|
||||
{ key: 'cols-4', label: '四列', icon: LayoutGrid, hint: '四宫格' },
|
||||
];
|
||||
|
||||
/** 按张数推荐默认布局 */
|
||||
export function suggestImageGroupLayout(count: number): ImageGroupLayout {
|
||||
if (count >= 4) return 'cols-4';
|
||||
if (count === 3) return 'cols-3';
|
||||
return 'cols-2';
|
||||
}
|
||||
|
||||
function findImageGroupDepth($pos: {
|
||||
depth: number;
|
||||
node: (d: number) => { type: { name: string } };
|
||||
}): number {
|
||||
for (let d = $pos.depth; d > 0; d -= 1) {
|
||||
if ($pos.node(d).type.name === 'imageGroup') return d;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 空段落可夹在连续图片之间,合并时一并吃掉 */
|
||||
function isEmptyParagraph(node: ProseMirrorNode): boolean {
|
||||
return node.type.name === 'paragraph' && node.content.size === 0;
|
||||
}
|
||||
|
||||
/** 定位一张可作为合并起点的图片位置 */
|
||||
function findAnchorImagePos(state: EditorState): number | null {
|
||||
const { selection, doc } = state;
|
||||
if (selection instanceof NodeSelection && selection.node.type.name === 'image') {
|
||||
return selection.from;
|
||||
}
|
||||
|
||||
const { $from, from, to } = selection;
|
||||
if (findImageGroupDepth($from) >= 0) return null;
|
||||
|
||||
let firstImagePos: number | null = null;
|
||||
doc.nodesBetween(from, Math.max(to, from + 1), (node, pos) => {
|
||||
if (node.type.name === 'image' && firstImagePos == null) {
|
||||
firstImagePos = pos;
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
if (firstImagePos != null) return firstImagePos;
|
||||
|
||||
if ($from.nodeBefore?.type.name === 'image') {
|
||||
return $from.pos - $from.nodeBefore.nodeSize;
|
||||
}
|
||||
if ($from.nodeAfter?.type.name === 'image') {
|
||||
return $from.pos;
|
||||
}
|
||||
|
||||
// 光标在图片之间的段落时:沿祖先层级找相邻图片块
|
||||
for (let depth = $from.depth; depth >= 1; depth -= 1) {
|
||||
const parent = $from.node(depth);
|
||||
const index = $from.index(depth);
|
||||
|
||||
for (let i = index - 1; i >= 0; i -= 1) {
|
||||
const n = parent.child(i);
|
||||
if (n.type.name === 'image') return $from.posAtIndex(i, depth);
|
||||
if (!isEmptyParagraph(n)) break;
|
||||
}
|
||||
for (let i = index + 1; i < parent.childCount; i += 1) {
|
||||
const n = parent.child(i);
|
||||
if (n.type.name === 'image') return $from.posAtIndex(i, depth);
|
||||
if (!isEmptyParagraph(n)) break;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 以某张图为锚点,向两侧扩展「连续图片块」
|
||||
* (允许中间夹空段落;富文本难以框选多张 atom 图片)
|
||||
*/
|
||||
function collectConsecutiveImageRun(
|
||||
state: EditorState,
|
||||
imagePos: number,
|
||||
): { from: number; to: number; images: ProseMirrorNode[] } | null {
|
||||
const node = state.doc.nodeAt(imagePos);
|
||||
if (!node || node.type.name !== 'image') return null;
|
||||
|
||||
const $pos = state.doc.resolve(imagePos);
|
||||
const parent = $pos.parent;
|
||||
const index = $pos.index();
|
||||
if (parent.child(index) !== node) return null;
|
||||
|
||||
let start = index;
|
||||
while (start > 0) {
|
||||
const prev = parent.child(start - 1);
|
||||
if (prev.type.name === 'image' || isEmptyParagraph(prev)) start -= 1;
|
||||
else break;
|
||||
}
|
||||
while (start < index && isEmptyParagraph(parent.child(start))) start += 1;
|
||||
|
||||
let end = index;
|
||||
while (end < parent.childCount - 1) {
|
||||
const next = parent.child(end + 1);
|
||||
if (next.type.name === 'image' || isEmptyParagraph(next)) end += 1;
|
||||
else break;
|
||||
}
|
||||
while (end > index && isEmptyParagraph(parent.child(end))) end -= 1;
|
||||
|
||||
const images: ProseMirrorNode[] = [];
|
||||
for (let i = start; i <= end; i += 1) {
|
||||
const child = parent.child(i);
|
||||
if (child.type.name === 'image') images.push(child);
|
||||
}
|
||||
if (images.length < 2) return null;
|
||||
|
||||
let from = $pos.start();
|
||||
for (let i = 0; i < start; i += 1) from += parent.child(i).nodeSize;
|
||||
let to = from;
|
||||
for (let i = start; i <= end; i += 1) to += parent.child(i).nodeSize;
|
||||
|
||||
return { from, to, images };
|
||||
}
|
||||
|
||||
function ImageGroupView({ selected, editor, node, getPos }: NodeViewProps) {
|
||||
const layout = (node.attrs.layout as ImageGroupLayout) || 'cols-2';
|
||||
const count = node.childCount;
|
||||
|
||||
const setLayout = (next: ImageGroupLayout) => {
|
||||
const pos = getPos();
|
||||
if (typeof pos !== 'number') {
|
||||
editor.chain().focus().setImageGroupLayout(next).run();
|
||||
return;
|
||||
}
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch }) => {
|
||||
if (dispatch) tr.setNodeMarkup(pos, undefined, { ...node.attrs, layout: next });
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
};
|
||||
|
||||
const unwrap = () => {
|
||||
const pos = getPos();
|
||||
if (typeof pos !== 'number') {
|
||||
editor.chain().focus().unwrapImageGroup().run();
|
||||
return;
|
||||
}
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch }) => {
|
||||
if (dispatch) tr.replaceWith(pos, pos + node.nodeSize, node.content);
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
};
|
||||
|
||||
const addImage = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
|
||||
input.multiple = true;
|
||||
input.onchange = async () => {
|
||||
const files = [...(input.files ?? [])];
|
||||
if (!files.length) 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 : '图片上传失败');
|
||||
}
|
||||
}
|
||||
if (!urls.length) return;
|
||||
const pos = getPos();
|
||||
if (typeof pos !== 'number') return;
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch, state }) => {
|
||||
const imageType = state.schema.nodes.image;
|
||||
if (!imageType || !dispatch) return false;
|
||||
let cur = pos + node.nodeSize - 1;
|
||||
for (const src of urls) {
|
||||
const img = imageType.create({ src });
|
||||
tr.insert(cur, img);
|
||||
cur += img.nodeSize;
|
||||
}
|
||||
const nextLayout = suggestImageGroupLayout(count + urls.length);
|
||||
tr.setNodeMarkup(pos, undefined, { ...node.attrs, layout: nextLayout });
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className={`image-group image-group--${layout}${selected ? ' image-group--selected' : ''}`}
|
||||
data-image-group=""
|
||||
data-layout={layout}
|
||||
>
|
||||
<div className="image-group__toolbar" contentEditable={false}>
|
||||
<span className="image-group__toolbar-label">图组 · {count} 张</span>
|
||||
<div className="image-group__toolbar-actions">
|
||||
{LAYOUTS.map(item => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={`image-group__layout-btn${layout === item.key ? ' is-active' : ''}`}
|
||||
title={item.hint}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => setLayout(item.key)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
className="image-group__layout-btn"
|
||||
title="向本组追加图片"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={addImage}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>添加</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="image-group__layout-btn"
|
||||
title="拆开为单独图片"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={unwrap}
|
||||
>
|
||||
<Ungroup size={14} />
|
||||
<span>拆开</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<NodeViewContent className="image-group__grid" as="div" />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
imageGroup: {
|
||||
insertImageGroup: (srcs: string[], layout?: ImageGroupLayout) => ReturnType;
|
||||
setImageGroupLayout: (layout: ImageGroupLayout) => ReturnType;
|
||||
unwrapImageGroup: () => ReturnType;
|
||||
wrapImagesInGroup: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** TipTap 图组:多图并排 / 宫格布局 */
|
||||
export const ImageGroup = Node.create({
|
||||
name: 'imageGroup',
|
||||
group: 'block',
|
||||
content: 'image+',
|
||||
defining: true,
|
||||
isolating: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
layout: {
|
||||
default: 'cols-2' satisfies ImageGroupLayout,
|
||||
parseHTML: (el) => (el.getAttribute('data-layout') as ImageGroupLayout) || 'cols-2',
|
||||
renderHTML: (attrs) => ({ 'data-layout': attrs.layout || 'cols-2' }),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'div[data-image-group]' }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const layout = HTMLAttributes['data-layout'] || 'cols-2';
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-image-group': '',
|
||||
'data-layout': layout,
|
||||
class: `image-group image-group--${layout}`,
|
||||
}),
|
||||
0,
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ImageGroupView);
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertImageGroup: (srcs, layout) => ({ chain }) => {
|
||||
if (!srcs.length) return false;
|
||||
const nextLayout = layout || suggestImageGroupLayout(srcs.length);
|
||||
return chain()
|
||||
.insertContent({
|
||||
type: this.name,
|
||||
attrs: { layout: nextLayout },
|
||||
content: srcs.map(src => ({ type: 'image', attrs: { src } })),
|
||||
})
|
||||
.run();
|
||||
},
|
||||
|
||||
setImageGroupLayout: (layout) => ({ commands }) =>
|
||||
commands.updateAttributes(this.name, { layout }),
|
||||
|
||||
unwrapImageGroup: () => ({ tr, state, dispatch }) => {
|
||||
const { $from } = state.selection;
|
||||
const depth = findImageGroupDepth($from);
|
||||
if (depth < 0) return false;
|
||||
const pos = $from.before(depth);
|
||||
const node = $from.node(depth);
|
||||
tr.replaceWith(pos, pos + node.nodeSize, node.content);
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* 将光标/选区附近的连续图片包成图组。
|
||||
* TipTap 图片是 atom,无法像文本那样拖选多张,因此自动扩展相邻图片。
|
||||
*/
|
||||
wrapImagesInGroup: () => ({ tr, state, dispatch }) => {
|
||||
const anchor = findAnchorImagePos(state);
|
||||
if (anchor == null) return false;
|
||||
const run = collectConsecutiveImageRun(state, anchor);
|
||||
if (!run) return false;
|
||||
|
||||
const groupType = state.schema.nodes.imageGroup;
|
||||
if (!groupType) return false;
|
||||
|
||||
const group = groupType.create(
|
||||
{ layout: suggestImageGroupLayout(run.images.length) },
|
||||
run.images,
|
||||
);
|
||||
if (dispatch) {
|
||||
tr.replaceWith(run.from, run.to, group);
|
||||
// 选中新建图组,便于立刻改列数
|
||||
tr.setSelection(NodeSelection.create(tr.doc, run.from));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user