feat: enhance exported html code blocks

This commit is contained in:
purocean
2026-05-01 10:53:27 +08:00
parent 798f81ab50
commit 12ccb00b3b
8 changed files with 242 additions and 5 deletions
+6 -1
View File
@@ -74,6 +74,7 @@
</template>
</div>
<label style="display: block; margin-bottom: 10px;"><input v-model="convert.localHtmlOptions.highlightCode" type="checkbox" /> {{$t('copy-content.highlight-code')}} </label>
<label style="display: block; margin-bottom: 10px;"><input v-model="convert.localHtmlOptions.codeLineNumbers" type="checkbox" /> {{$t('copy-content.line-numbers')}} </label>
<label style="display: block; margin-bottom: 10px;"><input v-model="convert.localHtmlOptions.uploadLocalImage" type="checkbox" /> {{$t('copy-content.upload-image')}} </label>
<label style="display: block; margin-bottom: 10px;"><input v-model="convert.localHtmlOptions.inlineLocalImage" type="checkbox" /> {{$t('copy-content.inline-image')}} </label>
<label style="display: block; margin-bottom: 10px;"><input v-model="convert.localHtmlOptions.includeStyle" type="checkbox" /> {{$t('copy-content.include-style')}} </label>
@@ -126,6 +127,7 @@ export default defineComponent({
inlineStyle: false,
includeStyle: true,
highlightCode: true,
codeLineNumbers: true,
includeToc: [] as number[],
},
pdfOptions: {
@@ -206,7 +208,10 @@ export default defineComponent({
const blob = await convertCurrentDocument({
fromType: convert.fromType as any,
toType: convert.toType as any,
fromHtmlOptions: convert.localHtmlOptions,
fromHtmlOptions: {
...convert.localHtmlOptions,
codeCopyButton: convert.localHtmlOptions.includeStyle || convert.localHtmlOptions.inlineStyle,
},
})
downloadContent(fileName.value + '.' + convert.toType, blob)
@@ -266,6 +266,24 @@ export default {
?.text
if (code) {
if (options.codeCopyButton) {
node.dataset.code = code
}
if (options.codeLineNumbers) {
if (!options.highlightCode) {
const wrapper = document.createElement('div')
wrapper.innerHTML = highlight(code, 'text', true)
if (options.codeCopyButton) {
wrapper.firstElementChild?.setAttribute('data-code', code)
}
node.outerHTML = wrapper.innerHTML
}
return
}
if (options.highlightCode) {
if (options.inlineStyle || options.includeStyle) {
node.outerHTML = ctx.lib.juice(
+210 -4
View File
@@ -13,9 +13,214 @@ import { getRepo } from './repo'
import { t } from './i18n'
import { getContentHtml, getHeadings, getPreviewStyles, getRenderEnv, getRenderIframe } from './view'
function buildHtml (title: string, body: string, options: { includeStyle: boolean, includeToc: number[] }) {
const hasToc = options.includeToc.length > 0
type HtmlExportOptions = Partial<NonNullable<ConvertOpts['fromHtmlOptions']>>
function getCodeBlockText (code: HTMLElement, lineNumberTable: HTMLElement | null) {
if (!lineNumberTable) {
return code.textContent || ''
}
return Array.from(lineNumberTable.querySelectorAll<HTMLElement>('.hljs-ln-code .hljs-ln-line'))
.map(line => line.textContent === ' ' ? '' : line.textContent || '')
.join('\n')
}
function enhanceCodeBlocks (body: string, options: HtmlExportOptions) {
if (!options.codeLineNumbers && !options.codeCopyButton) {
return body
}
const container = document.createElement('div')
container.innerHTML = body
container.querySelectorAll<HTMLPreElement>('pre').forEach(pre => {
const code = pre.querySelector('code')
if (!code) {
return
}
const lineNumberTable = code.querySelector<HTMLElement>('table.hljs-ln')
const copyText = lineNumberTable?.dataset.code || getCodeBlockText(code, lineNumberTable)
lineNumberTable?.removeAttribute('data-code')
if (options.codeLineNumbers && lineNumberTable) {
pre.classList.add('yn-code-with-lines')
}
if (options.codeCopyButton) {
const wrapper = document.createElement('div')
wrapper.className = 'yn-code-block'
const button = document.createElement('button')
button.className = 'yn-code-copy'
button.type = 'button'
button.dataset.code = copyText
button.dataset.label = t('copy-code')
button.textContent = t('copy-code')
pre.parentNode?.insertBefore(wrapper, pre)
wrapper.appendChild(button)
wrapper.appendChild(pre)
}
})
return container.innerHTML
}
function buildCodeBlockAssets (options: HtmlExportOptions) {
if (!options.codeLineNumbers && !options.codeCopyButton) {
return ''
}
const copyButtonAssets = options.codeCopyButton
? `
.yn-code-block {
position: relative;
margin: 1em 0;
}
.yn-code-block > pre {
margin: 0;
}
.yn-code-copy {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
border: 1px solid rgba(127, 127, 127, 0.35);
border-radius: 6px;
padding: 4px 8px;
background: rgba(255, 255, 255, 0.88);
color: #24292f;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
opacity: 0;
transition: opacity 160ms ease;
}
.yn-code-block:hover .yn-code-copy {
opacity: 1;
}
.yn-code-copy:focus {
opacity: 1;
outline: 2px solid #0969da;
outline-offset: 2px;
}
`
: ''
const lineNumberAssets = options.codeLineNumbers
? `
pre.yn-code-with-lines table.hljs-ln {
width: 100%;
margin: 0;
border: 0;
border-collapse: separate;
border-spacing: 0;
}
pre.yn-code-with-lines table.hljs-ln tr,
pre.yn-code-with-lines table.hljs-ln td {
border: 0;
padding: 0;
background: transparent;
}
pre.yn-code-with-lines table.hljs-ln .hljs-ln-line {
white-space: pre;
}
pre.yn-code-with-lines table.hljs-ln .hljs-ln-numbers {
width: 2em;
min-width: 2em;
padding-right: 8px;
user-select: none;
text-align: right;
vertical-align: top;
color: #6e7781;
border-right: 1px solid rgba(127, 127, 127, 0.25);
}
pre.yn-code-with-lines table.hljs-ln .hljs-ln-code {
padding-left: 10px;
}
pre.yn-code-with-lines table.hljs-ln .hljs-ln-n:before {
content: attr(data-line-number);
}
`
: ''
return `<style>
${copyButtonAssets}
${lineNumberAssets}
</style>
${options.codeCopyButton ? `<script>
(() => {
const copiedText = ${JSON.stringify(t('copied'))};
function fallbackCopy (text) {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
}
async function copyCode (button) {
const text = button.dataset.code || '';
const label = button.dataset.label || button.textContent;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
} else {
fallbackCopy(text);
}
button.textContent = copiedText;
setTimeout(() => {
button.textContent = label;
}, 1200);
} catch (error) {
fallbackCopy(text);
button.textContent = copiedText;
setTimeout(() => {
button.textContent = label;
}, 1200);
}
}
document.addEventListener('click', event => {
const button = event.target instanceof Element
? event.target.closest('.yn-code-copy')
: null;
if (button) {
copyCode(button);
}
});
})();
</script>` : ''}`
}
function buildHtml (title: string, body: string, options: HtmlExportOptions) {
const codeOptions = {
...options,
codeCopyButton: !!options.codeCopyButton && !!(options.includeStyle || options.inlineStyle),
}
const includeToc = options.includeToc || []
const hasToc = includeToc.length > 0
const headingNumber = !!(getRenderEnv()?.attributes?.headingNumber)
const bodyHtml = enhanceCodeBlocks(body, codeOptions)
const codeBlockAssets = buildCodeBlockAssets(codeOptions)
return `<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang xml:lang>
@@ -31,6 +236,7 @@ function buildHtml (title: string, body: string, options: { includeStyle: boolea
</style>`
: ''
}
${codeBlockAssets}
${hasToc ? `<style>
body {
padding-left: 240px;
@@ -95,12 +301,12 @@ function buildHtml (title: string, body: string, options: { includeStyle: boolea
</style>` : ''}
</head>
<body>
${body}
${bodyHtml}
${hasToc ? `
<nav id="yn-article-toc">
<div class="toc-title">${t('table-of-contents')}</div>
<ul class="${headingNumber ? 'heading-number' : ''}">
${getHeadings(false).filter(heading => options.includeToc.includes(heading.level)).map(heading => `<li data-level="${heading.level}" data-id="${heading.sourceLine}-${escape(heading.id)}" style="padding-left: ${heading.level}em">
${getHeadings(false).filter(heading => includeToc.includes(heading.level)).map(heading => `<li data-level="${heading.level}" data-id="${heading.sourceLine}-${escape(heading.id)}" style="padding-left: ${heading.level}em">
<a href="#${heading.sourceLine}-${escape(heading.id)}">
${escape(heading.text)}
</a>
+4
View File
@@ -344,6 +344,8 @@ export type ConvertOpts = {
inlineStyle: boolean,
includeStyle: boolean,
highlightCode: boolean,
codeLineNumbers?: boolean,
codeCopyButton?: boolean,
includeToc: number[],
}
}
@@ -570,6 +572,8 @@ export type BuildInHookTypes = {
useRemoteSrcOfLocalImage?: boolean,
uploadLocalImage?: boolean,
highlightCode?: boolean,
codeLineNumbers?: boolean,
codeCopyButton?: boolean,
preferPng?: boolean,
onlySelected?: boolean,
nodeProcessor?: (node: HTMLElement) => void,
+1
View File
@@ -651,6 +651,7 @@ const data = {
'inline-image': 'Inline Local Image',
'upload-image': 'Upload Local Image',
'highlight-code': 'Highlight Code',
'line-numbers': 'Code Line Numbers',
'rt': 'Rich Text',
'complete': 'Conversion completed, click OK to copy',
'copy-tips': 'If you need to copy part of the content, you need select it in the preview area first.',
+1
View File
@@ -652,6 +652,7 @@ const data: BaseLanguage = {
'inline-image': 'Встроенное локальное изображение',
'upload-image': 'Загрузить локальное изображение',
'highlight-code': 'Выделить код',
'line-numbers': 'Номера строк кода',
'rt': 'Форматированный текст',
'complete': 'Конвертация завершена, нажмите ОК, чтобы скопировать',
'copy-tips': 'Если вам необходимо скопировать часть содержимого, сначала выделите его в области предпросмотра.',
+1
View File
@@ -652,6 +652,7 @@ const data: BaseLanguage = {
'inline-image': '内联本地图片',
'upload-image': '上传本地图片',
'highlight-code': '高亮代码',
'line-numbers': '代码行号',
'rt': '富文本',
'complete': '转换完成,点击确定复制',
'copy-tips': '如需复制部分内容,需要预览区选中后再操作',
+1
View File
@@ -652,6 +652,7 @@ const data: BaseLanguage = {
'inline-image': '內聯本地圖片',
'upload-image': '上傳本地圖片',
'highlight-code': '高亮代碼',
'line-numbers': '程式碼行號',
'rt': '富文本',
'complete': '轉換完成,點選確定複製',
'copy-tips': '如需複製部分內容,需要預覽區選取後再操作',