Added code block export image extension

This commit is contained in:
2025-12-22 00:13:55 +08:00
parent fa134d31d6
commit c47f7de5b8
12 changed files with 463 additions and 68 deletions

View File

@@ -83,6 +83,7 @@ export default {
blockCopy: 'Copy',
blockCut: 'Cut',
blockPaste: 'Paste',
copyBlockImage: 'Copy block image',
historyUndo: 'Undo',
historyRedo: 'Redo',
historyUndoSelection: 'Undo selection',
@@ -328,6 +329,11 @@ export default {
httpClient: {
name: 'HTTP Client',
description: 'Send HTTP requests directly in the editor and view responses'
},
blockImage: {
name: 'Block Image Export',
description: 'Render the current code block to an image and copy it to the clipboard',
copyMenu: 'Copy block as image'
}
},
monitor: {

View File

@@ -83,6 +83,7 @@ export default {
blockCopy: '复制',
blockCut: '剪切',
blockPaste: '粘贴',
copyBlockImage: '复制块图片',
historyUndo: '撤销',
historyRedo: '重做',
historyUndoSelection: '撤销选择',
@@ -330,6 +331,11 @@ export default {
httpClient: {
name: 'HTTP 客户端',
description: '在编辑器中直接发送 HTTP 请求并查看响应'
},
blockImage: {
name: '代码块导出图片',
description: '将当前代码块渲染为图片并复制到剪贴板',
copyMenu: '复制块为图片'
}
},
monitor: {

View File

@@ -0,0 +1,19 @@
import type {MenuSchemaNode} from '../contextMenu/menuSchema';
import {getActiveNoteBlock} from '../codeblock/state';
import {blockImageEnabledFacet, copyBlockImageCommand} from './index';
export const blockImageMenuNodes: MenuSchemaNode[] = [
{
id: 'copy-block-image',
labelKey: 'extensions.blockImage.copyMenu',
command: copyBlockImageCommand,
visible: context =>
context.view.state.facet(blockImageEnabledFacet) &&
Boolean(getActiveNoteBlock(context.view.state)),
enabled: context =>
context.view.state.facet(blockImageEnabledFacet) &&
Boolean(getActiveNoteBlock(context.view.state)),
},
];

View File

@@ -0,0 +1,319 @@
import {snapdom} from '@zumer/snapdom';
import {syntaxTree, highlightingFor} from '@codemirror/language';
import {Highlighter, highlightTree} from '@lezer/highlight';
import {Facet, type Extension} from '@codemirror/state';
import {EditorView, Command} from '@codemirror/view';
import type {Block} from '../codeblock/types';
import {blockState, getActiveNoteBlock} from '../codeblock/state';
/**
* 高亮片段信息
*/
interface HighlightSpan {
from: number;
to: number;
cssClass: string;
}
/**
* 从语法树获取指定范围的高亮信息
*/
function getHighlights(view: EditorView, from: number, to: number): HighlightSpan[] {
const tree = syntaxTree(view.state);
const highlights: HighlightSpan[] = [];
if (tree.length === 0) {
return highlights;
}
const highlighter: Highlighter = {
style: tags => highlightingFor(view.state, tags),
};
highlightTree(
tree,
highlighter,
(hlFrom, hlTo, cssClass) => {
if (hlFrom < to && hlTo > from) {
highlights.push({
from: Math.max(hlFrom, from),
to: Math.min(hlTo, to),
cssClass: cssClass || '',
});
}
},
from,
to,
);
return highlights;
}
/**
* 构建带高亮的单行元素
*/
function createHighlightedLine(
lineText: string,
lineFrom: number,
lineTo: number,
highlights: HighlightSpan[],
): HTMLElement {
const lineElement = document.createElement('div');
lineElement.className = 'cm-line';
lineElement.style.whiteSpace = 'pre';
if (highlights.length === 0 || lineText.length === 0) {
lineElement.textContent = lineText || ' ';
return lineElement;
}
const spans: Array<{text: string; cssClass: string}> = [];
let pos = lineFrom;
const lineHighlights = highlights
.filter(h => h.from < lineTo && h.to > lineFrom)
.sort((a, b) => a.from - b.from);
for (const hl of lineHighlights) {
if (hl.from > pos) {
spans.push({
text: lineText.slice(pos - lineFrom, hl.from - lineFrom),
cssClass: '',
});
}
const hlStart = Math.max(hl.from, lineFrom);
const hlEnd = Math.min(hl.to, lineTo);
spans.push({
text: lineText.slice(hlStart - lineFrom, hlEnd - lineFrom),
cssClass: hl.cssClass,
});
pos = hlEnd;
}
if (pos < lineTo) {
spans.push({
text: lineText.slice(pos - lineFrom),
cssClass: '',
});
}
for (const span of spans) {
if (span.cssClass) {
const spanElement = document.createElement('span');
spanElement.className = span.cssClass;
spanElement.textContent = span.text;
lineElement.appendChild(spanElement);
} else {
lineElement.appendChild(document.createTextNode(span.text));
}
}
return lineElement;
}
/**
* 构建用于截图的块 DOM
*/
function inlineStyle(style: CSSStyleDeclaration, props: string[]): string {
return props
.map(prop => {
const val = style.getPropertyValue(prop);
return val ? `${prop}:${val};` : '';
})
.join('');
}
function getBlockDomElement(view: EditorView, block: Block): HTMLElement | null {
try {
const blocks = view.state.field(blockState, false);
if (!blocks) return null;
const blockIndex = blocks.indexOf(block);
const isEvenBlock = blockIndex % 2 === 0;
const blockLayerElem = view.dom.querySelector(
`.code-blocks-layer .${isEvenBlock ? 'block-even' : 'block-odd'}`,
) as HTMLElement | null;
const backgroundColor =
blockLayerElem?.ownerDocument
? getComputedStyle(blockLayerElem).backgroundColor
: isEvenBlock
? '#252B37'
: '#213644';
const contentDom = view.dom.querySelector('.cm-content') as HTMLElement | null;
const sourceStyle = contentDom ? getComputedStyle(contentDom) : getComputedStyle(view.dom);
const container = document.createElement('div');
container.className = 'cm-editor cm-focused block-export-wrapper';
container.style.cssText = `
padding: 18px 22px;
background-color: ${backgroundColor};
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
display: inline-block;
min-width: 360px;
max-width: 960px;
color: ${sourceStyle.color};
font-family: ${sourceStyle.fontFamily};
font-size: ${sourceStyle.fontSize};
line-height: ${sourceStyle.lineHeight};
position: relative;
`;
const contentWrapper = document.createElement('div');
contentWrapper.className = 'cm-content';
contentWrapper.style.whiteSpace = 'pre';
contentWrapper.style.cssText += inlineStyle(sourceStyle, [
'color',
'font-family',
'font-size',
'font-weight',
'font-style',
'line-height',
'letter-spacing',
'tab-size',
'text-rendering',
'background',
'background-color',
'text-shadow',
]);
const highlights = getHighlights(view, block.content.from, block.content.to);
const fromLine = view.state.doc.lineAt(block.content.from);
const toLine = view.state.doc.lineAt(block.content.to);
for (let lineNum = fromLine.number; lineNum <= toLine.number; lineNum++) {
const line = view.state.doc.line(lineNum);
const lineElement = createHighlightedLine(line.text, line.from, line.to, highlights);
contentWrapper.appendChild(lineElement);
}
if (block.language.name && block.language.name !== 'text') {
const langLabel = document.createElement('div');
langLabel.className = 'block-language-label';
langLabel.textContent = block.language.name;
langLabel.style.cssText = `
position: absolute;
top: 6px;
right: 10px;
padding: 3px 8px;
background-color: rgba(0, 0, 0, 0.35);
color: rgba(255, 255, 255, 0.85);
font-size: 11px;
font-family: system-ui, -apple-system, sans-serif;
font-weight: 600;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
pointer-events: none;
`;
container.appendChild(langLabel);
}
container.appendChild(contentWrapper);
return container;
} catch (error) {
console.error('[blockImage] Failed to build block DOM:', error);
return null;
}
}
/**
* 将 Canvas 转换为 PNG Blob
*/
function canvasToPngBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(blob => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Canvas toBlob returned null'));
}
}, 'image/png');
});
}
/**
* 写入剪贴板PNG
*/
async function writeImageToClipboard(blob: Blob): Promise<void> {
const ClipboardItemCtor = (window as any).ClipboardItem;
if (ClipboardItemCtor && navigator.clipboard?.write) {
const item = new ClipboardItemCtor({'image/png': blob});
await navigator.clipboard.write([item]);
return;
}
}
/**
* 将当前活动块导出为图片并复制到剪贴板
*/
async function copyActiveBlockAsImage(view: EditorView): Promise<boolean> {
const activeBlock = getActiveNoteBlock(view.state);
if (!activeBlock) {
console.warn('[blockImage] No active block found');
return false;
}
const targetDom = view.scrollDOM || document.body;
const prevCursor = (targetDom as HTMLElement).style.cursor;
(targetDom as HTMLElement).style.cursor = 'progress';
const blockDom = getBlockDomElement(view, activeBlock);
if (!blockDom) {
console.warn('[blockImage] Cannot create block DOM');
(targetDom as HTMLElement).style.cursor = prevCursor;
return false;
}
// 将节点挂到文档外层,确保样式可用
const mount = document.createElement('div');
mount.style.cssText = 'position: fixed; left: -10000px; top: -10000px; pointer-events: none; z-index: -1;';
mount.appendChild(blockDom);
document.body.appendChild(mount);
try {
const canvas = await snapdom.toCanvas(blockDom, {
scale: 2,
dpr: window.devicePixelRatio || 1,
cache: 'auto',
backgroundColor: getComputedStyle(blockDom).backgroundColor,
outerShadows: false,
});
const blob = await canvasToPngBlob(canvas);
await writeImageToClipboard(blob);
return true;
} catch (error) {
console.error('[blockImage] Failed to copy block image:', error);
return false;
} finally {
mount.remove();
(targetDom as HTMLElement).style.cursor = prevCursor;
}
}
/**
* 命令:复制当前块为图片
*/
export const copyBlockImageCommand: Command = view => {
void copyActiveBlockAsImage(view);
return true;
};
export const blockImageEnabledFacet = Facet.define<boolean, boolean>({
combine: values => values.some(Boolean),
});
/**
* BlockImage 扩展入口
*/
export function createBlockImageExtension(): Extension {
return [
blockImageEnabledFacet.of(true),
];
}
export default createBlockImageExtension;

