编辑工具增加表格输入菜单,优化表格样式
This commit is contained in:
19
frontend/package-lock.json
generated
19
frontend/package-lock.json
generated
@@ -21,6 +21,7 @@
|
||||
"@tiptap/extension-image": "^3.26.1",
|
||||
"@tiptap/extension-link": "^3.26.1",
|
||||
"@tiptap/extension-placeholder": "^3.26.1",
|
||||
"@tiptap/extension-table": "^3.26.1",
|
||||
"@tiptap/extension-underline": "^3.26.1",
|
||||
"@tiptap/pm": "^3.26.1",
|
||||
"@tiptap/react": "^3.26.1",
|
||||
@@ -2268,6 +2269,20 @@
|
||||
"@tiptap/core": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.26.1.tgz",
|
||||
"integrity": "sha512-epxUhc5ecxsH39lzNejc2WxFPXAXWGs9g2ofKDrIaoSlZlfFHf89/sEGSz048a46E5Sb+fYCtzUvRUUx+aG4xw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.26.1",
|
||||
"@tiptap/pm": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.26.1.tgz",
|
||||
@@ -2462,14 +2477,12 @@
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
@@ -2480,7 +2493,6 @@
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
@@ -2787,7 +2799,6 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"@tiptap/extension-image": "^3.26.1",
|
||||
"@tiptap/extension-link": "^3.26.1",
|
||||
"@tiptap/extension-placeholder": "^3.26.1",
|
||||
"@tiptap/extension-table": "^3.26.1",
|
||||
"@tiptap/extension-underline": "^3.26.1",
|
||||
"@tiptap/pm": "^3.26.1",
|
||||
"@tiptap/react": "^3.26.1",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4231,29 +4231,32 @@ a.post-title:visited {
|
||||
}
|
||||
.post-detail-content blockquote p:last-child { margin-bottom: 0; }
|
||||
|
||||
/* 表格:按内容收缩,边框与表头背景同宽;宽表由外包层横向滚动 */
|
||||
/* 表格:简约网格,表头轻量区分;宽表外包层横向滚动 */
|
||||
.post-detail-content .md-table-wrap {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin: 1.1em 0;
|
||||
margin: 1.25em 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--j13-border-light);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--j13-border);
|
||||
border-radius: 8px;
|
||||
background: var(--j13-bg-surface);
|
||||
}
|
||||
|
||||
.post-detail-content table {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
margin: 1.1em 0;
|
||||
border-collapse: collapse;
|
||||
margin: 1.25em 0;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
display: table;
|
||||
border: 1px solid var(--j13-border-light);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--j13-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--j13-bg-surface);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.post-detail-content .md-table-wrap table {
|
||||
@@ -4263,19 +4266,47 @@ a.post-title:visited {
|
||||
border-radius: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.post-detail-content th,
|
||||
.post-detail-content td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--j13-border-light);
|
||||
padding: 9px 14px;
|
||||
border-right: 1px solid var(--j13-border);
|
||||
border-bottom: 1px solid var(--j13-border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
.post-detail-content th:last-child,
|
||||
.post-detail-content td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.post-detail-content tr:last-child th,
|
||||
.post-detail-content tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.post-detail-content th {
|
||||
background: var(--j13-bg-block-muted);
|
||||
background: color-mix(in srgb, var(--j13-bg-block-muted) 88%, var(--j13-bg-surface));
|
||||
color: var(--color-text-2);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.post-detail-content tr:last-child td { border-bottom: none; }
|
||||
|
||||
.post-detail-content tbody tr:nth-child(even) td {
|
||||
background: color-mix(in srgb, var(--j13-bg-block-muted) 35%, transparent);
|
||||
}
|
||||
|
||||
.post-detail-content tbody tr:hover td {
|
||||
background: color-mix(in srgb, var(--j13-green) 6%, var(--j13-bg-surface));
|
||||
}
|
||||
|
||||
.post-detail-content table p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.post-detail-content pre,
|
||||
.post-detail-content code,
|
||||
@@ -6954,11 +6985,18 @@ button.profile-stat:hover strong {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 编辑器内表格:允许单元格换行,过宽时在编辑区内横滑 */
|
||||
/* 编辑器内表格:完整网格、空格可见,便于点选与填写 */
|
||||
.article-prosemirror.post-detail-content table,
|
||||
.article-prosemirror table {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 1em 0;
|
||||
box-sizing: border-box;
|
||||
table-layout: fixed;
|
||||
border: 1px solid color-mix(in srgb, var(--j13-border) 80%, var(--color-text-3));
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--j13-bg-surface);
|
||||
}
|
||||
|
||||
.article-prosemirror.post-detail-content th,
|
||||
@@ -6968,6 +7006,108 @@ button.profile-stat:hover strong {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
position: relative;
|
||||
min-width: 72px;
|
||||
min-height: 2.4em;
|
||||
padding: 8px 10px;
|
||||
border-right: 1px solid color-mix(in srgb, var(--j13-border) 80%, var(--color-text-3));
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--j13-border) 80%, var(--color-text-3));
|
||||
vertical-align: top;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.article-prosemirror.post-detail-content th:last-child,
|
||||
.article-prosemirror.post-detail-content td:last-child,
|
||||
.article-prosemirror th:last-child,
|
||||
.article-prosemirror td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.article-prosemirror.post-detail-content tr:last-child th,
|
||||
.article-prosemirror.post-detail-content tr:last-child td,
|
||||
.article-prosemirror tr:last-child th,
|
||||
.article-prosemirror tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.article-prosemirror.post-detail-content th,
|
||||
.article-prosemirror th {
|
||||
background: color-mix(in srgb, var(--j13-bg-block-muted) 92%, var(--j13-bg-surface));
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
/* 编辑态关闭斑马纹与悬停,避免干扰光标所在格 */
|
||||
.article-prosemirror.post-detail-content tbody tr:nth-child(even) td,
|
||||
.article-prosemirror tbody tr:nth-child(even) td,
|
||||
.article-prosemirror.post-detail-content tbody tr:hover td,
|
||||
.article-prosemirror tbody tr:hover td {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.article-prosemirror.post-detail-content th.selectedCell,
|
||||
.article-prosemirror.post-detail-content td.selectedCell,
|
||||
.article-prosemirror th.selectedCell,
|
||||
.article-prosemirror td.selectedCell {
|
||||
background: color-mix(in srgb, var(--j13-green) 12%, var(--j13-bg-surface)) !important;
|
||||
box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--j13-green) 55%, transparent);
|
||||
}
|
||||
|
||||
/* 单元格内段落:去掉外边距,空格也有可点高度 */
|
||||
.article-prosemirror table p {
|
||||
margin: 0;
|
||||
min-height: 1.45em;
|
||||
}
|
||||
|
||||
.article-prosemirror table p + p {
|
||||
margin-top: 0.35em;
|
||||
}
|
||||
|
||||
/* 表格插入弹窗 */
|
||||
.article-table-dialog__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.article-table-dialog__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.article-table-dialog__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.article-table-dialog__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--j13-border-light);
|
||||
border-radius: 12px;
|
||||
background: var(--j13-bg-block-muted);
|
||||
}
|
||||
.article-table-dialog__toggle-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.article-table-dialog__toggle-text p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.article-table-dialog__footer {
|
||||
gap: 8px;
|
||||
}
|
||||
.article-table-dialog__remove {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* 源码双栏:充分利用加宽画布 */
|
||||
|
||||
@@ -143,6 +143,42 @@ function addTurndownContentRules(service: TurndownService): void {
|
||||
replacement: () => '\n\n---\n\n',
|
||||
});
|
||||
|
||||
// HTML 表格 → GFM 管道表,便于富文本与源码来回切换
|
||||
service.addRule('table', {
|
||||
filter: 'table',
|
||||
replacement: (_content, node) => {
|
||||
const table = node as HTMLTableElement;
|
||||
const rows = [...table.querySelectorAll('tr')];
|
||||
if (!rows.length) return '';
|
||||
|
||||
const cellText = (cell: Element) =>
|
||||
(cell.textContent || '')
|
||||
.replace(/\u00A0/g, ' ')
|
||||
.trim()
|
||||
.replace(/\|/g, '\\|')
|
||||
.replace(/\n+/g, ' ');
|
||||
|
||||
const matrix = rows
|
||||
.map(tr => [...tr.querySelectorAll('th, td')].map(cellText))
|
||||
.filter(row => row.length > 0);
|
||||
if (!matrix.length) return '';
|
||||
|
||||
const colCount = Math.max(...matrix.map(r => r.length));
|
||||
if (!colCount) return '';
|
||||
|
||||
const pad = (row: string[]) => {
|
||||
const next = [...row];
|
||||
while (next.length < colCount) next.push('');
|
||||
return next.slice(0, colCount);
|
||||
};
|
||||
|
||||
const lines = matrix.map(row => `| ${pad(row).join(' | ')} |`);
|
||||
const sep = `| ${Array.from({ length: colCount }, () => '---').join(' | ')} |`;
|
||||
lines.splice(1, 0, sep);
|
||||
return `\n\n${lines.join('\n')}\n\n`;
|
||||
},
|
||||
});
|
||||
|
||||
service.addRule('anchor', {
|
||||
filter: (node) => {
|
||||
if (node.nodeName !== 'A') return false;
|
||||
|
||||
@@ -16,6 +16,7 @@ export const POST_CONTENT_PURIFY_CONFIG: Config = {
|
||||
'data-code-style', 'data-line-numbers', 'data-collapsed', 'data-line-count', 'data-lineno-digits',
|
||||
'data-image-group', 'data-layout', 'data-display',
|
||||
'data-clear-float',
|
||||
'colspan', 'rowspan',
|
||||
'class',
|
||||
],
|
||||
FORBID_TAGS: ['style', 'link', 'meta', 'base', 'object', 'embed', 'form', 'input', 'button', 'textarea', 'select'],
|
||||
|
||||
@@ -24,6 +24,7 @@ func postContentHTMLPolicy() *bluemonday.Policy {
|
||||
"blockquote", "ul", "ol", "li", "table", "thead", "tbody", "tr", "th", "td",
|
||||
"members-only",
|
||||
)
|
||||
p.AllowAttrs("colspan", "rowspan").OnElements("th", "td")
|
||||
p.AllowAttrs(
|
||||
"data-locked", "data-length",
|
||||
"data-code-copy", "data-code-fold", "data-lang", "data-full",
|
||||
|
||||
Reference in New Issue
Block a user