新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。
作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,62 +1,106 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
import {
|
||||
ReactNodeViewRenderer,
|
||||
NodeViewWrapper,
|
||||
NodeViewContent,
|
||||
type NodeViewProps,
|
||||
} from '@tiptap/react';
|
||||
import { LockKeyhole, LogOut } from 'lucide-react';
|
||||
import { LockKeyhole, Trash2 } from 'lucide-react';
|
||||
|
||||
/** 查找光标所在的登录可见节点深度 */
|
||||
function findMembersOnlyDepth($pos: { depth: number; node: (d: number) => { type: { name: string } } }): number {
|
||||
function findMembersOnlyDepth($pos: {
|
||||
depth: number;
|
||||
node: (d: number) => { type: { name: string }; nodeSize: number };
|
||||
before: (d: number) => number;
|
||||
start: (d: number) => number;
|
||||
}): number {
|
||||
for (let d = $pos.depth; d > 0; d -= 1) {
|
||||
if ($pos.node(d).type.name === 'membersOnly') return d;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 登录可见区块是否无实质文字 */
|
||||
function isMembersOnlyEmpty(node: ProseMirrorNode): boolean {
|
||||
return node.textContent.trim().length === 0;
|
||||
}
|
||||
|
||||
/** 编辑态「登录可见」区块视图 */
|
||||
function MembersOnlyView({ selected, editor }: NodeViewProps) {
|
||||
const handleExit = () => {
|
||||
editor.chain().focus().exitMembersOnly().run();
|
||||
function MembersOnlyView({ selected, editor, node, getPos }: NodeViewProps) {
|
||||
const empty = isMembersOnlyEmpty(node);
|
||||
|
||||
/** 按 NodeView 自身位置删除,不依赖光标是否仍在块内 */
|
||||
const deleteThisBlock = () => {
|
||||
const pos = getPos();
|
||||
if (typeof pos !== 'number') {
|
||||
editor.chain().focus().removeMembersOnly().run();
|
||||
return;
|
||||
}
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch }) => {
|
||||
if (dispatch) tr.delete(pos, pos + node.nodeSize);
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
};
|
||||
|
||||
const handleUnwrap = () => {
|
||||
editor.chain().focus().unwrapMembersOnly().run();
|
||||
const pos = getPos();
|
||||
if (typeof pos !== 'number') {
|
||||
editor.chain().focus().unwrapMembersOnly().run();
|
||||
return;
|
||||
}
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch }) => {
|
||||
if (isMembersOnlyEmpty(node)) {
|
||||
if (dispatch) tr.delete(pos, pos + node.nodeSize);
|
||||
} else if (dispatch) {
|
||||
tr.replaceWith(pos, pos + node.nodeSize, node.content);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
as="members-only"
|
||||
className={`post-members-only post-members-only--visible editor-members-only${selected ? ' editor-members-only--selected' : ''}`}
|
||||
className={`post-members-only post-members-only--visible editor-members-only${selected ? ' editor-members-only--selected' : ''}${empty ? ' editor-members-only--empty' : ''}`}
|
||||
>
|
||||
<div className="post-members-only__badge" contentEditable={false}>
|
||||
<span className="post-members-only__badge-icon" aria-hidden="true">
|
||||
<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 className="post-members-only__badge-actions">
|
||||
{!empty && (
|
||||
<button
|
||||
type="button"
|
||||
className="post-members-only__unwrap-btn"
|
||||
title="取消登录可见包裹,保留正文"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={handleUnwrap}
|
||||
>
|
||||
取消包裹
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="post-members-only__remove-btn"
|
||||
title={empty ? '删除空的登录可见区块' : '删除整个登录可见区块'}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={deleteThisBlock}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<NodeViewContent className="post-members-only__body" />
|
||||
<NodeViewContent className="post-members-only__body" data-placeholder="此处内容游客不可见…" />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -68,6 +112,7 @@ declare module '@tiptap/core' {
|
||||
wrapMembersOnly: () => ReturnType;
|
||||
exitMembersOnly: () => ReturnType;
|
||||
unwrapMembersOnly: () => ReturnType;
|
||||
removeMembersOnly: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -94,6 +139,37 @@ export const MembersOnly = Node.create({
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
// 空区块内 Backspace / Delete:整块删除
|
||||
Backspace: ({ editor }) => {
|
||||
const { $from, empty } = editor.state.selection;
|
||||
if (!empty) return false;
|
||||
|
||||
const depth = findMembersOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const node = $from.node(depth);
|
||||
if (!isMembersOnlyEmpty(node)) {
|
||||
// 有内容时:在区块首字位置再按 Backspace 则解除包裹(与常见编辑器一致)
|
||||
if ($from.parentOffset !== 0) return false;
|
||||
const start = $from.start(depth);
|
||||
if ($from.pos !== start) return false;
|
||||
return editor.commands.unwrapMembersOnly();
|
||||
}
|
||||
|
||||
return editor.commands.removeMembersOnly();
|
||||
},
|
||||
Delete: ({ editor }) => {
|
||||
const { $from, empty } = editor.state.selection;
|
||||
if (!empty) return false;
|
||||
|
||||
const depth = findMembersOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const node = $from.node(depth);
|
||||
if (!isMembersOnlyEmpty(node)) return false;
|
||||
|
||||
return editor.commands.removeMembersOnly();
|
||||
},
|
||||
// 在区块末尾空行按 Enter 时退出到公开区域
|
||||
Enter: ({ editor }) => {
|
||||
const { $from, empty } = editor.state.selection;
|
||||
@@ -107,6 +183,12 @@ export const MembersOnly = Node.create({
|
||||
const isEmptyBlock = parent.textContent.trim().length === 0;
|
||||
if (!atBlockEnd || !isEmptyBlock) return false;
|
||||
|
||||
// 整块为空时直接删除,避免退出后仍残留空登录可见壳
|
||||
const membersNode = $from.node(depth);
|
||||
if (isMembersOnlyEmpty(membersNode) && membersNode.childCount <= 1) {
|
||||
return editor.commands.removeMembersOnly();
|
||||
}
|
||||
|
||||
return editor.commands.exitMembersOnly();
|
||||
},
|
||||
// Ctrl+Enter / Cmd+Enter 退出到公开区域
|
||||
@@ -162,7 +244,25 @@ export const MembersOnly = Node.create({
|
||||
|
||||
const pos = $from.before(depth);
|
||||
const node = $from.node(depth);
|
||||
tr.replaceWith(pos, pos + node.nodeSize, node.content);
|
||||
|
||||
// 空区块:直接删除,避免留下空段落套壳
|
||||
if (isMembersOnlyEmpty(node)) {
|
||||
tr.delete(pos, pos + node.nodeSize);
|
||||
} else {
|
||||
tr.replaceWith(pos, pos + node.nodeSize, node.content);
|
||||
}
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
|
||||
removeMembersOnly: () => ({ 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.delete(pos, pos + node.nodeSize);
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user