编辑工具增加表格输入菜单,优化表格样式

This commit is contained in:
2026-08-05 12:21:14 +08:00
parent e50047e1c8
commit 5b2d9a4ea4
8 changed files with 467 additions and 19 deletions

View File

@@ -7,12 +7,14 @@ import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline';
import { TableKit } from '@tiptap/extension-table';
import DOMPurify from 'dompurify';
import {
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIcon, Code, Quote,
List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
FileCode, PenLine, Maximize2, Minimize2,
Columns2, PanelLeft, PanelRight, StretchHorizontal,
Table as TableIcon, BetweenHorizonalStart, BetweenVerticalStart, Rows3, Columns3,
} from 'lucide-react';
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent';
@@ -36,6 +38,10 @@ import { ClearFloatParagraph, ClearFloatSync } from './editor/ClearFloatParagrap
import { ArticleLinkDialog } from './editor/ArticleLinkDialog';
import { ArticleCodeBlockDialog } from './editor/ArticleCodeBlockDialog';
import { ArticleCodeBlock } from './editor/ArticleCodeBlockExtension';
import {
ArticleTableDialog,
type TableInsertOptions,
} from './editor/ArticleTableDialog';
import {
formatFenceInfo,
type CodeBlockInsertOptions,
@@ -57,6 +63,7 @@ interface Props {
type EditorMode = 'rich' | 'markdown';
type LinkTarget = 'rich' | 'markdown';
type CodeBlockTarget = 'rich' | 'markdown';
type TableTarget = 'rich' | 'markdown';
interface ToolBtn {
icon: ReactNode;
@@ -77,6 +84,22 @@ function buildMarkdownCodeBlockSnippet(opts: CodeBlockInsertOptions, body = '代
return `\n${fence}\n${body}\n\`\`\`\n`;
}
/** 生成 GFM 管道表;源码侧始终带表头分隔行 */
function buildMarkdownTableSnippet(opts: TableInsertOptions): string {
const cols = Math.max(1, opts.cols);
const totalRows = Math.max(1, opts.rows);
const bodyRows = Math.max(0, totalRows - 1);
const headerCells = Array.from({ length: cols }, (_, i) => `${i + 1}`);
const sepCells = Array.from({ length: cols }, () => '---');
const emptyCells = Array.from({ length: cols }, () => ' ');
const lines = [
`| ${headerCells.join(' | ')} |`,
`| ${sepCells.join(' | ')} |`,
...Array.from({ length: bodyRows }, () => `| ${emptyCells.join(' | ')} |`),
];
return `\n\n${lines.join('\n')}\n\n`;
}
/** 净化编辑器 HTML保留 members-only 自定义标签 */
function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
@@ -197,6 +220,9 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
const [codeBlockTarget, setCodeBlockTarget] = useState<CodeBlockTarget>('rich');
const [codeBlockEditing, setCodeBlockEditing] = useState(false);
const [codeBlockInitial, setCodeBlockInitial] = useState<Partial<CodeBlockInsertOptions> | null>(null);
const [tableDialogOpen, setTableDialogOpen] = useState(false);
const [tableTarget, setTableTarget] = useState<TableTarget>('rich');
const [tableEditing, setTableEditing] = useState(false);
const markdownRef = useRef<HTMLTextAreaElement>(null);
const editor = useEditor({
@@ -210,6 +236,12 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
underline: false,
}),
ArticleCodeBlock,
TableKit.configure({
table: {
resizable: false,
HTMLAttributes: { class: 'article-table' },
},
}),
ClearFloatParagraph,
ClearFloatSync,
Underline,
@@ -398,6 +430,34 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
}
}, [editor]);
const openTableDialog = useCallback((target: TableTarget) => {
setTableTarget(target);
setTableEditing(target === 'rich' && Boolean(editor?.isActive('table')));
setTableDialogOpen(true);
}, [editor]);
const applyTable = useCallback((opts: TableInsertOptions) => {
if (tableTarget === 'markdown') {
const textarea = markdownRef.current;
if (!textarea) return;
insertAtCursor(textarea, markdownSource, buildMarkdownTableSnippet(opts), handleMarkdownChange);
return;
}
if (!editor) return;
editor.chain().focus().insertTable({
rows: opts.rows,
cols: opts.cols,
withHeaderRow: opts.withHeaderRow,
}).run();
}, [tableTarget, editor, markdownSource, handleMarkdownChange]);
const removeTable = useCallback(() => {
if (!editor) return;
if (editor.isActive('table')) {
editor.chain().focus().deleteTable().run();
}
}, [editor]);
const applyLink = useCallback((url: string) => {
if (linkTarget === 'markdown') {
const textarea = markdownRef.current;
@@ -524,6 +584,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
{ 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: '代码块', hint: '语言、行号与折叠', active: editor.isActive('codeBlock'), action: () => openCodeBlockDialog('rich') },
{ icon: <TableIcon size={15} />, title: '表格', hint: '插入表格;表内可增删行列', active: editor.isActive('table'), action: () => openTableDialog('rich') },
{ icon: <LinkIcon size={15} />, title: '链接', active: editor.isActive('link'), action: () => openLinkDialog('rich') },
{
icon: <ImageIcon size={15} />,
@@ -540,6 +601,33 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
},
];
if (editor.isActive('table')) {
tools.push(
{
icon: <BetweenHorizonalStart size={15} />,
title: '下方插入行',
action: () => editor.chain().focus().addRowAfter().run(),
},
{
icon: <BetweenVerticalStart size={15} />,
title: '右侧插入列',
action: () => editor.chain().focus().addColumnAfter().run(),
},
{
icon: <Rows3 size={15} />,
title: '删除行',
hint: '删除当前行',
action: () => editor.chain().focus().deleteRow().run(),
},
{
icon: <Columns3 size={15} />,
title: '删除列',
hint: '删除当前列',
action: () => editor.chain().focus().deleteColumn().run(),
},
);
}
if (imageActive && !groupActive) {
tools.push(
{
@@ -575,7 +663,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
});
return tools;
}, [editor, openLinkDialog, openCodeBlockDialog, setImage, wrapMembersOnly, wrapSelectedAsGroup, setImageDisplay]);
}, [editor, openLinkDialog, openCodeBlockDialog, openTableDialog, setImage, wrapMembersOnly, wrapSelectedAsGroup, setImageDisplay]);
const buildMarkdownTools = useCallback((): ToolBtn[] => [
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) },
@@ -588,6 +676,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
{ 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: '代码块', hint: '语言、行号与折叠', action: () => openCodeBlockDialog('markdown') },
{ icon: <TableIcon size={15} />, title: '表格', hint: '插入 GFM 管道表', action: () => openTableDialog('markdown') },
{ icon: <LinkIcon size={15} />, title: '链接', action: () => openLinkDialog('markdown') },
{ icon: <ImageIcon size={15} />, title: '上传图片', action: insertMarkdownImage },
{
@@ -597,7 +686,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
className: 'article-tool-btn--members',
action: withMarkdown(insertMarkdownMembersOnly),
},
], [withMarkdown, openLinkDialog, openCodeBlockDialog, insertMarkdownImage]);
], [withMarkdown, openLinkDialog, openCodeBlockDialog, openTableDialog, insertMarkdownImage]);
const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools();
const words = mode === 'markdown'
@@ -707,6 +796,13 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
onConfirm={applyCodeBlock}
onRemove={codeBlockEditing ? removeCodeBlock : undefined}
/>
<ArticleTableDialog
open={tableDialogOpen}
onOpenChange={setTableDialogOpen}
editing={tableEditing}
onConfirm={applyTable}
onRemove={tableEditing ? removeTable : undefined}
/>
</div>
);
});

