From e50047e1c81e98a80128fcbd8b11ebf66c2a6c43 Mon Sep 17 00:00:00 2001 From: freefire Date: Wed, 5 Aug 2026 11:58:35 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=9D=97=E4=BC=98=E5=8C=96?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=8A=A0aardio=E3=80=81R=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E9=AB=98=E4=BA=AE=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package-lock.json | 7 +- frontend/package.json | 2 + frontend/src/components/ArticleEditor.tsx | 88 ++++- frontend/src/components/PostContent.tsx | 20 +- .../editor/ArticleCodeBlockDialog.tsx | 183 +++++++++ .../editor/ArticleCodeBlockExtension.tsx | 208 +++++++++++ frontend/src/styles/global.css | 346 ++++++++++++++++-- .../src/utils/codeBlockHighlightPlugin.ts | 71 ++++ frontend/src/utils/codeBlockOptions.ts | 125 +++++++ frontend/src/utils/enhanceCodeBlocks.ts | 85 ++++- frontend/src/utils/hljsAardio.ts | 110 ++++++ frontend/src/utils/markdownContent.ts | 68 +++- frontend/src/utils/postContent.ts | 3 +- service/sanitize_html.go | 4 +- 14 files changed, 1270 insertions(+), 50 deletions(-) create mode 100644 frontend/src/components/editor/ArticleCodeBlockDialog.tsx create mode 100644 frontend/src/components/editor/ArticleCodeBlockExtension.tsx create mode 100644 frontend/src/utils/codeBlockHighlightPlugin.ts create mode 100644 frontend/src/utils/codeBlockOptions.ts create mode 100644 frontend/src/utils/hljsAardio.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fc0f2a5..fe966bf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" }, diff --git a/frontend/package.json b/frontend/package.json index 022d733..ace4597 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/ArticleEditor.tsx b/frontend/src/components/ArticleEditor.tsx index 34257e9..414153b 100644 --- a/frontend/src/components/ArticleEditor.tsx +++ b/frontend/src/components/ArticleEditor.tsx @@ -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(function ArticleEdi const [linkDialogOpen, setLinkDialogOpen] = useState(false); const [linkDialogUrl, setLinkDialogUrl] = useState(''); const [linkTarget, setLinkTarget] = useState('rich'); + const [codeBlockDialogOpen, setCodeBlockDialogOpen] = useState(false); + const [codeBlockTarget, setCodeBlockTarget] = useState('rich'); + const [codeBlockEditing, setCodeBlockEditing] = useState(false); + const [codeBlockInitial, setCodeBlockInitial] = useState | null>(null); const markdownRef = useRef(null); const editor = useEditor({ @@ -186,7 +204,12 @@ const ArticleEditor = forwardRef(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(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(function ArticleEdi { icon: , title: '引用', active: editor.isActive('blockquote'), action: () => editor.chain().focus().toggleBlockquote().run() }, { icon: , title: '无序列表', active: editor.isActive('bulletList'), action: () => editor.chain().focus().toggleBulletList().run() }, { icon: , title: '有序列表', active: editor.isActive('orderedList'), action: () => editor.chain().focus().toggleOrderedList().run() }, - { icon: , title: '代码块', active: editor.isActive('codeBlock'), action: () => editor.chain().focus().toggleCodeBlock().run() }, + { icon: , title: '代码块', hint: '语言、行号与折叠', active: editor.isActive('codeBlock'), action: () => openCodeBlockDialog('rich') }, { icon: , title: '链接', active: editor.isActive('link'), action: () => openLinkDialog('rich') }, { icon: , @@ -505,7 +575,7 @@ const ArticleEditor = forwardRef(function ArticleEdi }); return tools; - }, [editor, openLinkDialog, setImage, wrapMembersOnly, wrapSelectedAsGroup, setImageDisplay]); + }, [editor, openLinkDialog, openCodeBlockDialog, setImage, wrapMembersOnly, wrapSelectedAsGroup, setImageDisplay]); const buildMarkdownTools = useCallback((): ToolBtn[] => [ { icon: H, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) }, @@ -517,7 +587,7 @@ const ArticleEditor = forwardRef(function ArticleEdi { icon: , title: '引用', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '> ', ch)) }, { icon: , title: '无序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '- ', ch)) }, { icon: , title: '有序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '1. ', ch)) }, - { icon: , title: '代码块', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '```\n', '\n```', '代码', ch)) }, + { icon: , title: '代码块', hint: '语言、行号与折叠', action: () => openCodeBlockDialog('markdown') }, { icon: , title: '链接', action: () => openLinkDialog('markdown') }, { icon: , title: '上传图片', action: insertMarkdownImage }, { @@ -527,7 +597,7 @@ const ArticleEditor = forwardRef(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(function ArticleEdi onConfirm={applyLink} onRemove={linkTarget === 'rich' && linkDialogUrl ? removeLink : undefined} /> + ); }); diff --git a/frontend/src/components/PostContent.tsx b/frontend/src/components/PostContent.tsx index cd8f808..2a61f6d 100644 --- a/frontend/src/components/PostContent.tsx +++ b/frontend/src/components/PostContent.tsx @@ -80,11 +80,29 @@ export default function PostContent({ } return; } + const foldBtn = target.closest('[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('[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; diff --git a/frontend/src/components/editor/ArticleCodeBlockDialog.tsx b/frontend/src/components/editor/ArticleCodeBlockDialog.tsx new file mode 100644 index 0000000..7506e49 --- /dev/null +++ b/frontend/src/components/editor/ArticleCodeBlockDialog.tsx @@ -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 | 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 ( + + + + {editing ? '代码块设置' : '插入代码块'} + + 选择语言与阅读展示。外观随站点亮/暗主题自动切换。源码模式会写成{' '} + {'```js lines collapsed'} + {' '}这类围栏,可直接手改。 + + + +
+
+ + setLangQuery(e.target.value)} + autoFocus + /> +
+ {filteredLangs.map(lang => { + const active = language === lang.id; + return ( + + ); + })} + {filteredLangs.length === 0 ? ( +

+ 无匹配项。可直接使用下方自定义标识。 +

+ ) : null} +
+ setLanguage(e.target.value.trim().toLowerCase())} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + handleConfirm(); + } + }} + /> +
+ +
+
+
+ +

