完善 TipTap 编辑器与个人资料页:头像裁剪、Tab 缩进、文章内链、Markdown 工具与 Tooltip,并更新样式与 Feed 缓存。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
freefire
2026-06-16 05:57:15 +08:00
parent e4d1dd139e
commit 57172eb053
56 changed files with 3128 additions and 531 deletions

View File

@@ -1,7 +1,8 @@
import {
useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, type ReactNode,
useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, useMemo, type ReactNode,
} from 'react';
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';
@@ -9,14 +10,28 @@ import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline';
import DOMPurify from 'dompurify';
import {
Bold, Italic, Strikethrough, Link as LinkIcon, Code, Quote,
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIcon, Code, Quote,
List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
FileCode, PenLine, Maximize2, Minimize2,
} from 'lucide-react';
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
import { renderPostContentHtml } from '../utils/postContent';
import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent';
import { handleMarkdownTabKey, insertAtCursor } from '../utils/markdownIndent';
import {
wrapMarkdownSelection,
prefixMarkdownLines,
cycleMarkdownHeading,
insertMarkdownMembersOnly,
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 { TabIndent } from './editor/TabIndentExtension';
import { ArticleLinkDialog } from './editor/ArticleLinkDialog';
import { Tooltip } from './ui/Tooltip';
export interface ArticleEditorHandle {
getHTML: () => string;
@@ -30,14 +45,21 @@ interface Props {
placeholder?: string;
}
type EditorMode = 'rich' | 'markdown';
type LinkTarget = 'rich' | 'markdown';
interface ToolBtn {
icon: ReactNode;
title: string;
hint?: string;
align?: 'start' | 'center' | 'end';
active?: boolean;
className?: string;
action: () => void;
}
const MEMBERS_ONLY_PLACEHOLDER = '在此输入仅登录用户可见的内容…';
/** 净化编辑器 HTML保留 members-only 自定义标签 */
function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
@@ -48,7 +70,7 @@ function isEditorEmpty(editor: Editor): boolean {
return editor.state.doc.textContent.trim().length === 0;
}
/** 标题循环:无标题 → H2 → H3 → … → H6 → 正文 */
/** 标题循环:正文 → H2 → H3 → … → H6 → 正文 */
function cycleHeading(editor: Editor) {
for (let level = 2; level <= 6; level += 1) {
if (editor.isActive('heading', { level })) {
@@ -63,6 +85,51 @@ function cycleHeading(editor: Editor) {
editor.chain().focus().toggleHeading({ level: 2 }).run();
}
/** 触发图片文件选择并上传 */
async function uploadPostImageFile(): Promise<string | null> {
return new Promise(resolve => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) {
resolve(null);
return;
}
try {
const { url } = await api.uploadPostImage(file);
resolve(url);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '图片上传失败');
resolve(null);
}
};
input.click();
});
}
/** 渲染工具栏按钮列表 */
function renderToolButtons(tools: ToolBtn[]) {
return tools.map((t, i) => (
<span key={i} className={t.className === 'article-tool-btn--members' ? 'article-editor-tools-members' : undefined}>
{t.className === 'article-tool-btn--members' ? (
<span className="article-editor-tools-sep" aria-hidden="true" />
) : null}
<Tooltip content={t.title} hint={t.hint} align={t.align} side="bottom">
<button
type="button"
className={`article-tool-btn${t.active ? ' active' : ''}${t.className ? ` ${t.className}` : ''}`}
onMouseDown={e => e.preventDefault()}
onClick={t.action}
>
{t.icon}
</button>
</Tooltip>
</span>
));
}
const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEditor(
{ value, onChange, placeholder = '在此撰写正文…' },
ref,
@@ -70,11 +137,18 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
const isInternalUpdate = useRef(false);
const lastValueRef = useRef(value);
const [, setEditorTick] = useState(0);
const [mode, setMode] = useState<EditorMode>('rich');
const [fullscreen, setFullscreen] = useState(false);
const [markdownSource, setMarkdownSource] = useState('');
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [linkDialogUrl, setLinkDialogUrl] = useState('');
const [linkTarget, setLinkTarget] = useState<LinkTarget>('rich');
const markdownRef = useRef<HTMLTextAreaElement>(null);
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3, 4, 5, 6] },
heading: { levels: [2, 3, 4, 5, 6] },
}),
Underline,
Link.configure({
@@ -83,8 +157,17 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
defaultProtocol: 'https',
}),
Image.configure({ inline: false, allowBase64: false }),
Placeholder.configure({ placeholder }),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === 'paragraph' && node.parent?.type.name === 'membersOnly') {
return MEMBERS_ONLY_PLACEHOLDER;
}
return placeholder;
},
includeChildren: true,
}),
MembersOnly,
TabIndent,
],
content: sanitizeHtml(value) || '',
onUpdate: ({ editor: ed }) => {
@@ -92,20 +175,41 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
isInternalUpdate.current = true;
lastValueRef.current = html;
onChange(html);
// 清空后折叠选区,避免空文档残留全选高亮
if (isEditorEmpty(ed) && !ed.state.selection.empty) {
ed.commands.setTextSelection(1);
}
},
onSelectionUpdate: () => {
setEditorTick(t => t + 1);
},
onTransaction: () => {
setEditorTick(t => t + 1);
},
onSelectionUpdate: () => setEditorTick(t => t + 1),
onTransaction: () => setEditorTick(t => t + 1),
editorProps: {
attributes: {
class: 'article-prosemirror post-detail-content',
spellcheck: 'false',
},
handleKeyDown: (view, event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
if (!view.state.doc.textContent.trim()) {
event.preventDefault();
view.dispatch(view.state.tr.setSelection(
TextSelection.create(view.state.doc, 1),
));
return true;
}
}
return false;
},
},
});
// 外部 value 变更时同步到编辑器(如加载已有帖子)
useEffect(() => {
if (!editor) return;
if (!editor || mode !== 'rich') return;
if (isInternalUpdate.current) {
isInternalUpdate.current = false;
return;
@@ -114,46 +218,104 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
if (next === lastValueRef.current) return;
lastValueRef.current = next;
editor.commands.setContent(next || '', { emitUpdate: false });
}, [value, editor]);
}, [value, editor, mode]);
// 全屏时锁定页面滚动Esc 退出
useEffect(() => {
if (!fullscreen) return undefined;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setFullscreen(false);
};
window.addEventListener('keydown', onKeyDown);
return () => {
document.body.style.overflow = prevOverflow;
window.removeEventListener('keydown', onKeyDown);
};
}, [fullscreen]);
useImperativeHandle(ref, () => ({
getHTML: () => (editor ? sanitizeHtml(editor.getHTML()) : value),
isEmpty: () => (editor ? isEditorEmpty(editor) : !value.trim()),
focus: () => editor?.commands.focus(),
}), [editor, value]);
getHTML: () => {
if (mode === 'markdown') {
return sanitizeHtml(markdownToHtml(markdownSource));
}
return editor ? sanitizeHtml(editor.getHTML()) : value;
},
isEmpty: () => {
if (mode === 'markdown') {
return markdownSource.trim().length === 0;
}
return editor ? isEditorEmpty(editor) : !value.trim();
},
focus: () => {
if (mode === 'markdown') {
markdownRef.current?.focus();
return;
}
editor?.commands.focus();
},
}), [editor, value, mode, markdownSource]);
const setLink = useCallback(() => {
const handleMarkdownChange = useCallback((next: string) => {
setMarkdownSource(next);
const html = sanitizeHtml(markdownToHtml(next));
isInternalUpdate.current = true;
lastValueRef.current = html;
onChange(html);
}, [onChange]);
const openLinkDialog = useCallback((target: LinkTarget) => {
if (target === 'rich') {
if (!editor) return;
const prev = editor.getAttributes('link').href as string | undefined;
setLinkDialogUrl(prev ?? '');
} else {
setLinkDialogUrl('');
}
setLinkTarget(target);
setLinkDialogOpen(true);
}, [editor]);
const applyLink = useCallback((url: string) => {
if (linkTarget === 'markdown') {
const textarea = markdownRef.current;
if (!textarea || !url) return;
insertMarkdownLink(textarea, markdownSource, url, handleMarkdownChange);
return;
}
if (!editor) return;
const prev = editor.getAttributes('link').href as string | undefined;
const url = window.prompt('链接地址', prev ?? 'https://');
if (url === null) return;
if (!url.trim()) {
if (!url) {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url.trim() }).run();
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}, [editor, linkTarget, markdownSource, handleMarkdownChange]);
const removeLink = useCallback(() => {
if (!editor) return;
editor.chain().focus().extendMarkRange('link').unsetLink().run();
}, [editor]);
const setImage = useCallback(() => {
const setImage = useCallback(async () => {
if (!editor) return;
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
try {
const { url } = await api.uploadPostImage(file);
editor.chain().focus().setImage({ src: url }).run();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '图片上传失败');
}
};
input.click();
const url = await uploadPostImageFile();
if (url) {
editor.chain().focus().setImage({ src: url }).run();
}
}, [editor]);
const wrapMembersOnly = useCallback(() => {
if (!editor) return;
if (editor.isActive('membersOnly')) {
editor.chain().focus().exitMembersOnly().run();
return;
}
const { from, to, empty } = editor.state.selection;
if (!empty && from !== to) {
editor.chain().focus().wrapMembersOnly().run();
@@ -162,113 +324,197 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
editor.chain().focus().insertMembersOnly().run();
}, [editor]);
const buildTools = useCallback((): ToolBtn[] => {
const switchToMarkdown = useCallback(() => {
if (!editor) return;
const html = sanitizeHtml(editor.getHTML());
lastValueRef.current = html;
setMarkdownSource(htmlToMarkdown(html));
setMode('markdown');
}, [editor]);
const switchToRich = useCallback(() => {
const html = sanitizeHtml(markdownToHtml(markdownSource));
lastValueRef.current = html;
isInternalUpdate.current = true;
onChange(html);
if (editor) {
editor.commands.setContent(html || '', { emitUpdate: false });
}
setMode('rich');
}, [editor, markdownSource, onChange]);
const withMarkdown = useCallback((fn: (
textarea: HTMLTextAreaElement,
value: string,
onChange: (v: string) => void,
) => void) => () => {
const textarea = markdownRef.current;
if (!textarea) return;
fn(textarea, markdownSource, handleMarkdownChange);
}, [markdownSource, handleMarkdownChange]);
const insertMarkdownImage = useCallback(async () => {
const textarea = markdownRef.current;
if (!textarea) return;
const url = await uploadPostImageFile();
if (!url) return;
insertAtCursor(textarea, markdownSource, `\n\n![图片](${url})\n\n`, handleMarkdownChange);
}, [markdownSource, handleMarkdownChange]);
const markdownPreviewHtml = useMemo(
() => renderPostContentHtml(sanitizeHtml(markdownToHtml(markdownSource)), true),
[markdownSource],
);
const buildRichTools = useCallback((): ToolBtn[] => {
if (!editor) return [];
return [
{
icon: <strong>H</strong>,
title: '标题H2再次点击升级',
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(),
},
{
icon: <Strikethrough size={15} />,
title: '删除线',
active: editor.isActive('strike'),
action: () => editor.chain().focus().toggleStrike().run(),
},
{
icon: <Minus size={15} />,
title: '分割线',
action: () => editor.chain().focus().setHorizontalRule().run(),
},
{
icon: <Quote size={15} />,
title: '引用',
active: editor.isActive('blockquote'),
action: () => editor.chain().focus().toggleBlockquote().run(),
},
{
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: () => editor.chain().focus().toggleCodeBlock().run(),
},
{
icon: <LinkIcon size={15} />,
title: '链接',
active: editor.isActive('link'),
action: setLink,
},
{
icon: <ImageIcon size={15} />,
title: '上传图片',
action: setImage,
},
{ 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() },
{ icon: <UnderlineIcon size={15} />, title: '下划线', active: editor.isActive('underline'), action: () => editor.chain().focus().toggleUnderline().run() },
{ icon: <Strikethrough size={15} />, title: '删除线', active: editor.isActive('strike'), action: () => editor.chain().focus().toggleStrike().run() },
{ icon: <Minus size={15} />, title: '分割线', action: () => editor.chain().focus().setHorizontalRule().run() },
{ icon: <Quote size={15} />, title: '引用', active: editor.isActive('blockquote'), action: () => editor.chain().focus().toggleBlockquote().run() },
{ 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: () => 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: '登录可见(选中文字后点击可包裹)',
title: '登录可见',
hint: '独立输入区Ctrl+Enter 退出',
active: editor.isActive('membersOnly'),
className: 'article-tool-btn--members',
action: wrapMembersOnly,
},
];
}, [editor, setLink, setImage, wrapMembersOnly]);
}, [editor, openLinkDialog, setImage, wrapMembersOnly]);
const tools = buildTools();
const words = editor ? countWords(editor.getText()) : 0;
const buildMarkdownTools = useCallback((): ToolBtn[] => [
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) },
{ icon: <Bold size={15} />, title: '加粗', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '**', '**', '加粗文字', ch)) },
{ icon: <Italic size={15} />, title: '斜体', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '*', '*', '斜体文字', ch)) },
{ icon: <UnderlineIcon size={15} />, title: '下划线', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '<u>', '</u>', '下划线文字', ch)) },
{ icon: <Strikethrough size={15} />, title: '删除线', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '~~', '~~', '删除线文字', ch)) },
{ icon: <Minus size={15} />, title: '分割线', action: withMarkdown((ta, v, ch) => insertAtCursor(ta, v, '\n\n---\n\n', ch)) },
{ icon: <Quote size={15} />, title: '引用', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '> ', ch)) },
{ icon: <List size={15} />, title: '无序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '- ', ch)) },
{ icon: <ListOrdered size={15} />, title: '有序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '1. ', ch)) },
{ icon: <Code size={15} />, title: '代码块', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '```\n', '\n```', '代码', ch)) },
{ icon: <LinkIcon size={15} />, title: '链接', action: () => openLinkDialog('markdown') },
{ icon: <ImageIcon size={15} />, title: '上传图片', action: insertMarkdownImage },
{
icon: <LockKeyhole size={15} />,
title: '登录可见',
hint: '插入 <members-only> 区块',
className: 'article-tool-btn--members',
action: withMarkdown(insertMarkdownMembersOnly),
},
], [withMarkdown, openLinkDialog, insertMarkdownImage]);
const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools();
const words = mode === 'markdown'
? countWords(markdownSource)
: (editor ? countWords(editor.getText()) : 0);
return (
<div className="article-editor">
<div className={`article-editor article-editor--${mode}${fullscreen ? ' article-editor--fullscreen' : ''}`}>
<div className="article-editor-bar">
<div className="article-editor-tools">
{tools.map((t, i) => (
<button
key={i}
type="button"
className={`article-tool-btn${t.active ? ' active' : ''}${t.className ? ` ${t.className}` : ''}`}
title={t.title}
onMouseDown={e => e.preventDefault()}
onClick={t.action}
>
{t.icon}
</button>
))}
{renderToolButtons(tools)}
</div>
</div>
<div className="article-editor-body">
<EditorContent editor={editor} className="article-editor-content" />
{mode === 'rich' ? (
<div className="article-editor-pane article-editor-pane--rich">
<div className="article-editor-scroll">
<EditorContent editor={editor} className="article-editor-content" />
</div>
</div>
) : (
<div className="article-editor-markdown">
<div className="article-editor-pane article-editor-pane--source">
<div className="article-editor-scroll">
<textarea
ref={markdownRef}
className="article-editor-markdown-input"
value={markdownSource}
onChange={e => handleMarkdownChange(e.target.value)}
onKeyDown={e => handleMarkdownTabKey(e, handleMarkdownChange)}
placeholder="在此编写 Markdown 源码…"
spellCheck={false}
/>
</div>
</div>
<div className="article-editor-markdown-preview">
<div className="article-editor-markdown-preview-label"></div>
<div
className="article-editor-markdown-preview-body post-detail-content"
dangerouslySetInnerHTML={{ __html: markdownPreviewHtml }}
/>
</div>
</div>
)}
</div>
<div className="article-editor-status">
<span>{words} </span>
<span></span>
<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>
</div>
<div className="article-editor-status-actions">
<Tooltip
content={mode === 'rich' ? 'Markdown 源码' : '富文本编辑'}
hint={mode === 'rich' ? '切换为 Markdown 源码编写' : '返回所见即所得编辑'}
align="end"
side="top"
>
<button
type="button"
className={`article-editor-view-btn${mode === 'markdown' ? ' active' : ''}`}
onMouseDown={e => e.preventDefault()}
onClick={mode === 'rich' ? switchToMarkdown : switchToRich}
>
{mode === 'rich' ? <FileCode size={15} /> : <PenLine size={15} />}
<span>{mode === 'rich' ? '源码' : '富文本'}</span>
</button>
</Tooltip>
<Tooltip
content={fullscreen ? '退出全屏' : '全屏编辑'}
hint={fullscreen ? 'Esc 也可退出' : '沉浸式编写长文'}
align="end"
side="top"
>
<button
type="button"
className="article-editor-view-btn"
onMouseDown={e => e.preventDefault()}
onClick={() => setFullscreen(v => !v)}
>
{fullscreen ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
<span>{fullscreen ? '退出全屏' : '全屏'}</span>
</button>
</Tooltip>
</div>
</div>
<ArticleLinkDialog
open={linkDialogOpen}
onOpenChange={setLinkDialogOpen}
initialUrl={linkDialogUrl}
onConfirm={applyLink}
onRemove={linkTarget === 'rich' && linkDialogUrl ? removeLink : undefined}
/>
</div>
);
});

