编辑工具增加表格输入菜单,优化表格样式
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-image": "^3.26.1",
|
||||||
"@tiptap/extension-link": "^3.26.1",
|
"@tiptap/extension-link": "^3.26.1",
|
||||||
"@tiptap/extension-placeholder": "^3.26.1",
|
"@tiptap/extension-placeholder": "^3.26.1",
|
||||||
|
"@tiptap/extension-table": "^3.26.1",
|
||||||
"@tiptap/extension-underline": "^3.26.1",
|
"@tiptap/extension-underline": "^3.26.1",
|
||||||
"@tiptap/pm": "^3.26.1",
|
"@tiptap/pm": "^3.26.1",
|
||||||
"@tiptap/react": "^3.26.1",
|
"@tiptap/react": "^3.26.1",
|
||||||
@@ -2268,6 +2269,20 @@
|
|||||||
"@tiptap/core": "3.26.1"
|
"@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": {
|
"node_modules/@tiptap/extension-text": {
|
||||||
"version": "3.26.1",
|
"version": "3.26.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.26.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.26.1.tgz",
|
||||||
@@ -2462,14 +2477,12 @@
|
|||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.31",
|
"version": "18.3.31",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
@@ -2480,7 +2493,6 @@
|
|||||||
"version": "18.3.7",
|
"version": "18.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
@@ -2787,7 +2799,6 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/dayjs": {
|
"node_modules/dayjs": {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"@tiptap/extension-image": "^3.26.1",
|
"@tiptap/extension-image": "^3.26.1",
|
||||||
"@tiptap/extension-link": "^3.26.1",
|
"@tiptap/extension-link": "^3.26.1",
|
||||||
"@tiptap/extension-placeholder": "^3.26.1",
|
"@tiptap/extension-placeholder": "^3.26.1",
|
||||||
|
"@tiptap/extension-table": "^3.26.1",
|
||||||
"@tiptap/extension-underline": "^3.26.1",
|
"@tiptap/extension-underline": "^3.26.1",
|
||||||
"@tiptap/pm": "^3.26.1",
|
"@tiptap/pm": "^3.26.1",
|
||||||
"@tiptap/react": "^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 Link from '@tiptap/extension-link';
|
||||||
import Placeholder from '@tiptap/extension-placeholder';
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
import Underline from '@tiptap/extension-underline';
|
import Underline from '@tiptap/extension-underline';
|
||||||
|
import { TableKit } from '@tiptap/extension-table';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import {
|
import {
|
||||||
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIcon, Code, Quote,
|
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIcon, Code, Quote,
|
||||||
List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
|
List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
|
||||||
FileCode, PenLine, Maximize2, Minimize2,
|
FileCode, PenLine, Maximize2, Minimize2,
|
||||||
Columns2, PanelLeft, PanelRight, StretchHorizontal,
|
Columns2, PanelLeft, PanelRight, StretchHorizontal,
|
||||||
|
Table as TableIcon, BetweenHorizonalStart, BetweenVerticalStart, Rows3, Columns3,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
|
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
|
||||||
import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent';
|
import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent';
|
||||||
@@ -36,6 +38,10 @@ import { ClearFloatParagraph, ClearFloatSync } from './editor/ClearFloatParagrap
|
|||||||
import { ArticleLinkDialog } from './editor/ArticleLinkDialog';
|
import { ArticleLinkDialog } from './editor/ArticleLinkDialog';
|
||||||
import { ArticleCodeBlockDialog } from './editor/ArticleCodeBlockDialog';
|
import { ArticleCodeBlockDialog } from './editor/ArticleCodeBlockDialog';
|
||||||
import { ArticleCodeBlock } from './editor/ArticleCodeBlockExtension';
|
import { ArticleCodeBlock } from './editor/ArticleCodeBlockExtension';
|
||||||
|
import {
|
||||||
|
ArticleTableDialog,
|
||||||
|
type TableInsertOptions,
|
||||||
|
} from './editor/ArticleTableDialog';
|
||||||
import {
|
import {
|
||||||
formatFenceInfo,
|
formatFenceInfo,
|
||||||
type CodeBlockInsertOptions,
|
type CodeBlockInsertOptions,
|
||||||
@@ -57,6 +63,7 @@ interface Props {
|
|||||||
type EditorMode = 'rich' | 'markdown';
|
type EditorMode = 'rich' | 'markdown';
|
||||||
type LinkTarget = 'rich' | 'markdown';
|
type LinkTarget = 'rich' | 'markdown';
|
||||||
type CodeBlockTarget = 'rich' | 'markdown';
|
type CodeBlockTarget = 'rich' | 'markdown';
|
||||||
|
type TableTarget = 'rich' | 'markdown';
|
||||||
|
|
||||||
interface ToolBtn {
|
interface ToolBtn {
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
@@ -77,6 +84,22 @@ function buildMarkdownCodeBlockSnippet(opts: CodeBlockInsertOptions, body = '代
|
|||||||
return `\n${fence}\n${body}\n\`\`\`\n`;
|
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 自定义标签 */
|
/** 净化编辑器 HTML,保留 members-only 自定义标签 */
|
||||||
function sanitizeHtml(html: string): string {
|
function sanitizeHtml(html: string): string {
|
||||||
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
|
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 [codeBlockTarget, setCodeBlockTarget] = useState<CodeBlockTarget>('rich');
|
||||||
const [codeBlockEditing, setCodeBlockEditing] = useState(false);
|
const [codeBlockEditing, setCodeBlockEditing] = useState(false);
|
||||||
const [codeBlockInitial, setCodeBlockInitial] = useState<Partial<CodeBlockInsertOptions> | null>(null);
|
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 markdownRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
@@ -210,6 +236,12 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
|||||||
underline: false,
|
underline: false,
|
||||||
}),
|
}),
|
||||||
ArticleCodeBlock,
|
ArticleCodeBlock,
|
||||||
|
TableKit.configure({
|
||||||
|
table: {
|
||||||
|
resizable: false,
|
||||||
|
HTMLAttributes: { class: 'article-table' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
ClearFloatParagraph,
|
ClearFloatParagraph,
|
||||||
ClearFloatSync,
|
ClearFloatSync,
|
||||||
Underline,
|
Underline,
|
||||||
@@ -398,6 +430,34 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
|||||||
}
|
}
|
||||||
}, [editor]);
|
}, [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) => {
|
const applyLink = useCallback((url: string) => {
|
||||||
if (linkTarget === 'markdown') {
|
if (linkTarget === 'markdown') {
|
||||||
const textarea = markdownRef.current;
|
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: <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: <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: <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: <LinkIcon size={15} />, title: '链接', active: editor.isActive('link'), action: () => openLinkDialog('rich') },
|
||||||
{
|
{
|
||||||
icon: <ImageIcon size={15} />,
|
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) {
|
if (imageActive && !groupActive) {
|
||||||
tools.push(
|
tools.push(
|
||||||
{
|
{
|
||||||
@@ -575,7 +663,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
|||||||
});
|
});
|
||||||
|
|
||||||
return tools;
|
return tools;
|
||||||
}, [editor, openLinkDialog, openCodeBlockDialog, setImage, wrapMembersOnly, wrapSelectedAsGroup, setImageDisplay]);
|
}, [editor, openLinkDialog, openCodeBlockDialog, openTableDialog, setImage, wrapMembersOnly, wrapSelectedAsGroup, setImageDisplay]);
|
||||||
|
|
||||||
const buildMarkdownTools = useCallback((): ToolBtn[] => [
|
const buildMarkdownTools = useCallback((): ToolBtn[] => [
|
||||||
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) },
|
{ 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: <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: <ListOrdered size={15} />, title: '有序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '1. ', ch)) },
|
||||||
{ icon: <Code size={15} />, title: '代码块', hint: '语言、行号与折叠', action: () => openCodeBlockDialog('markdown') },
|
{ 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: <LinkIcon size={15} />, title: '链接', action: () => openLinkDialog('markdown') },
|
||||||
{ icon: <ImageIcon size={15} />, title: '上传图片', action: insertMarkdownImage },
|
{ icon: <ImageIcon size={15} />, title: '上传图片', action: insertMarkdownImage },
|
||||||
{
|
{
|
||||||
@@ -597,7 +686,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
|||||||
className: 'article-tool-btn--members',
|
className: 'article-tool-btn--members',
|
||||||
action: withMarkdown(insertMarkdownMembersOnly),
|
action: withMarkdown(insertMarkdownMembersOnly),
|
||||||
},
|
},
|
||||||
], [withMarkdown, openLinkDialog, openCodeBlockDialog, insertMarkdownImage]);
|
], [withMarkdown, openLinkDialog, openCodeBlockDialog, openTableDialog, insertMarkdownImage]);
|
||||||
|
|
||||||
const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools();
|
const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools();
|
||||||
const words = mode === 'markdown'
|
const words = mode === 'markdown'
|
||||||
@@ -707,6 +796,13 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
|||||||
onConfirm={applyCodeBlock}
|
onConfirm={applyCodeBlock}
|
||||||
onRemove={codeBlockEditing ? removeCodeBlock : undefined}
|
onRemove={codeBlockEditing ? removeCodeBlock : undefined}
|
||||||
/>
|
/>
|
||||||
|
<ArticleTableDialog
|
||||||
|
open={tableDialogOpen}
|
||||||
|
onOpenChange={setTableDialogOpen}
|
||||||
|
editing={tableEditing}
|
||||||
|
onConfirm={applyTable}
|
||||||
|
onRemove={tableEditing ? removeTable : undefined}
|
||||||
|
/>
|
||||||
</div>
|
</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 blockquote p:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
/* 表格:按内容收缩,边框与表头背景同宽;宽表由外包层横向滚动 */
|
/* 表格:简约网格,表头轻量区分;宽表外包层横向滚动 */
|
||||||
.post-detail-content .md-table-wrap {
|
.post-detail-content .md-table-wrap {
|
||||||
display: block;
|
display: block;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
margin: 1.1em 0;
|
margin: 1.25em 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border: 1px solid var(--j13-border-light);
|
border: 1px solid var(--j13-border);
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
background: var(--j13-bg-surface);
|
background: var(--j13-bg-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-detail-content table {
|
.post-detail-content table {
|
||||||
width: auto;
|
width: auto;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
margin: 1.1em 0;
|
margin: 1.25em 0;
|
||||||
border-collapse: collapse;
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
display: table;
|
display: table;
|
||||||
border: 1px solid var(--j13-border-light);
|
border: 1px solid var(--j13-border);
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--j13-bg-surface);
|
background: var(--j13-bg-surface);
|
||||||
|
color: var(--color-text-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-detail-content .md-table-wrap table {
|
.post-detail-content .md-table-wrap table {
|
||||||
@@ -4263,19 +4266,47 @@ a.post-title:visited {
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-detail-content th,
|
.post-detail-content th,
|
||||||
.post-detail-content td {
|
.post-detail-content td {
|
||||||
padding: 10px 12px;
|
padding: 9px 14px;
|
||||||
border-bottom: 1px solid var(--j13-border-light);
|
border-right: 1px solid var(--j13-border);
|
||||||
|
border-bottom: 1px solid var(--j13-border);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
vertical-align: top;
|
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 {
|
.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-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
white-space: nowrap;
|
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 pre,
|
||||||
.post-detail-content code,
|
.post-detail-content code,
|
||||||
@@ -6954,11 +6985,18 @@ button.profile-stat:hover strong {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 编辑器内表格:允许单元格换行,过宽时在编辑区内横滑 */
|
/* 编辑器内表格:完整网格、空格可见,便于点选与填写 */
|
||||||
.article-prosemirror.post-detail-content table,
|
.article-prosemirror.post-detail-content table,
|
||||||
.article-prosemirror table {
|
.article-prosemirror table {
|
||||||
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
margin: 1em 0;
|
||||||
box-sizing: border-box;
|
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,
|
.article-prosemirror.post-detail-content th,
|
||||||
@@ -6968,6 +7006,108 @@ button.profile-stat:hover strong {
|
|||||||
white-space: normal;
|
white-space: normal;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
word-break: break-word;
|
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',
|
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', {
|
service.addRule('anchor', {
|
||||||
filter: (node) => {
|
filter: (node) => {
|
||||||
if (node.nodeName !== 'A') return false;
|
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-code-style', 'data-line-numbers', 'data-collapsed', 'data-line-count', 'data-lineno-digits',
|
||||||
'data-image-group', 'data-layout', 'data-display',
|
'data-image-group', 'data-layout', 'data-display',
|
||||||
'data-clear-float',
|
'data-clear-float',
|
||||||
|
'colspan', 'rowspan',
|
||||||
'class',
|
'class',
|
||||||
],
|
],
|
||||||
FORBID_TAGS: ['style', 'link', 'meta', 'base', 'object', 'embed', 'form', 'input', 'button', 'textarea', 'select'],
|
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",
|
"blockquote", "ul", "ol", "li", "table", "thead", "tbody", "tr", "th", "td",
|
||||||
"members-only",
|
"members-only",
|
||||||
)
|
)
|
||||||
|
p.AllowAttrs("colspan", "rowspan").OnElements("th", "td")
|
||||||
p.AllowAttrs(
|
p.AllowAttrs(
|
||||||
"data-locked", "data-length",
|
"data-locked", "data-length",
|
||||||
"data-code-copy", "data-code-fold", "data-lang", "data-full",
|
"data-code-copy", "data-code-fold", "data-lang", "data-full",
|
||||||
|
|||||||
Reference in New Issue
Block a user