代码块优化,增加aardio、R语言代码高亮等
This commit is contained in:
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"@radix-ui/react-switch": "^1.3.0",
|
||||
"@tanstack/react-virtual": "^3.11.2",
|
||||
"@tiptap/core": "^3.26.1",
|
||||
"@tiptap/extension-code-block": "^3.26.1",
|
||||
"@tiptap/extension-image": "^3.26.1",
|
||||
"@tiptap/extension-link": "^3.26.1",
|
||||
"@tiptap/extension-placeholder": "^3.26.1",
|
||||
@@ -34,6 +35,7 @@
|
||||
"lucide-react": "^1.18.0",
|
||||
"marked": "^18.0.5",
|
||||
"postcss": "^8.5.15",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-easy-crop": "^6.0.2",
|
||||
"react-hook-form": "^7.79.0",
|
||||
@@ -2460,12 +2462,14 @@
|
||||
"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": "*",
|
||||
@@ -2476,6 +2480,7 @@
|
||||
"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"
|
||||
@@ -2782,6 +2787,7 @@
|
||||
"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": {
|
||||
@@ -3686,7 +3692,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@radix-ui/react-switch": "^1.3.0",
|
||||
"@tanstack/react-virtual": "^3.11.2",
|
||||
"@tiptap/core": "^3.26.1",
|
||||
"@tiptap/extension-code-block": "^3.26.1",
|
||||
"@tiptap/extension-image": "^3.26.1",
|
||||
"@tiptap/extension-link": "^3.26.1",
|
||||
"@tiptap/extension-placeholder": "^3.26.1",
|
||||
@@ -35,6 +36,7 @@
|
||||
"lucide-react": "^1.18.0",
|
||||
"marked": "^18.0.5",
|
||||
"postcss": "^8.5.15",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-easy-crop": "^6.0.2",
|
||||
"react-hook-form": "^7.79.0",
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
|
||||
import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent';
|
||||
import PostContent from './PostContent';
|
||||
import { handleMarkdownTabKey, insertAtCursor } from '../utils/markdownIndent';
|
||||
import { handleMarkdownTabKey, insertAtCursor, applyTextareaChange } from '../utils/markdownIndent';
|
||||
import {
|
||||
wrapMarkdownSelection,
|
||||
prefixMarkdownLines,
|
||||
@@ -34,6 +34,12 @@ import { ArticleImage, type ImageDisplay } from './editor/ArticleImageExtension'
|
||||
import { ImageGroup, suggestImageGroupLayout } from './editor/ImageGroupExtension';
|
||||
import { ClearFloatParagraph, ClearFloatSync } from './editor/ClearFloatParagraph';
|
||||
import { ArticleLinkDialog } from './editor/ArticleLinkDialog';
|
||||
import { ArticleCodeBlockDialog } from './editor/ArticleCodeBlockDialog';
|
||||
import { ArticleCodeBlock } from './editor/ArticleCodeBlockExtension';
|
||||
import {
|
||||
formatFenceInfo,
|
||||
type CodeBlockInsertOptions,
|
||||
} from '../utils/codeBlockOptions';
|
||||
import { Tooltip } from './ui/Tooltip';
|
||||
|
||||
export interface ArticleEditorHandle {
|
||||
@@ -50,6 +56,7 @@ interface Props {
|
||||
|
||||
type EditorMode = 'rich' | 'markdown';
|
||||
type LinkTarget = 'rich' | 'markdown';
|
||||
type CodeBlockTarget = 'rich' | 'markdown';
|
||||
|
||||
interface ToolBtn {
|
||||
icon: ReactNode;
|
||||
@@ -63,6 +70,13 @@ interface ToolBtn {
|
||||
|
||||
const MEMBERS_ONLY_PLACEHOLDER = '在此输入仅登录用户可见的内容…';
|
||||
|
||||
/** 按选项生成 Markdown 侧插入片段(围栏 meta,便于手写) */
|
||||
function buildMarkdownCodeBlockSnippet(opts: CodeBlockInsertOptions, body = '代码'): string {
|
||||
const info = formatFenceInfo(opts);
|
||||
const fence = info ? `\`\`\`${info}` : '```';
|
||||
return `\n${fence}\n${body}\n\`\`\`\n`;
|
||||
}
|
||||
|
||||
/** 净化编辑器 HTML,保留 members-only 自定义标签 */
|
||||
function sanitizeHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
|
||||
@@ -179,6 +193,10 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
||||
const [linkDialogUrl, setLinkDialogUrl] = useState('');
|
||||
const [linkTarget, setLinkTarget] = useState<LinkTarget>('rich');
|
||||
const [codeBlockDialogOpen, setCodeBlockDialogOpen] = useState(false);
|
||||
const [codeBlockTarget, setCodeBlockTarget] = useState<CodeBlockTarget>('rich');
|
||||
const [codeBlockEditing, setCodeBlockEditing] = useState(false);
|
||||
const [codeBlockInitial, setCodeBlockInitial] = useState<Partial<CodeBlockInsertOptions> | null>(null);
|
||||
const markdownRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const editor = useEditor({
|
||||
@@ -186,7 +204,12 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
StarterKit.configure({
|
||||
heading: { levels: [2, 3, 4, 5, 6] },
|
||||
paragraph: false,
|
||||
codeBlock: false,
|
||||
// StarterKit v3 已内置;下面单独配置,需先关掉避免重复
|
||||
link: false,
|
||||
underline: false,
|
||||
}),
|
||||
ArticleCodeBlock,
|
||||
ClearFloatParagraph,
|
||||
ClearFloatSync,
|
||||
Underline,
|
||||
@@ -328,6 +351,53 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
setLinkDialogOpen(true);
|
||||
}, [editor]);
|
||||
|
||||
const openCodeBlockDialog = useCallback((target: CodeBlockTarget) => {
|
||||
setCodeBlockTarget(target);
|
||||
if (target === 'rich' && editor?.isActive('codeBlock')) {
|
||||
const attrs = editor.getAttributes('codeBlock');
|
||||
setCodeBlockEditing(true);
|
||||
setCodeBlockInitial({
|
||||
language: (attrs.language as string) || '',
|
||||
lineNumbers: Boolean(attrs.lineNumbers),
|
||||
collapsed: Boolean(attrs.collapsed),
|
||||
});
|
||||
} else {
|
||||
setCodeBlockEditing(false);
|
||||
setCodeBlockInitial(null);
|
||||
}
|
||||
setCodeBlockDialogOpen(true);
|
||||
}, [editor]);
|
||||
|
||||
const applyCodeBlock = useCallback((opts: CodeBlockInsertOptions) => {
|
||||
if (codeBlockTarget === 'markdown') {
|
||||
const textarea = markdownRef.current;
|
||||
if (!textarea) return;
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const selected = markdownSource.slice(selectionStart, selectionEnd);
|
||||
const body = selected || '代码';
|
||||
const snippet = buildMarkdownCodeBlockSnippet(opts, body);
|
||||
const next = markdownSource.slice(0, selectionStart) + snippet + markdownSource.slice(selectionEnd);
|
||||
const bodyOffset = snippet.indexOf(body);
|
||||
const newStart = bodyOffset >= 0 ? selectionStart + bodyOffset : selectionStart + snippet.length;
|
||||
const newEnd = bodyOffset >= 0 ? newStart + body.length : newStart;
|
||||
applyTextareaChange(textarea, next, newStart, newEnd, handleMarkdownChange);
|
||||
return;
|
||||
}
|
||||
if (!editor) return;
|
||||
editor.chain().focus().setArticleCodeBlock({
|
||||
language: opts.language || null,
|
||||
lineNumbers: opts.lineNumbers,
|
||||
collapsed: opts.collapsed,
|
||||
}).run();
|
||||
}, [codeBlockTarget, editor, markdownSource, handleMarkdownChange]);
|
||||
|
||||
const removeCodeBlock = useCallback(() => {
|
||||
if (!editor) return;
|
||||
if (editor.isActive('codeBlock')) {
|
||||
editor.chain().focus().toggleCodeBlock().run();
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
const applyLink = useCallback((url: string) => {
|
||||
if (linkTarget === 'markdown') {
|
||||
const textarea = markdownRef.current;
|
||||
@@ -453,7 +523,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
{ icon: <Quote size={15} />, title: '引用', active: editor.isActive('blockquote'), action: () => editor.chain().focus().toggleBlockquote().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: <Code size={15} />, title: '代码块', active: editor.isActive('codeBlock'), action: () => editor.chain().focus().toggleCodeBlock().run() },
|
||||
{ icon: <Code size={15} />, title: '代码块', hint: '语言、行号与折叠', active: editor.isActive('codeBlock'), action: () => openCodeBlockDialog('rich') },
|
||||
{ icon: <LinkIcon size={15} />, title: '链接', active: editor.isActive('link'), action: () => openLinkDialog('rich') },
|
||||
{
|
||||
icon: <ImageIcon size={15} />,
|
||||
@@ -505,7 +575,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
});
|
||||
|
||||
return tools;
|
||||
}, [editor, openLinkDialog, setImage, wrapMembersOnly, wrapSelectedAsGroup, setImageDisplay]);
|
||||
}, [editor, openLinkDialog, openCodeBlockDialog, setImage, wrapMembersOnly, wrapSelectedAsGroup, setImageDisplay]);
|
||||
|
||||
const buildMarkdownTools = useCallback((): ToolBtn[] => [
|
||||
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) },
|
||||
@@ -517,7 +587,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
{ icon: <Quote 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: <Code size={15} />, title: '代码块', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '```\n', '\n```', '代码', ch)) },
|
||||
{ icon: <Code size={15} />, title: '代码块', hint: '语言、行号与折叠', action: () => openCodeBlockDialog('markdown') },
|
||||
{ icon: <LinkIcon size={15} />, title: '链接', action: () => openLinkDialog('markdown') },
|
||||
{ icon: <ImageIcon size={15} />, title: '上传图片', action: insertMarkdownImage },
|
||||
{
|
||||
@@ -527,7 +597,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
className: 'article-tool-btn--members',
|
||||
action: withMarkdown(insertMarkdownMembersOnly),
|
||||
},
|
||||
], [withMarkdown, openLinkDialog, insertMarkdownImage]);
|
||||
], [withMarkdown, openLinkDialog, openCodeBlockDialog, insertMarkdownImage]);
|
||||
|
||||
const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools();
|
||||
const words = mode === 'markdown'
|
||||
@@ -629,6 +699,14 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
onConfirm={applyLink}
|
||||
onRemove={linkTarget === 'rich' && linkDialogUrl ? removeLink : undefined}
|
||||
/>
|
||||
<ArticleCodeBlockDialog
|
||||
open={codeBlockDialogOpen}
|
||||
onOpenChange={setCodeBlockDialogOpen}
|
||||
initial={codeBlockInitial}
|
||||
editing={codeBlockEditing}
|
||||
onConfirm={applyCodeBlock}
|
||||
onRemove={codeBlockEditing ? removeCodeBlock : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -80,11 +80,29 @@ export default function PostContent({
|
||||
}
|
||||
return;
|
||||
}
|
||||
const foldBtn = target.closest<HTMLElement>('[data-code-fold]');
|
||||
if (foldBtn) {
|
||||
e.preventDefault();
|
||||
const block = foldBtn.closest('.md-codeblock');
|
||||
if (!block) return;
|
||||
const collapsed = block.classList.toggle('md-codeblock--collapsed');
|
||||
const lineCount = parseInt(block.getAttribute('data-line-count') || '0', 10)
|
||||
|| block.querySelectorAll('.md-code-line').length
|
||||
|| 1;
|
||||
if (collapsed && lineCount <= 5) block.classList.add('md-codeblock--short');
|
||||
else block.classList.remove('md-codeblock--short');
|
||||
foldBtn.textContent = collapsed ? '展开' : '收起';
|
||||
return;
|
||||
}
|
||||
const copyBtn = target.closest<HTMLElement>('[data-code-copy]');
|
||||
if (copyBtn) {
|
||||
e.preventDefault();
|
||||
const block = copyBtn.closest('.md-codeblock');
|
||||
const text = block?.querySelector('pre')?.textContent ?? '';
|
||||
// 行号列不参与复制:取各行正文拼接
|
||||
const bodies = block?.querySelectorAll('.md-code-line__body');
|
||||
const text = bodies && bodies.length
|
||||
? [...bodies].map(el => el.textContent ?? '').join('\n')
|
||||
: (block?.querySelector('pre')?.textContent ?? '');
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
const prev = copyBtn.textContent;
|
||||
|
||||
183
frontend/src/components/editor/ArticleCodeBlockDialog.tsx
Normal file
183
frontend/src/components/editor/ArticleCodeBlockDialog.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useMemo, 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';
|
||||
import {
|
||||
CODE_BLOCK_LANGUAGES,
|
||||
DEFAULT_CODE_BLOCK_OPTIONS,
|
||||
loadCodeBlockPrefs,
|
||||
saveCodeBlockPrefs,
|
||||
type CodeBlockInsertOptions,
|
||||
} from '../../utils/codeBlockOptions';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** 编辑已有代码块时的初始值;新建时用偏好 */
|
||||
initial?: Partial<CodeBlockInsertOptions> | null;
|
||||
/** 是否处于已有代码块(显示「移除」) */
|
||||
editing?: boolean;
|
||||
onConfirm: (opts: CodeBlockInsertOptions) => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
/** 文章编辑器:插入 / 配置代码块 */
|
||||
export function ArticleCodeBlockDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
initial,
|
||||
editing = false,
|
||||
onConfirm,
|
||||
onRemove,
|
||||
}: Props) {
|
||||
const [language, setLanguage] = useState(DEFAULT_CODE_BLOCK_OPTIONS.language);
|
||||
const [lineNumbers, setLineNumbers] = useState(DEFAULT_CODE_BLOCK_OPTIONS.lineNumbers);
|
||||
const [collapsed, setCollapsed] = useState(DEFAULT_CODE_BLOCK_OPTIONS.collapsed);
|
||||
const [langQuery, setLangQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prefs = loadCodeBlockPrefs();
|
||||
setLanguage(initial?.language ?? '');
|
||||
setLineNumbers(initial?.lineNumbers ?? prefs.lineNumbers);
|
||||
setCollapsed(initial?.collapsed ?? prefs.collapsed);
|
||||
setLangQuery('');
|
||||
}, [open, initial]);
|
||||
|
||||
const filteredLangs = useMemo(() => {
|
||||
const q = langQuery.trim().toLowerCase();
|
||||
if (!q) return CODE_BLOCK_LANGUAGES;
|
||||
return CODE_BLOCK_LANGUAGES.filter(
|
||||
l => l.id.includes(q) || l.label.toLowerCase().includes(q),
|
||||
);
|
||||
}, [langQuery]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
const opts: CodeBlockInsertOptions = {
|
||||
language: language.trim().toLowerCase(),
|
||||
lineNumbers,
|
||||
collapsed,
|
||||
};
|
||||
saveCodeBlockPrefs(opts);
|
||||
onConfirm(opts);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="article-codeblock-dialog sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? '代码块设置' : '插入代码块'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择语言与阅读展示。外观随站点亮/暗主题自动切换。源码模式会写成{' '}
|
||||
<code className="article-codeblock-dialog__codehint">{'```js lines collapsed'}</code>
|
||||
{' '}这类围栏,可直接手改。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="article-codeblock-dialog__body">
|
||||
<section className="article-codeblock-dialog__section">
|
||||
<Label htmlFor="codeblock-lang-search">语言</Label>
|
||||
<Input
|
||||
id="codeblock-lang-search"
|
||||
value={langQuery}
|
||||
placeholder="搜索语言…"
|
||||
onChange={e => setLangQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="article-codeblock-dialog__langs" role="listbox" aria-label="编程语言">
|
||||
{filteredLangs.map(lang => {
|
||||
const active = language === lang.id;
|
||||
return (
|
||||
<button
|
||||
key={lang.id || 'plain'}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`article-codeblock-dialog__lang${active ? ' is-active' : ''}`}
|
||||
onClick={() => setLanguage(lang.id)}
|
||||
>
|
||||
{lang.label}
|
||||
{lang.id ? <span className="article-codeblock-dialog__lang-id">{lang.id}</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filteredLangs.length === 0 ? (
|
||||
<p className="article-codeblock-dialog__empty">
|
||||
无匹配项。可直接使用下方自定义标识。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Input
|
||||
value={language}
|
||||
placeholder="自定义语言标识(如 aardio)"
|
||||
onChange={e => setLanguage(e.target.value.trim().toLowerCase())}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleConfirm();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="article-codeblock-dialog__toggles">
|
||||
<div className="article-codeblock-dialog__toggle">
|
||||
<div className="article-codeblock-dialog__toggle-text">
|
||||
<Label htmlFor="codeblock-lines">显示行号</Label>
|
||||
<p>阅读态在左侧标注行号</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="codeblock-lines"
|
||||
checked={lineNumbers}
|
||||
onCheckedChange={setLineNumbers}
|
||||
/>
|
||||
</div>
|
||||
<div className="article-codeblock-dialog__toggle">
|
||||
<div className="article-codeblock-dialog__toggle-text">
|
||||
<Label htmlFor="codeblock-fold">默认折叠</Label>
|
||||
<p>阅读时先收起;不足 5 行不显示折叠按钮</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="codeblock-fold"
|
||||
checked={collapsed}
|
||||
onCheckedChange={setCollapsed}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="article-codeblock-dialog__footer">
|
||||
{editing && onRemove ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="article-codeblock-dialog__remove"
|
||||
onClick={() => {
|
||||
onRemove();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
移除代码块
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={handleConfirm}>
|
||||
{editing ? '应用' : '插入'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
208
frontend/src/components/editor/ArticleCodeBlockExtension.tsx
Normal file
208
frontend/src/components/editor/ArticleCodeBlockExtension.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import CodeBlock from '@tiptap/extension-code-block';
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { createCodeBlockHighlightPlugin } from '../../utils/codeBlockHighlightPlugin';
|
||||
import { notify } from '@/lib/notify';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
articleCodeBlock: {
|
||||
/** 按选项插入或更新代码块 */
|
||||
setArticleCodeBlock: (attrs: {
|
||||
language?: string | null;
|
||||
lineNumbers?: boolean;
|
||||
collapsed?: boolean;
|
||||
}) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formatLangLabel(language: string): string {
|
||||
if (language === 'aardio') return 'aardio';
|
||||
return language || 'code';
|
||||
}
|
||||
|
||||
/** 同步编辑态外壳:主题壳 + 语言 + 选项角标(外观随站点主题,不手选风格) */
|
||||
function applyEditorChrome(ctx: {
|
||||
wrap: HTMLElement;
|
||||
langEl: HTMLElement;
|
||||
metaEl: HTMLElement;
|
||||
pre: HTMLElement;
|
||||
code: HTMLElement;
|
||||
node: ProseMirrorNode;
|
||||
}) {
|
||||
const { wrap, langEl, metaEl, pre, code, node } = ctx;
|
||||
const lineNumbers = Boolean(node.attrs.lineNumbers);
|
||||
const collapsed = Boolean(node.attrs.collapsed);
|
||||
const language = ((node.attrs.language as string) || '').trim();
|
||||
|
||||
wrap.className = 'md-codeblock md-codeblock--editor';
|
||||
wrap.removeAttribute('data-code-style');
|
||||
if (lineNumbers) wrap.setAttribute('data-line-numbers', 'true');
|
||||
else wrap.removeAttribute('data-line-numbers');
|
||||
if (collapsed) wrap.setAttribute('data-collapsed', 'true');
|
||||
else wrap.removeAttribute('data-collapsed');
|
||||
wrap.setAttribute('data-lang', formatLangLabel(language));
|
||||
|
||||
langEl.textContent = formatLangLabel(language);
|
||||
|
||||
const badges: string[] = [];
|
||||
if (lineNumbers) badges.push('行号');
|
||||
if (collapsed) badges.push('默认折叠');
|
||||
metaEl.textContent = badges.join(' · ');
|
||||
metaEl.hidden = badges.length === 0;
|
||||
|
||||
pre.className = 'md-codeblock__pre';
|
||||
code.className = [
|
||||
'hljs',
|
||||
language ? `language-${language}` : '',
|
||||
].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 文章代码块:行号 / 折叠属性 + 编辑壳 + hljs 着色。
|
||||
* 外观跟站点亮/暗主题;行号列与折叠裁切仅在阅读态渲染。
|
||||
*/
|
||||
export const ArticleCodeBlock = CodeBlock.extend({
|
||||
name: 'codeBlock',
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
lineNumbers: {
|
||||
default: false,
|
||||
parseHTML: (el) => el.getAttribute('data-line-numbers') === 'true',
|
||||
renderHTML: (attrs) => {
|
||||
if (!attrs.lineNumbers) return {};
|
||||
return { 'data-line-numbers': 'true' };
|
||||
},
|
||||
},
|
||||
collapsed: {
|
||||
default: false,
|
||||
parseHTML: (el) => el.getAttribute('data-collapsed') === 'true',
|
||||
renderHTML: (attrs) => {
|
||||
if (!attrs.collapsed) return {};
|
||||
return { 'data-collapsed': 'true' };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node: initialNode, editor, getPos }) => {
|
||||
let node = initialNode;
|
||||
const wrap = document.createElement('div');
|
||||
const head = document.createElement('div');
|
||||
head.className = 'md-codeblock__head';
|
||||
head.contentEditable = 'false';
|
||||
|
||||
const langEl = document.createElement('span');
|
||||
langEl.className = 'md-codeblock__lang';
|
||||
|
||||
const metaEl = document.createElement('span');
|
||||
metaEl.className = 'md-codeblock__editor-meta';
|
||||
|
||||
const actions = document.createElement('span');
|
||||
actions.className = 'md-codeblock__actions';
|
||||
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.type = 'button';
|
||||
copyBtn.className = 'md-codeblock__copy';
|
||||
copyBtn.textContent = '复制';
|
||||
|
||||
actions.append(metaEl, copyBtn);
|
||||
head.append(langEl, actions);
|
||||
|
||||
const pre = document.createElement('pre');
|
||||
const code = document.createElement('code');
|
||||
pre.appendChild(code);
|
||||
wrap.append(head, pre);
|
||||
|
||||
const sync = (n: ProseMirrorNode) => {
|
||||
applyEditorChrome({ wrap, langEl, metaEl, pre, code, node: n });
|
||||
};
|
||||
|
||||
copyBtn.addEventListener('mousedown', e => e.preventDefault());
|
||||
copyBtn.addEventListener('click', async e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const pos = typeof getPos === 'function' ? getPos() : null;
|
||||
const current = typeof pos === 'number' ? editor.state.doc.nodeAt(pos) : null;
|
||||
const text = current?.textContent ?? code.textContent ?? '';
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
const prev = copyBtn.textContent;
|
||||
copyBtn.textContent = '已复制';
|
||||
copyBtn.classList.add('is-copied');
|
||||
window.setTimeout(() => {
|
||||
copyBtn.textContent = prev || '复制';
|
||||
copyBtn.classList.remove('is-copied');
|
||||
}, 1600);
|
||||
} catch {
|
||||
notify.error('复制失败');
|
||||
}
|
||||
});
|
||||
|
||||
sync(node);
|
||||
|
||||
return {
|
||||
dom: wrap,
|
||||
contentDOM: code,
|
||||
update: (updated) => {
|
||||
if (updated.type.name !== 'codeBlock') return false;
|
||||
node = updated;
|
||||
sync(updated);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const parentPlugins = this.parent?.() || [];
|
||||
return [
|
||||
...parentPlugins,
|
||||
createCodeBlockHighlightPlugin(),
|
||||
// 代码块内粘贴:强制纯文本,保留换行(避免 HTML 分块把换行吃掉)
|
||||
new Plugin({
|
||||
key: new PluginKey('articleCodeBlockPaste'),
|
||||
props: {
|
||||
handlePaste: (view, event) => {
|
||||
const { state } = view;
|
||||
if (state.selection.$from.parent.type.name !== this.name) {
|
||||
return false;
|
||||
}
|
||||
const text = event.clipboardData?.getData('text/plain');
|
||||
if (text == null) return false;
|
||||
event.preventDefault();
|
||||
const normalized = text.replace(/\r\n?/g, '\n');
|
||||
const tr = state.tr;
|
||||
if (!state.selection.empty) {
|
||||
tr.deleteSelection();
|
||||
}
|
||||
tr.insertText(normalized);
|
||||
view.dispatch(tr.scrollIntoView());
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
setArticleCodeBlock: (attrs) => ({ commands, editor }) => {
|
||||
const next = {
|
||||
language: attrs.language || null,
|
||||
lineNumbers: Boolean(attrs.lineNumbers),
|
||||
collapsed: Boolean(attrs.collapsed),
|
||||
};
|
||||
if (editor.isActive(this.name)) {
|
||||
return commands.updateAttributes(this.name, next);
|
||||
}
|
||||
return commands.setNode(this.name, next);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -4292,12 +4292,12 @@ a.post-title:visited {
|
||||
border: 1px solid color-mix(in srgb, var(--j13-green) 12%, var(--j13-border-light));
|
||||
}
|
||||
|
||||
/* 代码块外壳 */
|
||||
/* 代码块外壳(默认跟亮色主题;暗色见下方 .dark) */
|
||||
.post-detail-content .md-codeblock {
|
||||
margin: 1.15em 0;
|
||||
border: 1px solid var(--j13-border-light);
|
||||
border: 1px solid #d8dee4;
|
||||
border-radius: 12px;
|
||||
background: #0f1419;
|
||||
background: #f6f8fa;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--j13-shadow-soft);
|
||||
}
|
||||
@@ -4307,34 +4307,78 @@ a.post-title:visited {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #161b22;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: #eef1f4;
|
||||
border-bottom: 1px solid #d8dee4;
|
||||
}
|
||||
.post-detail-content .md-codeblock__lang {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
color: #656d76;
|
||||
}
|
||||
.post-detail-content .md-codeblock__copy {
|
||||
/* aardio 品牌名保持全小写,不做 uppercase */
|
||||
.post-detail-content .md-codeblock[data-lang="aardio"] .md-codeblock__lang {
|
||||
text-transform: none;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.post-detail-content .md-codeblock__actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.post-detail-content .md-codeblock__editor-meta {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: #656d76;
|
||||
}
|
||||
/* 编辑态代码块:只保留主题壳 / 头栏角标 / 可编辑正文,行号与折叠交给阅读态 */
|
||||
.article-prosemirror .md-codeblock--editor {
|
||||
margin: 1em 0;
|
||||
}
|
||||
.article-prosemirror .md-codeblock--editor .md-codeblock__head {
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
.article-prosemirror .md-codeblock--editor .md-codeblock__pre {
|
||||
min-height: 1.65em;
|
||||
/* 编辑态永不按折叠裁切,即使误带 collapsed class */
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
.article-prosemirror .md-codeblock--editor .md-codeblock__pre::after {
|
||||
display: none !important;
|
||||
}
|
||||
.article-prosemirror .md-codeblock--editor .md-codeblock__pre code,
|
||||
.article-prosemirror .md-codeblock--editor.md-codeblock--lines .md-codeblock__pre code {
|
||||
display: block;
|
||||
outline: none;
|
||||
/* 覆盖阅读态行号布局的 white-space: normal,避免粘贴多行被挤成一行 */
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: normal;
|
||||
word-break: normal;
|
||||
}
|
||||
.post-detail-content .md-codeblock__copy,
|
||||
.post-detail-content .md-codeblock__fold {
|
||||
padding: 3px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
background: #fff;
|
||||
color: #57606a;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.post-detail-content .md-codeblock__copy:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
.post-detail-content .md-codeblock__copy:hover,
|
||||
.post-detail-content .md-codeblock__fold:hover {
|
||||
background: #f3f4f6;
|
||||
color: #24292f;
|
||||
}
|
||||
.post-detail-content .md-codeblock__copy.is-copied {
|
||||
border-color: color-mix(in srgb, var(--j13-green) 50%, transparent);
|
||||
color: #7ee2a8;
|
||||
color: color-mix(in srgb, var(--j13-green) 55%, #24292f);
|
||||
}
|
||||
.post-detail-content .md-codeblock__pre,
|
||||
.post-detail-content pre {
|
||||
@@ -4343,10 +4387,11 @@ a.post-title:visited {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
overflow-x: auto;
|
||||
color: #e6edf3;
|
||||
color: #24292f;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
tab-size: 2;
|
||||
position: relative;
|
||||
}
|
||||
.post-detail-content .md-codeblock__pre code,
|
||||
.post-detail-content pre code {
|
||||
@@ -4357,56 +4402,182 @@ a.post-title:visited {
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
white-space: pre;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 行号布局:外层改为 normal,正文行内保留 pre */
|
||||
.post-detail-content .md-codeblock--lines .md-codeblock__pre code {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* 行号:列宽随 data-lineno-digits 自适应;nowrap 避免父级 white-space:normal 把多位数折行 */
|
||||
.post-detail-content .md-codeblock--lines {
|
||||
--md-lineno-col: 1ch;
|
||||
}
|
||||
.post-detail-content .md-codeblock--lines[data-lineno-digits="2"] {
|
||||
--md-lineno-col: 2ch;
|
||||
}
|
||||
.post-detail-content .md-codeblock--lines[data-lineno-digits="3"] {
|
||||
--md-lineno-col: 3ch;
|
||||
}
|
||||
.post-detail-content .md-codeblock--lines[data-lineno-digits="4"] {
|
||||
--md-lineno-col: 4ch;
|
||||
}
|
||||
.post-detail-content .md-code-line {
|
||||
display: block;
|
||||
min-height: 1.65em;
|
||||
}
|
||||
.post-detail-content .md-codeblock--lines .md-code-line {
|
||||
display: grid;
|
||||
grid-template-columns: var(--md-lineno-col) minmax(0, 1fr);
|
||||
column-gap: 10px;
|
||||
}
|
||||
.post-detail-content .md-code-line__num {
|
||||
user-select: none;
|
||||
text-align: right;
|
||||
color: #8c959f;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.post-detail-content .md-code-line__body {
|
||||
min-width: 0;
|
||||
overflow-wrap: normal;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* 默认折叠:约 5 行;刚好 5 行时再压到约 2 行,保证能看出效果 */
|
||||
.post-detail-content .md-codeblock--collapsed .md-codeblock__pre {
|
||||
max-height: calc(5 * 1.65em + 28px);
|
||||
overflow: hidden;
|
||||
}
|
||||
.post-detail-content .md-codeblock--collapsed.md-codeblock--short .md-codeblock__pre {
|
||||
max-height: calc(2 * 1.65em + 20px);
|
||||
}
|
||||
.post-detail-content .md-codeblock--collapsed .md-codeblock__pre::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 56px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to bottom, transparent, #f6f8fa 85%);
|
||||
}
|
||||
|
||||
/* 未包壳的遗留 pre(兜底) */
|
||||
.post-detail-content > pre {
|
||||
margin: 1.15em 0;
|
||||
padding: 14px 16px;
|
||||
background: #0f1419;
|
||||
border: 1px solid var(--j13-border-light);
|
||||
background: #f6f8fa;
|
||||
border: 1px solid #d8dee4;
|
||||
border-radius: 12px;
|
||||
color: #e6edf3;
|
||||
color: #24292f;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* highlight.js token(深色代码块) */
|
||||
/* highlight.js token(亮色) */
|
||||
.post-detail-content .hljs-comment,
|
||||
.post-detail-content .hljs-quote { color: #8b949e; font-style: italic; }
|
||||
.post-detail-content .hljs-quote { color: #6e7781; font-style: italic; }
|
||||
.post-detail-content .hljs-keyword,
|
||||
.post-detail-content .hljs-selector-tag,
|
||||
.post-detail-content .hljs-addition { color: #ff7b72; }
|
||||
.post-detail-content .hljs-addition { color: #cf222e; }
|
||||
.post-detail-content .hljs-string,
|
||||
.post-detail-content .hljs-meta .hljs-string,
|
||||
.post-detail-content .hljs-attribute { color: #a5d6ff; }
|
||||
.post-detail-content .hljs-attribute { color: #0a3069; }
|
||||
.post-detail-content .hljs-number,
|
||||
.post-detail-content .hljs-literal,
|
||||
.post-detail-content .hljs-variable,
|
||||
.post-detail-content .hljs-template-variable { color: #79c0ff; }
|
||||
.post-detail-content .hljs-template-variable { color: #0550ae; }
|
||||
.post-detail-content .hljs-title,
|
||||
.post-detail-content .hljs-section,
|
||||
.post-detail-content .hljs-name { color: #d2a8ff; }
|
||||
.post-detail-content .hljs-name { color: #8250df; }
|
||||
.post-detail-content .hljs-type,
|
||||
.post-detail-content .hljs-class .hljs-title { color: #ffa657; }
|
||||
.post-detail-content .hljs-class .hljs-title { color: #953800; }
|
||||
.post-detail-content .hljs-built_in,
|
||||
.post-detail-content .hljs-params { color: #ffa657; }
|
||||
.post-detail-content .hljs-params { color: #953800; }
|
||||
.post-detail-content .hljs-attr,
|
||||
.post-detail-content .hljs-symbol,
|
||||
.post-detail-content .hljs-bullet { color: #7ee787; }
|
||||
.post-detail-content .hljs-bullet { color: #116329; }
|
||||
.post-detail-content .hljs-meta,
|
||||
.post-detail-content .hljs-deletion { color: #ffa198; }
|
||||
.post-detail-content .hljs-deletion { color: #82071e; }
|
||||
.post-detail-content .hljs-regexp,
|
||||
.post-detail-content .hljs-link { color: #a5d6ff; }
|
||||
.post-detail-content .hljs-link { color: #0a3069; }
|
||||
.post-detail-content .hljs-operator { color: #cf222e; }
|
||||
.post-detail-content .hljs-emphasis { font-style: italic; }
|
||||
.post-detail-content .hljs-strong { font-weight: 700; }
|
||||
|
||||
/* 暗色主题:代码块跟随站点主题自动切换 */
|
||||
.dark .post-detail-content .md-codeblock {
|
||||
background: #0b0d10;
|
||||
background: #0f1419;
|
||||
border-color: #2a2f36;
|
||||
}
|
||||
.dark .post-detail-content .md-codeblock__head {
|
||||
background: #12151a;
|
||||
background: #161b22;
|
||||
border-bottom-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.dark .post-detail-content .md-codeblock__lang {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.dark .post-detail-content .md-codeblock__editor-meta {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
.dark .post-detail-content .md-codeblock__copy,
|
||||
.dark .post-detail-content .md-codeblock__fold {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
.dark .post-detail-content .md-codeblock__copy:hover,
|
||||
.dark .post-detail-content .md-codeblock__fold:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
.dark .post-detail-content .md-codeblock__copy.is-copied {
|
||||
border-color: color-mix(in srgb, var(--j13-green) 50%, transparent);
|
||||
color: #7ee2a8;
|
||||
}
|
||||
.dark .post-detail-content .md-codeblock__pre,
|
||||
.dark .post-detail-content pre {
|
||||
color: #e6edf3;
|
||||
}
|
||||
.dark .post-detail-content .md-code-line__num {
|
||||
color: rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
.dark .post-detail-content .md-codeblock--collapsed .md-codeblock__pre::after {
|
||||
background: linear-gradient(to bottom, transparent, #0f1419 85%);
|
||||
}
|
||||
.dark .post-detail-content > pre {
|
||||
background: #0f1419;
|
||||
border-color: #2a2f36;
|
||||
color: #e6edf3;
|
||||
}
|
||||
.dark .post-detail-content .hljs-comment,
|
||||
.dark .post-detail-content .hljs-quote { color: #8b949e; }
|
||||
.dark .post-detail-content .hljs-keyword,
|
||||
.dark .post-detail-content .hljs-selector-tag,
|
||||
.dark .post-detail-content .hljs-addition { color: #ff7b72; }
|
||||
.dark .post-detail-content .hljs-string,
|
||||
.dark .post-detail-content .hljs-meta .hljs-string,
|
||||
.dark .post-detail-content .hljs-attribute { color: #a5d6ff; }
|
||||
.dark .post-detail-content .hljs-number,
|
||||
.dark .post-detail-content .hljs-literal,
|
||||
.dark .post-detail-content .hljs-variable,
|
||||
.dark .post-detail-content .hljs-template-variable { color: #79c0ff; }
|
||||
.dark .post-detail-content .hljs-title,
|
||||
.dark .post-detail-content .hljs-section,
|
||||
.dark .post-detail-content .hljs-name { color: #d2a8ff; }
|
||||
.dark .post-detail-content .hljs-type,
|
||||
.dark .post-detail-content .hljs-class .hljs-title { color: #ffa657; }
|
||||
.dark .post-detail-content .hljs-built_in,
|
||||
.dark .post-detail-content .hljs-params { color: #ffa657; }
|
||||
.dark .post-detail-content .hljs-attr,
|
||||
.dark .post-detail-content .hljs-symbol,
|
||||
.dark .post-detail-content .hljs-bullet { color: #7ee787; }
|
||||
.dark .post-detail-content .hljs-meta,
|
||||
.dark .post-detail-content .hljs-deletion { color: #ffa198; }
|
||||
.dark .post-detail-content .hljs-regexp,
|
||||
.dark .post-detail-content .hljs-link { color: #a5d6ff; }
|
||||
.dark .post-detail-content .hljs-operator { color: #ff7b72; }
|
||||
|
||||
/* 评论区 — 独立卡片板块 */
|
||||
.comment-section {
|
||||
@@ -7273,6 +7444,119 @@ button.profile-stat:hover strong {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 代码块插入弹窗 */
|
||||
.article-codeblock-dialog__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.article-codeblock-dialog__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.article-codeblock-dialog__langs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--j13-border-light);
|
||||
border-radius: 12px;
|
||||
background: var(--j13-bg-block-muted);
|
||||
}
|
||||
.article-codeblock-dialog__lang {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
background: var(--j13-bg-surface);
|
||||
color: var(--color-text-1, inherit);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, border-color 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
.article-codeblock-dialog__lang:hover {
|
||||
border-color: color-mix(in srgb, var(--j13-green) 25%, var(--j13-border));
|
||||
}
|
||||
.article-codeblock-dialog__lang.is-active {
|
||||
border-color: color-mix(in srgb, var(--j13-green) 45%, transparent);
|
||||
background: var(--j13-green-bg);
|
||||
color: var(--j13-green-hover);
|
||||
font-weight: 600;
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--j13-green) 20%, transparent);
|
||||
}
|
||||
.article-codeblock-dialog__lang-id {
|
||||
font-size: 11px;
|
||||
opacity: 0.55;
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-weight: 400;
|
||||
}
|
||||
.article-codeblock-dialog__empty {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-3, #8b949e);
|
||||
text-align: center;
|
||||
}
|
||||
.article-codeblock-dialog__codehint {
|
||||
display: inline;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: var(--j13-bg-block-muted);
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-2, inherit);
|
||||
}
|
||||
.article-codeblock-dialog__toggles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.article-codeblock-dialog__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--j13-border-light);
|
||||
border-radius: 12px;
|
||||
background: var(--j13-bg-block-muted);
|
||||
}
|
||||
.article-codeblock-dialog__toggle-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.article-codeblock-dialog__toggle-text p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3, #8b949e);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.article-codeblock-dialog__footer {
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.article-codeblock-dialog__remove {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.article-codeblock-dialog__langs {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.article-editor-markdown {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
71
frontend/src/utils/codeBlockHighlightPlugin.ts
Normal file
71
frontend/src/utils/codeBlockHighlightPlugin.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import hljs from 'highlight.js/lib/common';
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import { registerAardioLanguage } from './hljsAardio';
|
||||
|
||||
registerAardioLanguage(hljs);
|
||||
|
||||
const key = new PluginKey('articleCodeBlockHighlight');
|
||||
|
||||
/** 将 hljs HTML 映射为 ProseMirror inline Decoration */
|
||||
function decorationsForCodeBlock(node: ProseMirrorNode, pos: number): Decoration[] {
|
||||
const language = ((node.attrs.language as string) || '').trim();
|
||||
const text = node.textContent;
|
||||
if (!text || !language || !hljs.getLanguage(language)) return [];
|
||||
|
||||
let html: string;
|
||||
try {
|
||||
html = hljs.highlight(text, { language, ignoreIllegals: true }).value;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = html;
|
||||
const out: Decoration[] = [];
|
||||
let offset = 0;
|
||||
|
||||
const walk = (dom: Node, className: string | null) => {
|
||||
if (dom.nodeType === Node.TEXT_NODE) {
|
||||
const len = dom.textContent?.length ?? 0;
|
||||
if (len > 0 && className) {
|
||||
const from = pos + 1 + offset;
|
||||
out.push(Decoration.inline(from, from + len, { class: className }));
|
||||
}
|
||||
offset += len;
|
||||
return;
|
||||
}
|
||||
if (dom.nodeType !== Node.ELEMENT_NODE) return;
|
||||
const el = dom as Element;
|
||||
const nextClass = el.className || className;
|
||||
el.childNodes.forEach(child => walk(child, nextClass));
|
||||
};
|
||||
|
||||
root.childNodes.forEach(child => walk(child, null));
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildDecorations(doc: ProseMirrorNode): DecorationSet {
|
||||
const decos: Decoration[] = [];
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name !== 'codeBlock') return true;
|
||||
decos.push(...decorationsForCodeBlock(node, pos));
|
||||
return false;
|
||||
});
|
||||
return DecorationSet.create(doc, decos);
|
||||
}
|
||||
|
||||
/** 富文本代码块:用 Decoration 叠 hljs 着色,保持可编辑纯文本 */
|
||||
export function createCodeBlockHighlightPlugin(): Plugin {
|
||||
return new Plugin({
|
||||
key,
|
||||
state: {
|
||||
init: (_, state) => buildDecorations(state.doc),
|
||||
apply: (tr, old) => (tr.docChanged ? buildDecorations(tr.doc) : old),
|
||||
},
|
||||
props: {
|
||||
decorations: state => key.getState(state),
|
||||
},
|
||||
});
|
||||
}
|
||||
125
frontend/src/utils/codeBlockOptions.ts
Normal file
125
frontend/src/utils/codeBlockOptions.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/** 插入 / 编辑代码块时的选项(外观随站点亮/暗主题自动切换,不可手选) */
|
||||
export interface CodeBlockInsertOptions {
|
||||
/** 语言标识,空字符串表示纯文本 */
|
||||
language: string;
|
||||
lineNumbers: boolean;
|
||||
/** 阅读态默认折叠(仅 ≥5 行时显示折叠按钮) */
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
/** 常用语言(与 highlight.js/lib/common 对齐,并保留若干手写标签) */
|
||||
export const CODE_BLOCK_LANGUAGES: { id: string; label: string }[] = [
|
||||
{ id: '', label: '纯文本' },
|
||||
{ id: 'aardio', label: 'aardio' },
|
||||
{ id: 'javascript', label: 'JavaScript' },
|
||||
{ id: 'typescript', label: 'TypeScript' },
|
||||
{ id: 'python', label: 'Python' },
|
||||
{ id: 'r', label: 'R' },
|
||||
{ id: 'go', label: 'Go' },
|
||||
{ id: 'rust', label: 'Rust' },
|
||||
{ id: 'java', label: 'Java' },
|
||||
{ id: 'c', label: 'C' },
|
||||
{ id: 'cpp', label: 'C++' },
|
||||
{ id: 'csharp', label: 'C#' },
|
||||
{ id: 'php', label: 'PHP' },
|
||||
{ id: 'ruby', label: 'Ruby' },
|
||||
{ id: 'swift', label: 'Swift' },
|
||||
{ id: 'kotlin', label: 'Kotlin' },
|
||||
{ id: 'sql', label: 'SQL' },
|
||||
{ id: 'bash', label: 'Bash / Shell' },
|
||||
{ id: 'powershell', label: 'PowerShell' },
|
||||
{ id: 'json', label: 'JSON' },
|
||||
{ id: 'yaml', label: 'YAML' },
|
||||
{ id: 'xml', label: 'XML / HTML' },
|
||||
{ id: 'css', label: 'CSS' },
|
||||
{ id: 'scss', label: 'SCSS' },
|
||||
{ id: 'markdown', label: 'Markdown' },
|
||||
{ id: 'dockerfile', label: 'Dockerfile' },
|
||||
{ id: 'nginx', label: 'Nginx' },
|
||||
];
|
||||
|
||||
export const DEFAULT_CODE_BLOCK_OPTIONS: CodeBlockInsertOptions = {
|
||||
language: '',
|
||||
lineNumbers: false,
|
||||
collapsed: false,
|
||||
};
|
||||
|
||||
const PREFS_KEY = 'j13-codeblock-prefs';
|
||||
|
||||
/** 读取上次插入偏好(不含语言,避免误带到无关帖子) */
|
||||
export function loadCodeBlockPrefs(): Pick<CodeBlockInsertOptions, 'lineNumbers' | 'collapsed'> {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(PREFS_KEY);
|
||||
if (!raw) return { lineNumbers: false, collapsed: false };
|
||||
const parsed = JSON.parse(raw) as Partial<CodeBlockInsertOptions>;
|
||||
return {
|
||||
lineNumbers: Boolean(parsed.lineNumbers),
|
||||
collapsed: Boolean(parsed.collapsed),
|
||||
};
|
||||
} catch {
|
||||
return { lineNumbers: false, collapsed: false };
|
||||
}
|
||||
}
|
||||
|
||||
/** 记住展示相关偏好 */
|
||||
export function saveCodeBlockPrefs(opts: CodeBlockInsertOptions): void {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
PREFS_KEY,
|
||||
JSON.stringify({
|
||||
lineNumbers: opts.lineNumbers,
|
||||
collapsed: opts.collapsed,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* 隐私模式等忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否为非默认展示选项 */
|
||||
export function hasNonDefaultCodeBlockDisplay(opts: {
|
||||
lineNumbers?: boolean;
|
||||
collapsed?: boolean;
|
||||
}): boolean {
|
||||
return Boolean(opts.lineNumbers) || Boolean(opts.collapsed);
|
||||
}
|
||||
|
||||
/** 解析围栏信息串:`js lines collapsed`(兼容旧的 style=*,解析时忽略) */
|
||||
export function parseFenceInfo(info: string): CodeBlockInsertOptions {
|
||||
const parts = info.trim().split(/\s+/).filter(Boolean);
|
||||
let language = '';
|
||||
let lineNumbers = false;
|
||||
let collapsed = false;
|
||||
|
||||
for (const part of parts) {
|
||||
const lower = part.toLowerCase();
|
||||
if (lower === 'lines' || lower === 'line-numbers') {
|
||||
lineNumbers = true;
|
||||
continue;
|
||||
}
|
||||
if (lower === 'collapsed' || lower === 'fold') {
|
||||
collapsed = true;
|
||||
continue;
|
||||
}
|
||||
// 旧版 style=light|soft|dark:忽略,外观由站点主题决定
|
||||
if (lower.startsWith('style=')) {
|
||||
continue;
|
||||
}
|
||||
// 首个非 flag 片段视为语言
|
||||
if (!language && !lower.includes('=')) {
|
||||
language = lower;
|
||||
}
|
||||
}
|
||||
|
||||
return { language, lineNumbers, collapsed };
|
||||
}
|
||||
|
||||
/** 生成围栏信息串(便于手写) */
|
||||
export function formatFenceInfo(opts: Pick<CodeBlockInsertOptions, 'language' | 'lineNumbers' | 'collapsed'>): string {
|
||||
const parts: string[] = [];
|
||||
const lang = (opts.language || '').trim().toLowerCase();
|
||||
if (lang) parts.push(lang);
|
||||
if (opts.lineNumbers) parts.push('lines');
|
||||
if (opts.collapsed) parts.push('collapsed');
|
||||
return parts.join(' ');
|
||||
}
|
||||
@@ -1,4 +1,16 @@
|
||||
import hljs from 'highlight.js/lib/common';
|
||||
import { registerAardioLanguage } from './hljsAardio';
|
||||
|
||||
registerAardioLanguage(hljs);
|
||||
|
||||
/** 至少这么多行才显示折叠按钮(与默认折叠裁切高度一致) */
|
||||
const CODE_FOLD_MIN_LINES = 5;
|
||||
|
||||
/** 预览头栏语言标签:aardio 保持全小写,其余沿用检测结果 */
|
||||
function formatLangLabel(lang: string): string {
|
||||
if (lang === 'aardio') return 'aardio';
|
||||
return lang;
|
||||
}
|
||||
|
||||
/** 从 class / data-lang 中解析作者标注的语言标识 */
|
||||
function detectLang(...els: Element[]): string {
|
||||
@@ -20,55 +32,110 @@ function escapeHtml(s: string): string {
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** 美化并高亮文档中的代码块(加语言标签与复制按钮) */
|
||||
/** 读取作者配置的展示选项(写在 pre 上;外观由站点主题决定) */
|
||||
function readDisplayOptions(pre: Element) {
|
||||
return {
|
||||
lineNumbers: pre.getAttribute('data-line-numbers') === 'true',
|
||||
collapsed: pre.getAttribute('data-collapsed') === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
/** 为高亮后的 HTML 按行包一层,便于行号与折叠计数 */
|
||||
function wrapCodeLines(highlightedHtml: string, withLineNumbers: boolean): string {
|
||||
const lines = highlightedHtml.split('\n');
|
||||
return lines
|
||||
.map((line, i) => {
|
||||
const num = i + 1;
|
||||
const body = line.length > 0 ? line : '';
|
||||
if (withLineNumbers) {
|
||||
return `<span class="md-code-line"><span class="md-code-line__num" aria-hidden="true">${num}</span><span class="md-code-line__body">${body}</span></span>`;
|
||||
}
|
||||
return `<span class="md-code-line"><span class="md-code-line__body">${body}</span></span>`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** 美化并高亮文档中的代码块(语言标签、复制、行号、折叠;外观跟主题) */
|
||||
export function enhanceCodeBlocks(root: ParentNode): void {
|
||||
root.querySelectorAll('pre').forEach(pre => {
|
||||
if (pre.closest('.md-codeblock')) return;
|
||||
const code = pre.querySelector('code') || pre;
|
||||
const raw = code.textContent || '';
|
||||
const display = readDisplayOptions(pre);
|
||||
|
||||
// 作者写了语言标签则以标注为准,绝不被自动识别覆盖
|
||||
const declaredLang = detectLang(code, pre);
|
||||
let label = declaredLang || 'code';
|
||||
let label = formatLangLabel(declaredLang || 'code');
|
||||
|
||||
try {
|
||||
if (declaredLang && hljs.getLanguage(declaredLang)) {
|
||||
const result = hljs.highlight(raw, { language: declaredLang, ignoreIllegals: true });
|
||||
code.innerHTML = result.value;
|
||||
code.innerHTML = wrapCodeLines(result.value, display.lineNumbers);
|
||||
code.classList.add('hljs', `language-${declaredLang}`);
|
||||
} else if (declaredLang) {
|
||||
// 未收录语言(如 aardio):保留原文与标签,不做自动猜测
|
||||
// 未收录语言:保留原文与标签,不做自动猜测
|
||||
code.innerHTML = wrapCodeLines(escapeHtml(raw), display.lineNumbers);
|
||||
code.classList.add('hljs', `language-${declaredLang}`);
|
||||
} else if (raw.length >= 24) {
|
||||
const result = hljs.highlightAuto(raw);
|
||||
code.innerHTML = result.value;
|
||||
code.innerHTML = wrapCodeLines(result.value, display.lineNumbers);
|
||||
code.classList.add('hljs');
|
||||
if (result.language) {
|
||||
label = result.language;
|
||||
label = formatLangLabel(result.language);
|
||||
code.classList.add(`language-${result.language}`);
|
||||
}
|
||||
} else {
|
||||
code.innerHTML = wrapCodeLines(escapeHtml(raw), display.lineNumbers);
|
||||
code.classList.add('hljs');
|
||||
}
|
||||
} catch {
|
||||
code.textContent = raw;
|
||||
code.innerHTML = wrapCodeLines(escapeHtml(raw), display.lineNumbers);
|
||||
code.classList.add('hljs');
|
||||
if (declaredLang) code.classList.add(`language-${declaredLang}`);
|
||||
}
|
||||
|
||||
if (display.lineNumbers) {
|
||||
code.classList.add('md-code--lines');
|
||||
}
|
||||
|
||||
const lineCount = raw === '' ? 1 : raw.split('\n').length;
|
||||
const canFold = lineCount >= CODE_FOLD_MIN_LINES;
|
||||
const initiallyCollapsed = display.collapsed && canFold;
|
||||
const wrap = pre.ownerDocument.createElement('div');
|
||||
wrap.className = 'md-codeblock';
|
||||
wrap.className = [
|
||||
'md-codeblock',
|
||||
display.lineNumbers ? 'md-codeblock--lines' : '',
|
||||
initiallyCollapsed ? 'md-codeblock--collapsed' : '',
|
||||
initiallyCollapsed && lineCount <= CODE_FOLD_MIN_LINES ? 'md-codeblock--short' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
wrap.setAttribute('data-lang', label);
|
||||
if (display.lineNumbers) wrap.setAttribute('data-line-numbers', 'true');
|
||||
if (display.collapsed) wrap.setAttribute('data-collapsed', 'true');
|
||||
wrap.setAttribute('data-line-count', String(lineCount));
|
||||
// 行号列宽按最大位数自适应(用 data-*,避免 style 被消毒剥掉)
|
||||
if (display.lineNumbers) {
|
||||
wrap.setAttribute('data-lineno-digits', String(String(lineCount).length));
|
||||
}
|
||||
|
||||
const head = pre.ownerDocument.createElement('div');
|
||||
head.className = 'md-codeblock__head';
|
||||
|
||||
const actions: string[] = [];
|
||||
if (canFold) {
|
||||
const foldLabel = initiallyCollapsed ? '展开' : '收起';
|
||||
actions.push(`<button type="button" class="md-codeblock__fold" data-code-fold>${foldLabel}</button>`);
|
||||
}
|
||||
actions.push('<button type="button" class="md-codeblock__copy" data-code-copy>复制</button>');
|
||||
|
||||
head.innerHTML = `
|
||||
<span class="md-codeblock__lang">${escapeHtml(label)}</span>
|
||||
<button type="button" class="md-codeblock__copy" data-code-copy>复制</button>
|
||||
<span class="md-codeblock__actions">${actions.join('')}</span>
|
||||
`;
|
||||
|
||||
pre.parentNode?.insertBefore(wrap, pre);
|
||||
wrap.appendChild(head);
|
||||
wrap.appendChild(pre);
|
||||
pre.classList.add('md-codeblock__pre');
|
||||
if (display.lineNumbers) pre.classList.add('md-codeblock__pre--lines');
|
||||
});
|
||||
}
|
||||
|
||||
110
frontend/src/utils/hljsAardio.ts
Normal file
110
frontend/src/utils/hljsAardio.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { HLJSApi, Language } from 'highlight.js';
|
||||
|
||||
/**
|
||||
* aardio 语法高亮(对齐官方文档 Prism 定义):
|
||||
* https://www.aardio.com/zh-cn/docs/js/prism.js
|
||||
*/
|
||||
export function aardioLanguage(hljs: HLJSApi): Language {
|
||||
const KEYWORD_RE = (
|
||||
'begin|end|if|lambda|λ|else|elseif|class|function|return|while|do|namespace|'
|
||||
+ 'select|case|catch|try|for|in|this|global|self|owner|var|def|null|and|not|or|'
|
||||
+ 'break|continue|import|with|ctor|eval|type|assert|assert2|assertf|error|rget|'
|
||||
+ 'callex|errput|loadcode|dumpcode|collectgarbage|call|invoke|tostring|topointer|'
|
||||
+ 'tonumber|sleep|execute|setlocale|setprivilege|loadcodex|reduce|switch|'
|
||||
+ 'true|false'
|
||||
);
|
||||
|
||||
const KEYWORDS = {
|
||||
keyword:
|
||||
'begin end if lambda λ else elseif class function return while do namespace '
|
||||
+ 'select case catch try for in this global self owner var def and not or '
|
||||
+ 'break continue import with ctor eval type assert assert2 assertf error '
|
||||
+ 'rget callex errput loadcode dumpcode collectgarbage call invoke tostring '
|
||||
+ 'topointer tonumber sleep execute setlocale setprivilege loadcodex reduce switch',
|
||||
literal: 'true false null',
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'aardio',
|
||||
aliases: ['aardio'],
|
||||
keywords: KEYWORDS,
|
||||
illegal: /<\//,
|
||||
contains: [
|
||||
// /*...*/ 与 /**...**/ 等成对星号注释(宽松匹配)
|
||||
{
|
||||
className: 'comment',
|
||||
begin: /\/\*+/,
|
||||
end: /\*+\//,
|
||||
contains: ['self'],
|
||||
},
|
||||
hljs.C_LINE_COMMENT_MODE,
|
||||
// "..."("" 转义)
|
||||
{
|
||||
className: 'string',
|
||||
begin: '"',
|
||||
end: '"',
|
||||
contains: [{ begin: /""/ }],
|
||||
},
|
||||
// `...`(`` 转义)
|
||||
{
|
||||
className: 'string',
|
||||
begin: '`',
|
||||
end: '`',
|
||||
contains: [{ begin: /``/ }],
|
||||
},
|
||||
// '...'(反斜杠转义)
|
||||
{
|
||||
className: 'string',
|
||||
begin: "'",
|
||||
end: "'",
|
||||
contains: [hljs.BACKSLASH_ESCAPE],
|
||||
},
|
||||
// _CONST 常量
|
||||
{
|
||||
className: 'literal',
|
||||
begin: /\b_[A-Za-z]\w*\b/,
|
||||
},
|
||||
// class Name
|
||||
{
|
||||
beginKeywords: 'class',
|
||||
end: /(?=[{;])/,
|
||||
contains: [
|
||||
{
|
||||
className: 'title',
|
||||
begin: /[\w.\\]+/,
|
||||
relevance: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
// 函数调用名(排除关键字,避免 if( 被当成函数)
|
||||
{
|
||||
className: 'title function_',
|
||||
begin: new RegExp(
|
||||
String.raw`\b(?!(?:${KEYWORD_RE})\b)[_$a-zA-Z\u00A0-\uFFFF][$\w\u00A0-\uFFFF]*(?=\s*\()`,
|
||||
),
|
||||
relevance: 0,
|
||||
},
|
||||
// 数字(十六进制 / 进制字面量 / 小数 / 科学计数)
|
||||
{
|
||||
className: 'number',
|
||||
relevance: 0,
|
||||
variants: [
|
||||
{ begin: /\b0x[a-fA-F_\d]+(?:\.[a-fA-F_\d]*)?(?:p[+-]?\d[_\d]*)?\b/i },
|
||||
{ begin: /\b\d+#[\d_]+\b/ },
|
||||
{ begin: /\b\d[_\d]*(?:\.\B|(?:\.[_\d]*)?(?:e[+-]?\d[_\d]*)?\b)/i },
|
||||
{ begin: /\B\.\d[_\d]*(?:e[+-]?\d[_\d]*)?\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
className: 'operator',
|
||||
begin: /\.\.|--|\+\+|\*\*=?|&&=?|\|\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|[~:?]/,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** 向 highlight.js 注册 aardio(可重复调用) */
|
||||
export function registerAardioLanguage(hljs: HLJSApi): void {
|
||||
if (hljs.getLanguage('aardio')) return;
|
||||
hljs.registerLanguage('aardio', aardioLanguage);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import TurndownService from 'turndown';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
|
||||
import { parseFenceInfo, formatFenceInfo } from './codeBlockOptions';
|
||||
|
||||
const MEMBERS_ONLY_BLOCK_RE = /<members-only>([\s\S]*?)<\/members-only>/gi;
|
||||
|
||||
@@ -50,6 +51,39 @@ function splitParagraphBreaks(html: string): string {
|
||||
|
||||
/** 为 Turndown 注册通用正文规则(不含 members-only) */
|
||||
function addTurndownContentRules(service: TurndownService): void {
|
||||
// TipTap 列表项内是 <li><p>…</p></li>:默认 paragraph 规则会塞多余空行,
|
||||
// 导致列表结构异常;列表内段落只输出内容本身。
|
||||
service.addRule('paragraphInListItem', {
|
||||
filter: (node) =>
|
||||
node.nodeName === 'P' && Boolean(node.parentNode && node.parentNode.nodeName === 'LI'),
|
||||
replacement: (content) => content.trim(),
|
||||
});
|
||||
|
||||
// 代码块统一输出围栏,展示选项写入 info 串:```js lines collapsed
|
||||
service.addRule('fencedCodeBlock', {
|
||||
filter: (node) => node.nodeName === 'PRE',
|
||||
replacement: (_content, node) => {
|
||||
const pre = node as HTMLElement;
|
||||
const codeEl = pre.querySelector('code') || pre;
|
||||
// 行号装饰 span 时仍取纯文本
|
||||
const text = codeEl.textContent || '';
|
||||
const langMatch = (codeEl.getAttribute('class') || '').match(/(?:language|lang)-([a-z0-9_+-]+)/i);
|
||||
const wrap = pre.closest('.md-codeblock') as HTMLElement | null;
|
||||
const language = (
|
||||
pre.getAttribute('data-lang')
|
||||
|| wrap?.getAttribute('data-lang')
|
||||
|| langMatch?.[1]
|
||||
|| ''
|
||||
).trim().toLowerCase();
|
||||
const lineNumbers = pre.getAttribute('data-line-numbers') === 'true'
|
||||
|| wrap?.getAttribute('data-line-numbers') === 'true';
|
||||
const collapsed = pre.getAttribute('data-collapsed') === 'true'
|
||||
|| wrap?.getAttribute('data-collapsed') === 'true';
|
||||
const info = formatFenceInfo({ language, lineNumbers, collapsed });
|
||||
return `\n\n\`\`\`${info}\n${text}\n\`\`\`\n\n`;
|
||||
},
|
||||
});
|
||||
|
||||
service.addRule('imageGroup', {
|
||||
filter: (node) =>
|
||||
node.nodeName === 'DIV' && (node as HTMLElement).hasAttribute('data-image-group'),
|
||||
@@ -127,9 +161,20 @@ function addTurndownContentRules(service: TurndownService): void {
|
||||
/** 子节点转 Markdown 专用,避免 members-only 规则递归 */
|
||||
const contentTurndown = new TurndownService(TURNDOWN_OPTIONS);
|
||||
addTurndownContentRules(contentTurndown);
|
||||
patchTurndownEscape(contentTurndown);
|
||||
|
||||
const turndown = new TurndownService(TURNDOWN_OPTIONS);
|
||||
addTurndownContentRules(turndown);
|
||||
patchTurndownEscape(turndown);
|
||||
|
||||
/**
|
||||
* Turndown 默认会把「行首 1. 」转义成 1\. ,避免被当成列表。
|
||||
* 富文本有序列表 / 用户手写序号在源码里都应保持 1. ,故去掉该转义。
|
||||
*/
|
||||
function patchTurndownEscape(service: TurndownService): void {
|
||||
const original = service.escape.bind(service);
|
||||
service.escape = (str: string) => original(str).replace(/(\d+)\\(\.)/g, '$1$2');
|
||||
}
|
||||
|
||||
/** 登录可见区块转为 Markdown:逐子节点转换,保留首行缩进 */
|
||||
turndown.addRule('membersOnly', {
|
||||
@@ -159,13 +204,34 @@ marked.setOptions({
|
||||
breaks: true,
|
||||
});
|
||||
|
||||
/** 禁用缩进代码块,Tab/空格缩进仅作正文排版;围栏 ``` 代码块不受影响 */
|
||||
/** HTML 转义代码正文 */
|
||||
function escapeCodeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** 禁用缩进代码块;围栏支持 lines/collapsed 简写(旧 style=* 解析时忽略) */
|
||||
marked.use({
|
||||
tokenizer: {
|
||||
code() {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
code({ text, lang, escaped }) {
|
||||
const opts = parseFenceInfo(lang || '');
|
||||
const attrs: string[] = [];
|
||||
if (opts.lineNumbers) attrs.push('data-line-numbers="true"');
|
||||
if (opts.collapsed) attrs.push('data-collapsed="true"');
|
||||
const preOpen = attrs.length ? `<pre ${attrs.join(' ')}>` : '<pre>';
|
||||
const classAttr = opts.language ? ` class="language-${opts.language}"` : '';
|
||||
const body = escaped ? text : escapeCodeHtml(text);
|
||||
return `${preOpen}<code${classAttr}>${body}</code></pre>\n`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** 净化并保留 members-only 标签 */
|
||||
|
||||
@@ -12,7 +12,8 @@ export const POST_CONTENT_PURIFY_CONFIG: Config = {
|
||||
ADD_TAGS: ['members-only'],
|
||||
ADD_ATTR: [
|
||||
'data-locked', 'data-length', 'target', 'rel',
|
||||
'data-code-copy', 'data-lang', 'data-full',
|
||||
'data-code-copy', 'data-code-fold', 'data-lang', 'data-full',
|
||||
'data-code-style', 'data-line-numbers', 'data-collapsed', 'data-line-count', 'data-lineno-digits',
|
||||
'data-image-group', 'data-layout', 'data-display',
|
||||
'data-clear-float',
|
||||
'class',
|
||||
|
||||
@@ -26,7 +26,9 @@ func postContentHTMLPolicy() *bluemonday.Policy {
|
||||
)
|
||||
p.AllowAttrs(
|
||||
"data-locked", "data-length",
|
||||
"data-code-copy", "data-lang", "data-full",
|
||||
"data-code-copy", "data-code-fold", "data-lang", "data-full",
|
||||
"data-code-style", "data-line-numbers", "data-collapsed",
|
||||
"data-line-count", "data-lineno-digits",
|
||||
"data-image-group", "data-layout", "data-display",
|
||||
"data-clear-float",
|
||||
).Globally()
|
||||
|
||||
Reference in New Issue
Block a user