代码块优化,增加aardio、R语言代码高亮等
This commit is contained in:
@@ -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);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user