编辑工具增加表格输入菜单,优化表格样式
This commit is contained in:
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
162
frontend/src/components/editor/ArticleTableDialog.tsx
Normal file
162
frontend/src/components/editor/ArticleTableDialog.tsx
Normal 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
|
||||
? '可再插入一张新表,或删除当前表格。也可用工具栏增删行列。'
|
||||
: '选择行数与列数。源码模式会写入 GFM(GitHub 风格 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user