209 lines
6.5 KiB
TypeScript
209 lines
6.5 KiB
TypeScript
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);
|
|
},
|
|
};
|
|
},
|
|
});
|