完善 TipTap 编辑器与个人资料页:头像裁剪、Tab 缩进、文章内链、Markdown 工具与 Tooltip,并更新样式与 Feed 缓存。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
61
frontend/package-lock.json
generated
61
frontend/package-lock.json
generated
@@ -31,20 +31,24 @@
|
||||
"diff": "^9.0.0",
|
||||
"dompurify": "^3.4.10",
|
||||
"lucide-react": "^1.18.0",
|
||||
"marked": "^18.0.5",
|
||||
"postcss": "^8.5.15",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-easy-crop": "^6.0.2",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"turndown": "^7.2.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
@@ -831,6 +835,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mixmark-io/domino": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
||||
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -2477,6 +2487,13 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/turndown": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz",
|
||||
"integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
@@ -3179,6 +3196,18 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.5",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz",
|
||||
"integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@@ -3255,6 +3284,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-wheel": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
|
||||
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -3662,6 +3697,19 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-easy-crop": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-6.0.2.tgz",
|
||||
"integrity": "sha512-nY/YiNEuRjc851+/PsOR6Q7XoshmnXMl+oEOsxp3Ah0PrhECi5388jjRnHwsTFx3W0o2zPwvq85oljzUqZNpEw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"normalize-wheel": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.4.0",
|
||||
"react-dom": ">=16.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.79.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.79.0.tgz",
|
||||
@@ -4133,6 +4181,19 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/turndown": {
|
||||
"version": "7.2.4",
|
||||
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
|
||||
"integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mixmark-io/domino": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18",
|
||||
"npm": ">=9"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
@@ -32,20 +32,24 @@
|
||||
"diff": "^9.0.0",
|
||||
"dompurify": "^3.4.10",
|
||||
"lucide-react": "^1.18.0",
|
||||
"marked": "^18.0.5",
|
||||
"postcss": "^8.5.15",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-easy-crop": "^6.0.2",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"turndown": "^7.2.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
|
||||
@@ -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\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>
|
||||
);
|
||||
});
|
||||
|
||||
116
frontend/src/components/AvatarCropDialog.tsx
Normal file
116
frontend/src/components/AvatarCropDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
76
frontend/src/components/editor/ArticleLinkDialog.tsx
Normal file
76
frontend/src/components/editor/ArticleLinkDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
189
frontend/src/components/editor/TabIndentExtension.tsx
Normal file
189
frontend/src/components/editor/TabIndentExtension.tsx
Normal 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);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
49
frontend/src/components/ui/Tooltip.tsx
Normal file
49
frontend/src/components/ui/Tooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
|
||||
import RightPanel from '../components/RightPanel';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
|
||||
import { navigateFeed } from '../utils/feedCache';
|
||||
import { notify } from '@/lib/notify';
|
||||
|
||||
export default function MainLayout() {
|
||||
@@ -113,7 +114,7 @@ export default function MainLayout() {
|
||||
<div className="app-frame">
|
||||
<header className="app-header">
|
||||
<div className="header-inner">
|
||||
<button type="button" className="header-brand" onClick={() => nav('/')}>
|
||||
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
|
||||
<span className="header-logo-mark">姜</span>
|
||||
{!isMobile && <span className="header-logo-text">姜十三论坛</span>}
|
||||
</button>
|
||||
@@ -214,13 +215,13 @@ export default function MainLayout() {
|
||||
<div className="mobile-board-bar">
|
||||
<span
|
||||
className={`board-chip ${mobileActiveBoard === 0 ? 'active' : ''}`}
|
||||
onClick={() => { setBoardId(0); nav(buildHomeUrl(0, feedSort)); }}
|
||||
onClick={() => { setBoardId(0); navigateFeed(nav, buildHomeUrl(0, feedSort)); }}
|
||||
>全部</span>
|
||||
{boards.map(b => (
|
||||
<span
|
||||
key={b.id}
|
||||
className={`board-chip ${mobileActiveBoard === b.id ? 'active' : ''}`}
|
||||
onClick={() => { setBoardId(b.id); nav(buildHomeUrl(b.id, feedSort)); }}
|
||||
onClick={() => { setBoardId(b.id); navigateFeed(nav, buildHomeUrl(b.id, feedSort)); }}
|
||||
>{b.name}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, useOutletContext, useSearchParams, useLocation } from 'react-router-dom';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import type { PostItem } from '../api/types';
|
||||
@@ -7,10 +7,18 @@ import type { LayoutCtx } from '../layouts/MainLayout';
|
||||
import VirtualPostList from '../components/VirtualPostList';
|
||||
import FeedHeader from '../components/FeedHeader';
|
||||
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
|
||||
import { getFeedCache, setFeedCache, clearAllFeedCache } from '../utils/feedCache';
|
||||
import {
|
||||
getFeedCache,
|
||||
setFeedCache,
|
||||
clearAllFeedCache,
|
||||
navigateFeed,
|
||||
FEED_RESET_EVENT,
|
||||
type FeedNavState,
|
||||
} from '../utils/feedCache';
|
||||
|
||||
export default function HomePage() {
|
||||
const nav = useNavigate();
|
||||
const location = useLocation();
|
||||
const [params] = useSearchParams();
|
||||
const ctx = useOutletContext<LayoutCtx>();
|
||||
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
|
||||
@@ -24,7 +32,24 @@ export default function HomePage() {
|
||||
const [hasMore, setHasMore] = useState(() => initialCache?.hasMore ?? true);
|
||||
const [loading, setLoading] = useState(() => !initialCache);
|
||||
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(() => initialCache?.scrollTop ?? null);
|
||||
const [listResetKey, setListResetKey] = useState(0);
|
||||
const scrollTopRef = useRef(initialCache?.scrollTop ?? 0);
|
||||
const pageWrapRef = useRef<HTMLDivElement>(null);
|
||||
/** 主动刷新时不把旧列表/滚动位置写回 cache */
|
||||
const skipCacheSaveRef = useRef(false);
|
||||
|
||||
const resetFeedView = useCallback(() => {
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
setListResetKey(k => k + 1);
|
||||
pageWrapRef.current?.scrollTo(0);
|
||||
}, []);
|
||||
|
||||
const beginFeedRefresh = useCallback(() => {
|
||||
skipCacheSaveRef.current = true;
|
||||
clearAllFeedCache();
|
||||
resetFeedView();
|
||||
}, [resetFeedView]);
|
||||
|
||||
const load = useCallback(async (p: number, reset = false) => {
|
||||
setLoading(true);
|
||||
@@ -37,7 +62,6 @@ export default function HomePage() {
|
||||
sort: sort === 'latest' ? '' : sort,
|
||||
});
|
||||
const batch = Array.isArray(data.posts) ? data.posts : [];
|
||||
// 切换筛选时保留旧列表,避免中间区域瞬间空白
|
||||
setPosts(prev => (reset ? batch : [...prev, ...batch]));
|
||||
setPostTotal(data.total ?? 0);
|
||||
setHasMore(!!data.has_more);
|
||||
@@ -80,6 +104,12 @@ export default function HomePage() {
|
||||
}, [boardId, keyword, sort]);
|
||||
|
||||
useEffect(() => {
|
||||
const forceRefresh = (location.state as FeedNavState | null)?.refreshFeed;
|
||||
if (forceRefresh) {
|
||||
beginFeedRefresh();
|
||||
load(1, true);
|
||||
return;
|
||||
}
|
||||
const cached = getFeedCache(boardId, keyword, sort);
|
||||
if (cached) {
|
||||
setPosts(cached.posts);
|
||||
@@ -95,11 +125,11 @@ export default function HomePage() {
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
load(1, true);
|
||||
}, [boardId, keyword, sort, load, revalidate]);
|
||||
}, [boardId, keyword, sort, location.key, location.state, load, revalidate, beginFeedRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (posts.length === 0) return;
|
||||
if (skipCacheSaveRef.current || posts.length === 0) return;
|
||||
setFeedCache(boardId, keyword, sort, {
|
||||
posts,
|
||||
postTotal,
|
||||
@@ -110,29 +140,42 @@ export default function HomePage() {
|
||||
};
|
||||
}, [boardId, keyword, sort, posts, postTotal, page, hasMore]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && posts.length > 0) {
|
||||
skipCacheSaveRef.current = false;
|
||||
}
|
||||
}, [loading, posts.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const onFeedReset = () => {
|
||||
beginFeedRefresh();
|
||||
};
|
||||
window.addEventListener(FEED_RESET_EVENT, onFeedReset);
|
||||
return () => window.removeEventListener(FEED_RESET_EVENT, onFeedReset);
|
||||
}, [beginFeedRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
const fn = () => {
|
||||
clearAllFeedCache();
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
beginFeedRefresh();
|
||||
load(1, true);
|
||||
};
|
||||
window.addEventListener('posts-refresh', fn);
|
||||
return () => window.removeEventListener('posts-refresh', fn);
|
||||
}, [boardId, keyword, sort, load]);
|
||||
}, [beginFeedRefresh, load]);
|
||||
|
||||
const handleSortChange = (next: FeedSort) => {
|
||||
if (next === sort) return;
|
||||
clearAllFeedCache();
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
nav(buildHomeUrl(boardId, next));
|
||||
if (next === sort) {
|
||||
beginFeedRefresh();
|
||||
load(1, true);
|
||||
return;
|
||||
}
|
||||
navigateFeed(nav, buildHomeUrl(boardId, next));
|
||||
};
|
||||
|
||||
const showSortBar = !keyword;
|
||||
|
||||
return (
|
||||
<div className="page-wrap">
|
||||
<div className="page-wrap" ref={pageWrapRef}>
|
||||
<div className="feed-top">
|
||||
<FeedHeader
|
||||
boardId={boardId}
|
||||
@@ -153,6 +196,7 @@ export default function HomePage() {
|
||||
onLoadMore={() => !loading && hasMore && load(page + 1)}
|
||||
onSelect={(id) => nav(`/post/${id}`)}
|
||||
restoreScrollTop={restoreScrollTop}
|
||||
resetScrollKey={listResetKey}
|
||||
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
|
||||
onScrollRestored={() => setRestoreScrollTop(null)}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { ArrowLeft, LayoutDashboard, Settings } from 'lucide-react';
|
||||
import { ArrowLeft, Camera, LayoutDashboard, Settings, Upload, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -13,6 +13,8 @@ import { notify } from '@/lib/notify';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { api } from '../api/client';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import AvatarCropDialog from '../components/AvatarCropDialog';
|
||||
import { AVATAR_ACCEPT, validateAvatarFile } from '../utils/avatarCrop';
|
||||
|
||||
const nickSchema = z.object({
|
||||
nickname: z.string().min(1, '请输入昵称').max(64),
|
||||
@@ -36,7 +38,14 @@ export default function ProfilePage() {
|
||||
const [nickLoading, setNickLoading] = useState(false);
|
||||
const [pwdLoading, setPwdLoading] = useState(false);
|
||||
const [avatarLoading, setAvatarLoading] = useState(false);
|
||||
const [pendingAvatar, setPendingAvatar] = useState<File | null>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
const [cropOpen, setCropOpen] = useState(false);
|
||||
const [cropImageSrc, setCropImageSrc] = useState<string | null>(null);
|
||||
const [cropFileName, setCropFileName] = useState('');
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
const { limits } = useForumLimits();
|
||||
|
||||
@@ -56,6 +65,30 @@ export default function ProfilePage() {
|
||||
}
|
||||
}, [authLoading, user, nav]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||
};
|
||||
}, [avatarPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cropImageSrc) URL.revokeObjectURL(cropImageSrc);
|
||||
};
|
||||
}, [cropImageSrc]);
|
||||
|
||||
const closeCropDialog = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setCropOpen(false);
|
||||
setCropImageSrc(prev => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
setCropFileName('');
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (authLoading) {
|
||||
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
|
||||
}
|
||||
@@ -90,26 +123,88 @@ export default function ProfilePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const onAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > limits.avatar_max_mb * 1024 * 1024) {
|
||||
notify.error(`头像不能超过 ${limits.avatar_max_mb}MB`);
|
||||
const clearPendingAvatar = () => {
|
||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||
setPendingAvatar(null);
|
||||
setAvatarPreview(null);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
};
|
||||
|
||||
const openCropForFile = (file: File) => {
|
||||
const err = validateAvatarFile(file, limits.avatar_max_mb);
|
||||
if (err) {
|
||||
notify.error(err);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
return;
|
||||
}
|
||||
if (cropImageSrc) URL.revokeObjectURL(cropImageSrc);
|
||||
setCropFileName(file.name);
|
||||
setCropImageSrc(URL.createObjectURL(file));
|
||||
setCropOpen(true);
|
||||
};
|
||||
|
||||
const onAvatarSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) openCropForFile(file);
|
||||
};
|
||||
|
||||
const onCropConfirm = (file: File) => {
|
||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||
setPendingAvatar(file);
|
||||
setAvatarPreview(URL.createObjectURL(file));
|
||||
};
|
||||
|
||||
const onSaveAvatar = async () => {
|
||||
if (!pendingAvatar) return;
|
||||
setAvatarLoading(true);
|
||||
try {
|
||||
await api.uploadAvatar(file);
|
||||
await api.uploadAvatar(pendingAvatar);
|
||||
await refresh();
|
||||
clearPendingAvatar();
|
||||
notify.success('头像已更新');
|
||||
} catch (err: unknown) {
|
||||
notify.error(err instanceof Error ? err.message : '上传失败');
|
||||
} finally {
|
||||
setAvatarLoading(false);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const onDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter.current += 1;
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
setDragOver(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter.current -= 1;
|
||||
if (dragCounter.current <= 0) {
|
||||
dragCounter.current = 0;
|
||||
setDragOver(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter.current = 0;
|
||||
setDragOver(false);
|
||||
if (avatarLoading) return;
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) openCropForFile(file);
|
||||
};
|
||||
|
||||
const displayAvatar = avatarPreview ?? user.avatar;
|
||||
|
||||
return (
|
||||
<div className="page-wrap">
|
||||
<div className="page-inner-wide" style={{ maxWidth: 640 }}>
|
||||
@@ -117,18 +212,82 @@ export default function ProfilePage() {
|
||||
<ArrowLeft />
|
||||
返回
|
||||
</Button>
|
||||
<h1 className="page-title mb-4">个人中心</h1>
|
||||
|
||||
<div className="profile-header">
|
||||
<div className="profile-avatar-lg">
|
||||
{user.avatar ? <img src={user.avatar} alt="" /> : user.nickname[0]}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="page-title" style={{ marginBottom: 4 }}>{user.nickname}</h1>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-3)' }}>@{user.username}</div>
|
||||
{user.role === 'admin' && <Badge variant="green" className="mt-1.5">管理员</Badge>}
|
||||
<div
|
||||
className={`profile-header-card${dragOver ? ' profile-header-card--dragover' : ''}`}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
{dragOver && (
|
||||
<div className="profile-drop-overlay">
|
||||
<Upload size={28} strokeWidth={1.5} />
|
||||
<span>松开以上传头像</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept={AVATAR_ACCEPT}
|
||||
className="sr-only"
|
||||
onChange={onAvatarSelect}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="profile-avatar-btn"
|
||||
title="点击或拖拽图片到此处更换头像"
|
||||
disabled={avatarLoading}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<div className={`profile-avatar-lg${pendingAvatar ? ' profile-avatar-lg--pending' : ''}`}>
|
||||
{displayAvatar
|
||||
? <img src={displayAvatar} alt="" />
|
||||
: user.nickname[0]}
|
||||
<span className="profile-avatar-overlay">
|
||||
{avatarLoading
|
||||
? <Spinner size="sm" className="text-white" />
|
||||
: <Camera size={22} strokeWidth={1.75} />}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="profile-header-info">
|
||||
<div className="profile-header-main">
|
||||
<h1 className="profile-display-name">{user.nickname}</h1>
|
||||
<div className="profile-username">@{user.username}</div>
|
||||
<p className="profile-avatar-tip">点击头像选择图片,或拖拽到此处</p>
|
||||
{user.role === 'admin' && <Badge variant="green" className="mt-1.5">管理员</Badge>}
|
||||
</div>
|
||||
{pendingAvatar && (
|
||||
<div className="profile-avatar-actions">
|
||||
<span className="profile-avatar-hint">已裁剪,待保存</span>
|
||||
<Button size="sm" loading={avatarLoading} onClick={onSaveAvatar}>
|
||||
保存头像
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={avatarLoading}
|
||||
onClick={clearPendingAvatar}
|
||||
aria-label="取消"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AvatarCropDialog
|
||||
open={cropOpen}
|
||||
imageSrc={cropImageSrc}
|
||||
fileName={cropFileName}
|
||||
onOpenChange={closeCropDialog}
|
||||
onConfirm={onCropConfirm}
|
||||
/>
|
||||
|
||||
{user.role === 'admin' && (
|
||||
<div className="section-card admin-entry-card">
|
||||
<div className="section-card-title">管理员入口</div>
|
||||
@@ -151,7 +310,7 @@ export default function ProfilePage() {
|
||||
<div className="section-card">
|
||||
<div className="section-card-title">基本资料</div>
|
||||
<Form {...nickForm}>
|
||||
<form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="space-y-4">
|
||||
<form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="profile-form">
|
||||
<FormItem>
|
||||
<FormLabel>用户名</FormLabel>
|
||||
<FormControl>
|
||||
@@ -171,32 +330,20 @@ export default function ProfilePage() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" loading={nickLoading}>保存昵称</Button>
|
||||
<div className="profile-form-footer">
|
||||
<span className="profile-form-hint">
|
||||
支持 JPG、PNG、GIF、WebP,头像不超过 {limits.avatar_max_mb}MB
|
||||
</span>
|
||||
<Button type="submit" loading={nickLoading}>保存</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div className="section-card">
|
||||
<div className="section-card-title">头像</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--color-text-3)', margin: '0 0 12px' }}>
|
||||
支持 JPG、PNG、GIF、WebP,不超过 2MB
|
||||
</p>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
style={{ display: 'none' }}
|
||||
onChange={onAvatarChange}
|
||||
/>
|
||||
<Button variant="outline" loading={avatarLoading} onClick={() => fileRef.current?.click()}>
|
||||
选择图片上传
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="section-card">
|
||||
<div className="section-card-title">修改密码</div>
|
||||
<Form {...pwdForm}>
|
||||
<form onSubmit={pwdForm.handleSubmit(onUpdatePwd)} className="space-y-4">
|
||||
<form onSubmit={pwdForm.handleSubmit(onUpdatePwd)} className="profile-form">
|
||||
<FormField
|
||||
control={pwdForm.control}
|
||||
name="old_password"
|
||||
@@ -236,9 +383,11 @@ export default function ProfilePage() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" variant="destructive" loading={pwdLoading}>
|
||||
修改密码
|
||||
</Button>
|
||||
<div className="profile-form-footer profile-form-footer--end">
|
||||
<Button type="submit" variant="destructive" loading={pwdLoading}>
|
||||
修改密码
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
--j13-aside-w: 280px;
|
||||
--j13-header-h: 56px;
|
||||
--j13-max-w: 1400px;
|
||||
--j13-article-read-w: 820px;
|
||||
/* 层次:浅灰画布 → 工作区底 → 白色/淡色区块 */
|
||||
--j13-bg-page: #f5f7fa;
|
||||
--j13-bg-workspace: #f8fafc;
|
||||
@@ -354,10 +355,16 @@ a:hover { text-decoration: underline; }
|
||||
transition: box-shadow 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.header-user-btn:hover {
|
||||
.header-user-btn:hover,
|
||||
.header-user-btn:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--j13-green-bg), 0 0 0 3px var(--j13-green);
|
||||
}
|
||||
|
||||
/* 鼠标点击打开/关闭下拉后 focus 会回到按钮,隐藏默认黑色 outline */
|
||||
.header-user-btn:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.header-user-avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -1585,7 +1592,16 @@ a:hover { text-decoration: underline; }
|
||||
padding-left: 1.6em;
|
||||
}
|
||||
|
||||
.post-detail-content ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.post-detail-content ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.post-detail-content li {
|
||||
display: list-item;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
@@ -1800,18 +1816,63 @@ a:hover { text-decoration: underline; }
|
||||
background: linear-gradient(90deg, var(--j13-bg-block-muted) 0%, rgba(255, 255, 255, 0.08) 100%);
|
||||
}
|
||||
|
||||
.article-editor-tools-members {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.article-editor-tools-sep {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--j13-border-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.article-editor-tools .article-tool-btn--members {
|
||||
margin-left: 6px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--j13-border-light);
|
||||
color: var(--j13-green);
|
||||
}
|
||||
|
||||
.article-editor-tools .article-tool-btn--members:hover {
|
||||
.article-editor-tools .article-tool-btn--members:hover,
|
||||
.article-editor-tools .article-tool-btn--members.active {
|
||||
background: var(--j13-green-bg);
|
||||
color: var(--j13-green-hover);
|
||||
}
|
||||
|
||||
.post-members-only__exit-btn,
|
||||
.post-members-only__unwrap-btn {
|
||||
margin-left: 4px;
|
||||
padding: 2px 8px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: rgba(24, 160, 88, 0.16);
|
||||
color: var(--j13-green);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.post-members-only__unwrap-btn {
|
||||
background: transparent;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.post-members-only__exit-btn:hover {
|
||||
background: rgba(24, 160, 88, 0.28);
|
||||
color: var(--j13-green-hover);
|
||||
}
|
||||
|
||||
.post-members-only__unwrap-btn:hover {
|
||||
background: var(--j13-bg-block-muted);
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.post-detail-content strong,
|
||||
.post-detail-content b {
|
||||
font-weight: 700;
|
||||
@@ -2465,18 +2526,255 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
.widget-item:last-child { border-bottom: none; }
|
||||
.widget-item:hover { color: var(--j13-green); }
|
||||
|
||||
.widget-online-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.widget-online-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--j13-green);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.widget-online-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.profile-header { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
|
||||
|
||||
.profile-header-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--j13-bg-block);
|
||||
border: 1px solid var(--j13-border-light);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--j13-shadow-soft);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.profile-header-card--dragover {
|
||||
border-color: color-mix(in srgb, var(--j13-green) 50%, var(--j13-border-light));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--j13-green) 15%, transparent);
|
||||
}
|
||||
|
||||
.profile-drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--j13-green) 12%, var(--j13-bg-block));
|
||||
color: var(--j13-green);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.profile-avatar-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.profile-avatar-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.profile-avatar-lg {
|
||||
width: 72px; height: 72px; border-radius: 50%;
|
||||
position: relative;
|
||||
width: 88px; height: 88px; border-radius: 50%;
|
||||
background: var(--j13-green); color: #fff;
|
||||
font-size: 28px; font-weight: 600;
|
||||
font-size: 32px; font-weight: 600;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.profile-avatar-lg--pending {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--j13-green) 40%, transparent);
|
||||
}
|
||||
|
||||
.profile-avatar-lg img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.profile-avatar-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.profile-avatar-btn:hover .profile-avatar-overlay,
|
||||
.profile-avatar-btn:focus-visible .profile-avatar-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.profile-avatar-btn:disabled .profile-avatar-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.profile-header-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profile-header-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-display-name {
|
||||
margin: 0 0 4px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.profile-username {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.profile-avatar-tip {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.profile-avatar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-avatar-hint {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profile-form-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.profile-form-footer--end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.profile-form-hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.profile-header-card {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.profile-header-info {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.profile-form-footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.profile-form-hint {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* 头像裁剪弹窗 */
|
||||
.avatar-crop-dialog {
|
||||
max-width: 420px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.avatar-crop-stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.avatar-crop-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.avatar-crop-zoom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.avatar-crop-zoom-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-3);
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.avatar-crop-zoom input[type='range'] {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
accent-color: var(--j13-green);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-entry-card {
|
||||
border-color: color-mix(in srgb, var(--j13-green) 25%, var(--j13-border));
|
||||
background: var(--j13-bg-block-accent);
|
||||
@@ -2797,7 +3095,6 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 0 -32px;
|
||||
padding: 10px 32px;
|
||||
@@ -2808,6 +3105,114 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
/* 即时气泡提示(替代原生 title) */
|
||||
.ui-tooltip {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
width: max-content;
|
||||
background: var(--j13-bg-surface);
|
||||
border: 1px solid var(--j13-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--j13-shadow-card);
|
||||
transition: opacity 0.1s ease, transform 0.1s ease, visibility 0.1s;
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble--compact {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble--rich {
|
||||
min-width: 148px;
|
||||
max-width: 220px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble__title {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble--compact .ui-tooltip-bubble__title {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble__hint {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble--bottom {
|
||||
top: calc(100% + 8px);
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble--top {
|
||||
bottom: calc(100% + 8px);
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble--center.ui-tooltip-bubble--bottom {
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-2px);
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble--center.ui-tooltip-bubble--top {
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(2px);
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble--start.ui-tooltip-bubble--bottom,
|
||||
.ui-tooltip-bubble--start.ui-tooltip-bubble--top {
|
||||
left: 0;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble--end.ui-tooltip-bubble--bottom,
|
||||
.ui-tooltip-bubble--end.ui-tooltip-bubble--top {
|
||||
right: 0;
|
||||
left: auto;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.ui-tooltip:hover .ui-tooltip-bubble,
|
||||
.ui-tooltip:focus-within .ui-tooltip-bubble {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.ui-tooltip:hover .ui-tooltip-bubble--center.ui-tooltip-bubble--bottom,
|
||||
.ui-tooltip:focus-within .ui-tooltip-bubble--center.ui-tooltip-bubble--bottom {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.ui-tooltip:hover .ui-tooltip-bubble--center.ui-tooltip-bubble--top,
|
||||
.ui-tooltip:focus-within .ui-tooltip-bubble--center.ui-tooltip-bubble--top {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.ui-tooltip:hover .ui-tooltip-bubble--start.ui-tooltip-bubble--bottom,
|
||||
.ui-tooltip:hover .ui-tooltip-bubble--start.ui-tooltip-bubble--top,
|
||||
.ui-tooltip:hover .ui-tooltip-bubble--end.ui-tooltip-bubble--bottom,
|
||||
.ui-tooltip:hover .ui-tooltip-bubble--end.ui-tooltip-bubble--top,
|
||||
.ui-tooltip:focus-within .ui-tooltip-bubble--start.ui-tooltip-bubble--bottom,
|
||||
.ui-tooltip:focus-within .ui-tooltip-bubble--start.ui-tooltip-bubble--top,
|
||||
.ui-tooltip:focus-within .ui-tooltip-bubble--end.ui-tooltip-bubble--bottom,
|
||||
.ui-tooltip:focus-within .ui-tooltip-bubble--end.ui-tooltip-bubble--top {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.article-editor-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2852,6 +3257,53 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
/* 编辑面板 */
|
||||
.article-editor-pane {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 280px;
|
||||
border: 1px solid var(--j13-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--j13-bg-block);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.article-editor-pane--source {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
/* 编辑器选区:淡色高亮,避免浏览器默认深蓝 */
|
||||
.article-editor-scroll ::selection,
|
||||
.article-editor-markdown-input::selection {
|
||||
background: color-mix(in srgb, var(--j13-green) 24%, #d6e8f0);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.article-prosemirror ::selection {
|
||||
background: color-mix(in srgb, var(--j13-green) 24%, #d6e8f0);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.article-prosemirror .ProseMirror-selectednode {
|
||||
outline: 2px solid color-mix(in srgb, var(--j13-green) 45%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dark .article-editor-scroll ::selection,
|
||||
.dark .article-editor-markdown-input::selection,
|
||||
.dark .article-prosemirror ::selection {
|
||||
background: color-mix(in srgb, var(--j13-green) 35%, #2a3f50);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.article-editor-scroll {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
background: var(--j13-bg-block);
|
||||
}
|
||||
|
||||
.article-editor-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -2862,7 +3314,7 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
.article-prosemirror {
|
||||
flex: 1;
|
||||
min-height: 280px;
|
||||
padding: 12px 0;
|
||||
padding: 14px 20px 20px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -2872,6 +3324,17 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 空文档占位段落不参与选区高亮,避免 Ctrl+A 后出现幽灵蓝条 */
|
||||
.article-prosemirror p.is-editor-empty::selection,
|
||||
.article-prosemirror p.is-editor-empty *::selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.article-prosemirror.ProseMirror-focused p.is-editor-empty::selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.article-prosemirror a {
|
||||
@@ -2887,8 +3350,46 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.editor-members-only.post-members-only--visible {
|
||||
border: 1px solid rgba(24, 160, 88, 0.45);
|
||||
background: linear-gradient(135deg, rgba(24, 160, 88, 0.08), rgba(24, 160, 88, 0.02));
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.editor-members-only .post-members-only__badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px dashed rgba(24, 160, 88, 0.28);
|
||||
border-radius: 0;
|
||||
background: rgba(24, 160, 88, 0.1);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-members-only .post-members-only__body {
|
||||
padding: 14px 18px 18px;
|
||||
min-height: 96px;
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.dark .editor-members-only .post-members-only__body {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.post-members-only__shortcut-hint {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.editor-members-only {
|
||||
margin: 16px 0;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.editor-members-only--selected {
|
||||
@@ -2899,6 +3400,7 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 0 16px;
|
||||
border-top: 1px solid var(--j13-border-light);
|
||||
font-size: 12px;
|
||||
@@ -2906,6 +3408,176 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.article-editor-status-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.article-editor-status-sep {
|
||||
color: var(--color-text-4);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.article-editor-status-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.article-editor-view-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--j13-border-light);
|
||||
border-radius: 8px;
|
||||
background: var(--j13-bg-block);
|
||||
color: var(--color-text-2);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.article-editor-view-btn:hover {
|
||||
border-color: var(--j13-green);
|
||||
color: var(--j13-green);
|
||||
background: rgba(24, 160, 88, 0.06);
|
||||
}
|
||||
|
||||
.article-editor-view-btn.active {
|
||||
border-color: rgba(24, 160, 88, 0.45);
|
||||
color: var(--j13-green);
|
||||
background: rgba(24, 160, 88, 0.1);
|
||||
}
|
||||
|
||||
.article-editor--fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 120;
|
||||
margin: 0 !important;
|
||||
padding: 0 32px 24px;
|
||||
background: var(--j13-bg-surface);
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.article-editor--fullscreen .article-editor-bar {
|
||||
margin: 0;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
/* 全屏富文本:工具栏与编辑区同宽居中 */
|
||||
.article-editor--fullscreen.article-editor--rich .article-editor-bar {
|
||||
width: 100%;
|
||||
max-width: var(--j13-article-read-w);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.article-editor--fullscreen .article-editor-body {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 全屏富文本:编辑区居中,宽度对齐帖子正文阅读区 */
|
||||
.article-editor--fullscreen.article-editor--rich .article-editor-body {
|
||||
overflow-y: auto;
|
||||
padding: 20px 0 0;
|
||||
background: var(--j13-bg-workspace);
|
||||
}
|
||||
|
||||
.article-editor--fullscreen.article-editor--rich .article-editor-pane {
|
||||
width: 100%;
|
||||
max-width: var(--j13-article-read-w);
|
||||
margin: 0 auto;
|
||||
min-height: calc(100vh - 176px);
|
||||
}
|
||||
|
||||
.article-editor--fullscreen .article-editor-scroll,
|
||||
.article-editor--fullscreen .article-editor-content .tiptap,
|
||||
.article-editor--fullscreen .article-prosemirror {
|
||||
min-height: calc(100vh - 220px);
|
||||
}
|
||||
|
||||
.article-editor--fullscreen .article-editor-markdown {
|
||||
min-height: calc(100vh - 176px);
|
||||
}
|
||||
|
||||
.article-editor--fullscreen .article-editor-markdown-input,
|
||||
.article-editor--fullscreen .article-editor-markdown-preview,
|
||||
.article-editor--fullscreen .article-editor-pane--source {
|
||||
min-height: calc(100vh - 220px);
|
||||
}
|
||||
|
||||
.article-editor-tools--markdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.article-editor-tools--markdown-hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.article-link-dialog__footer {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.article-editor-markdown {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.article-editor-markdown-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
padding: 14px 16px 20px;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: var(--color-text-1);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.article-editor-markdown-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 320px;
|
||||
background: var(--j13-bg-surface);
|
||||
border: 1px solid var(--j13-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.article-editor-markdown-preview-label {
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--j13-border-light);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.article-editor-markdown-preview-body {
|
||||
flex: 1;
|
||||
padding: 14px 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.compose-canvas {
|
||||
padding: 0 16px;
|
||||
@@ -2916,11 +3588,44 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.article-editor--fullscreen {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.article-editor--fullscreen .article-editor-bar {
|
||||
margin: 0;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.article-editor-markdown {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(240px, 1fr) minmax(240px, 1fr);
|
||||
}
|
||||
|
||||
.article-editor-pane--source {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.article-editor--fullscreen.article-editor--rich .article-editor-pane {
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.article-editor-bar {
|
||||
margin: 0 -16px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.article-editor-status {
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.article-editor-status-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.compose-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
66
frontend/src/utils/avatarCrop.ts
Normal file
66
frontend/src/utils/avatarCrop.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Area } from 'react-easy-crop';
|
||||
|
||||
export const AVATAR_ACCEPT = 'image/jpeg,image/png,image/gif,image/webp';
|
||||
export const AVATAR_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
|
||||
/** 头像输出尺寸 */
|
||||
export const AVATAR_OUTPUT_SIZE = 512;
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('图片加载失败'));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
/** 校验头像文件,返回错误信息或 null */
|
||||
export function validateAvatarFile(file: File, maxMb: number): string | null {
|
||||
if (!AVATAR_MIME_TYPES.includes(file.type)) {
|
||||
return '仅支持 JPG、PNG、GIF、WebP 格式';
|
||||
}
|
||||
if (file.size > maxMb * 1024 * 1024) {
|
||||
return `头像不能超过 ${maxMb}MB`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 将裁剪区域渲染为 JPEG 文件 */
|
||||
export async function getCroppedAvatarFile(
|
||||
imageSrc: string,
|
||||
pixelCrop: Area,
|
||||
originalName = 'avatar.jpg',
|
||||
): Promise<File> {
|
||||
const image = await loadImage(imageSrc);
|
||||
const canvas = document.createElement('canvas');
|
||||
const size = AVATAR_OUTPUT_SIZE;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('裁剪失败');
|
||||
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
size,
|
||||
size,
|
||||
);
|
||||
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
b => (b ? resolve(b) : reject(new Error('裁剪失败'))),
|
||||
'image/jpeg',
|
||||
0.92,
|
||||
);
|
||||
});
|
||||
|
||||
const baseName = originalName.replace(/\.[^.]+$/, '') || 'avatar';
|
||||
return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg' });
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
import type { PostItem } from '../api/types';
|
||||
|
||||
import type { FeedSort } from '../components/FeedSortBar';
|
||||
|
||||
/** 导航到帖子列表时附带的状态,用于同 URL 重复点击时强制刷新 */
|
||||
export type FeedNavState = { refreshFeed?: boolean };
|
||||
|
||||
|
||||
|
||||
export type FeedCache = {
|
||||
@@ -89,7 +92,6 @@ export function clearFeedCache(boardId: number, keyword: string, sort: FeedSort)
|
||||
|
||||
|
||||
/** 清除所有帖子列表缓存(置顶等操作后列表需全量刷新) */
|
||||
|
||||
export function clearAllFeedCache() {
|
||||
|
||||
try {
|
||||
@@ -110,3 +112,12 @@ export function clearAllFeedCache() {
|
||||
|
||||
}
|
||||
|
||||
/** 主动刷新帖子列表时派发,用于同页内立即回到顶部 */
|
||||
export const FEED_RESET_EVENT = 'feed-reset';
|
||||
|
||||
/** 清除缓存并导航到帖子列表(重复点击同一入口时也会刷新) */
|
||||
export function navigateFeed(nav: NavigateFunction, url: string) {
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event(FEED_RESET_EVENT));
|
||||
nav(url, { state: { refreshFeed: true } satisfies FeedNavState });
|
||||
}
|
||||
|
||||
229
frontend/src/utils/markdownContent.ts
Normal file
229
frontend/src/utils/markdownContent.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import TurndownService from 'turndown';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
|
||||
|
||||
const MEMBERS_ONLY_BLOCK_RE = /<members-only>([\s\S]*?)<\/members-only>/gi;
|
||||
|
||||
const TURNDOWN_OPTIONS = {
|
||||
headingStyle: 'atx' as const,
|
||||
codeBlockStyle: 'fenced' as const,
|
||||
emDelimiter: '*',
|
||||
bulletListMarker: '-',
|
||||
};
|
||||
|
||||
/** 仅去除区块首尾空行,保留各行行首缩进 */
|
||||
function trimBlockBoundaryLines(content: string): string {
|
||||
return content.replace(/^\n+/, '').replace(/\n+$/, '');
|
||||
}
|
||||
|
||||
/** 不换行空格还原为普通空格,便于 Markdown 源码编辑 */
|
||||
function nbspToSpaces(text: string): string {
|
||||
return text.replace(/\u00A0/g, ' ');
|
||||
}
|
||||
|
||||
/** 将含 <br> 的段落拆成多个 <p>,避免聚合转换时丢失首行缩进 */
|
||||
function splitParagraphBreaks(html: string): string {
|
||||
if (!/<br\s*\/?>/i.test(html)) return html;
|
||||
|
||||
const doc = new DOMParser().parseFromString(`<div data-wrap="1">${html}</div>`, 'text/html');
|
||||
const container = doc.querySelector('[data-wrap]');
|
||||
if (!container) return html;
|
||||
|
||||
[...container.querySelectorAll('p')].forEach(p => {
|
||||
const inner = p.innerHTML;
|
||||
if (!/<br\s*\/?>/i.test(inner)) return;
|
||||
|
||||
const parts = inner.split(/<br\s*\/?>/i);
|
||||
const fragment = doc.createDocumentFragment();
|
||||
parts.forEach((part, index) => {
|
||||
if (part === '' && index === parts.length - 1) return;
|
||||
const newP = doc.createElement('p');
|
||||
newP.innerHTML = part;
|
||||
fragment.appendChild(newP);
|
||||
});
|
||||
p.replaceWith(fragment);
|
||||
});
|
||||
|
||||
return container.innerHTML;
|
||||
}
|
||||
|
||||
/** 为 Turndown 注册通用正文规则(不含 members-only) */
|
||||
function addTurndownContentRules(service: TurndownService): void {
|
||||
service.addRule('image', {
|
||||
filter: 'img',
|
||||
replacement: (_content, node) => {
|
||||
const el = node as HTMLImageElement;
|
||||
const alt = el.getAttribute('alt') ?? '';
|
||||
const src = el.getAttribute('src') ?? '';
|
||||
return src ? `` : '';
|
||||
},
|
||||
});
|
||||
|
||||
service.addRule('underline', {
|
||||
filter: ['u'],
|
||||
replacement: (content) => `<u>${content}</u>`,
|
||||
});
|
||||
|
||||
service.addRule('strikethrough', {
|
||||
filter: ['del', 's', 'strike'],
|
||||
replacement: (content) => `~~${content}~~`,
|
||||
});
|
||||
|
||||
service.addRule('horizontalRule', {
|
||||
filter: 'hr',
|
||||
replacement: () => '\n\n---\n\n',
|
||||
});
|
||||
|
||||
service.addRule('anchor', {
|
||||
filter: (node) => {
|
||||
if (node.nodeName !== 'A') return false;
|
||||
const href = (node as HTMLAnchorElement).getAttribute('href');
|
||||
return Boolean(href);
|
||||
},
|
||||
replacement: (content, node) => {
|
||||
const el = node as HTMLAnchorElement;
|
||||
const href = el.getAttribute('href') ?? '';
|
||||
const title = el.getAttribute('title');
|
||||
return title ? `[${content}](${href} "${title}")` : `[${content}](${href})`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 子节点转 Markdown 专用,避免 members-only 规则递归 */
|
||||
const contentTurndown = new TurndownService(TURNDOWN_OPTIONS);
|
||||
addTurndownContentRules(contentTurndown);
|
||||
|
||||
const turndown = new TurndownService(TURNDOWN_OPTIONS);
|
||||
addTurndownContentRules(turndown);
|
||||
|
||||
/** 登录可见区块转为 Markdown:逐子节点转换,保留首行缩进 */
|
||||
turndown.addRule('membersOnly', {
|
||||
filter: 'members-only',
|
||||
replacement: (_content, node) => {
|
||||
const el = node as HTMLElement;
|
||||
const parts: string[] = [];
|
||||
|
||||
el.childNodes.forEach(child => {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const text = (child.textContent ?? '').trim();
|
||||
if (text) parts.push(nbspToSpaces(text));
|
||||
return;
|
||||
}
|
||||
if (child instanceof HTMLElement) {
|
||||
parts.push(nbspToSpaces(contentTurndown.turndown(child.outerHTML).trim()));
|
||||
}
|
||||
});
|
||||
|
||||
const body = trimBlockBoundaryLines(parts.join('\n\n'));
|
||||
return `\n\n<members-only>\n\n${body}\n\n</members-only>\n\n`;
|
||||
},
|
||||
});
|
||||
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
});
|
||||
|
||||
/** 禁用缩进代码块,Tab/空格缩进仅作正文排版;围栏 ``` 代码块不受影响 */
|
||||
marked.use({
|
||||
tokenizer: {
|
||||
code() {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** 净化并保留 members-only 标签 */
|
||||
function sanitizeContentHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
|
||||
}
|
||||
|
||||
/** 将行首空格转为不换行空格,避免 HTML 折叠缩进;跳过围栏代码块 */
|
||||
function preserveLeadingIndent(markdown: string): string {
|
||||
return markdown.split(/(```[\s\S]*?```)/g).map((part, index) => {
|
||||
if (index % 2 === 1) return part;
|
||||
return part.replace(/^( +)(?=\S)/gm, (_match, spaces: string) => '\u00A0'.repeat(spaces.length));
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/** 将普通 Markdown 片段转为 HTML */
|
||||
function parseMarkdownFragment(markdown: string): string {
|
||||
if (!markdown.trim()) return '';
|
||||
return marked.parse(preserveLeadingIndent(markdown), { async: false }) as string;
|
||||
}
|
||||
|
||||
/** 转换前清理编辑态装饰结构,避免污染 Markdown */
|
||||
function prepareHtmlForMarkdown(html: string): string {
|
||||
const doc = new DOMParser().parseFromString(sanitizeContentHtml(html), 'text/html');
|
||||
|
||||
doc.querySelectorAll('.post-members-only__badge, .post-members-only__exit-btn, .post-members-only__unwrap-btn').forEach(el => {
|
||||
el.remove();
|
||||
});
|
||||
|
||||
doc.querySelectorAll('members-only').forEach(el => {
|
||||
const body = el.querySelector('.post-members-only__body');
|
||||
const raw = body ? body.innerHTML : el.innerHTML;
|
||||
el.innerHTML = splitParagraphBreaks(raw);
|
||||
});
|
||||
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
/** 规范化 members-only 标签边界,避免闭合标签与正文粘连 */
|
||||
function normalizeMembersOnlyMarkdown(markdown: string): string {
|
||||
return markdown
|
||||
.replace(/<\/members-only>(?=[^\s\n])/g, '</members-only>\n\n')
|
||||
.replace(/<members-only>\s*<\/members-only>/g, '<members-only>\n\n</members-only>');
|
||||
}
|
||||
|
||||
/** 列表标记后统一为单个空格(Turndown 默认会输出两个及以上空格) */
|
||||
function normalizeListMarkerSpacing(markdown: string): string {
|
||||
return markdown
|
||||
.replace(/^(\s*[-+*])\s+(?=\S)/gm, '$1 ')
|
||||
.replace(/^(\s*\d+\.)\s+(?=\S)/gm, '$1 ');
|
||||
}
|
||||
|
||||
/** 编辑器 HTML 转为 Markdown 源码 */
|
||||
export function htmlToMarkdown(html: string): string {
|
||||
if (!html.trim()) return '';
|
||||
const prepared = prepareHtmlForMarkdown(html);
|
||||
const raw = turndown.turndown(prepared).replace(/\n{3,}/g, '\n\n').trim();
|
||||
return normalizeListMarkerSpacing(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown 源码转为编辑器 HTML。
|
||||
* 先提取 members-only 块再分别解析,避免闭合标签后同行文字被吞入区块。
|
||||
*/
|
||||
export function markdownToHtml(markdown: string): string {
|
||||
if (!markdown.trim()) return '';
|
||||
|
||||
const normalized = normalizeMembersOnlyMarkdown(markdown);
|
||||
const re = new RegExp(MEMBERS_ONLY_BLOCK_RE.source, 'gi');
|
||||
let result = '';
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null = re.exec(normalized);
|
||||
|
||||
while (match) {
|
||||
const before = normalized.slice(lastIndex, match.index);
|
||||
if (before.trim()) {
|
||||
result += parseMarkdownFragment(before);
|
||||
}
|
||||
|
||||
const innerMd = trimBlockBoundaryLines(match[1]);
|
||||
const innerHtml = innerMd.trim()
|
||||
? splitParagraphBreaks(parseMarkdownFragment(innerMd))
|
||||
: '';
|
||||
result += `<members-only>${innerHtml}</members-only>`;
|
||||
lastIndex = re.lastIndex;
|
||||
match = re.exec(normalized);
|
||||
}
|
||||
|
||||
const tail = normalized.slice(lastIndex);
|
||||
if (tail.trim()) {
|
||||
result += parseMarkdownFragment(tail);
|
||||
}
|
||||
|
||||
return sanitizeContentHtml(result);
|
||||
}
|
||||
109
frontend/src/utils/markdownFormat.ts
Normal file
109
frontend/src/utils/markdownFormat.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { applyTextareaChange } from './markdownIndent';
|
||||
|
||||
type ChangeHandler = (value: string) => void;
|
||||
|
||||
/** 在选区两侧包裹 Markdown 标记 */
|
||||
export function wrapMarkdownSelection(
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
placeholder: string,
|
||||
onChange: ChangeHandler,
|
||||
) {
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const selected = value.slice(selectionStart, selectionEnd);
|
||||
const text = selected || placeholder;
|
||||
const insert = prefix + text + suffix;
|
||||
const next = value.slice(0, selectionStart) + insert + value.slice(selectionEnd);
|
||||
const newStart = selectionStart + prefix.length;
|
||||
const newEnd = newStart + text.length;
|
||||
applyTextareaChange(textarea, next, newStart, newEnd, onChange);
|
||||
}
|
||||
|
||||
/** 为当前行或选区行添加前缀(如引用) */
|
||||
export function prefixMarkdownLines(
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
prefix: string,
|
||||
onChange: ChangeHandler,
|
||||
) {
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const lineStart = value.lastIndexOf('\n', selectionStart - 1) + 1;
|
||||
const nextNewline = value.indexOf('\n', selectionEnd);
|
||||
const lineEnd = nextNewline === -1 ? value.length : nextNewline;
|
||||
const block = value.slice(lineStart, lineEnd);
|
||||
const lines = block.split('\n').map(line => `${prefix}${line}`);
|
||||
const next = value.slice(0, lineStart) + lines.join('\n') + value.slice(lineEnd);
|
||||
applyTextareaChange(
|
||||
textarea,
|
||||
next,
|
||||
selectionStart + prefix.length,
|
||||
selectionEnd + prefix.length * lines.length,
|
||||
onChange,
|
||||
);
|
||||
}
|
||||
|
||||
/** 切换当前行标题级别(源码模式) */
|
||||
export function cycleMarkdownHeading(
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
onChange: ChangeHandler,
|
||||
) {
|
||||
const { selectionStart } = textarea;
|
||||
const lineStart = value.lastIndexOf('\n', selectionStart - 1) + 1;
|
||||
const lineEnd = value.indexOf('\n', selectionStart);
|
||||
const end = lineEnd === -1 ? value.length : lineEnd;
|
||||
const line = value.slice(lineStart, end);
|
||||
const match = line.match(/^(#{2,6})\s+(.*)$/);
|
||||
let nextLine: string;
|
||||
if (!match) {
|
||||
nextLine = `## ${line}`;
|
||||
} else {
|
||||
const level = match[1].length;
|
||||
const body = match[2];
|
||||
nextLine = level >= 6 ? body : `${'#'.repeat(level + 1)} ${body}`;
|
||||
}
|
||||
const next = value.slice(0, lineStart) + nextLine + value.slice(end);
|
||||
const offset = nextLine.length - line.length;
|
||||
applyTextareaChange(
|
||||
textarea,
|
||||
next,
|
||||
selectionStart + Math.max(0, offset),
|
||||
selectionStart + Math.max(0, offset),
|
||||
onChange,
|
||||
);
|
||||
}
|
||||
|
||||
/** 插入登录可见区块模板 */
|
||||
export function insertMarkdownMembersOnly(
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
onChange: ChangeHandler,
|
||||
) {
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const snippet = '\n\n<members-only>\n\n\n</members-only>\n\n';
|
||||
const next = value.slice(0, selectionStart) + snippet + value.slice(selectionEnd);
|
||||
const cursor = selectionStart + '\n\n<members-only>\n\n'.length;
|
||||
applyTextareaChange(textarea, next, cursor, cursor, onChange);
|
||||
}
|
||||
|
||||
/** 在光标处插入链接 Markdown */
|
||||
export function insertMarkdownLink(
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
url: string,
|
||||
onChange: ChangeHandler,
|
||||
) {
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const selected = value.slice(selectionStart, selectionEnd) || '链接文字';
|
||||
const insert = `[${selected}](${url})`;
|
||||
const next = value.slice(0, selectionStart) + insert + value.slice(selectionEnd);
|
||||
applyTextareaChange(
|
||||
textarea,
|
||||
next,
|
||||
selectionStart + insert.length,
|
||||
selectionStart + insert.length,
|
||||
onChange,
|
||||
);
|
||||
}
|
||||
149
frontend/src/utils/markdownIndent.ts
Normal file
149
frontend/src/utils/markdownIndent.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
export const TAB_SPACES = ' ';
|
||||
|
||||
interface LineRange {
|
||||
lineStart: number;
|
||||
lineEnd: number;
|
||||
}
|
||||
|
||||
/** 获取选区覆盖的整行文本范围 */
|
||||
function getLineRange(value: string, start: number, end: number): LineRange {
|
||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
||||
const nextNewline = value.indexOf('\n', end);
|
||||
const lineEnd = nextNewline === -1 ? value.length : nextNewline;
|
||||
return { lineStart, lineEnd };
|
||||
}
|
||||
|
||||
/** 多行整体增加一级缩进 */
|
||||
function indentBlock(value: string, lineStart: number, lineEnd: number): string {
|
||||
const block = value.slice(lineStart, lineEnd);
|
||||
const indented = block.split('\n').map(line => TAB_SPACES + line).join('\n');
|
||||
return value.slice(0, lineStart) + indented + value.slice(lineEnd);
|
||||
}
|
||||
|
||||
/** 多行整体减少一级缩进,返回新文本及选区偏移 */
|
||||
function outdentBlock(
|
||||
value: string,
|
||||
lineStart: number,
|
||||
lineEnd: number,
|
||||
selectionStart: number,
|
||||
selectionEnd: number,
|
||||
): { next: string; newStart: number; newEnd: number } {
|
||||
const block = value.slice(lineStart, lineEnd);
|
||||
const lines = block.split('\n');
|
||||
let cursor = lineStart;
|
||||
let newStart = selectionStart;
|
||||
let newEnd = selectionEnd;
|
||||
|
||||
const outdented = lines.map(line => {
|
||||
const match = line.match(/^ {1,4}/);
|
||||
const removed = match ? match[0].length : 0;
|
||||
if (removed > 0) {
|
||||
if (selectionStart > cursor) {
|
||||
newStart -= Math.min(removed, selectionStart - cursor);
|
||||
}
|
||||
if (selectionEnd > cursor) {
|
||||
newEnd -= Math.min(removed, selectionEnd - cursor);
|
||||
}
|
||||
}
|
||||
cursor += line.length + 1;
|
||||
return line.replace(/^ {1,4}/, '');
|
||||
}).join('\n');
|
||||
|
||||
return {
|
||||
next: value.slice(0, lineStart) + outdented + value.slice(lineEnd),
|
||||
newStart: Math.max(lineStart, newStart),
|
||||
newEnd: Math.max(lineStart, newEnd),
|
||||
};
|
||||
}
|
||||
|
||||
/** 在光标处插入文本并恢复选区 */
|
||||
export function applyTextareaChange(
|
||||
textarea: HTMLTextAreaElement,
|
||||
nextValue: string,
|
||||
cursorStart: number,
|
||||
cursorEnd = cursorStart,
|
||||
onChange: (value: string) => void,
|
||||
) {
|
||||
onChange(nextValue);
|
||||
requestAnimationFrame(() => {
|
||||
textarea.selectionStart = cursorStart;
|
||||
textarea.selectionEnd = cursorEnd;
|
||||
textarea.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/** 在 textarea 光标处插入文本 */
|
||||
export function insertAtCursor(
|
||||
textarea: HTMLTextAreaElement,
|
||||
currentValue: string,
|
||||
insertText: string,
|
||||
onChange: (value: string) => void,
|
||||
) {
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const next = currentValue.slice(0, selectionStart) + insertText + currentValue.slice(selectionEnd);
|
||||
const cursor = selectionStart + insertText.length;
|
||||
applyTextareaChange(textarea, next, cursor, cursor, onChange);
|
||||
}
|
||||
|
||||
/** Markdown 源码 Tab / Shift+Tab 缩进 */
|
||||
export function handleMarkdownTabKey(
|
||||
e: React.KeyboardEvent<HTMLTextAreaElement>,
|
||||
onChange: (value: string) => void,
|
||||
) {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
e.preventDefault();
|
||||
const textarea = e.currentTarget;
|
||||
const { selectionStart, selectionEnd, value } = textarea;
|
||||
const { lineStart, lineEnd } = getLineRange(value, selectionStart, selectionEnd);
|
||||
const hasSelection = selectionStart !== selectionEnd;
|
||||
const block = value.slice(lineStart, lineEnd);
|
||||
const multiLine = hasSelection && block.split('\n').length > 1;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (multiLine) {
|
||||
const { next, newStart, newEnd } = outdentBlock(value, lineStart, lineEnd, selectionStart, selectionEnd);
|
||||
applyTextareaChange(textarea, next, newStart, newEnd, onChange);
|
||||
return;
|
||||
}
|
||||
|
||||
const lineText = value.slice(lineStart, selectionStart);
|
||||
const trailingMatch = lineText.match(/ {1,4}$/);
|
||||
if (trailingMatch) {
|
||||
const removeLen = trailingMatch[0].length;
|
||||
const next = value.slice(0, selectionStart - removeLen) + value.slice(selectionStart);
|
||||
applyTextareaChange(textarea, next, selectionStart - removeLen, selectionEnd - removeLen, onChange);
|
||||
return;
|
||||
}
|
||||
|
||||
const leadingMatch = value.slice(lineStart, selectionStart).match(/^ {1,4}/);
|
||||
if (leadingMatch) {
|
||||
const removeLen = leadingMatch[0].length;
|
||||
const next = value.slice(0, lineStart) + value.slice(lineStart + removeLen);
|
||||
applyTextareaChange(
|
||||
textarea,
|
||||
next,
|
||||
selectionStart - removeLen,
|
||||
selectionEnd - removeLen,
|
||||
onChange,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (multiLine) {
|
||||
const next = indentBlock(value, lineStart, lineEnd);
|
||||
const lineCount = value.slice(lineStart, lineEnd).split('\n').length;
|
||||
applyTextareaChange(
|
||||
textarea,
|
||||
next,
|
||||
selectionStart + TAB_SPACES.length,
|
||||
selectionEnd + TAB_SPACES.length * lineCount,
|
||||
onChange,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = value.slice(0, selectionStart) + TAB_SPACES + value.slice(selectionEnd);
|
||||
applyTextareaChange(textarea, next, selectionStart + TAB_SPACES.length, selectionStart + TAB_SPACES.length, onChange);
|
||||
}
|
||||
Reference in New Issue
Block a user