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

@@ -402,6 +402,11 @@ export enum ExtensionName {
* HTTP 客户端
*/
HttpClient = "httpClient",
/**
* 代码块导出图片
*/
BlockImage = "blockImage",
};
/**
@@ -1170,6 +1175,11 @@ export enum KeyBindingName {
* 重做选择
*/
HistoryRedoSelection = "historyRedoSelection",
/**
* 复制块为图片
*/
CopyBlockImage = "copyBlockImage",
};
export enum KeyBindingType {

View File

@@ -45,6 +45,7 @@
"@toml-tools/lexer": "^1.0.0",
"@toml-tools/parser": "^1.0.0",
"@types/katex": "^0.16.7",
"@zumer/snapdom": "^2.0.1",
"codemirror": "^6.0.2",
"codemirror-lang-elixir": "^4.0.0",
"colors-named": "^1.0.4",
@@ -60,8 +61,8 @@
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"prettier": "^3.7.4",
"sass": "^1.97.0",
"vue": "^3.5.25",
"sass": "^1.97.1",
"vue": "^3.5.26",
"vue-i18n": "^11.2.2",
"vue-pick-colors": "^1.8.0",
"vue-router": "^4.6.4"
@@ -71,7 +72,7 @@
"@lezer/generator": "^1.8.0",
"@types/node": "^25.0.3",
"@vitejs/plugin-vue": "^6.0.3",
"@wailsio/runtime": "^3.0.0-alpha.76",
"@wailsio/runtime": "^3.0.0-alpha.77",
"cross-env": "^10.1.0",
"eslint": "^9.39.2",
"eslint-plugin-vue": "^10.6.2",
@@ -3297,39 +3298,39 @@
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.25",
"resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.25.tgz",
"integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==",
"version": "3.5.26",
"resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.26.tgz",
"integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@vue/shared": "3.5.25",
"entities": "^4.5.0",
"@vue/shared": "3.5.26",
"entities": "^7.0.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.25",
"resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz",
"integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==",
"version": "3.5.26",
"resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz",
"integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.25",
"@vue/shared": "3.5.25"
"@vue/compiler-core": "3.5.26",
"@vue/shared": "3.5.26"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.25",
"resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz",
"integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==",
"version": "3.5.26",
"resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz",
"integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@vue/compiler-core": "3.5.25",
"@vue/compiler-dom": "3.5.25",
"@vue/compiler-ssr": "3.5.25",
"@vue/shared": "3.5.25",
"@vue/compiler-core": "3.5.26",
"@vue/compiler-dom": "3.5.26",
"@vue/compiler-ssr": "3.5.26",
"@vue/shared": "3.5.26",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.6",
@@ -3337,13 +3338,13 @@
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.25",
"resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.25.tgz",
"integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==",
"version": "3.5.26",
"resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz",
"integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.25",
"@vue/shared": "3.5.25"
"@vue/compiler-dom": "3.5.26",
"@vue/shared": "3.5.26"
}
},
"node_modules/@vue/devtools-api": {
@@ -3417,53 +3418,53 @@
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.25",
"resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.25.tgz",
"integrity": "sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==",
"version": "3.5.26",
"resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.26.tgz",
"integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.25"
"@vue/shared": "3.5.26"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.25",
"resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.25.tgz",
"integrity": "sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==",
"version": "3.5.26",
"resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.26.tgz",
"integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.25",
"@vue/shared": "3.5.25"
"@vue/reactivity": "3.5.26",
"@vue/shared": "3.5.26"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.25",
"resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.25.tgz",
"integrity": "sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==",
"version": "3.5.26",
"resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz",
"integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.25",
"@vue/runtime-core": "3.5.25",
"@vue/shared": "3.5.25",
"csstype": "^3.1.3"
"@vue/reactivity": "3.5.26",
"@vue/runtime-core": "3.5.26",
"@vue/shared": "3.5.26",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.25",
"resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.25.tgz",
"integrity": "sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==",
"version": "3.5.26",
"resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.26.tgz",
"integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.25",
"@vue/shared": "3.5.25"
"@vue/compiler-ssr": "3.5.26",
"@vue/shared": "3.5.26"
},
"peerDependencies": {
"vue": "3.5.25"
"vue": "3.5.26"
}
},
"node_modules/@vue/shared": {
"version": "3.5.25",
"resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.25.tgz",
"integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==",
"version": "3.5.26",
"resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.26.tgz",
"integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
"license": "MIT"
},
"node_modules/@vueuse/core": {
@@ -3599,6 +3600,12 @@
"regexp-to-ast": "0.5.0"
}
},
"node_modules/@zumer/snapdom": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/@zumer/snapdom/-/snapdom-2.0.1.tgz",
"integrity": "sha512-78/qbYl2FTv4H6qaXcNfAujfIOSzdvs83NW63VbyC9QA3sqNPfPvhn4xYMO6Gy11hXwJUEhd0z65yKiNzDwy9w==",
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz",
@@ -5168,9 +5175,9 @@
"license": "MIT"
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.0.tgz",
"integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@ -7656,9 +7663,9 @@
"license": "MIT"
},
"node_modules/sass": {
"version": "1.97.0",
"resolved": "https://registry.npmmirror.com/sass/-/sass-1.97.0.tgz",
"integrity": "sha512-KR0igP1z4avUJetEuIeOdDlwaUDvkH8wSx7FdSjyYBS3dpyX3TzHfAMO0G1Q4/3cdjcmi3r7idh+KCmKqS+KeQ==",
"version": "1.97.1",
"resolved": "https://registry.npmmirror.com/sass/-/sass-1.97.1.tgz",
"integrity": "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==",
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.0",
@@ -8902,16 +8909,16 @@
"license": "MIT"
},
"node_modules/vue": {
"version": "3.5.25",
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.25.tgz",
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
"version": "3.5.26",
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.26.tgz",
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.25",
"@vue/compiler-sfc": "3.5.25",
"@vue/runtime-dom": "3.5.25",
"@vue/server-renderer": "3.5.25",
"@vue/shared": "3.5.25"
"@vue/compiler-dom": "3.5.26",
"@vue/compiler-sfc": "3.5.26",
"@vue/runtime-dom": "3.5.26",
"@vue/server-renderer": "3.5.26",
"@vue/shared": "3.5.26"
},
"peerDependencies": {
"typescript": "*"

View File

@@ -59,6 +59,7 @@
"@toml-tools/lexer": "^1.0.0",
"@toml-tools/parser": "^1.0.0",
"@types/katex": "^0.16.7",
"@zumer/snapdom": "^2.0.1",
"codemirror": "^6.0.2",
"codemirror-lang-elixir": "^4.0.0",
"colors-named": "^1.0.4",
@@ -74,8 +75,8 @@
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"prettier": "^3.7.4",
"sass": "^1.97.0",
"vue": "^3.5.25",
"sass": "^1.97.1",
"vue": "^3.5.26",
"vue-i18n": "^11.2.2",
"vue-pick-colors": "^1.8.0",
"vue-router": "^4.6.4"
@@ -85,7 +86,7 @@
"@lezer/generator": "^1.8.0",
"@types/node": "^25.0.3",
"@vitejs/plugin-vue": "^6.0.3",
"@wailsio/runtime": "^3.0.0-alpha.76",
"@wailsio/runtime": "^3.0.0-alpha.77",
"cross-env": "^10.1.0",
"eslint": "^9.39.2",
"eslint-plugin-vue": "^10.6.2",

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'
}
};