View File

@@ -9,6 +9,7 @@ import {useSystemStore} from '@/stores/systemStore';
import {showContextMenu} from './manager';
import type {MenuSchemaNode} from './menuSchema';
import {buildRegisteredMenu, createMenuContext, registerMenuNodes} from './menuSchema';
import {blockImageMenuNodes} from '../blockImage/contextMenu';
function t(key: string): string {
@@ -105,7 +106,7 @@ let builtinMenuRegistered = false;
function ensureBuiltinMenuRegistered(): void {
if (builtinMenuRegistered) return;
registerMenuNodes(builtinMenuNodes());
registerMenuNodes([...builtinMenuNodes(), ...blockImageMenuNodes]);
builtinMenuRegistered = true;
}

View File

@@ -75,6 +75,7 @@ import {
import {foldAll, foldCode, unfoldAll, unfoldCode} from '@codemirror/language';
import i18n from '@/i18n';
import {KeyBindingName} from '@/../bindings/voidraft/internal/models/models';
import {copyBlockImageCommand} from '../extensions/blockImage';
const defaultBlockExtensionOptions = {
defaultBlockToken: 'text',
@@ -170,6 +171,10 @@ export const commands: Record<string, { handler: any; descriptionKey: string }>
handler: pasteCommand,
descriptionKey: 'keybindings.commands.blockPaste'
},
[KeyBindingName.CopyBlockImage]: {
handler: copyBlockImageCommand,
descriptionKey: 'keybindings.commands.copyBlockImage'
},
[KeyBindingName.HistoryUndo]: {
handler: undo,
descriptionKey: 'keybindings.commands.historyUndo'

View File

@@ -15,6 +15,7 @@ import {highlightActiveLineGutter, highlightWhitespace, highlightTrailingWhitesp
import createEditorContextMenu from '../extensions/contextMenu';
import {blockLineNumbers} from '../extensions/codeblock';
import {createHttpClientExtension} from '../extensions/httpclient';
import {createBlockImageExtension} from '../extensions/blockImage';
import {ExtensionName} from '@/../bindings/voidraft/internal/models/models';
type ExtensionEntry = {
@@ -104,6 +105,11 @@ const EXTENSION_REGISTRY: Record<ValidExtensionName, ExtensionEntry> = {
definition: defineExtension(() => createHttpClientExtension()),
displayNameKey: 'extensions.httpClient.name',
descriptionKey: 'extensions.httpClient.description'
},
[ExtensionName.BlockImage]: {
definition: defineExtension(() => createBlockImageExtension()),
displayNameKey: 'extensions.blockImage.name',
descriptionKey: 'extensions.blockImage.description'
}
};