diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fe966bf..2b268e9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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": { diff --git a/frontend/package.json b/frontend/package.json index ace4597..18ac317 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/ArticleEditor.tsx b/frontend/src/components/ArticleEditor.tsx index 414153b..2b311cf 100644 --- a/frontend/src/components/ArticleEditor.tsx +++ b/frontend/src/components/ArticleEditor.tsx @@ -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(function ArticleEdi const [codeBlockTarget, setCodeBlockTarget] = useState('rich'); const [codeBlockEditing, setCodeBlockEditing] = useState(false); const [codeBlockInitial, setCodeBlockInitial] = useState | null>(null); + const [tableDialogOpen, setTableDialogOpen] = useState(false); + const [tableTarget, setTableTarget] = useState('rich'); + const [tableEditing, setTableEditing] = useState(false); const markdownRef = useRef(null); const editor = useEditor({ @@ -210,6 +236,12 @@ const ArticleEditor = forwardRef(function ArticleEdi underline: false, }), ArticleCodeBlock, + TableKit.configure({ + table: { + resizable: false, + HTMLAttributes: { class: 'article-table' }, + }, + }), ClearFloatParagraph, ClearFloatSync, Underline, @@ -398,6 +430,34 @@ const ArticleEditor = forwardRef(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(function ArticleEdi { icon: , title: '无序列表', active: editor.isActive('bulletList'), action: () => editor.chain().focus().toggleBulletList().run() }, { icon: , title: '有序列表', active: editor.isActive('orderedList'), action: () => editor.chain().focus().toggleOrderedList().run() }, { icon: , title: '代码块', hint: '语言、行号与折叠', active: editor.isActive('codeBlock'), action: () => openCodeBlockDialog('rich') }, + { icon: , title: '表格', hint: '插入表格;表内可增删行列', active: editor.isActive('table'), action: () => openTableDialog('rich') }, { icon: , title: '链接', active: editor.isActive('link'), action: () => openLinkDialog('rich') }, { icon: , @@ -540,6 +601,33 @@ const ArticleEditor = forwardRef(function ArticleEdi }, ]; + if (editor.isActive('table')) { + tools.push( + { + icon: , + title: '下方插入行', + action: () => editor.chain().focus().addRowAfter().run(), + }, + { + icon: , + title: '右侧插入列', + action: () => editor.chain().focus().addColumnAfter().run(), + }, + { + icon: , + title: '删除行', + hint: '删除当前行', + action: () => editor.chain().focus().deleteRow().run(), + }, + { + icon: , + title: '删除列', + hint: '删除当前列', + action: () => editor.chain().focus().deleteColumn().run(), + }, + ); + } + if (imageActive && !groupActive) { tools.push( { @@ -575,7 +663,7 @@ const ArticleEditor = forwardRef(function ArticleEdi }); return tools; - }, [editor, openLinkDialog, openCodeBlockDialog, setImage, wrapMembersOnly, wrapSelectedAsGroup, setImageDisplay]); + }, [editor, openLinkDialog, openCodeBlockDialog, openTableDialog, setImage, wrapMembersOnly, wrapSelectedAsGroup, setImageDisplay]); const buildMarkdownTools = useCallback((): ToolBtn[] => [ { icon: H, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) }, @@ -588,6 +676,7 @@ const ArticleEditor = forwardRef(function ArticleEdi { icon: , title: '无序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '- ', ch)) }, { icon: , title: '有序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '1. ', ch)) }, { icon: , title: '代码块', hint: '语言、行号与折叠', action: () => openCodeBlockDialog('markdown') }, + { icon: , title: '表格', hint: '插入 GFM 管道表', action: () => openTableDialog('markdown') }, { icon: , title: '链接', action: () => openLinkDialog('markdown') }, { icon: , title: '上传图片', action: insertMarkdownImage }, { @@ -597,7 +686,7 @@ const ArticleEditor = forwardRef(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(function ArticleEdi onConfirm={applyCodeBlock} onRemove={codeBlockEditing ? removeCodeBlock : undefined} /> + ); }); diff --git a/frontend/src/components/editor/ArticleTableDialog.tsx b/frontend/src/components/editor/ArticleTableDialog.tsx new file mode 100644 index 0000000..479c6fa --- /dev/null +++ b/frontend/src/components/editor/ArticleTableDialog.tsx @@ -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 ( + + + + {editing ? '表格' : '插入表格'} + + {editing + ? '可再插入一张新表,或删除当前表格。也可用工具栏增删行列。' + : '选择行数与列数。源码模式会写入 GFM(GitHub 风格 Markdown)管道表。'} + + + +
+
+
+ + setRows(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + handleConfirm(); + } + }} + autoFocus + /> +
+
+ + setCols(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + handleConfirm(); + } + }} + /> +
+
+ +
+
+ +

表头单元格使用加粗样式

+
+ +
+
+ + + {editing && onRemove ? ( + + ) : null} + + + +
+
+ ); +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index c414e52..6cd38b4 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -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; } /* 源码双栏:充分利用加宽画布 */ diff --git a/frontend/src/utils/markdownContent.ts b/frontend/src/utils/markdownContent.ts index c38d1ff..530ff21 100644 --- a/frontend/src/utils/markdownContent.ts +++ b/frontend/src/utils/markdownContent.ts @@ -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; diff --git a/frontend/src/utils/postContent.ts b/frontend/src/utils/postContent.ts index ce2aca7..b1cf7fe 100644 --- a/frontend/src/utils/postContent.ts +++ b/frontend/src/utils/postContent.ts @@ -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'], diff --git a/service/sanitize_html.go b/service/sanitize_html.go index 913ea5a..36ac511 100644 --- a/service/sanitize_html.go +++ b/service/sanitize_html.go @@ -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",