diff --git a/frontend/bindings/voidraft/internal/models/models.ts b/frontend/bindings/voidraft/internal/models/models.ts index ff1cc73..4aa14dc 100644 --- a/frontend/bindings/voidraft/internal/models/models.ts +++ b/frontend/bindings/voidraft/internal/models/models.ts @@ -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 { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9676fbe..737b573 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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": "*" diff --git a/frontend/package.json b/frontend/package.json index e3d1a5c..b9e6849 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index f128e7f..b79feac 100644 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -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: { diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 48576fd..a91e23a 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -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: { diff --git a/frontend/src/views/editor/extensions/blockImage/contextMenu.ts b/frontend/src/views/editor/extensions/blockImage/contextMenu.ts new file mode 100644 index 0000000..2e662b5 --- /dev/null +++ b/frontend/src/views/editor/extensions/blockImage/contextMenu.ts @@ -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)), + }, +]; + diff --git a/frontend/src/views/editor/extensions/blockImage/index.ts b/frontend/src/views/editor/extensions/blockImage/index.ts new file mode 100644 index 0000000..07817e8 --- /dev/null +++ b/frontend/src/views/editor/extensions/blockImage/index.ts @@ -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 { + 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 { + 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 { + 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({ + combine: values => values.some(Boolean), +}); + +/** + * BlockImage 扩展入口 + */ +export function createBlockImageExtension(): Extension { + return [ + blockImageEnabledFacet.of(true), + ]; +} + +export default createBlockImageExtension; diff --git a/frontend/src/views/editor/extensions/contextMenu/index.ts b/frontend/src/views/editor/extensions/contextMenu/index.ts index 8ba50e9..11aa646 100644 --- a/frontend/src/views/editor/extensions/contextMenu/index.ts +++ b/frontend/src/views/editor/extensions/contextMenu/index.ts @@ -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; } diff --git a/frontend/src/views/editor/keymap/commands.ts b/frontend/src/views/editor/keymap/commands.ts index 1dcf17e..48d0cc1 100644 --- a/frontend/src/views/editor/keymap/commands.ts +++ b/frontend/src/views/editor/keymap/commands.ts @@ -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 handler: pasteCommand, descriptionKey: 'keybindings.commands.blockPaste' }, + [KeyBindingName.CopyBlockImage]: { + handler: copyBlockImageCommand, + descriptionKey: 'keybindings.commands.copyBlockImage' + }, [KeyBindingName.HistoryUndo]: { handler: undo, descriptionKey: 'keybindings.commands.historyUndo' diff --git a/frontend/src/views/editor/manager/extensions.ts b/frontend/src/views/editor/manager/extensions.ts index 5bc7282..94808f6 100644 --- a/frontend/src/views/editor/manager/extensions.ts +++ b/frontend/src/views/editor/manager/extensions.ts @@ -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 = { definition: defineExtension(() => createHttpClientExtension()), displayNameKey: 'extensions.httpClient.name', descriptionKey: 'extensions.httpClient.description' + }, + [ExtensionName.BlockImage]: { + definition: defineExtension(() => createBlockImageExtension()), + displayNameKey: 'extensions.blockImage.name', + descriptionKey: 'extensions.blockImage.description' } }; diff --git a/internal/models/extension.go b/internal/models/extension.go index 372b968..fb82116 100644 --- a/internal/models/extension.go +++ b/internal/models/extension.go @@ -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{}, + }, } } diff --git a/internal/models/key_binding.go b/internal/models/key_binding.go index 0e9b8bd..48f956c 100644 --- a/internal/models/key_binding.go +++ b/internal/models/key_binding.go @@ -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, + }, // 代码折叠相关 {