阅读态在左侧标注行号

+
+ +
+
+
+ +

阅读时先收起;不足 5 行不显示折叠按钮

+
+ +
+
+
+ + + {editing && onRemove ? ( + + ) : null} + + + +
+
+ ); +} diff --git a/frontend/src/components/editor/ArticleCodeBlockExtension.tsx b/frontend/src/components/editor/ArticleCodeBlockExtension.tsx new file mode 100644 index 0000000..14bcb9c --- /dev/null +++ b/frontend/src/components/editor/ArticleCodeBlockExtension.tsx @@ -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 { + 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); + }, + }; + }, +}); diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 063d114..c414e52 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -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; diff --git a/frontend/src/utils/codeBlockHighlightPlugin.ts b/frontend/src/utils/codeBlockHighlightPlugin.ts new file mode 100644 index 0000000..36c7d4e --- /dev/null +++ b/frontend/src/utils/codeBlockHighlightPlugin.ts @@ -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), + }, + }); +} diff --git a/frontend/src/utils/codeBlockOptions.ts b/frontend/src/utils/codeBlockOptions.ts new file mode 100644 index 0000000..5c415e2 --- /dev/null +++ b/frontend/src/utils/codeBlockOptions.ts @@ -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 { + try { + const raw = sessionStorage.getItem(PREFS_KEY); + if (!raw) return { lineNumbers: false, collapsed: false }; + const parsed = JSON.parse(raw) as Partial; + 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): 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(' '); +} diff --git a/frontend/src/utils/enhanceCodeBlocks.ts b/frontend/src/utils/enhanceCodeBlocks.ts index 6f1d4a7..c86c80e 100644 --- a/frontend/src/utils/enhanceCodeBlocks.ts +++ b/frontend/src/utils/enhanceCodeBlocks.ts @@ -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 `${body}`; + } + return `${body}`; + }) + .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(``); + } + actions.push(''); + head.innerHTML = ` ${escapeHtml(label)} - + ${actions.join('')} `; 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'); }); } diff --git a/frontend/src/utils/hljsAardio.ts b/frontend/src/utils/hljsAardio.ts new file mode 100644 index 0000000..c89e889 --- /dev/null +++ b/frontend/src/utils/hljsAardio.ts @@ -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); +} diff --git a/frontend/src/utils/markdownContent.ts b/frontend/src/utils/markdownContent.ts index cba18a7..c38d1ff 100644 --- a/frontend/src/utils/markdownContent.ts +++ b/frontend/src/utils/markdownContent.ts @@ -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 = /([\s\S]*?)<\/members-only>/gi; @@ -50,6 +51,39 @@ function splitParagraphBreaks(html: string): string { /** 为 Turndown 注册通用正文规则(不含 members-only) */ function addTurndownContentRules(service: TurndownService): void { + // TipTap 列表项内是
  • :默认 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, '"'); +} + +/** 禁用缩进代码块;围栏支持 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 ? `
    ` : '
    ';
    +      const classAttr = opts.language ? ` class="language-${opts.language}"` : '';
    +      const body = escaped ? text : escapeCodeHtml(text);
    +      return `${preOpen}${body}
    \n`; + }, + }, }); /** 净化并保留 members-only 标签 */ diff --git a/frontend/src/utils/postContent.ts b/frontend/src/utils/postContent.ts index 778a239..ce2aca7 100644 --- a/frontend/src/utils/postContent.ts +++ b/frontend/src/utils/postContent.ts @@ -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', diff --git a/service/sanitize_html.go b/service/sanitize_html.go index 7e30bfb..913ea5a 100644 --- a/service/sanitize_html.go +++ b/service/sanitize_html.go @@ -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()