View File

@@ -0,0 +1,116 @@
import { useCallback, useEffect, useState } from 'react';
import Cropper, { type Area } from 'react-easy-crop';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { getCroppedAvatarFile } from '../utils/avatarCrop';
interface Props {
open: boolean;
imageSrc: string | null;
fileName?: string;
onOpenChange: (open: boolean) => void;
onConfirm: (file: File) => void;
}
export default function AvatarCropDialog({
open,
imageSrc,
fileName,
onOpenChange,
onConfirm,
}: Props) {
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
const [confirming, setConfirming] = useState(false);
useEffect(() => {
if (open) {
setCrop({ x: 0, y: 0 });
setZoom(1);
setCroppedAreaPixels(null);
}
}, [open, imageSrc]);
const onCropComplete = useCallback((_: Area, pixels: Area) => {
setCroppedAreaPixels(pixels);
}, []);
const handleConfirm = async () => {
if (!imageSrc || !croppedAreaPixels) return;
setConfirming(true);
try {
const file = await getCroppedAvatarFile(imageSrc, croppedAreaPixels, fileName);
onConfirm(file);
onOpenChange(false);
} catch {
notify.error('裁剪失败,请重试');
} finally {
setConfirming(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="avatar-crop-dialog">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
GIF JPG
</DialogDescription>
</DialogHeader>
<div className="avatar-crop-stage">
{imageSrc ? (
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="round"
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
) : (
<div className="avatar-crop-loading">
<Spinner size="lg" />
</div>
)}
</div>
<div className="avatar-crop-zoom">
<span className="avatar-crop-zoom-label"></span>
<input
type="range"
min={1}
max={3}
step={0.01}
value={zoom}
onChange={e => setZoom(Number(e.target.value))}
aria-label="缩放"
/>
</div>
<DialogFooter>
<Button variant="outline" disabled={confirming} onClick={() => onOpenChange(false)}>
</Button>
<Button loading={confirming} disabled={!imageSrc} onClick={handleConfirm}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -48,13 +48,12 @@ export default function RightPanel({ hot, notifications, online, onPostClick }:
<div style={{ fontSize: 12, color: 'var(--color-text-3)', marginBottom: 8 }}>
{online?.members ?? 0} · {online?.guests ?? 0}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
<div className="widget-online-list">
{members.map(u => (
<span key={u.id} title={u.nickname} style={{
width: 28, height: 28, borderRadius: '50%', background: 'var(--j13-green)',
color: '#fff', fontSize: 12, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>
{u.nickname?.[0] || '?'}
<span key={u.id} className="widget-online-avatar" title={u.nickname}>
{u.avatar
? <img src={u.avatar} alt="" />
: (u.nickname?.[0] || '?')}
</span>
))}
{members.length === 0 && (

View File

@@ -6,6 +6,7 @@ import type { Board } from '../api/types';
import { useAuth } from '../hooks/useAuth';
import { cn } from '@/lib/utils';
import { buildHomeUrl, parseFeedSort } from './FeedSortBar';
import { navigateFeed } from '../utils/feedCache';
// 内容页不参与左侧栏高亮(非 feed 浏览上下文)
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile'];
@@ -53,7 +54,7 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
<aside className="sidebar">
<div className="sidebar-section"></div>
<nav className="sidebar-nav">
{navItem('all', '全部帖子', <Home />, () => { onSelectBoard(0); nav(buildHomeUrl(0, sort)); })}
{navItem('all', '全部帖子', <Home />, () => { onSelectBoard(0); navigateFeed(nav, buildHomeUrl(0, sort)); })}
{user && navItem('favorites', '我的收藏', <Star />, () => nav('/favorites'))}
</nav>
@@ -66,7 +67,7 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
type="button"
key={b.id}
className={cn('sidebar-nav-item', menuKey != null && menuKey === String(b.id) && 'active')}
onClick={() => { onSelectBoard(b.id); nav(buildHomeUrl(b.id, sort)); }}
onClick={() => { onSelectBoard(b.id); navigateFeed(nav, buildHomeUrl(b.id, sort)); }}
>
<span className="flex-1 truncate">{b.name}</span>
</button>

View File

@@ -14,6 +14,8 @@ interface Props {
onSelect: (id: number) => void;
/** 返回列表时恢复的滚动位置 */
restoreScrollTop?: number | null;
/** 递增时强制回到列表顶部(主动刷新导航) */
resetScrollKey?: number;
onScrollTopChange?: (top: number) => void;
onScrollRestored?: () => void;
}
@@ -26,6 +28,7 @@ export default function VirtualPostList({
onLoadMore,
onSelect,
restoreScrollTop,
resetScrollKey = 0,
onScrollTopChange,
onScrollRestored,
}: Props) {
@@ -39,6 +42,17 @@ export default function VirtualPostList({
overscan: 8,
});
useLayoutEffect(() => {
if (resetScrollKey <= 0) return;
const el = parentRef.current;
if (el) {
el.scrollTop = 0;
virtualizer.scrollToOffset(0);
}
restoredRef.current = true;
onScrollTopChange?.(0);
}, [resetScrollKey, virtualizer, onScrollTopChange]);
useLayoutEffect(() => {
if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return;
virtualizer.scrollToOffset(restoreScrollTop);

View File

@@ -0,0 +1,76 @@
import { useEffect, 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';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
initialUrl?: string;
onConfirm: (url: string) => void;
onRemove?: () => void;
}
/** 文章编辑器链接输入弹窗 */
export function ArticleLinkDialog({
open,
onOpenChange,
initialUrl = '',
onConfirm,
onRemove,
}: Props) {
const [url, setUrl] = useState(initialUrl);
useEffect(() => {
if (open) setUrl(initialUrl || 'https://');
}, [open, initialUrl]);
const handleConfirm = () => {
onConfirm(url.trim());
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="article-link-dialog">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription> URL</DialogDescription>
</DialogHeader>
<Input
type="url"
value={url}
placeholder="https://"
onChange={e => setUrl(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
handleConfirm();
}
}}
autoFocus
/>
<DialogFooter className="article-link-dialog__footer">
{onRemove && initialUrl ? (
<Button type="button" variant="outline" onClick={() => { onRemove(); onOpenChange(false); }}>
</Button>
) : null}
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button type="button" onClick={handleConfirm}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -5,10 +5,26 @@ import {
NodeViewContent,
type NodeViewProps,
} from '@tiptap/react';
import { LockKeyhole } from 'lucide-react';
import { LockKeyhole, LogOut } from 'lucide-react';
/** 查找光标所在的登录可见节点深度 */
function findMembersOnlyDepth($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 === 'membersOnly') return d;
}
return -1;
}
/** 编辑态「登录可见」区块视图 */
function MembersOnlyView({ selected }: NodeViewProps) {
function MembersOnlyView({ selected, editor }: NodeViewProps) {
const handleExit = () => {
editor.chain().focus().exitMembersOnly().run();
};
const handleUnwrap = () => {
editor.chain().focus().unwrapMembersOnly().run();
};
return (
<NodeViewWrapper
as="members-only"
@@ -19,6 +35,26 @@ function MembersOnlyView({ selected }: NodeViewProps) {
<LockKeyhole size={12} />
</span>
<span></span>
<button
type="button"
className="post-members-only__exit-btn"
title="Ctrl+Enter 退出到公开区域"
onMouseDown={e => e.preventDefault()}
onClick={handleExit}
>
<LogOut size={11} />
退
</button>
<span className="post-members-only__shortcut-hint">Ctrl+Enter 退</span>
<button
type="button"
className="post-members-only__unwrap-btn"
title="取消登录可见包裹,保留正文"
onMouseDown={e => e.preventDefault()}
onClick={handleUnwrap}
>
</button>
</div>
<NodeViewContent className="post-members-only__body" />
</NodeViewWrapper>
@@ -30,6 +66,8 @@ declare module '@tiptap/core' {
membersOnly: {
insertMembersOnly: () => ReturnType;
wrapMembersOnly: () => ReturnType;
exitMembersOnly: () => ReturnType;
unwrapMembersOnly: () => ReturnType;
};
}
}
@@ -54,15 +92,37 @@ export const MembersOnly = Node.create({
return ReactNodeViewRenderer(MembersOnlyView);
},
addKeyboardShortcuts() {
return {
// 在区块末尾空行按 Enter 时退出到公开区域
Enter: ({ editor }) => {
const { $from, empty } = editor.state.selection;
if (!empty) return false;
const depth = findMembersOnlyDepth($from);
if (depth < 0) return false;
const parent = $from.parent;
const atBlockEnd = $from.parentOffset === parent.content.size;
const isEmptyBlock = parent.textContent.trim().length === 0;
if (!atBlockEnd || !isEmptyBlock) return false;
return editor.commands.exitMembersOnly();
},
// Ctrl+Enter / Cmd+Enter 退出到公开区域
'Mod-Enter': ({ editor }) => {
if (!editor.isActive('membersOnly')) return false;
return editor.commands.exitMembersOnly();
},
};
},
addCommands() {
return {
insertMembersOnly: () => ({ chain }) => chain()
.insertContent({
type: this.name,
content: [{
type: 'paragraph',
content: [{ type: 'text', text: '在此输入仅登录用户可见的内容…' }],
}],
content: [{ type: 'paragraph' }],
})
.run(),
@@ -79,6 +139,33 @@ export const MembersOnly = Node.create({
}
return true;
},
exitMembersOnly: () => ({ state, chain }) => {
const { $from } = state.selection;
const depth = findMembersOnlyDepth($from);
if (depth < 0) return false;
const pos = $from.before(depth);
const node = $from.node(depth);
const end = pos + node.nodeSize;
return chain()
.insertContentAt(end, { type: 'paragraph' })
.setTextSelection(end + 1)
.run();
},
unwrapMembersOnly: () => ({ tr, state, dispatch }) => {
const { $from } = state.selection;
const depth = findMembersOnlyDepth($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;
},
};
},
});

View File

@@ -0,0 +1,189 @@
import { Extension } from '@tiptap/core';
import type { Editor } from '@tiptap/react';
export const TAB_SPACES = ' ';
/** 收集选区覆盖的文本块 */
function collectSelectedTextblocks(editor: Editor): { pos: number }[] {
const { from, to } = editor.state.selection;
const blocks: { pos: number }[] = [];
editor.state.doc.nodesBetween(from, to, (node, pos) => {
if (node.isTextblock) {
blocks.push({ pos });
return false;
}
return undefined;
});
return blocks;
}
/** 代码块多行选区:每行首插入缩进 */
function indentCodeBlockSelection(editor: Editor): boolean {
const { from, to, empty } = editor.state.selection;
if (empty) return false;
const text = editor.state.doc.textBetween(from, to, '\n', '\n');
const lineStarts = [from];
for (let i = 0; i < text.length; i += 1) {
if (text[i] === '\n') lineStarts.push(from + i + 1);
}
let tr = editor.state.tr;
let offset = 0;
lineStarts.forEach(pos => {
tr = tr.insertText(TAB_SPACES, pos + offset);
offset += TAB_SPACES.length;
});
editor.view.dispatch(tr);
return true;
}
/** 代码块多行选区:每行首移除最多 4 个空格 */
function outdentCodeBlockSelection(editor: Editor): boolean {
const { from, to, empty } = editor.state.selection;
if (empty) return false;
const doc = editor.state.doc;
const text = doc.textBetween(from, to, '\n', '\n');
const lineStarts = [from];
for (let i = 0; i < text.length; i += 1) {
if (text[i] === '\n') lineStarts.push(from + i + 1);
}
let tr = editor.state.tr;
let offset = 0;
let changed = false;
lineStarts.forEach(pos => {
const adjPos = pos + offset;
const lineEnd = doc.textBetween(adjPos, to + offset).split('\n')[0] ?? '';
const match = lineEnd.match(/^ {1,4}/);
if (!match) return;
tr = tr.delete(adjPos, adjPos + match[0].length);
offset -= match[0].length;
changed = true;
});
if (!changed) return false;
editor.view.dispatch(tr);
return true;
}
/** 多行选区:在每个文本块行首插入缩进 */
function indentSelection(editor: Editor): boolean {
const { empty } = editor.state.selection;
if (empty) return false;
if (editor.isActive('codeBlock')) {
return indentCodeBlockSelection(editor);
}
if (editor.isActive('listItem')) {
return editor.chain().focus().sinkListItem('listItem').run();
}
const blocks = collectSelectedTextblocks(editor);
if (blocks.length === 0) return false;
let tr = editor.state.tr;
let offset = 0;
blocks.forEach(({ pos }) => {
const insertPos = pos + 1 + offset;
tr = tr.insertText(TAB_SPACES, insertPos);
offset += TAB_SPACES.length;
});
editor.view.dispatch(tr);
return true;
}
/** 多行选区:移除每个文本块行首最多 4 个空格 */
function outdentSelection(editor: Editor): boolean {
const { empty } = editor.state.selection;
if (empty) return false;
if (editor.isActive('codeBlock')) {
return outdentCodeBlockSelection(editor);
}
if (editor.isActive('listItem')) {
return editor.chain().focus().liftListItem('listItem').run();
}
const blocks = collectSelectedTextblocks(editor);
if (blocks.length === 0) return false;
let tr = editor.state.tr;
let offset = 0;
blocks.forEach(({ pos }) => {
const node = editor.state.doc.nodeAt(pos);
if (!node?.isTextblock) return;
const text = node.textContent;
const match = text.match(/^ {1,4}/);
if (!match) return;
const removeLen = match[0].length;
const from = pos + 1 + offset;
tr = tr.delete(from, from + removeLen);
offset -= removeLen;
});
if (tr.steps.length === 0) return false;
editor.view.dispatch(tr);
return true;
}
/** 在代码块或段落中移除光标前的 4 个空格缩进 */
function outdentTextblock(editor: Editor): boolean {
const { state } = editor;
const { $from, empty } = state.selection;
if (!empty) return false;
if (!$from.parent.isTextblock) return false;
const lineStart = $from.start();
const beforeCursor = state.doc.textBetween(lineStart, $from.pos);
if (beforeCursor.endsWith(TAB_SPACES)) {
return editor.chain().focus().deleteRange({ from: $from.pos - TAB_SPACES.length, to: $from.pos }).run();
}
const leading = beforeCursor.match(/^ {1,4}/);
if (leading) {
return editor.chain().focus().deleteRange({ from: lineStart, to: lineStart + leading[0].length }).run();
}
return false;
}
/** Tab 缩进:列表缩进/反缩进,多行选区整体缩进,其余插入 4 个空格 */
export const TabIndent = Extension.create({
name: 'tabIndent',
addKeyboardShortcuts() {
return {
Tab: ({ editor }) => {
if (!editor.state.selection.empty) {
return indentSelection(editor);
}
if (editor.isActive('codeBlock')) {
return editor.chain().focus().insertContent(TAB_SPACES).run();
}
if (editor.isActive('listItem')) {
return editor.chain().focus().sinkListItem('listItem').run();
}
return editor.chain().focus().insertContent(TAB_SPACES).run();
},
'Shift-Tab': ({ editor }) => {
if (!editor.state.selection.empty) {
return outdentSelection(editor);
}
if (editor.isActive('codeBlock')) {
return outdentTextblock(editor);
}
if (editor.isActive('listItem')) {
return editor.chain().focus().liftListItem('listItem').run();
}
return outdentTextblock(editor);
},
};
},
});

View File

@@ -0,0 +1,49 @@
import { cloneElement, isValidElement, type ReactElement } from 'react';
import { cn } from '@/lib/utils';
type TooltipSide = 'top' | 'bottom';
type TooltipAlign = 'start' | 'center' | 'end';
interface TooltipProps {
content: string;
hint?: string;
side?: TooltipSide;
align?: TooltipAlign;
children: ReactElement;
className?: string;
}
/** 即时显示的网页式气泡提示,替代原生 title */
export function Tooltip({
content,
hint,
side = 'bottom',
align = 'center',
children,
className,
}: TooltipProps) {
if (!isValidElement(children)) return children;
const ariaLabel = hint ? `${content}${hint}` : content;
const child = cloneElement(children, {
'aria-label': ariaLabel,
} as Record<string, unknown>);
return (
<span className={cn('ui-tooltip', className)}>
{child}
<span
role="tooltip"
className={cn(
'ui-tooltip-bubble',
`ui-tooltip-bubble--${side}`,
`ui-tooltip-bubble--${align}`,
hint ? 'ui-tooltip-bubble--rich' : 'ui-tooltip-bubble--compact',
)}
>
<span className="ui-tooltip-bubble__title">{content}</span>
{hint ? <span className="ui-tooltip-bubble__hint">{hint}</span> : null}
</span>
</span>
);
}