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

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

View File

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

View File

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

View File

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