支持公开用户主页、帖子图缩略图与编辑器图组排版。

新增用户签名与活动统计、图片灯箱;正文按需生成缩略图;TipTap 支持多图分组与环绕排版,并注入站点标题避免刷新闪烁。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-01 12:29:50 +08:00
parent 822eef96be
commit 060b7707cb
51 changed files with 3563 additions and 397 deletions

View File

@@ -0,0 +1,67 @@
import Image from '@tiptap/extension-image';
import { mergeAttributes } from '@tiptap/core';
/** 单图展示形态(对齐 Notion / Medium 常见选项) */
export type ImageDisplay = 'default' | 'wide' | 'float-left' | 'float-right';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
articleImage: {
setImageDisplay: (display: ImageDisplay) => ReturnType;
};
}
}
/**
* 文章图片:在 TipTap Image 上增加 data-display
* 支持通栏 / 左绕排 / 右绕排。
*/
export const ArticleImage = Image.extend({
name: 'image',
addAttributes() {
return {
...this.parent?.(),
display: {
default: 'default' satisfies ImageDisplay,
parseHTML: (el) =>
(el.getAttribute('data-display') as ImageDisplay) || 'default',
renderHTML: (attrs) => {
const display = (attrs.display as ImageDisplay) || 'default';
if (display === 'default') return {};
return {
'data-display': display,
class: `article-img article-img--${display}`,
};
},
},
};
},
renderHTML({ HTMLAttributes }) {
const display = (HTMLAttributes['data-display'] as ImageDisplay) || 'default';
const cls = [
HTMLAttributes.class,
'article-img',
display !== 'default' ? `article-img--${display}` : '',
]
.filter(Boolean)
.join(' ');
return [
'img',
mergeAttributes(HTMLAttributes, {
class: cls || undefined,
draggable: false,
}),
];
},
addCommands() {
return {
...this.parent?.(),
setImageDisplay: (display) => ({ commands }) =>
commands.updateAttributes(this.name, { display }),
};
},
});

View File

@@ -0,0 +1,144 @@
import Paragraph from '@tiptap/extension-paragraph';
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import type { Node as ProseMirrorNode, NodeType } from '@tiptap/pm/model';
import type { Transaction } from '@tiptap/pm/state';
function isFloatImage(node: ProseMirrorNode): boolean {
if (node.type.name !== 'image') return false;
const display = node.attrs.display as string | undefined;
return display === 'float-left' || display === 'float-right';
}
function isBlankParagraph(node: ProseMirrorNode): boolean {
if (node.type.name !== 'paragraph') return false;
if (node.content.size === 0) return true;
let blank = true;
node.forEach(child => {
if (child.type.name === 'hardBreak') return;
if (child.isText && !(child.text || '').replace(/\u00a0/g, ' ').trim()) return;
blank = false;
});
return blank;
}
function isHardClearBlock(node: ProseMirrorNode): boolean {
const name = node.type.name;
if (name === 'image') return !isFloatImage(node);
return name === 'imageGroup'
|| name === 'heading'
|| name === 'horizontalRule'
|| name === 'codeBlock'
|| name === 'blockquote'
|| name === 'table'
|| name === 'bulletList'
|| name === 'orderedList';
}
/**
* 计算顶层块是否应带 clearFloat。
* - 双空行后自动打开
* - 一旦打开,只要仍在绕排图之后就保持(避免源码往返丢空段后失效)
*/
function computeClearFloatFlags(doc: ProseMirrorNode): boolean[] {
const flags: boolean[] = [];
let seenFloat = false;
let blankRun = 0;
doc.forEach(node => {
if (isFloatImage(node)) {
seenFloat = true;
blankRun = 0;
flags.push(false);
return;
}
if (isHardClearBlock(node)) {
seenFloat = false;
blankRun = 0;
flags.push(false);
return;
}
if (!seenFloat) {
flags.push(false);
blankRun = 0;
return;
}
if (isBlankParagraph(node)) {
blankRun += 1;
flags.push(false);
return;
}
const already = Boolean(node.attrs.clearFloat);
flags.push(blankRun >= 2 || already);
blankRun = 0;
});
return flags;
}
function syncClearFloatAttrs(doc: ProseMirrorNode, paragraphType: NodeType, tr: Transaction): boolean {
const flags = computeClearFloatFlags(doc);
let modified = false;
let index = 0;
doc.forEach((node, offset) => {
const should = flags[index] ?? false;
index += 1;
if (node.type !== paragraphType) return;
const current = Boolean(node.attrs.clearFloat);
if (current === should) return;
tr.setNodeMarkup(offset, undefined, { ...node.attrs, clearFloat: should });
modified = true;
});
return modified;
}
/** 段落:支持 data-clear-float源码往返可保留「写到绕排图下方」 */
export const ClearFloatParagraph = Paragraph.extend({
addAttributes() {
return {
...this.parent?.(),
clearFloat: {
default: false,
parseHTML: (el) => el.hasAttribute('data-clear-float'),
renderHTML: (attrs) => (
attrs.clearFloat
? { 'data-clear-float': '', class: 'article-clear-float' }
: {}
),
},
};
},
});
const syncKey = new PluginKey('clearFloatSync');
/** 根据双空行自动写入/保持段落 clearFloat 属性 */
export const ClearFloatSync = Extension.create({
name: 'clearFloatSync',
addProseMirrorPlugins() {
return [
new Plugin({
key: syncKey,
appendTransaction(transactions, _oldState, newState) {
if (!transactions.some(tr => tr.docChanged)) return null;
if (transactions.some(tr => tr.getMeta(syncKey))) return null;
const paragraphType = newState.schema.nodes.paragraph;
if (!paragraphType) return null;
const tr = newState.tr;
if (!syncClearFloatAttrs(newState.doc, paragraphType, tr)) return null;
tr.setMeta(syncKey, true);
return tr;
},
}),
];
},
});