View File

@@ -0,0 +1,162 @@
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';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
export interface TableInsertOptions {
rows: number;
cols: number;
withHeaderRow: boolean;
}
const DEFAULT_OPTS: TableInsertOptions = {
rows: 3,
cols: 3,
withHeaderRow: true,
};
const MIN_SIZE = 1;
const MAX_ROWS = 20;
const MAX_COLS = 10;
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 光标已在表格内时显示「删除表格」 */
editing?: boolean;
onConfirm: (opts: TableInsertOptions) => void;
onRemove?: () => void;
}
function clampInt(raw: string, min: number, max: number, fallback: number): number {
const n = Number.parseInt(raw, 10);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, n));
}
/** 文章编辑器:插入表格(行列与表头) */
export function ArticleTableDialog({
open,
onOpenChange,
editing = false,
onConfirm,
onRemove,
}: Props) {
const [rows, setRows] = useState(String(DEFAULT_OPTS.rows));
const [cols, setCols] = useState(String(DEFAULT_OPTS.cols));
const [withHeaderRow, setWithHeaderRow] = useState(DEFAULT_OPTS.withHeaderRow);
useEffect(() => {
if (!open) return;
setRows(String(DEFAULT_OPTS.rows));
setCols(String(DEFAULT_OPTS.cols));
setWithHeaderRow(DEFAULT_OPTS.withHeaderRow);
}, [open]);
const handleConfirm = () => {
onConfirm({
rows: clampInt(rows, MIN_SIZE, MAX_ROWS, DEFAULT_OPTS.rows),
cols: clampInt(cols, MIN_SIZE, MAX_COLS, DEFAULT_OPTS.cols),
withHeaderRow,
});
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="article-table-dialog sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>{editing ? '表格' : '插入表格'}</DialogTitle>
<DialogDescription>
{editing
? '可再插入一张新表,或删除当前表格。也可用工具栏增删行列。'
: '选择行数与列数。源码模式会写入 GFMGitHub 风格 Markdown管道表。'}
</DialogDescription>
</DialogHeader>
<div className="article-table-dialog__body">
<div className="article-table-dialog__grid">
<div className="article-table-dialog__field">
<Label htmlFor="article-table-rows"></Label>
<Input
id="article-table-rows"
type="number"
min={MIN_SIZE}
max={MAX_ROWS}
value={rows}
onChange={e => setRows(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
handleConfirm();
}
}}
autoFocus
/>
</div>
<div className="article-table-dialog__field">
<Label htmlFor="article-table-cols"></Label>
<Input
id="article-table-cols"
type="number"
min={MIN_SIZE}
max={MAX_COLS}
value={cols}
onChange={e => setCols(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
handleConfirm();
}
}}
/>
</div>
</div>
<div className="article-table-dialog__toggle">
<div className="article-table-dialog__toggle-text">
<Label htmlFor="article-table-header"></Label>
<p>使</p>
</div>
<Switch
id="article-table-header"
checked={withHeaderRow}
onCheckedChange={setWithHeaderRow}
/>
</div>
</div>
<DialogFooter className="article-table-dialog__footer">
{editing && onRemove ? (
<Button
type="button"
variant="outline"
className="article-table-dialog__remove"
onClick={() => {
onRemove();
onOpenChange(false);
}}
>
</Button>
) : null}
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button type="button" onClick={handleConfirm}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}