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

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

@@ -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": {

View File

@@ -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",

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>
);
}

View File

@@ -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;
}
/* 源码双栏:充分利用加宽画布 */

View File

@@ -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;

View File

@@ -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'],

View File

@@ -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",