View File

@@ -27,6 +27,7 @@ const (
ContextMenu ExtensionName = "contextMenu" // 上下文菜单
Search ExtensionName = "search" // 搜索功能
HttpClient ExtensionName = "httpClient" // HTTP 客户端
BlockImage ExtensionName = "blockImage" // 代码块导出图片
)
// NewDefaultExtensions 创建默认扩展配置
@@ -106,5 +107,10 @@ func NewDefaultExtensions() []Extension {
Enabled: true,
Config: ExtensionConfig{},
},
{
Name: BlockImage,
Enabled: true,
Config: ExtensionConfig{},
},
}
}

View File

@@ -103,6 +103,7 @@ const (
HistoryRedo KeyBindingName = "historyRedo" // 重做
HistoryUndoSelection KeyBindingName = "historyUndoSelection" // 撤销选择
HistoryRedoSelection KeyBindingName = "historyRedoSelection" // 重做选择
CopyBlockImage KeyBindingName = "copyBlockImage" // 复制块为图片
)
const defaultExtension = "editor"
@@ -282,6 +283,14 @@ func NewDefaultKeyBindings() []KeyBinding {
Enabled: true,
PreventDefault: true,
},
{
Name: CopyBlockImage,
Type: Standard,
Key: "Mod-Shift-Alt-C",
Extension: BlockImage,
Enabled: true,
PreventDefault: true,
},
// 代码折叠相关
{