View File

@@ -0,0 +1,371 @@
import { Node, mergeAttributes } from '@tiptap/core';
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
import { NodeSelection } from '@tiptap/pm/state';
import type { EditorState } from '@tiptap/pm/state';
import {
ReactNodeViewRenderer,
NodeViewWrapper,
NodeViewContent,
type NodeViewProps,
} from '@tiptap/react';
import { Columns2, Columns3, LayoutGrid, Plus, Ungroup } from 'lucide-react';
import { api } from '../../api/client';
import { notify } from '@/lib/notify';
export type ImageGroupLayout = 'cols-2' | 'cols-3' | 'cols-4';
const LAYOUTS: { key: ImageGroupLayout; label: string; icon: typeof Columns2; hint: string }[] = [
{ key: 'cols-2', label: '两列', icon: Columns2, hint: '并排两张' },
{ key: 'cols-3', label: '三列', icon: Columns3, hint: '并排三张' },
{ key: 'cols-4', label: '四列', icon: LayoutGrid, hint: '四宫格' },
];
/** 按张数推荐默认布局 */
export function suggestImageGroupLayout(count: number): ImageGroupLayout {
if (count >= 4) return 'cols-4';
if (count === 3) return 'cols-3';
return 'cols-2';
}
function findImageGroupDepth($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 === 'imageGroup') return d;
}
return -1;
}
/** 空段落可夹在连续图片之间,合并时一并吃掉 */
function isEmptyParagraph(node: ProseMirrorNode): boolean {
return node.type.name === 'paragraph' && node.content.size === 0;
}
/** 定位一张可作为合并起点的图片位置 */
function findAnchorImagePos(state: EditorState): number | null {
const { selection, doc } = state;
if (selection instanceof NodeSelection && selection.node.type.name === 'image') {
return selection.from;
}
const { $from, from, to } = selection;
if (findImageGroupDepth($from) >= 0) return null;
let firstImagePos: number | null = null;
doc.nodesBetween(from, Math.max(to, from + 1), (node, pos) => {
if (node.type.name === 'image' && firstImagePos == null) {
firstImagePos = pos;
return false;
}
return undefined;
});
if (firstImagePos != null) return firstImagePos;
if ($from.nodeBefore?.type.name === 'image') {
return $from.pos - $from.nodeBefore.nodeSize;
}
if ($from.nodeAfter?.type.name === 'image') {
return $from.pos;
}
// 光标在图片之间的段落时:沿祖先层级找相邻图片块
for (let depth = $from.depth; depth >= 1; depth -= 1) {
const parent = $from.node(depth);
const index = $from.index(depth);
for (let i = index - 1; i >= 0; i -= 1) {
const n = parent.child(i);
if (n.type.name === 'image') return $from.posAtIndex(i, depth);
if (!isEmptyParagraph(n)) break;
}
for (let i = index + 1; i < parent.childCount; i += 1) {
const n = parent.child(i);
if (n.type.name === 'image') return $from.posAtIndex(i, depth);
if (!isEmptyParagraph(n)) break;
}
}
return null;
}
/**
* 以某张图为锚点,向两侧扩展「连续图片块」
* (允许中间夹空段落;富文本难以框选多张 atom 图片)
*/
function collectConsecutiveImageRun(
state: EditorState,
imagePos: number,
): { from: number; to: number; images: ProseMirrorNode[] } | null {
const node = state.doc.nodeAt(imagePos);
if (!node || node.type.name !== 'image') return null;
const $pos = state.doc.resolve(imagePos);
const parent = $pos.parent;
const index = $pos.index();
if (parent.child(index) !== node) return null;
let start = index;
while (start > 0) {
const prev = parent.child(start - 1);
if (prev.type.name === 'image' || isEmptyParagraph(prev)) start -= 1;
else break;
}
while (start < index && isEmptyParagraph(parent.child(start))) start += 1;
let end = index;
while (end < parent.childCount - 1) {
const next = parent.child(end + 1);
if (next.type.name === 'image' || isEmptyParagraph(next)) end += 1;
else break;
}
while (end > index && isEmptyParagraph(parent.child(end))) end -= 1;
const images: ProseMirrorNode[] = [];
for (let i = start; i <= end; i += 1) {
const child = parent.child(i);
if (child.type.name === 'image') images.push(child);
}
if (images.length < 2) return null;
let from = $pos.start();
for (let i = 0; i < start; i += 1) from += parent.child(i).nodeSize;
let to = from;
for (let i = start; i <= end; i += 1) to += parent.child(i).nodeSize;
return { from, to, images };
}
function ImageGroupView({ selected, editor, node, getPos }: NodeViewProps) {
const layout = (node.attrs.layout as ImageGroupLayout) || 'cols-2';
const count = node.childCount;
const setLayout = (next: ImageGroupLayout) => {
const pos = getPos();
if (typeof pos !== 'number') {
editor.chain().focus().setImageGroupLayout(next).run();
return;
}
editor
.chain()
.focus()
.command(({ tr, dispatch }) => {
if (dispatch) tr.setNodeMarkup(pos, undefined, { ...node.attrs, layout: next });
return true;
})
.run();
};
const unwrap = () => {
const pos = getPos();
if (typeof pos !== 'number') {
editor.chain().focus().unwrapImageGroup().run();
return;
}
editor
.chain()
.focus()
.command(({ tr, dispatch }) => {
if (dispatch) tr.replaceWith(pos, pos + node.nodeSize, node.content);
return true;
})
.run();
};
const addImage = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
input.multiple = true;
input.onchange = async () => {
const files = [...(input.files ?? [])];
if (!files.length) return;
const urls: string[] = [];
for (const file of files) {
try {
const { url } = await api.uploadPostImage(file);
urls.push(url);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '图片上传失败');
}
}
if (!urls.length) return;
const pos = getPos();
if (typeof pos !== 'number') return;
editor
.chain()
.focus()
.command(({ tr, dispatch, state }) => {
const imageType = state.schema.nodes.image;
if (!imageType || !dispatch) return false;
let cur = pos + node.nodeSize - 1;
for (const src of urls) {
const img = imageType.create({ src });
tr.insert(cur, img);
cur += img.nodeSize;
}
const nextLayout = suggestImageGroupLayout(count + urls.length);
tr.setNodeMarkup(pos, undefined, { ...node.attrs, layout: nextLayout });
return true;
})
.run();
};
input.click();
};
return (
<NodeViewWrapper
className={`image-group image-group--${layout}${selected ? ' image-group--selected' : ''}`}
data-image-group=""
data-layout={layout}
>
<div className="image-group__toolbar" contentEditable={false}>
<span className="image-group__toolbar-label"> · {count} </span>
<div className="image-group__toolbar-actions">
{LAYOUTS.map(item => {
const Icon = item.icon;
return (
<button
key={item.key}
type="button"
className={`image-group__layout-btn${layout === item.key ? ' is-active' : ''}`}
title={item.hint}
onMouseDown={e => e.preventDefault()}
onClick={() => setLayout(item.key)}
>
<Icon size={14} />
<span>{item.label}</span>
</button>
);
})}
<button
type="button"
className="image-group__layout-btn"
title="向本组追加图片"
onMouseDown={e => e.preventDefault()}
onClick={addImage}
>
<Plus size={14} />
<span></span>
</button>
<button
type="button"
className="image-group__layout-btn"
title="拆开为单独图片"
onMouseDown={e => e.preventDefault()}
onClick={unwrap}
>
<Ungroup size={14} />
<span></span>
</button>
</div>
</div>
<NodeViewContent className="image-group__grid" as="div" />
</NodeViewWrapper>
);
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
imageGroup: {
insertImageGroup: (srcs: string[], layout?: ImageGroupLayout) => ReturnType;
setImageGroupLayout: (layout: ImageGroupLayout) => ReturnType;
unwrapImageGroup: () => ReturnType;
wrapImagesInGroup: () => ReturnType;
};
}
}
/** TipTap 图组:多图并排 / 宫格布局 */
export const ImageGroup = Node.create({
name: 'imageGroup',
group: 'block',
content: 'image+',
defining: true,
isolating: true,
addAttributes() {
return {
layout: {
default: 'cols-2' satisfies ImageGroupLayout,
parseHTML: (el) => (el.getAttribute('data-layout') as ImageGroupLayout) || 'cols-2',
renderHTML: (attrs) => ({ 'data-layout': attrs.layout || 'cols-2' }),
},
};
},
parseHTML() {
return [{ tag: 'div[data-image-group]' }];
},
renderHTML({ HTMLAttributes }) {
const layout = HTMLAttributes['data-layout'] || 'cols-2';
return [
'div',
mergeAttributes(HTMLAttributes, {
'data-image-group': '',
'data-layout': layout,
class: `image-group image-group--${layout}`,
}),
0,
];
},
addNodeView() {
return ReactNodeViewRenderer(ImageGroupView);
},
addCommands() {
return {
insertImageGroup: (srcs, layout) => ({ chain }) => {
if (!srcs.length) return false;
const nextLayout = layout || suggestImageGroupLayout(srcs.length);
return chain()
.insertContent({
type: this.name,
attrs: { layout: nextLayout },
content: srcs.map(src => ({ type: 'image', attrs: { src } })),
})
.run();
},
setImageGroupLayout: (layout) => ({ commands }) =>
commands.updateAttributes(this.name, { layout }),
unwrapImageGroup: () => ({ tr, state, dispatch }) => {
const { $from } = state.selection;
const depth = findImageGroupDepth($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;
},
/**
* 将光标/选区附近的连续图片包成图组。
* TipTap 图片是 atom无法像文本那样拖选多张因此自动扩展相邻图片。
*/
wrapImagesInGroup: () => ({ tr, state, dispatch }) => {
const anchor = findAnchorImagePos(state);
if (anchor == null) return false;
const run = collectConsecutiveImageRun(state, anchor);
if (!run) return false;
const groupType = state.schema.nodes.imageGroup;
if (!groupType) return false;
const group = groupType.create(
{ layout: suggestImageGroupLayout(run.images.length) },
run.images,
);
if (dispatch) {
tr.replaceWith(run.from, run.to, group);
// 选中新建图组,便于立刻改列数
tr.setSelection(NodeSelection.create(tr.doc, run.from));
}
return true;
},
};
},
});