改进 Markdown 渲染部分
This commit is contained in:
parent
66290ea1d0
commit
5e5f7cd76e
@ -11,8 +11,8 @@
|
|||||||
"gen": "openapi-generator-cli generate -i ./api/swagger.yaml -g typescript-axios -o ./src/api"
|
"gen": "openapi-generator-cli generate -i ./api/swagger.yaml -g typescript-axios -o ./src/api"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@iktakahiro/markdown-it-katex": "^4.0.1",
|
||||||
"@notable/html2markdown": "^2.0.3",
|
"@notable/html2markdown": "^2.0.3",
|
||||||
"@traptitech/markdown-it-katex": "^3.6.0",
|
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"highlight.js": "^11.10.0",
|
"highlight.js": "^11.10.0",
|
||||||
@ -48,6 +48,7 @@
|
|||||||
"naive-ui": "^2.39.0",
|
"naive-ui": "^2.39.0",
|
||||||
"postcss": "^8.4.45",
|
"postcss": "^8.4.45",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
|
"sass-embedded": "^1.79.5",
|
||||||
"tailwindcss": "^3.4.10",
|
"tailwindcss": "^3.4.10",
|
||||||
"typescript": "^5.5.2",
|
"typescript": "^5.5.2",
|
||||||
"unplugin-auto-import": "^0.18.2",
|
"unplugin-auto-import": "^0.18.2",
|
||||||
|
1
src/components.d.ts
vendored
1
src/components.d.ts
vendored
@ -17,6 +17,7 @@ declare module 'vue' {
|
|||||||
LeftSettings: typeof import('./components/settings/LeftSettings.vue')['default']
|
LeftSettings: typeof import('./components/settings/LeftSettings.vue')['default']
|
||||||
LibrarySettings: typeof import('./components/settings/LibrarySettings.vue')['default']
|
LibrarySettings: typeof import('./components/settings/LibrarySettings.vue')['default']
|
||||||
Lottie: typeof import('./components/Lottie.vue')['default']
|
Lottie: typeof import('./components/Lottie.vue')['default']
|
||||||
|
Markdown: typeof import('./components/markdown/index.vue')['default']
|
||||||
Mask: typeof import('./components/Mask.vue')['default']
|
Mask: typeof import('./components/Mask.vue')['default']
|
||||||
MemorySettings: typeof import('./components/settings/MemorySettings.vue')['default']
|
MemorySettings: typeof import('./components/settings/MemorySettings.vue')['default']
|
||||||
Menu: typeof import('./components/Menu.vue')['default']
|
Menu: typeof import('./components/Menu.vue')['default']
|
||||||
|
@ -35,7 +35,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="(message.role === 'user' || message.role === 'user_hide' || message.role === 'user_later') && message.content">
|
<div
|
||||||
|
v-else-if="
|
||||||
|
(message.role === 'user' ||
|
||||||
|
message.role === 'user_hide' ||
|
||||||
|
message.role === 'user_later') &&
|
||||||
|
message.content
|
||||||
|
"
|
||||||
|
>
|
||||||
<!-- 用户消息 -->
|
<!-- 用户消息 -->
|
||||||
<n-flex justify="end">
|
<n-flex justify="end">
|
||||||
<div class="flex items-center flex-nowrap">
|
<div class="flex items-center flex-nowrap">
|
||||||
@ -51,11 +58,14 @@
|
|||||||
{{ userStore.user.name }}
|
{{ userStore.user.name }}
|
||||||
</n-divider>
|
</n-divider>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<!-- <div
|
||||||
v-if="mdInited"
|
v-if="mdInited"
|
||||||
class="break-all break-words markdown-body"
|
class="break-all break-words markdown-body"
|
||||||
v-html="mdIt.render(message.content)"
|
v-html="mdIt.render(message.content)"
|
||||||
></div>
|
></div> -->
|
||||||
|
<div>
|
||||||
|
<Markdown :typing="false" :content="message.content" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative h-full">
|
<div class="relative h-full">
|
||||||
@ -103,11 +113,10 @@
|
|||||||
{{ message.assistant?.name }}
|
{{ message.assistant?.name }}
|
||||||
</n-divider>
|
</n-divider>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
v-if="mdInited"
|
<div>
|
||||||
class="break-all break-words markdown-body"
|
<Markdown :typing="false" :content="message.content" />
|
||||||
v-html="mdIt.render(message.content)"
|
</div>
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- <div v-html="mdIt.render(message.content)"></div> -->
|
<!-- <div v-html="mdIt.render(message.content)"></div> -->
|
||||||
|
|
||||||
@ -126,50 +135,9 @@ import { Ref } from "vue";
|
|||||||
import { EntityChatMessage } from "@/api";
|
import { EntityChatMessage } from "@/api";
|
||||||
import { useUserStore } from "@/stores/user";
|
import { useUserStore } from "@/stores/user";
|
||||||
import leaflowPng from "@/assets/images/leaflow.png";
|
import leaflowPng from "@/assets/images/leaflow.png";
|
||||||
import markdownKatex from "@traptitech/markdown-it-katex";
|
import Markdown from "@/components/markdown/index.vue";
|
||||||
import markdownIt from "markdown-it";
|
|
||||||
// highlightjs
|
|
||||||
import hljs from "highlight.js";
|
|
||||||
import config from "@/config/config";
|
import config from "@/config/config";
|
||||||
|
|
||||||
const mdIt = markdownIt();
|
|
||||||
const mdInited = ref(true);
|
|
||||||
|
|
||||||
const unsupportedLanguages = ["assembly", "blade", "vue"];
|
|
||||||
mdIt.options.highlight = function (str: string, lang: string) {
|
|
||||||
// TODO: 前面的区域以后再来探索吧
|
|
||||||
lang = "text"
|
|
||||||
if (!lang || unsupportedLanguages.includes(lang)) {
|
|
||||||
// return str;
|
|
||||||
lang = "text"
|
|
||||||
}
|
|
||||||
|
|
||||||
return hljs.highlight(str, { language: lang }).value;
|
|
||||||
};
|
|
||||||
mdIt.use(markdownKatex, {
|
|
||||||
throwOnError: false,
|
|
||||||
errorColor: "#cc0000",
|
|
||||||
output: "html",
|
|
||||||
});
|
|
||||||
|
|
||||||
// async function initMD() {
|
|
||||||
// mdIt.use(
|
|
||||||
// await Shiki({
|
|
||||||
// themes: {
|
|
||||||
// light: "vitesse-light",
|
|
||||||
// dark: "vitesse-dark",
|
|
||||||
// },
|
|
||||||
// })
|
|
||||||
// );
|
|
||||||
|
|
||||||
// mdIt.use(markdownKatex, {
|
|
||||||
// throwOnError: false,
|
|
||||||
// errorColor: "#cc0000",
|
|
||||||
// output: "html",
|
|
||||||
// });
|
|
||||||
|
|
||||||
// mdInited.value = true;
|
|
||||||
// }
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -182,7 +150,4 @@ const props = defineProps({
|
|||||||
const chat_messages = toRef(props, "chat_messages") as Ref<EntityChatMessage[]>;
|
const chat_messages = toRef(props, "chat_messages") as Ref<EntityChatMessage[]>;
|
||||||
const fileBaseUrl = config.backend + "/api/v1/files";
|
const fileBaseUrl = config.backend + "/api/v1/files";
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
// initMD();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
17
src/components/markdown/aPlugin/index.ts
Normal file
17
src/components/markdown/aPlugin/index.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import type { PluginWithOptions } from 'markdown-it'
|
||||||
|
|
||||||
|
export interface APluginOptions {}
|
||||||
|
|
||||||
|
export const aPlugin: PluginWithOptions<APluginOptions> = (
|
||||||
|
md,
|
||||||
|
options = {}
|
||||||
|
): void => {
|
||||||
|
md.renderer.rules.link_open = (tokens, idx, options, env, slf) => {
|
||||||
|
const aIndex = tokens[idx].attrIndex('href')
|
||||||
|
|
||||||
|
if (aIndex !== -1) {
|
||||||
|
tokens[idx].attrs.push(['target', '_blank'])
|
||||||
|
}
|
||||||
|
return slf.renderToken(tokens, idx, options)
|
||||||
|
}
|
||||||
|
}
|
52
src/components/markdown/codePlugin/index.ts
Normal file
52
src/components/markdown/codePlugin/index.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import type { PluginWithOptions } from 'markdown-it'
|
||||||
|
|
||||||
|
import { resolveLanguage } from './resolveLanguage.js'
|
||||||
|
import { resolveLineNumbers } from './resolveLineNumbers.js'
|
||||||
|
|
||||||
|
export interface CodePluginOptions {
|
||||||
|
lineNumbers?: boolean | number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const codePlugin: PluginWithOptions<CodePluginOptions> = (
|
||||||
|
md,
|
||||||
|
{ lineNumbers = true } = {}
|
||||||
|
): void => {
|
||||||
|
md.renderer.rules.fence = (tokens, idx, options, env, slf) => {
|
||||||
|
const token = tokens[idx]
|
||||||
|
const info = token.info ? md.utils.unescapeAll(token.info).trim() : ''
|
||||||
|
const language = resolveLanguage(info)
|
||||||
|
const languageClass = `${options.langPrefix}${language.name}`
|
||||||
|
|
||||||
|
const code =
|
||||||
|
options.highlight?.(token.content, language.name, '') ||
|
||||||
|
md.utils.escapeHtml(token.content)
|
||||||
|
|
||||||
|
token.attrJoin('class', languageClass)
|
||||||
|
let result = code.startsWith('<pre')
|
||||||
|
? code
|
||||||
|
: `<pre${slf.renderAttrs(token)}><code>${code}</code></pre>`
|
||||||
|
result = `<div data-ext="${language.ext}" v-pre class="code-copy-line">
|
||||||
|
<div class="code-copy-btn">复制代码</div>
|
||||||
|
</div>${result}`
|
||||||
|
const lines = code.split('\n').slice(0, -1)
|
||||||
|
|
||||||
|
const useLineNumbers =
|
||||||
|
resolveLineNumbers(info) ??
|
||||||
|
(typeof lineNumbers === 'number'
|
||||||
|
? lines.length >= lineNumbers
|
||||||
|
: lineNumbers)
|
||||||
|
if (useLineNumbers) {
|
||||||
|
const lineNumbersCode = lines
|
||||||
|
.map(() => `<div class="line-number"></div>`)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
result = `<div class="line-numbers" aria-hidden="true">${lineNumbersCode}</div>${result}`
|
||||||
|
}
|
||||||
|
|
||||||
|
result = `<div><div class="${languageClass}${
|
||||||
|
useLineNumbers ? ' line-numbers-mode' : ''
|
||||||
|
}"><div class="pre-code-scroll">${result}</div></div></div>`
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
105
src/components/markdown/codePlugin/languages.ts
Normal file
105
src/components/markdown/codePlugin/languages.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Language type for syntax highlight
|
||||||
|
*/
|
||||||
|
export interface HighlightLanguage {
|
||||||
|
/**
|
||||||
|
* Name of the language
|
||||||
|
*
|
||||||
|
* The name to be used for the class name,
|
||||||
|
* e.g. `class="language-typescript"`
|
||||||
|
*/
|
||||||
|
name: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension of the language
|
||||||
|
*
|
||||||
|
* The file extension, which will be used for the
|
||||||
|
* class name, e.g. `class="ext-ts"`
|
||||||
|
*/
|
||||||
|
ext: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aliases that point to this language
|
||||||
|
*
|
||||||
|
* Do not conflict with other languages
|
||||||
|
*/
|
||||||
|
aliases: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const languageBash: HighlightLanguage = {
|
||||||
|
name: 'bash',
|
||||||
|
ext: 'sh',
|
||||||
|
aliases: ['bash', 'sh', 'shell', 'zsh']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const languageCsharp: HighlightLanguage = {
|
||||||
|
name: 'csharp',
|
||||||
|
ext: 'cs',
|
||||||
|
aliases: ['cs', 'csharp']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const languageDocker: HighlightLanguage = {
|
||||||
|
name: 'docker',
|
||||||
|
ext: 'docker',
|
||||||
|
aliases: ['docker', 'dockerfile']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const languageFsharp: HighlightLanguage = {
|
||||||
|
name: 'fsharp',
|
||||||
|
ext: 'fs',
|
||||||
|
aliases: ['fs', 'fsharp']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const languageJavascript: HighlightLanguage = {
|
||||||
|
name: 'javascript',
|
||||||
|
ext: 'js',
|
||||||
|
aliases: ['javascript', 'js']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const languageKotlin: HighlightLanguage = {
|
||||||
|
name: 'kotlin',
|
||||||
|
ext: 'kt',
|
||||||
|
aliases: ['kotlin', 'kt']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const languageMarkdown: HighlightLanguage = {
|
||||||
|
name: 'markdown',
|
||||||
|
ext: 'md',
|
||||||
|
aliases: ['markdown', 'md']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const languagePython: HighlightLanguage = {
|
||||||
|
name: 'python',
|
||||||
|
ext: 'py',
|
||||||
|
aliases: ['py', 'python']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const languageRuby: HighlightLanguage = {
|
||||||
|
name: 'ruby',
|
||||||
|
ext: 'rb',
|
||||||
|
aliases: ['rb', 'ruby']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const languageRust: HighlightLanguage = {
|
||||||
|
name: 'rust',
|
||||||
|
ext: 'rs',
|
||||||
|
aliases: ['rs', 'rust']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const languageStylus: HighlightLanguage = {
|
||||||
|
name: 'stylus',
|
||||||
|
ext: 'styl',
|
||||||
|
aliases: ['styl', 'stylus']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const languageTypescript: HighlightLanguage = {
|
||||||
|
name: 'typescript',
|
||||||
|
ext: 'ts',
|
||||||
|
aliases: ['ts', 'typescript']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const languageYaml: HighlightLanguage = {
|
||||||
|
name: 'yaml',
|
||||||
|
ext: 'yml',
|
||||||
|
aliases: ['yaml', 'yml']
|
||||||
|
}
|
46
src/components/markdown/codePlugin/resolveLanguage.ts
Normal file
46
src/components/markdown/codePlugin/resolveLanguage.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import * as languages from './languages.js'
|
||||||
|
import type { HighlightLanguage } from './languages.js'
|
||||||
|
|
||||||
|
type LanguagesMap = Record<string, HighlightLanguage>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key-value map to get language info from alias
|
||||||
|
*
|
||||||
|
* - key: alias
|
||||||
|
* - value: language
|
||||||
|
*/
|
||||||
|
let languagesMap: LanguagesMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy generate languages map
|
||||||
|
*/
|
||||||
|
const getLanguagesMap = (): LanguagesMap => {
|
||||||
|
if (!languagesMap) {
|
||||||
|
languagesMap = Object.values(languages).reduce((result, item) => {
|
||||||
|
item.aliases.forEach((alias) => {
|
||||||
|
result[alias] = item
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}, {} as LanguagesMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
return languagesMap
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve language for highlight from token info
|
||||||
|
*/
|
||||||
|
export const resolveLanguage = (info: string): HighlightLanguage => {
|
||||||
|
// get user-defined language alias
|
||||||
|
const alias = info.match(/^([^ :[{]+)/)?.[1] || ''
|
||||||
|
|
||||||
|
// if the alias does not have a match in the map
|
||||||
|
// fallback to the alias itself
|
||||||
|
return (
|
||||||
|
getLanguagesMap()[alias] ?? {
|
||||||
|
name: alias,
|
||||||
|
ext: alias,
|
||||||
|
aliases: [alias]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
14
src/components/markdown/codePlugin/resolveLineNumbers.ts
Normal file
14
src/components/markdown/codePlugin/resolveLineNumbers.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Resolve the `:line-numbers` / `:no-line-numbers` mark from token info
|
||||||
|
*/
|
||||||
|
export const resolveLineNumbers = (info: string): boolean | null => {
|
||||||
|
if (/:line-numbers\b/.test(info)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/:no-line-numbers\b/.test(info)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
76
src/components/markdown/customLink/index.ts
Normal file
76
src/components/markdown/customLink/index.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import type { PluginWithOptions } from 'markdown-it'
|
||||||
|
|
||||||
|
const defaultMarker = '#'
|
||||||
|
interface CustomLinkPluginOptions {
|
||||||
|
marker: string
|
||||||
|
}
|
||||||
|
export const customLinkPlugin: PluginWithOptions<
|
||||||
|
Partial<CustomLinkPluginOptions>
|
||||||
|
> = (md, options = {}): void => {
|
||||||
|
const marker = options.marker || defaultMarker
|
||||||
|
|
||||||
|
md.block.ruler.before(
|
||||||
|
'paragraph',
|
||||||
|
'custom_link',
|
||||||
|
function (state, startLine, endLine, silent) {
|
||||||
|
const pos = state.bMarks[startLine] + state.tShift[startLine]
|
||||||
|
const max = state.eMarks[startLine]
|
||||||
|
|
||||||
|
if (pos >= max) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (silent) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const text = state.src.substring(pos, max)
|
||||||
|
const start = text.indexOf(marker)
|
||||||
|
const end = text.lastIndexOf(marker)
|
||||||
|
if (start < 0 || end < 0 || start == end) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
//#...#规则前面的内容
|
||||||
|
const startContent = text.substring(0, start)
|
||||||
|
//#...#规则后面的内容
|
||||||
|
const endContent = text.substring(end + 1)
|
||||||
|
//#...#规则中间的内容
|
||||||
|
const content = text.substring(start + 1, end)
|
||||||
|
|
||||||
|
//插入<div>
|
||||||
|
const token_div_o = state.push('div_open', 'div', 1)
|
||||||
|
token_div_o.map = [startLine, state.line]
|
||||||
|
|
||||||
|
//插入#...#规则前面的内容
|
||||||
|
const token_s = state.push('inline', '', 0)
|
||||||
|
token_s.content = startContent
|
||||||
|
token_s.children = []
|
||||||
|
|
||||||
|
//插入<a class="markdown-custom-link">
|
||||||
|
const token_a_o = state.push('link_open', 'a', 1)
|
||||||
|
token_a_o.attrs = [['class', 'markdown-custom-link']]
|
||||||
|
token_a_o.map = [startLine, state.line]
|
||||||
|
|
||||||
|
//插入#...#规则中间的内容
|
||||||
|
const token = state.push('inline', '', 0)
|
||||||
|
token.content = content
|
||||||
|
token.children = []
|
||||||
|
|
||||||
|
//闭合a标签
|
||||||
|
state.push('link_close', 'a', -1)
|
||||||
|
const token_e = state.push('inline', '', 0)
|
||||||
|
|
||||||
|
//插入#...#规则后面的内容
|
||||||
|
token_e.content = endContent
|
||||||
|
token_e.children = []
|
||||||
|
|
||||||
|
//闭合div标签
|
||||||
|
state.push('div_close', 'div', -1)
|
||||||
|
|
||||||
|
state.line = startLine + 1
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
alt: ['paragraph', 'reference', 'blockquote']
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
1102
src/components/markdown/github-markdown.css
Normal file
1102
src/components/markdown/github-markdown.css
Normal file
File diff suppressed because it is too large
Load Diff
168
src/components/markdown/index.vue
Normal file
168
src/components/markdown/index.vue
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
<template>
|
||||||
|
<div class="markdown-it-container">
|
||||||
|
<div
|
||||||
|
ref="markdownBodyRef"
|
||||||
|
class="markdown-body"
|
||||||
|
@click="handleClick($event)"
|
||||||
|
v-html="result"
|
||||||
|
/>
|
||||||
|
<div v-if="typing" class="markdown-typing" :style="cursorPosition"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import MarkdownIt from 'markdown-it'
|
||||||
|
import hljs from 'highlight.js'
|
||||||
|
// import 'highlight.js/styles/atom-one-dark.css'
|
||||||
|
// import './github-markdown.css'
|
||||||
|
// @ts-ignore
|
||||||
|
import markdownItMath from '@iktakahiro/markdown-it-katex'
|
||||||
|
import type {CodePluginOptions} from './codePlugin'
|
||||||
|
import {codePlugin} from './codePlugin'
|
||||||
|
import {customLinkPlugin} from './customLink'
|
||||||
|
import {aPlugin} from './aPlugin'
|
||||||
|
|
||||||
|
interface Options extends Partial<MarkdownIt.Options> {
|
||||||
|
lineNumbers?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
content: string
|
||||||
|
html?: boolean
|
||||||
|
breaks?: boolean
|
||||||
|
linkify?: boolean
|
||||||
|
typographer?: boolean
|
||||||
|
// 是否显示代码行
|
||||||
|
lineNumbers?: boolean
|
||||||
|
// 是否显示打字效果
|
||||||
|
typing: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
content: '',
|
||||||
|
html: true,
|
||||||
|
breaks: true,
|
||||||
|
typographer: true,
|
||||||
|
linkify: true,
|
||||||
|
lineNumbers: true,
|
||||||
|
typing: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'click-custom-link', value: string): void
|
||||||
|
}>()
|
||||||
|
const result = ref('')
|
||||||
|
const markdownBodyRef = shallowRef<HTMLDivElement>()
|
||||||
|
const createMarkdown = (options: Options) => {
|
||||||
|
const md = new MarkdownIt({
|
||||||
|
...options,
|
||||||
|
langPrefix: 'language-',
|
||||||
|
highlight(str: any, lang: any) {
|
||||||
|
try {
|
||||||
|
if (lang && hljs.getLanguage(lang)) {
|
||||||
|
return hljs.highlight(lang, str, true).value
|
||||||
|
}
|
||||||
|
return hljs.highlightAuto(str).value
|
||||||
|
} catch (error) {
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
md.use<CodePluginOptions>(codePlugin, {
|
||||||
|
lineNumbers: options.lineNumbers
|
||||||
|
})
|
||||||
|
md.use(markdownItMath, {
|
||||||
|
output: 'mathml'
|
||||||
|
})
|
||||||
|
md.use(aPlugin)
|
||||||
|
md.use(customLinkPlugin)
|
||||||
|
|
||||||
|
return md
|
||||||
|
}
|
||||||
|
let md: MarkdownIt
|
||||||
|
|
||||||
|
const findLastTextNode = (parent: Node): Node | undefined => {
|
||||||
|
const children = parent.childNodes
|
||||||
|
for (let i = children.length - 1; i >= 0; i--) {
|
||||||
|
const node = children[i]
|
||||||
|
if (node.nodeType === Node.TEXT_NODE && /\S/.test(node.nodeValue!)) {
|
||||||
|
node.nodeValue?.replace(/\S+$/, '')
|
||||||
|
return node
|
||||||
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const lastNode = findLastTextNode(node)
|
||||||
|
if (lastNode) {
|
||||||
|
return lastNode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cursorPosition = reactive({
|
||||||
|
top: 'auto',
|
||||||
|
left: 'auto'
|
||||||
|
})
|
||||||
|
const updateCursor = () => {
|
||||||
|
if (markdownBodyRef.value) {
|
||||||
|
const lastTextNode = findLastTextNode(markdownBodyRef.value)
|
||||||
|
const textNode = document.createTextNode('\u200B')
|
||||||
|
if (lastTextNode) {
|
||||||
|
lastTextNode.parentElement?.appendChild(textNode)
|
||||||
|
} else {
|
||||||
|
markdownBodyRef.value?.appendChild(textNode)
|
||||||
|
}
|
||||||
|
const range = document.createRange()
|
||||||
|
range.setStart(textNode, 0)
|
||||||
|
range.setEnd(textNode, 0)
|
||||||
|
const textRect = range.getBoundingClientRect()
|
||||||
|
const markdownBodyRect = markdownBodyRef.value?.getBoundingClientRect()
|
||||||
|
cursorPosition.left = `${textRect.left - markdownBodyRect.left}px`
|
||||||
|
cursorPosition.top = `${textRect.top - markdownBodyRect.top}px`
|
||||||
|
textNode.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const preprocessContent = (content: string) => {
|
||||||
|
return content.replace(/\n(#.*#)/g, '\n\n$1')
|
||||||
|
}
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
md = createMarkdown({
|
||||||
|
html: props.html,
|
||||||
|
breaks: props.breaks,
|
||||||
|
typographer: props.typographer,
|
||||||
|
linkify: props.linkify,
|
||||||
|
lineNumbers: props.lineNumbers
|
||||||
|
})
|
||||||
|
result.value = md.render(preprocessContent(props.content))
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.content,
|
||||||
|
async (value) => {
|
||||||
|
result.value = md?.render(preprocessContent(value))
|
||||||
|
await nextTick()
|
||||||
|
updateCursor()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// const {copy} = useCopy()
|
||||||
|
|
||||||
|
const handleClick = async (e: any) => {
|
||||||
|
console.log(e.target)
|
||||||
|
const target: HTMLElement = e.target
|
||||||
|
if (target.className === 'code-copy-btn') {
|
||||||
|
const text = e.target.parentElement.nextElementSibling.textContent
|
||||||
|
// await copy(text)
|
||||||
|
// copy
|
||||||
|
}
|
||||||
|
if (target.className === 'markdown-custom-link') {
|
||||||
|
emit('click-custom-link', target.textContent!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use 'markdown.scss';
|
||||||
|
</style>
|
212
src/components/markdown/markdown.scss
Normal file
212
src/components/markdown/markdown.scss
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
$line-height: 1.375;
|
||||||
|
:root {
|
||||||
|
--code-bg-color: #35373f;
|
||||||
|
--code-hl-bg-color: rgba(0, 0, 0, 0.66);
|
||||||
|
--code-ln-color: #6e6e7f;
|
||||||
|
--code-ln-wrapper-width: 3.5rem;
|
||||||
|
}
|
||||||
|
@keyframes blink {
|
||||||
|
from,
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.markdown-it-container {
|
||||||
|
.markdown-body {
|
||||||
|
background-color: transparent;
|
||||||
|
font-size: 15px;
|
||||||
|
.markdown-custom-link {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
position: relative;
|
||||||
|
.markdown-typing {
|
||||||
|
position: absolute;
|
||||||
|
display: inline-block;
|
||||||
|
content: '';
|
||||||
|
width: 5px;
|
||||||
|
height: 14px;
|
||||||
|
transform: translate(4px, 2px) scaleY(1.3);
|
||||||
|
color: #1a202c;
|
||||||
|
background-color: currentColor;
|
||||||
|
animation: blink 0.6s infinite;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
list-style: disc;
|
||||||
|
}
|
||||||
|
ol {
|
||||||
|
list-style: decimal;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
padding: 0.25rem;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85em;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
code[class*='language-'],
|
||||||
|
pre[class*='language-'] {
|
||||||
|
color: #ccc;
|
||||||
|
background: none;
|
||||||
|
font-size: 1em;
|
||||||
|
text-align: left;
|
||||||
|
white-space: pre;
|
||||||
|
word-spacing: normal;
|
||||||
|
word-break: normal;
|
||||||
|
word-wrap: normal;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
-moz-tab-size: 4;
|
||||||
|
-o-tab-size: 4;
|
||||||
|
tab-size: 4;
|
||||||
|
|
||||||
|
-webkit-hyphens: none;
|
||||||
|
-moz-hyphens: none;
|
||||||
|
-ms-hyphens: none;
|
||||||
|
hyphens: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code blocks */
|
||||||
|
pre[class*='language-'] {
|
||||||
|
padding: 20px 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:not(pre) > code[class*='language-'],
|
||||||
|
pre[class*='language-'] {
|
||||||
|
font-size: 0.85em;
|
||||||
|
background: #35373f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline code */
|
||||||
|
:not(pre) > code[class*='language-'] {
|
||||||
|
border-radius: 0.3em;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre,
|
||||||
|
pre[class*='language-'] {
|
||||||
|
line-height: $line-height;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: visible;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 20px;
|
||||||
|
code {
|
||||||
|
color: #fff;
|
||||||
|
padding: 0;
|
||||||
|
background-color: transparent !important;
|
||||||
|
border-radius: 0;
|
||||||
|
overflow-wrap: unset;
|
||||||
|
-webkit-font-smoothing: auto;
|
||||||
|
-moz-osx-font-smoothing: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div[class*='language-'] {
|
||||||
|
position: relative;
|
||||||
|
background-color: var(--code-bg-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding-top: 32px;
|
||||||
|
margin: 0.85rem 0;
|
||||||
|
overflow: hidden;
|
||||||
|
.code-copy-line {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
background-color: #595b63;
|
||||||
|
color: #e0e0e0;
|
||||||
|
height: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
font-size: 12px;
|
||||||
|
-webkit-box-sizing: border-box;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 12px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: attr(data-ext);
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
top: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
.code-copy-btn {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
top: 0;
|
||||||
|
right: 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #e0e0e0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pre,
|
||||||
|
pre[class*='language-'] {
|
||||||
|
// force override the background color to be compatible with shiki
|
||||||
|
background: transparent !important;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.pre-code-scroll {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.line-numbers-mode) {
|
||||||
|
.line-numbers {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.line-numbers-mode {
|
||||||
|
padding-left: var(--code-ln-wrapper-width);
|
||||||
|
pre {
|
||||||
|
vertical-align: middle;
|
||||||
|
padding: 20px 20px 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-numbers {
|
||||||
|
left: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: var(--code-ln-wrapper-width);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--code-ln-color);
|
||||||
|
padding-top: 52px;
|
||||||
|
line-height: $line-height;
|
||||||
|
counter-reset: line-number;
|
||||||
|
|
||||||
|
.line-number {
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
user-select: none;
|
||||||
|
height: #{$line-height - 0.2}em;
|
||||||
|
&::before {
|
||||||
|
display: block;
|
||||||
|
counter-increment: line-number;
|
||||||
|
content: counter(line-number);
|
||||||
|
font-size: 0.8em;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// &::after {
|
||||||
|
// content: "";
|
||||||
|
// position: absolute;
|
||||||
|
// top: 0;
|
||||||
|
// left: 0;
|
||||||
|
// width: var(--code-ln-wrapper-width);
|
||||||
|
// height: 100%;
|
||||||
|
// border-radius: 6px 0 0 6px;
|
||||||
|
// border-right: 1px solid var(--code-hl-bg-color);
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user