完善 TipTap 编辑器与个人资料页:头像裁剪、Tab 缩进、文章内链、Markdown 工具与 Tooltip,并更新样式与 Feed 缓存。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user