feat: 测试markdown
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MarkdownProps } from '../XMarkdownCore/shared/types';
|
||||
import { useShiki } from '@components/XMarkdownCore/hooks/useShiki';
|
||||
import { MarkdownRenderer } from '../XMarkdownCore';
|
||||
import { useMarkdownContext } from '../XMarkdownCore/components/MarkdownProvider';
|
||||
import { DEFAULT_PROPS } from '../XMarkdownCore/shared/constants';
|
||||
|
||||
const props = withDefaults(defineProps<MarkdownProps>(), DEFAULT_PROPS);
|
||||
|
||||
const slots = useSlots();
|
||||
const customComponents = useMarkdownContext();
|
||||
const colorReplacementsComputed = computed(() => {
|
||||
return props.colorReplacements;
|
||||
});
|
||||
const needViewCodeBtnComputed = computed(() => {
|
||||
return props.needViewCodeBtn;
|
||||
});
|
||||
useShiki();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="elx-xmarkdown-container">
|
||||
<MarkdownRenderer
|
||||
v-bind="props"
|
||||
:color-replacements="colorReplacementsComputed"
|
||||
:need-view-code-btn="needViewCodeBtnComputed"
|
||||
>
|
||||
<template
|
||||
v-for="(slot, name) in customComponents"
|
||||
:key="name"
|
||||
#[name]="slotProps"
|
||||
>
|
||||
<component :is="slot" v-bind="slotProps" />
|
||||
</template>
|
||||
<template v-for="(_, name) in slots" :key="name" #[name]="slotProps">
|
||||
<slot :name="name" v-bind="slotProps" />
|
||||
</template>
|
||||
</MarkdownRenderer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MarkdownProps } from '../XMarkdownCore/shared/types';
|
||||
import { useShiki } from '@components/XMarkdownCore/hooks/useShiki';
|
||||
import { MarkdownRendererAsync } from '../XMarkdownCore';
|
||||
import { useMarkdownContext } from '../XMarkdownCore/components/MarkdownProvider';
|
||||
import { DEFAULT_PROPS } from '../XMarkdownCore/shared/constants';
|
||||
|
||||
const props = withDefaults(defineProps<MarkdownProps>(), DEFAULT_PROPS);
|
||||
|
||||
const slots = useSlots();
|
||||
const customComponents = useMarkdownContext();
|
||||
const colorReplacementsComputed = computed(() => {
|
||||
return props.colorReplacements;
|
||||
});
|
||||
const needViewCodeBtnComputed = computed(() => {
|
||||
return props.needViewCodeBtn;
|
||||
});
|
||||
useShiki();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="elx-xmarkdown-container">
|
||||
<MarkdownRendererAsync
|
||||
v-bind="props"
|
||||
:color-replacements="colorReplacementsComputed"
|
||||
:need-view-code-btn="needViewCodeBtnComputed"
|
||||
>
|
||||
<template
|
||||
v-for="(slot, name) in customComponents"
|
||||
:key="name"
|
||||
#[name]="slotProps"
|
||||
>
|
||||
<component :is="slot" v-bind="slotProps" />
|
||||
</template>
|
||||
<template v-for="(_, name) in slots" :key="name" #[name]="slotProps">
|
||||
<slot :name="name" v-bind="slotProps" />
|
||||
</template>
|
||||
</MarkdownRendererAsync>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,59 @@
|
||||
import { defineComponent, h } from 'vue';
|
||||
import {
|
||||
MarkdownProvider,
|
||||
useMarkdownContext
|
||||
} from '../components/MarkdownProvider';
|
||||
import { VueMarkdown, VueMarkdownAsync } from '../core';
|
||||
import { useComponents } from '../hooks';
|
||||
import { MARKDOWN_CORE_PROPS } from '../shared/constants';
|
||||
|
||||
const InnerRenderer = defineComponent({
|
||||
name: 'InnerMarkdownRenderer',
|
||||
setup(_, { slots }) {
|
||||
const context = useMarkdownContext();
|
||||
const components = useComponents();
|
||||
return () =>
|
||||
h(VueMarkdown, context.value as any, {
|
||||
...components,
|
||||
...slots
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const InnerRendererAsync = defineComponent({
|
||||
name: 'InnerMarkdownRendererAsync',
|
||||
setup(_, { slots }) {
|
||||
const context: any = useMarkdownContext();
|
||||
const components = useComponents();
|
||||
|
||||
return () =>
|
||||
h(VueMarkdownAsync, context.value, {
|
||||
...components,
|
||||
...slots
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const MarkdownRenderer = defineComponent({
|
||||
name: 'MarkdownRenderer',
|
||||
props: MARKDOWN_CORE_PROPS,
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
h(MarkdownProvider, props, {
|
||||
default: () => h(InnerRenderer, {}, slots)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const MarkdownRendererAsync = defineComponent({
|
||||
name: 'MarkdownRendererAsync',
|
||||
props: MARKDOWN_CORE_PROPS,
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
h(MarkdownProvider, props, {
|
||||
default: () => h(InnerRendererAsync, {}, slots)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export { MarkdownRenderer, MarkdownRendererAsync };
|
||||
@@ -0,0 +1,63 @@
|
||||
# 使用
|
||||
|
||||
```vue
|
||||
import {MarkdownRenderer,MarkdownRendererAsync} from '@/components/Markdown';
|
||||
|
||||
<template>
|
||||
// 同步渲染
|
||||
<MarkdownRenderer class="markdown-render" :markdown="content" />
|
||||
|
||||
// 异步渲染
|
||||
<Suspense>
|
||||
<MarkdownRendererAsync class="markdown-render" :markdown="content" />
|
||||
</Suspense>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 属性
|
||||
|
||||
### customAttrs 自定义属性支持
|
||||
|
||||
通过 `customAttrs` 可以对 Markdown 渲染的节点动态添加自定义属性:
|
||||
|
||||
```ts
|
||||
const customAttrs = {
|
||||
heading: (node, { level }) => ({
|
||||
class: ['heading', `heading-${level}`]
|
||||
}),
|
||||
a: node => ({
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer'
|
||||
})
|
||||
};
|
||||
```
|
||||
|
||||
### 插槽
|
||||
|
||||
> 组件提供了多个插槽,可以自定义渲染,标签即为插槽,你可以接管任何插槽,自定义渲染逻辑。
|
||||
|
||||
**请注意:组件内部拦截了code标签的渲染,支持高亮代码块,mermaid图表等。如果你需要自定义渲染,可以接管code插槽。**
|
||||
|
||||
```vue
|
||||
<header></header>
|
||||
|
||||
<MarkdownRenderer>
|
||||
<template #heading="{ node, level }">
|
||||
可自定义标题渲染
|
||||
</template>
|
||||
</MarkdownRenderer>
|
||||
```
|
||||
|
||||
### 代码块渲染
|
||||
|
||||
组件内置了代码块渲染器,支持高亮代码块,mermaid图表等。
|
||||
codeXSlot自定义代码块顶部
|
||||
可通过 codeXRender 属性自定义代码块语言渲染器,如下可以自定义 echarts 渲染器:
|
||||
|
||||
```text
|
||||
codeXRender: {
|
||||
echarts: (props) => {
|
||||
return h()
|
||||
},
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { CopyDocument, Select } from '@element-plus/icons-vue';
|
||||
import { ElButton } from 'element-plus';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
onCopy: () => void;
|
||||
}>();
|
||||
|
||||
const copied = ref(false);
|
||||
|
||||
function handleClick() {
|
||||
if (!copied.value) {
|
||||
props.onCopy();
|
||||
copied.value = true;
|
||||
setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElButton
|
||||
class="shiki-header-button markdown-elxLanguage-header-button"
|
||||
@click="handleClick"
|
||||
>
|
||||
<component
|
||||
:is="copied ? Select : CopyDocument"
|
||||
class="markdown-elxLanguage-header-button-text"
|
||||
:class="[copied && 'copied']"
|
||||
/>
|
||||
</ElButton>
|
||||
</template>
|
||||
@@ -0,0 +1,255 @@
|
||||
<script lang="ts" setup>
|
||||
import type { GlobalShiki } from '@components/XMarkdownCore/hooks/useShiki';
|
||||
import type { BundledLanguage } from 'shiki';
|
||||
import type { ElxRunCodeProps } from '../RunCode/type';
|
||||
import type { CodeBlockExpose } from './shiki-header';
|
||||
import type { RawProps } from './types';
|
||||
import {
|
||||
transformerNotationDiff,
|
||||
transformerNotationErrorLevel,
|
||||
transformerNotationFocus,
|
||||
transformerNotationHighlight,
|
||||
transformerNotationWordHighlight
|
||||
} from '@shikijs/transformers';
|
||||
import { computed, h, reactive, ref, toValue, watch } from 'vue';
|
||||
import HighLightCode from '../../components/HighLightCode/index.vue';
|
||||
import { SHIKI_SUPPORT_LANGS, shikiThemeDefault } from '../../shared';
|
||||
import { useMarkdownContext } from '../MarkdownProvider';
|
||||
import RunCode from '../RunCode/index.vue';
|
||||
import {
|
||||
controlEle,
|
||||
controlHasRunCodeEle,
|
||||
copyCode,
|
||||
isDark,
|
||||
languageEle,
|
||||
toggleExpand,
|
||||
toggleTheme
|
||||
} from './shiki-header';
|
||||
import '../../style/shiki.scss';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
raw?: RawProps;
|
||||
}>(),
|
||||
{
|
||||
raw: () => ({})
|
||||
}
|
||||
);
|
||||
|
||||
const context = useMarkdownContext();
|
||||
const { codeXSlot, customAttrs, globalShiki } = toValue(context) || {};
|
||||
const renderLines = ref<string[]>([]);
|
||||
const preStyle = ref<any | null>(null);
|
||||
const preClass = ref<string | null>(null);
|
||||
const themes = computed(() => context?.value?.themes ?? shikiThemeDefault);
|
||||
const colorReplacements = computed(() => context?.value?.colorReplacements);
|
||||
const nowViewBtnShow = computed(() => context?.value?.needViewCodeBtn ?? false);
|
||||
const viewCodeModalOptions = computed(
|
||||
() => context?.value?.viewCodeModalOptions
|
||||
);
|
||||
const isExpand = ref(true);
|
||||
const nowCodeLanguage = ref<BundledLanguage>();
|
||||
const codeAttrs =
|
||||
typeof customAttrs?.code === 'function'
|
||||
? customAttrs.code(props.raw)
|
||||
: customAttrs?.code || {};
|
||||
const shikiTransformers = [
|
||||
transformerNotationDiff(),
|
||||
transformerNotationErrorLevel(),
|
||||
transformerNotationFocus(),
|
||||
transformerNotationHighlight(),
|
||||
transformerNotationWordHighlight()
|
||||
];
|
||||
|
||||
const { codeToHtml } = globalShiki as GlobalShiki;
|
||||
// 生成高亮HTML
|
||||
async function generateHtml() {
|
||||
let { language = 'text', content = '' } = props.raw || {};
|
||||
if (!(SHIKI_SUPPORT_LANGS as readonly string[]).includes(language)) {
|
||||
language = 'text';
|
||||
}
|
||||
nowCodeLanguage.value = language as BundledLanguage;
|
||||
const html = await codeToHtml(content.trim(), {
|
||||
lang: language as BundledLanguage,
|
||||
themes: themes.value,
|
||||
colorReplacements: colorReplacements.value,
|
||||
transformers: shikiTransformers
|
||||
});
|
||||
const parse = new DOMParser();
|
||||
const doc = parse.parseFromString(html, 'text/html');
|
||||
const preElement = doc.querySelector('pre');
|
||||
preStyle.value = preElement?.getAttribute('style');
|
||||
const preClassNames = preElement?.className;
|
||||
preClass.value = preClassNames ?? '';
|
||||
const codeElement = doc.querySelector('pre code');
|
||||
if (codeElement) {
|
||||
const lines = codeElement.querySelectorAll('.line'); // 获取所有代码行
|
||||
renderLines.value = Array.from(lines).map(line => line.outerHTML); // 存储每行HTML
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.raw?.content,
|
||||
async content => {
|
||||
if (content) {
|
||||
await generateHtml();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const runCodeOptions = reactive<ElxRunCodeProps>({
|
||||
code: [],
|
||||
content: '',
|
||||
visible: false,
|
||||
lang: '',
|
||||
preClass: '',
|
||||
preStyle: {}
|
||||
});
|
||||
function viewCode(renderLines: string[]) {
|
||||
if (!renderLines?.length) return;
|
||||
|
||||
Object.assign(runCodeOptions, {
|
||||
code: renderLines,
|
||||
content: props.raw?.content ?? '',
|
||||
lang: nowCodeLanguage.value || 'html',
|
||||
preClass: preClass.value || 'pre-md',
|
||||
preStyle: preStyle.value || {},
|
||||
visible: true
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => renderLines.value,
|
||||
val => {
|
||||
if (runCodeOptions.visible) {
|
||||
runCodeOptions.code = val;
|
||||
runCodeOptions.content = props.raw.content ?? '';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 渲染插槽函数
|
||||
function renderSlot(slotName: string) {
|
||||
if (!codeXSlot) {
|
||||
return 'div';
|
||||
}
|
||||
const slotFn = codeXSlot[slotName];
|
||||
if (typeof slotFn === 'function') {
|
||||
return slotFn({
|
||||
...props,
|
||||
renderLines: renderLines.value,
|
||||
isDark,
|
||||
isExpand,
|
||||
nowViewBtnShow: nowViewBtnShow.value,
|
||||
toggleExpand,
|
||||
toggleTheme,
|
||||
copyCode,
|
||||
viewCode
|
||||
} satisfies CodeBlockExpose);
|
||||
}
|
||||
|
||||
return h(slotFn as any, {
|
||||
...props,
|
||||
renderLines: renderLines.value,
|
||||
isDark,
|
||||
isExpand,
|
||||
nowViewBtnShow: nowViewBtnShow.value,
|
||||
toggleExpand,
|
||||
toggleTheme,
|
||||
copyCode,
|
||||
viewCode
|
||||
} satisfies CodeBlockExpose);
|
||||
}
|
||||
|
||||
function handleHeaderLanguageClick() {
|
||||
isExpand.value = !isExpand.value;
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const computedClass = computed(() => `pre-md ${preClass.value} is-expanded`);
|
||||
const codeClass = computed(() => `language-${props.raw?.language || 'text'}`);
|
||||
const RunCodeComputed = computed(() => {
|
||||
return nowCodeLanguage.value === 'html' && nowViewBtnShow.value
|
||||
? RunCode
|
||||
: undefined;
|
||||
});
|
||||
const codeControllerEleComputed = computed(() => {
|
||||
if (nowCodeLanguage.value === 'html' && nowViewBtnShow.value) {
|
||||
return controlHasRunCodeEle(
|
||||
() => {
|
||||
copyCode(renderLines.value);
|
||||
},
|
||||
() => {
|
||||
viewCode(renderLines.value);
|
||||
}
|
||||
);
|
||||
}
|
||||
return controlEle(() => {
|
||||
copyCode(renderLines.value);
|
||||
});
|
||||
});
|
||||
|
||||
watch(
|
||||
() => nowViewBtnShow.value,
|
||||
v => {
|
||||
if (!v) {
|
||||
runCodeOptions.visible = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 获取是否显示行号
|
||||
const enableCodeLineNumber = computed(() => {
|
||||
return context?.value?.codeXProps?.enableCodeLineNumber ?? false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :key="props.raw?.key" :class="computedClass" :style="preStyle">
|
||||
<div class="markdown-elxLanguage-header-div is-always-shadow">
|
||||
<component
|
||||
:is="renderSlot('codeHeader')"
|
||||
v-if="codeXSlot?.codeHeader && renderSlot('codeHeader')"
|
||||
/>
|
||||
<template v-else>
|
||||
<component
|
||||
:is="
|
||||
codeXSlot?.codeHeaderLanguage
|
||||
? renderSlot('codeHeaderLanguage')
|
||||
: languageEle(props.raw?.language ?? 'text')
|
||||
"
|
||||
@click="handleHeaderLanguageClick"
|
||||
/>
|
||||
<component
|
||||
:is="
|
||||
codeXSlot?.codeHeaderControl
|
||||
? renderSlot('codeHeaderControl')
|
||||
: codeControllerEleComputed
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<code
|
||||
:class="codeClass"
|
||||
:style="{
|
||||
display: 'block',
|
||||
overflowX: 'auto'
|
||||
}"
|
||||
v-bind="codeAttrs"
|
||||
>
|
||||
<HighLightCode
|
||||
:enable-code-line-number="enableCodeLineNumber"
|
||||
:lang="props.raw?.language ?? 'text'"
|
||||
:code="renderLines"
|
||||
/>
|
||||
</code>
|
||||
<!-- run-code -->
|
||||
<component
|
||||
:is="RunCodeComputed"
|
||||
v-bind="{ ...viewCodeModalOptions, ...runCodeOptions }"
|
||||
v-model:visible="runCodeOptions.visible"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { View } from '@element-plus/icons-vue';
|
||||
import { ElButton } from 'element-plus';
|
||||
|
||||
const props = defineProps<{
|
||||
onView: () => void;
|
||||
}>();
|
||||
|
||||
function handleClick() {
|
||||
props.onView();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElButton
|
||||
class="shiki-header-button markdown-elxLanguage-header-button"
|
||||
@click="handleClick"
|
||||
>
|
||||
<View />
|
||||
</ElButton>
|
||||
</template>
|
||||
@@ -0,0 +1,491 @@
|
||||
import type { Component, Ref, VNode } from 'vue';
|
||||
import type { MermaidExposeProps } from '../Mermaid/types';
|
||||
import type {
|
||||
ElxRunCodeCloseBtnExposeProps,
|
||||
ElxRunCodeContentExposeProps,
|
||||
ElxRunCodeExposeProps
|
||||
} from '../RunCode/type';
|
||||
import type { RawProps } from './types';
|
||||
import { useMarkdownContext } from '@components/XMarkdownCore/components/MarkdownProvider';
|
||||
import { ArrowDownBold, Moon, Sunny } from '@element-plus/icons-vue';
|
||||
import { ElButton, ElMessage, ElSpace } from 'element-plus';
|
||||
import { h, ref } from 'vue';
|
||||
import CopyCodeButton from './copy-code-button.vue';
|
||||
import RunCodeButton from './run-code-button.vue';
|
||||
|
||||
export interface CodeBlockExpose {
|
||||
/**
|
||||
* 代码块传入的代码原始数据属性
|
||||
*/
|
||||
raw: RawProps;
|
||||
/**
|
||||
* 渲染的行
|
||||
*/
|
||||
renderLines: Array<string>;
|
||||
/**
|
||||
* 当前主题色是否是暗色
|
||||
*/
|
||||
isDark: Ref<boolean>;
|
||||
/**
|
||||
* 当前代码块是否展开
|
||||
*/
|
||||
isExpand: Ref<boolean>;
|
||||
/**
|
||||
* 是否显示预览代码按钮
|
||||
*/
|
||||
nowViewBtnShow: boolean;
|
||||
/**
|
||||
* 切换展开折叠
|
||||
* @param ev MouseEvent
|
||||
* @returns
|
||||
*/
|
||||
toggleExpand: (ev: MouseEvent) => { isExpand: boolean };
|
||||
/**
|
||||
* 切换主题
|
||||
* @returns
|
||||
*/
|
||||
toggleTheme: () => boolean;
|
||||
/**
|
||||
* 复制代码
|
||||
* @param value
|
||||
*/
|
||||
copyCode: (value: string | Array<string>) => void;
|
||||
/**
|
||||
* 查看代码
|
||||
* @param value
|
||||
*/
|
||||
viewCode: (value: Array<string>) => void;
|
||||
}
|
||||
|
||||
export type ComponentRenderer<T> = Component<T>;
|
||||
|
||||
export type ComponentFunctionRenderer<T> = (props: T) => VNode;
|
||||
|
||||
/**
|
||||
* @description 代码块头部渲染器
|
||||
*/
|
||||
export type CodeBlockHeaderRenderer = ComponentRenderer<CodeBlockExpose>;
|
||||
export type CodeBlockHeaderFunctionRenderer =
|
||||
ComponentFunctionRenderer<CodeBlockExpose>;
|
||||
/**
|
||||
* @description 查看代码头部渲染器
|
||||
*/
|
||||
export type ViewCodeHeadRender = ComponentRenderer<ElxRunCodeExposeProps>;
|
||||
export type ViewCodeHeadFunctionRender =
|
||||
ComponentFunctionRenderer<ElxRunCodeExposeProps>;
|
||||
/**
|
||||
* @description 查看代码头部关闭按钮渲染器
|
||||
*/
|
||||
export type ViewCodeCloseBtnRender =
|
||||
ComponentRenderer<ElxRunCodeCloseBtnExposeProps>;
|
||||
export type ViewCodeCloseBtnFunctionRender =
|
||||
ComponentFunctionRenderer<ElxRunCodeCloseBtnExposeProps>;
|
||||
/**
|
||||
* @description 查看代码内容渲染器
|
||||
*/
|
||||
export type ViewCodeContentRender =
|
||||
ComponentRenderer<ElxRunCodeContentExposeProps>;
|
||||
export type ViewCodeContentFunctionRender =
|
||||
ComponentFunctionRenderer<ElxRunCodeContentExposeProps>;
|
||||
/**
|
||||
* @description Mermaid头部插槽渲染器
|
||||
*/
|
||||
export type MermaidHeaderControlRender = ComponentRenderer<MermaidExposeProps>;
|
||||
export type MermaidHeaderControlFunctionRender =
|
||||
ComponentFunctionRenderer<MermaidExposeProps>;
|
||||
|
||||
export interface CodeBlockHeaderExpose {
|
||||
/**
|
||||
* 代码块自定义头部(包括语言和复制按钮等)
|
||||
* 当有此属性时,将不会显示默认的代码头部 和 codeHeaderLanguage codeHeaderControl 插槽里面的内容
|
||||
*/
|
||||
codeHeader?: CodeBlockHeaderRenderer;
|
||||
/**
|
||||
* 代码块语言插槽
|
||||
*/
|
||||
codeHeaderLanguage?: CodeBlockHeaderRenderer;
|
||||
/**
|
||||
* 代码块右侧插槽
|
||||
*/
|
||||
codeHeaderControl?: CodeBlockHeaderRenderer;
|
||||
/**
|
||||
* 代码块查看代码弹窗的头部插槽
|
||||
*/
|
||||
viewCodeHeader?: ViewCodeHeadRender;
|
||||
/**
|
||||
* 代码块查看代码弹窗的关闭按钮插槽
|
||||
*/
|
||||
viewCodeCloseBtn?: ViewCodeCloseBtnRender;
|
||||
/**
|
||||
* 代码块查看代码弹窗的代码内容插槽
|
||||
*/
|
||||
viewCodeContent?: ViewCodeContentRender;
|
||||
/**
|
||||
* 代码块mermaid头部插槽
|
||||
*/
|
||||
codeMermaidHeaderControl?: MermaidHeaderControlRender;
|
||||
}
|
||||
|
||||
export interface CodeBlockHeaderFunctionExpose {
|
||||
/**
|
||||
* 代码块自定义头部(包括语言和复制按钮等)
|
||||
* 当有此属性时,将不会显示默认的代码头部 和 codeHeaderLanguage codeHeaderControl 插槽里面的内容
|
||||
*/
|
||||
codeHeader?: CodeBlockHeaderFunctionRenderer;
|
||||
/**
|
||||
* 代码块语言插槽
|
||||
*/
|
||||
codeHeaderLanguage?: CodeBlockHeaderFunctionRenderer;
|
||||
/**
|
||||
* 代码块右侧插槽
|
||||
*/
|
||||
codeHeaderControl?: CodeBlockHeaderFunctionRenderer;
|
||||
/**
|
||||
* 代码块查看代码弹窗的头部插槽
|
||||
*/
|
||||
viewCodeHeader?: ViewCodeHeadFunctionRender;
|
||||
/**
|
||||
* 代码块查看代码弹窗的关闭按钮插槽
|
||||
*/
|
||||
viewCodeCloseBtn?: ViewCodeCloseBtnFunctionRender;
|
||||
/**
|
||||
* 代码块查看代码弹窗的代码内容插槽
|
||||
*/
|
||||
viewCodeContent?: ViewCodeContentFunctionRender;
|
||||
/**
|
||||
* 代码块mermaid头部插槽
|
||||
*/
|
||||
codeMermaidHeaderControl?: MermaidHeaderControlFunctionRender;
|
||||
}
|
||||
|
||||
let copyCodeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// 记录当前是否暗色模式
|
||||
export const isDark = ref(document.body.classList.contains('dark'));
|
||||
|
||||
/* ----------------------------------- 按钮组 ---------------------------------- */
|
||||
|
||||
/**
|
||||
* @description 描述 language标签
|
||||
* @date 2025-06-25 17:48:15
|
||||
* @author tingfeng
|
||||
*
|
||||
* @export
|
||||
* @param language
|
||||
*/
|
||||
export function languageEle(language: string) {
|
||||
return h(
|
||||
ElSpace,
|
||||
{
|
||||
class: `markdown-elxLanguage-header-space markdown-elxLanguage-header-space-start markdown-elxLanguage-header-span`,
|
||||
direction: 'horizontal',
|
||||
onClick: (ev: MouseEvent) => {
|
||||
toggleExpand(ev);
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
class: 'markdown-elxLanguage-header-span is-always-shadow'
|
||||
},
|
||||
language || ''
|
||||
),
|
||||
h(
|
||||
ElButton,
|
||||
{
|
||||
class: 'shiki-header-button shiki-header-button-expand'
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h(ArrowDownBold, {
|
||||
class:
|
||||
'markdown-elxLanguage-header-toggle markdown-elxLanguage-header-toggle-expand '
|
||||
})
|
||||
]
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 描述 语言头部操作按钮
|
||||
* @date 2025-06-25 17:49:04
|
||||
* @author tingfeng
|
||||
*
|
||||
* @export
|
||||
* @param {() => void} copy
|
||||
*/
|
||||
export function controlEle(copy: () => void) {
|
||||
return h(
|
||||
ElSpace,
|
||||
{
|
||||
class: `markdown-elxLanguage-header-space`,
|
||||
direction: 'horizontal'
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
toggleThemeEle(),
|
||||
h(CopyCodeButton, { onCopy: copy }) // ✅ 改为组件形式
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 描述 语言头部操作按钮(带预览代码按钮)
|
||||
* @date 2025-07-09 11:15:27
|
||||
* @author tingfeng
|
||||
* @param copy
|
||||
* @param view
|
||||
*/
|
||||
export function controlHasRunCodeEle(copy: () => void, view: () => void) {
|
||||
const context = useMarkdownContext();
|
||||
const { codeXProps } = toValue(context) || {};
|
||||
return h(
|
||||
ElSpace,
|
||||
{
|
||||
class: `markdown-elxLanguage-header-space`,
|
||||
direction: 'horizontal'
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
codeXProps?.enableCodePreview
|
||||
? h(RunCodeButton, { onView: view })
|
||||
: null,
|
||||
codeXProps?.enableThemeToggle ? toggleThemeEle() : null,
|
||||
codeXProps?.enableCodeCopy ? h(CopyCodeButton, { onCopy: copy }) : null // ✅ 改为组件形式
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 描述 主题按钮
|
||||
* @date 2025-06-25 17:49:53
|
||||
* @author tingfeng
|
||||
*
|
||||
* @export
|
||||
*/
|
||||
export function toggleThemeEle() {
|
||||
return h(
|
||||
ElButton,
|
||||
{
|
||||
class: 'shiki-header-button markdown-elxLanguage-header-toggle',
|
||||
onClick: () => {
|
||||
toggleTheme();
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () =>
|
||||
h(!isDark.value ? Moon : Sunny, {
|
||||
class: 'markdown-elxLanguage-header-toggle'
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------------- 方法 ----------------------------------- */
|
||||
|
||||
/**
|
||||
* @description 描述 展开代码
|
||||
* @date 2025-07-01 11:33:32
|
||||
* @author tingfeng
|
||||
*
|
||||
* @export
|
||||
* @param elem
|
||||
*/
|
||||
export function expand(elem: HTMLElement) {
|
||||
elem.classList.add('is-expanded');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 描述 折叠代码
|
||||
* @date 2025-07-01 11:33:49
|
||||
* @author tingfeng
|
||||
*
|
||||
* @export
|
||||
* @param elem
|
||||
*/
|
||||
export function collapse(elem: HTMLElement) {
|
||||
elem.classList.remove('is-expanded');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 复制代码内容到剪贴板
|
||||
* @date 2025-03-28 14:03:22
|
||||
* @author tingfeng
|
||||
*
|
||||
* @async
|
||||
* @param v
|
||||
* @returns void
|
||||
*/
|
||||
async function copy(v: string) {
|
||||
try {
|
||||
// 现代浏览器 Clipboard API
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(v);
|
||||
ElMessage({
|
||||
message: '复制成功',
|
||||
type: 'success'
|
||||
});
|
||||
return; // 复制成功直接返回
|
||||
}
|
||||
|
||||
// 兼容旧浏览器的 execCommand 方案
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = v.trim();
|
||||
textarea.style.position = 'fixed'; // 避免滚动到文本框位置
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
// 执行复制命令
|
||||
const success = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
if (success) {
|
||||
ElMessage({
|
||||
message: '复制成功',
|
||||
type: 'success'
|
||||
});
|
||||
return; // 复制成功直接返回
|
||||
}
|
||||
if (!success) {
|
||||
throw new Error('复制失败,请检查浏览器权限');
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`复制失败: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 描述 将源代码行数转换可复制的string
|
||||
* @date 2025-06-25 17:50:42
|
||||
* @author tingfeng
|
||||
*
|
||||
* @export
|
||||
* @param lines
|
||||
*/
|
||||
export function extractCodeFromHtmlLines(lines: string[]): string {
|
||||
const container = document.createElement('div');
|
||||
const output: string[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
container.innerHTML = lines[i];
|
||||
const text = container.textContent?.trimEnd();
|
||||
output.push(text ?? '');
|
||||
}
|
||||
|
||||
container.remove();
|
||||
container.innerHTML = ''; // 清空引用内容
|
||||
container.textContent = null;
|
||||
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
let isToggling = false;
|
||||
|
||||
/**
|
||||
* @description 描述 切换展开状态
|
||||
* @date 2025-06-26 21:29:50
|
||||
* @author tingfeng
|
||||
*
|
||||
* @export
|
||||
* @param ev
|
||||
*/
|
||||
export function toggleExpand(ev: MouseEvent): { isExpand: boolean } {
|
||||
if (isToggling) return { isExpand: false }; // 防抖保护
|
||||
isToggling = true;
|
||||
|
||||
const ele = ev.currentTarget as HTMLElement;
|
||||
const preMd = ele.closest('.pre-md') as HTMLElement | null;
|
||||
|
||||
if (preMd) {
|
||||
setTimeout(() => {
|
||||
isToggling = false;
|
||||
}, 250);
|
||||
|
||||
if (preMd.classList.contains('is-expanded')) {
|
||||
collapse(preMd);
|
||||
return { isExpand: false };
|
||||
} else {
|
||||
expand(preMd);
|
||||
return { isExpand: true };
|
||||
}
|
||||
}
|
||||
|
||||
isToggling = false;
|
||||
return { isExpand: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 描述 切换主题
|
||||
* @date 2025-06-26 21:58:56
|
||||
* @author tingfeng
|
||||
*
|
||||
* @export
|
||||
*/
|
||||
export function toggleTheme() {
|
||||
const theme = document.body.classList.contains('dark') ? 'light' : 'dark';
|
||||
isDark.value = theme === 'dark';
|
||||
if (isDark.value) {
|
||||
document.body.classList.add('dark');
|
||||
} else {
|
||||
document.body.classList.remove('dark');
|
||||
}
|
||||
return isDark.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 描述 初始化主题模式
|
||||
* @date 2025-07-08 13:43:19
|
||||
* @author tingfeng
|
||||
*
|
||||
* @export
|
||||
* @param defaultThemeMode
|
||||
*/
|
||||
export function initThemeMode(defaultThemeMode: 'light' | 'dark') {
|
||||
const theme = document.body.classList.contains('dark') ? 'dark' : 'light';
|
||||
if (theme !== defaultThemeMode) {
|
||||
isDark.value = defaultThemeMode === 'dark';
|
||||
if (defaultThemeMode === 'dark') {
|
||||
document.body.classList.add('dark');
|
||||
} else {
|
||||
document.body.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 描述 复制代码
|
||||
* @date 2025-06-26 22:02:57
|
||||
* @author tingfeng
|
||||
*
|
||||
* @export
|
||||
* @param codeText
|
||||
*/
|
||||
export function copyCode(codeText: string | string[]) {
|
||||
try {
|
||||
if (copyCodeTimer) return false; // 阻止重复点击
|
||||
|
||||
if (Array.isArray(codeText)) {
|
||||
const code = extractCodeFromHtmlLines(codeText);
|
||||
copy(code);
|
||||
} else {
|
||||
copy(codeText);
|
||||
}
|
||||
|
||||
copyCodeTimer = setTimeout(() => {
|
||||
copyCodeTimer = null;
|
||||
}, 800);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log('🚀 ~ copyCode ~ error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
5
Yi.Ai.Vue3/src/vue-element-plus-y/components/XMarkdownCore/components/CodeBlock/types.d.ts
vendored
Normal file
5
Yi.Ai.Vue3/src/vue-element-plus-y/components/XMarkdownCore/components/CodeBlock/types.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface RawProps {
|
||||
language?: string;
|
||||
content?: string;
|
||||
key?: string | number;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { CodeLineProps } from './types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = withDefaults(defineProps<CodeLineProps>(), {
|
||||
raw: () => ({}),
|
||||
content: ''
|
||||
});
|
||||
|
||||
// 获取实际内容
|
||||
const content = computed(() => {
|
||||
const result = props.raw?.content || props.content || '';
|
||||
return result;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-code-tag">
|
||||
{{ content }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inline-code-tag {
|
||||
display: inline;
|
||||
background: #d7e2f8;
|
||||
color: #376fde;
|
||||
padding: 0 4px;
|
||||
margin: 0 4px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
border: 1px solid #d7e2f8;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
line-height: 2;
|
||||
}
|
||||
</style>
|
||||
7
Yi.Ai.Vue3/src/vue-element-plus-y/components/XMarkdownCore/components/CodeLine/types.d.ts
vendored
Normal file
7
Yi.Ai.Vue3/src/vue-element-plus-y/components/XMarkdownCore/components/CodeLine/types.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface CodeLineProps {
|
||||
raw?: {
|
||||
content?: string;
|
||||
inline?: boolean;
|
||||
};
|
||||
content?: string;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, h, toValue } from 'vue';
|
||||
import { CodeBlock, CodeLine, Mermaid } from '../index';
|
||||
import { useMarkdownContext } from '../MarkdownProvider';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
raw: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const context = useMarkdownContext();
|
||||
const { codeXRender } = toValue(context);
|
||||
return (): ReturnType<typeof h> | null => {
|
||||
if (props.raw.inline) {
|
||||
if (codeXRender && codeXRender.inline) {
|
||||
const renderer = codeXRender.inline;
|
||||
if (typeof renderer === 'function') {
|
||||
return renderer(props);
|
||||
}
|
||||
return h(renderer, props);
|
||||
}
|
||||
return h(CodeLine, { raw: props.raw });
|
||||
}
|
||||
const { language } = props.raw;
|
||||
if (codeXRender && codeXRender[language]) {
|
||||
const renderer = codeXRender[language];
|
||||
if (typeof renderer === 'function') {
|
||||
return renderer(props);
|
||||
}
|
||||
return h(renderer, props);
|
||||
}
|
||||
if (language === 'mermaid') {
|
||||
return h(Mermaid, props);
|
||||
}
|
||||
|
||||
return h(CodeBlock, props);
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts" setup>
|
||||
import { ElScrollbar } from 'element-plus';
|
||||
import { computed } from 'vue';
|
||||
|
||||
export interface HighLightCodeProps {
|
||||
code: string[];
|
||||
lang: string;
|
||||
enableCodeLineNumber: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<HighLightCodeProps>();
|
||||
|
||||
const codeClass = computed(() => `language-${props.lang || 'text'}`);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="elx-highlight-code-wrapper">
|
||||
<div v-if="props.enableCodeLineNumber" class="line-numbers">
|
||||
<span
|
||||
v-for="(_line, index) in props.code"
|
||||
:key="index"
|
||||
class="line-number"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
</div>
|
||||
<ElScrollbar class="elx-highlight-code-scrollbar">
|
||||
<div class="code-lines" :class="codeClass">
|
||||
<span
|
||||
v-for="(line, index) in props.code"
|
||||
:key="index"
|
||||
class="line-content"
|
||||
v-html="line"
|
||||
/>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" src="./style.scss"></style>
|
||||
@@ -0,0 +1,41 @@
|
||||
.elx-highlight-code-wrapper {
|
||||
display: flex;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
|
||||
.line-numbers {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
margin-right: 1rem;
|
||||
.line-number {
|
||||
display: inline-block;
|
||||
text-align: right;
|
||||
padding: 0 0 0 0.3em;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.elx-highlight-code-scrollbar {
|
||||
.code-lines {
|
||||
white-space: pre;
|
||||
& > span {
|
||||
width: max-content;
|
||||
display: block;
|
||||
.line {
|
||||
width: max-content;
|
||||
display: inline-block;
|
||||
white-space: pre;
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { GlobalShiki } from '@components/XMarkdownCore/hooks/useShiki';
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type { MarkdownContext } from './types';
|
||||
import deepmerge from 'deepmerge';
|
||||
|
||||
import { computed, defineComponent, h, inject, provide } from 'vue';
|
||||
import {
|
||||
useDarkModeWatcher,
|
||||
usePlugins,
|
||||
useProcessMarkdown
|
||||
} from '../../hooks';
|
||||
import { GLOBAL_SHIKI_KEY, MARKDOWN_PROVIDER_KEY } from '../../shared';
|
||||
import { MARKDOWN_CORE_PROPS } from '../../shared/constants';
|
||||
import { initThemeMode } from '../CodeBlock/shiki-header';
|
||||
import '../../style/index.scss';
|
||||
|
||||
const MarkdownProvider = defineComponent({
|
||||
name: 'MarkdownProvider',
|
||||
props: MARKDOWN_CORE_PROPS,
|
||||
setup(props, { slots, attrs }) {
|
||||
const { rehypePlugins, remarkPlugins } = usePlugins(props);
|
||||
const { isDark } = useDarkModeWatcher();
|
||||
const globalShiki = inject<GlobalShiki>(GLOBAL_SHIKI_KEY);
|
||||
const markdown = computed(() => {
|
||||
if (props.enableLatex) {
|
||||
return useProcessMarkdown(props.markdown);
|
||||
} else {
|
||||
return props.markdown;
|
||||
}
|
||||
});
|
||||
const processProps = computed(() => {
|
||||
return {
|
||||
...props,
|
||||
codeXProps: Object.assign(
|
||||
{},
|
||||
MARKDOWN_CORE_PROPS.codeXProps.default(),
|
||||
props.codeXProps
|
||||
),
|
||||
markdown: markdown.value
|
||||
};
|
||||
});
|
||||
watch(
|
||||
() => props.defaultThemeMode,
|
||||
v => {
|
||||
if (v) {
|
||||
initThemeMode(v);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const contextProps = computed(() => {
|
||||
return deepmerge(
|
||||
{
|
||||
rehypePlugins: toValue(rehypePlugins),
|
||||
remarkPlugins: toValue(remarkPlugins),
|
||||
isDark: toValue(isDark),
|
||||
globalShiki: toValue(globalShiki)
|
||||
},
|
||||
processProps.value
|
||||
);
|
||||
});
|
||||
provide(MARKDOWN_PROVIDER_KEY, contextProps);
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{ class: 'elx-xmarkdown-provider', ...attrs },
|
||||
slots.default && slots.default()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function useMarkdownContext(): Ref<MarkdownContext> {
|
||||
const context = inject<Ref<MarkdownContext>>(
|
||||
MARKDOWN_PROVIDER_KEY,
|
||||
computed(() => ({}))
|
||||
);
|
||||
if (!context) {
|
||||
return computed(() => ({})) as unknown as Ref<MarkdownContext>;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
export { MarkdownProvider, useMarkdownContext };
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { GlobalShiki } from '@components/XMarkdownCore/hooks/useShiki';
|
||||
import type { InitShikiOptions } from '../../shared';
|
||||
import type { ElxRunCodeOptions } from '../RunCode/type';
|
||||
|
||||
export interface MarkdownContext {
|
||||
// markdown 字符串内容
|
||||
markdown?: string;
|
||||
// 是否允许 HTML
|
||||
allowHtml?: boolean;
|
||||
// 是否启用代码行号
|
||||
enableCodeLineNumber?: boolean;
|
||||
// 是否启用 LaTeX 支持
|
||||
enableLatex?: boolean;
|
||||
// 是否开启动画
|
||||
enableAnimate?: boolean;
|
||||
// 是否启用换行符转 <br>
|
||||
enableBreaks?: boolean;
|
||||
// 自定义代码块渲染函数
|
||||
codeXRender?: Record<string, any>;
|
||||
// 自定义代码块插槽
|
||||
codeXSlot?: Record<string, any>;
|
||||
// 自定义代码块属性
|
||||
codeXProps?: Record<string, any>;
|
||||
// 自定义代码高亮主题
|
||||
codeHighlightTheme?: builtinTheme;
|
||||
// 自定义属性对象
|
||||
customAttrs?: CustomAttrs;
|
||||
// remark 插件列表
|
||||
remarkPlugins?: PluggableList;
|
||||
remarkPluginsAhead?: PluggableList;
|
||||
// rehype 插件列表
|
||||
rehypePlugins?: PluggableList;
|
||||
rehypePluginsAhead?: PluggableList;
|
||||
// rehype 配置项
|
||||
rehypeOptions?: Record<string, any>;
|
||||
// 是否启用内容清洗
|
||||
sanitize?: boolean;
|
||||
// 清洗选项
|
||||
sanitizeOptions?: SanitizeOptions;
|
||||
// Mermaid 配置对象
|
||||
mermaidConfig?: Record<string, any>;
|
||||
// 主题配置
|
||||
themes?: InitShikiOptions['themes'];
|
||||
// 默认主题模式
|
||||
defaultThemeMode?: 'light' | 'dark';
|
||||
// 是否是暗黑模式(代码高亮块)
|
||||
isDarkMode?: boolean;
|
||||
// 自定义当前主题下的代码颜色配置
|
||||
colorReplacements?: InitShikiOptions['colorReplacements'];
|
||||
// 是否显示查看代码按钮
|
||||
needViewCodeBtn?: boolean;
|
||||
// 是否是安全模式预览html
|
||||
secureViewCode?: boolean;
|
||||
// 预览代码弹窗部分配置
|
||||
viewCodeModalOptions?: ElxRunCodeOptions;
|
||||
// 全局shiki
|
||||
globalShiki?: GlobalShiki;
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
<script setup lang="ts">
|
||||
import type { MermaidToolbarConfig, MermaidToolbarEmits } from './types';
|
||||
import {
|
||||
Aim,
|
||||
Check,
|
||||
CopyDocument,
|
||||
Download,
|
||||
FullScreen,
|
||||
ZoomIn,
|
||||
ZoomOut
|
||||
} from '@element-plus/icons-vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
interface MermaidToolbarInternalProps {
|
||||
toolbarConfig?: MermaidToolbarConfig;
|
||||
isSourceCodeMode?: boolean;
|
||||
sourceCode?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<MermaidToolbarInternalProps>(), {
|
||||
toolbarConfig: () => ({}),
|
||||
isSourceCodeMode: false,
|
||||
sourceCode: ''
|
||||
});
|
||||
|
||||
const emit = defineEmits<MermaidToolbarEmits>();
|
||||
|
||||
// 复制成功状态
|
||||
const isCopySuccess = ref(false);
|
||||
|
||||
// 当前激活的 tab
|
||||
const activeTab = computed({
|
||||
get: () => (props.isSourceCodeMode ? 'code' : 'diagram'),
|
||||
set: (value: string) => {
|
||||
if (value === 'code' && !props.isSourceCodeMode) {
|
||||
handleToggleCode();
|
||||
} else if (value === 'diagram' && props.isSourceCodeMode) {
|
||||
handleToggleCode();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 合并默认配置
|
||||
const config = computed(() => {
|
||||
return {
|
||||
showToolbar: true,
|
||||
showFullscreen: true,
|
||||
showZoomIn: true,
|
||||
showZoomOut: true,
|
||||
showReset: true,
|
||||
showDownload: true,
|
||||
toolbarStyle: {},
|
||||
toolbarClass: '',
|
||||
iconColor: undefined,
|
||||
tabTextColor: undefined,
|
||||
hoverBackgroundColor: undefined,
|
||||
tabActiveBackgroundColor: undefined,
|
||||
...props.toolbarConfig
|
||||
};
|
||||
});
|
||||
|
||||
// 动态图标颜色
|
||||
const iconColorStyle = computed(() => {
|
||||
const style: Record<string, string> = {};
|
||||
|
||||
if (config.value.iconColor) {
|
||||
style.color = config.value.iconColor;
|
||||
style['--custom-icon-color'] = config.value.iconColor;
|
||||
}
|
||||
|
||||
// 设置hover背景色
|
||||
if (config.value.hoverBackgroundColor) {
|
||||
style['--custom-hover-bg'] = config.value.hoverBackgroundColor;
|
||||
} else if (config.value.iconColor) {
|
||||
// 如果设置了图标颜色但没有设置hover背景色,使用稍暗的背景
|
||||
style['--custom-hover-bg'] = 'rgba(0, 0, 0, 0.1)';
|
||||
}
|
||||
|
||||
return style;
|
||||
});
|
||||
|
||||
// 动态 tab 文字颜色
|
||||
const tabTextColorStyle = computed(() => {
|
||||
const style: Record<string, string> = {};
|
||||
|
||||
if (config.value.tabTextColor) {
|
||||
style['--tab-text-color'] = config.value.tabTextColor;
|
||||
}
|
||||
|
||||
// 设置tab激活状态背景色
|
||||
if (config.value.tabActiveBackgroundColor) {
|
||||
style['--tab-active-bg'] = config.value.tabActiveBackgroundColor;
|
||||
} else if (config.value.tabTextColor) {
|
||||
// 如果设置了文字颜色但没有设置激活背景色,使用稍暗的背景
|
||||
style['--tab-active-bg'] = 'rgba(0, 0, 0, 0.1)';
|
||||
}
|
||||
|
||||
return style;
|
||||
});
|
||||
|
||||
function handleZoomIn(event: Event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
emit('onZoomIn');
|
||||
}
|
||||
|
||||
function handleZoomOut(event: Event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
emit('onZoomOut');
|
||||
}
|
||||
|
||||
function handleReset(event: Event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
emit('onReset');
|
||||
}
|
||||
|
||||
function handleFullscreen(event: Event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
emit('onFullscreen');
|
||||
}
|
||||
|
||||
function handleToggleCode(event?: Event) {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
emit('onToggleCode');
|
||||
}
|
||||
|
||||
function handleDownload(event: Event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
emit('onDownload');
|
||||
}
|
||||
|
||||
async function handleCopyCode(event: Event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
// 如果正在显示成功状态,不执行复制操作
|
||||
if (isCopySuccess.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!props.sourceCode) {
|
||||
emit('onCopyCode');
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用现代剪贴板 API
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(props.sourceCode);
|
||||
} else {
|
||||
// 降级方案:使用传统方法
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = props.sourceCode;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
textArea.remove();
|
||||
}
|
||||
|
||||
// 设置复制成功状态
|
||||
isCopySuccess.value = true;
|
||||
|
||||
setTimeout(() => {
|
||||
isCopySuccess.value = false;
|
||||
}, 1500);
|
||||
|
||||
emit('onCopyCode');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy code: ', err);
|
||||
// 如果复制失败,也通知父组件,让父组件决定如何处理
|
||||
emit('onCopyCode');
|
||||
}
|
||||
}
|
||||
|
||||
function handleToolbarClick(event: Event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleTabClick(tabName: string) {
|
||||
activeTab.value = tabName;
|
||||
}
|
||||
|
||||
interface TabClickEvent {
|
||||
paneName: string;
|
||||
}
|
||||
|
||||
function handleTabClickEvent(pane: TabClickEvent) {
|
||||
handleTabClick(pane.paneName);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 正常状态:显示工具栏 -->
|
||||
<div
|
||||
v-if="config.showToolbar"
|
||||
class="mermaid-toolbar"
|
||||
:class="config.toolbarClass"
|
||||
:style="config.toolbarStyle"
|
||||
@click="handleToolbarClick"
|
||||
>
|
||||
<!-- 左侧 Tabs -->
|
||||
<div class="toolbar-left" :style="tabTextColorStyle">
|
||||
<el-tabs
|
||||
:model-value="activeTab"
|
||||
class="toolbar-tabs"
|
||||
@tab-click="handleTabClickEvent"
|
||||
>
|
||||
<el-tab-pane label="图片" name="diagram" />
|
||||
<el-tab-pane label="代码" name="code" />
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 右侧按钮组 -->
|
||||
<div class="toolbar-right">
|
||||
<!-- 代码视图:只显示复制按钮 -->
|
||||
<template v-if="props.isSourceCodeMode">
|
||||
<div
|
||||
class="toolbar-action-btn"
|
||||
:class="{ 'copy-success': isCopySuccess }"
|
||||
:style="iconColorStyle"
|
||||
@click="handleCopyCode($event)"
|
||||
>
|
||||
<el-icon :size="16">
|
||||
<Check v-if="isCopySuccess" />
|
||||
<CopyDocument v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 图片视图:显示所有操作按钮 -->
|
||||
<template v-else>
|
||||
<!-- 下载按钮 -->
|
||||
<div
|
||||
v-if="config.showDownload"
|
||||
class="toolbar-action-btn"
|
||||
:style="iconColorStyle"
|
||||
@click="handleDownload($event)"
|
||||
>
|
||||
<el-icon :size="16">
|
||||
<Download />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 分割线 -->
|
||||
<div v-if="config.showDownload" class="toolbar-divider" />
|
||||
|
||||
<!-- 缩小按钮 -->
|
||||
<div
|
||||
v-if="config.showZoomOut"
|
||||
class="toolbar-action-btn"
|
||||
:style="iconColorStyle"
|
||||
@click="handleZoomOut($event)"
|
||||
>
|
||||
<el-icon :size="16">
|
||||
<ZoomOut />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 放大按钮 -->
|
||||
<div
|
||||
v-if="config.showZoomIn"
|
||||
class="toolbar-action-btn"
|
||||
:style="iconColorStyle"
|
||||
@click="handleZoomIn($event)"
|
||||
>
|
||||
<el-icon :size="16">
|
||||
<ZoomIn />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 适应按钮 (重置) -->
|
||||
<div
|
||||
v-if="config.showReset"
|
||||
class="toolbar-action-btn"
|
||||
:style="iconColorStyle"
|
||||
@click="handleReset($event)"
|
||||
>
|
||||
<el-icon :size="16">
|
||||
<Aim />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 全屏按钮 -->
|
||||
<div
|
||||
v-if="config.showFullscreen"
|
||||
class="toolbar-action-btn"
|
||||
:style="iconColorStyle"
|
||||
@click="handleFullscreen($event)"
|
||||
>
|
||||
<el-icon :size="16">
|
||||
<FullScreen />
|
||||
</el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mermaid-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 42px;
|
||||
background: #ebecef;
|
||||
border-radius: 3px 3px 0 0;
|
||||
padding: 0 12px;
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
.toolbar-left {
|
||||
flex: 1;
|
||||
|
||||
.toolbar-tabs {
|
||||
:deep(.el-tabs__header) {
|
||||
margin: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav) {
|
||||
background: #dddee1;
|
||||
padding: 2px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav-wrap) {
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-tabs__item) {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
border: none;
|
||||
color: var(--tab-text-color, var(--el-text-color-regular));
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
font-weight: 700;
|
||||
|
||||
&.is-active {
|
||||
color: var(--tab-text-color, var(--el-text-color-primary));
|
||||
background: var(--tab-active-bg, rgba(255, 255, 255, 0.8));
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&:hover:not(.is-active) {
|
||||
color: var(--tab-text-color, var(--el-text-color-primary));
|
||||
background: #d1d2d5;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-tabs__active-bar) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
|
||||
.toolbar-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--el-text-color-regular);
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
color: var(--custom-icon-color, var(--el-text-color-primary));
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #dddee1;
|
||||
border-radius: 4px;
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
|
||||
&:active:not(.disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
&.toolbar-action-btn-last {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
&.copy-success {
|
||||
cursor: default;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--el-border-color);
|
||||
margin: 0 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 父容器悬停时显示工具栏 */
|
||||
:global(.markdown-mermaid:hover .mermaid-toolbar) {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* 全屏状态下的样式调整 */
|
||||
:global(.markdown-mermaid:fullscreen .mermaid-toolbar) {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
border-bottom-color: rgba(255, 255, 255, 0.1);
|
||||
|
||||
.toolbar-left .toolbar-tabs {
|
||||
:deep(.el-tabs__item) {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
|
||||
&.is-active {
|
||||
color: white;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&:hover:not(.is-active) {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
.toolbar-action-btn {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-divider {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
// 复制到剪贴板
|
||||
export async function copyToClipboard(content: string): Promise<boolean> {
|
||||
if (!content)
|
||||
return false;
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(content);
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = content;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
textArea.remove();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error('复制失败: ', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// SVG下载功能
|
||||
export function downloadSvgAsPng(svg: string): void {
|
||||
if (!svg)
|
||||
return;
|
||||
|
||||
try {
|
||||
const svgDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: false });
|
||||
if (!ctx)
|
||||
return;
|
||||
|
||||
const scale = 2;
|
||||
canvas.width = img.width * scale;
|
||||
canvas.height = img.height * scale;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
// 白色背景
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 绘制SVG
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 下载
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.slice(0, 19)
|
||||
.replace(/:/g, '-');
|
||||
|
||||
try {
|
||||
canvas.toBlob(
|
||||
blob => {
|
||||
if (!blob)
|
||||
return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `mermaid-diagram-${timestamp}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
'image/png',
|
||||
0.95
|
||||
);
|
||||
}
|
||||
catch (toBlobError) {
|
||||
console.error('toBlobError:', toBlobError);
|
||||
// 降级方案
|
||||
try {
|
||||
const dataUrl = canvas.toDataURL('image/png', 0.95);
|
||||
const link = document.createElement('a');
|
||||
link.href = dataUrl;
|
||||
link.download = `mermaid-diagram-${timestamp}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
catch (dataUrlError) {
|
||||
console.error('dataUrlError:', dataUrlError);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (canvasError) {
|
||||
console.error('Canvas操作失败:', canvasError);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = error => {
|
||||
console.error('Image load error:', error);
|
||||
};
|
||||
|
||||
img.src = svgDataUrl;
|
||||
}
|
||||
catch (error) {
|
||||
console.error('下载失败:', error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import type { MdComponent } from '../types';
|
||||
import type { MermaidExposeProps, MermaidToolbarConfig } from './types';
|
||||
import { debounce } from 'radash';
|
||||
import { computed, nextTick, ref, toValue, watch } from 'vue';
|
||||
import { useMermaid, useMermaidZoom } from '../../hooks';
|
||||
import { useMarkdownContext } from '../MarkdownProvider';
|
||||
import { copyToClipboard, downloadSvgAsPng } from './composables';
|
||||
import MermaidToolbar from './MermaidToolbar.vue';
|
||||
|
||||
interface MermaidProps extends MdComponent {
|
||||
toolbarConfig?: MermaidToolbarConfig;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<MermaidProps>(), {
|
||||
raw: () => ({}),
|
||||
toolbarConfig: () => ({})
|
||||
});
|
||||
|
||||
const mermaidContent = computed(() => props.raw?.content || '');
|
||||
const mermaidResult = useMermaid(mermaidContent, {
|
||||
id: `mermaid-${props.raw?.key || 'default'}`
|
||||
});
|
||||
|
||||
const svg = ref('');
|
||||
const isLoading = computed(
|
||||
() => !mermaidResult.data.value && !mermaidResult.error.value
|
||||
);
|
||||
|
||||
// 获取插槽上下文
|
||||
const context = useMarkdownContext();
|
||||
const { codeXSlot } = toValue(context);
|
||||
|
||||
// 计算工具栏配置,合并默认值
|
||||
const toolbarConfig = computed(() => {
|
||||
const contextMermaidConfig = toValue(context)?.mermaidConfig || {};
|
||||
return {
|
||||
showToolbar: true,
|
||||
showFullscreen: true,
|
||||
showZoomIn: true,
|
||||
showZoomOut: true,
|
||||
showReset: true,
|
||||
...contextMermaidConfig,
|
||||
...props.toolbarConfig
|
||||
};
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const showSourceCode = ref(false);
|
||||
|
||||
// 初始化缩放功能
|
||||
const zoomControls = useMermaidZoom({
|
||||
container: containerRef,
|
||||
scaleStep: 0.2,
|
||||
minScale: 0.1,
|
||||
maxScale: 5
|
||||
});
|
||||
|
||||
const debouncedInitialize = debounce({ delay: 500 }, onContentTransitionEnter);
|
||||
watch(
|
||||
() => mermaidResult.data.value,
|
||||
newSvg => {
|
||||
if (newSvg) {
|
||||
svg.value = newSvg;
|
||||
debouncedInitialize();
|
||||
}
|
||||
}
|
||||
);
|
||||
watch(svg, newSvg => {
|
||||
if (newSvg) {
|
||||
debouncedInitialize();
|
||||
}
|
||||
});
|
||||
|
||||
// 工具栏事件处理
|
||||
function handleZoomIn() {
|
||||
if (!showSourceCode.value) {
|
||||
zoomControls?.zoomIn();
|
||||
}
|
||||
}
|
||||
|
||||
function handleZoomOut() {
|
||||
if (!showSourceCode.value) {
|
||||
zoomControls?.zoomOut();
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
if (!showSourceCode.value) {
|
||||
zoomControls?.reset();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFullscreen() {
|
||||
if (!showSourceCode.value) {
|
||||
zoomControls?.fullscreen();
|
||||
zoomControls?.reset();
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleCode() {
|
||||
showSourceCode.value = !showSourceCode.value;
|
||||
}
|
||||
|
||||
async function handleCopyCode() {
|
||||
if (!props.raw.content) {
|
||||
return;
|
||||
}
|
||||
copyToClipboard(props.raw.content);
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
downloadSvgAsPng(svg.value);
|
||||
}
|
||||
// 处理图表内容过渡完成事件
|
||||
function onContentTransitionEnter() {
|
||||
// 只在图表模式下初始化缩放功能
|
||||
if (!showSourceCode.value) {
|
||||
// 使用 nextTick 确保 DOM 完全更新
|
||||
nextTick(() => {
|
||||
if (containerRef.value) {
|
||||
zoomControls.initialize();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 创建暴露给插槽的方法对象
|
||||
const exposedMethods = computed(() => {
|
||||
return {
|
||||
// 基础属性
|
||||
showSourceCode: showSourceCode.value,
|
||||
svg: svg.value,
|
||||
rawContent: props.raw.content || '',
|
||||
toolbarConfig: toolbarConfig.value,
|
||||
isLoading: isLoading.value,
|
||||
|
||||
// 缩放控制方法
|
||||
zoomIn: handleZoomIn,
|
||||
zoomOut: handleZoomOut,
|
||||
reset: handleReset,
|
||||
fullscreen: handleFullscreen,
|
||||
|
||||
// 其他操作方法
|
||||
toggleCode: handleToggleCode,
|
||||
copyCode: handleCopyCode,
|
||||
download: handleDownload,
|
||||
|
||||
// 原始 props(除了重复的 toolbarConfig)
|
||||
raw: props.raw
|
||||
} satisfies MermaidExposeProps;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" :key="props.raw.key" class="markdown-mermaid">
|
||||
<!-- 工具栏 -->
|
||||
<Transition name="toolbar" appear>
|
||||
<div class="toolbar-container">
|
||||
<!-- 自定义完整头部插槽 -->
|
||||
<component
|
||||
:is="codeXSlot.codeMermaidHeader"
|
||||
v-if="codeXSlot?.codeMermaidHeader"
|
||||
v-bind="exposedMethods"
|
||||
/>
|
||||
<!-- 默认工具栏 + 自定义操作插槽 -->
|
||||
<template v-else>
|
||||
<!-- 自定义操作按钮插槽 -->
|
||||
<component
|
||||
:is="codeXSlot.codeMermaidHeaderControl"
|
||||
v-if="codeXSlot?.codeMermaidHeaderControl"
|
||||
v-bind="exposedMethods"
|
||||
/>
|
||||
<!-- 默认工具栏 -->
|
||||
<MermaidToolbar
|
||||
v-else
|
||||
:toolbar-config="toolbarConfig"
|
||||
:is-source-code-mode="showSourceCode"
|
||||
:source-code="props.raw.content"
|
||||
@on-zoom-in="handleZoomIn"
|
||||
@on-zoom-out="handleZoomOut"
|
||||
@on-reset="handleReset"
|
||||
@on-fullscreen="handleFullscreen"
|
||||
@on-toggle-code="handleToggleCode"
|
||||
@on-copy-code="handleCopyCode"
|
||||
@on-download="handleDownload"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition
|
||||
name="content"
|
||||
mode="out-in"
|
||||
@after-enter="onContentTransitionEnter"
|
||||
>
|
||||
<pre v-if="showSourceCode" key="source" class="mermaid-source-code">{{
|
||||
props.raw.content
|
||||
}}</pre>
|
||||
<div v-else class="mermaid-content" v-html="svg" />
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style src="./style.scss"></style>
|
||||
@@ -0,0 +1,181 @@
|
||||
.markdown-mermaid {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 100px;
|
||||
min-height: 100px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// 工具栏容器样式
|
||||
.toolbar-container {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
flex-shrink: 0;
|
||||
background: white;
|
||||
|
||||
// 确保自定义头部插槽正确显示
|
||||
.custom-mermaid-header {
|
||||
position: relative;
|
||||
z-index: 11;
|
||||
}
|
||||
|
||||
// 默认工具栏的基础样式
|
||||
.mermaid-language-tag {
|
||||
display: inline-block;
|
||||
padding: 8px 12px;
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #e0f2fe;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #0891b2;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
// 简单默认工具栏样式
|
||||
.mermaid-default-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: white;
|
||||
|
||||
.toolbar-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
color: #374151;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mermaid-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
// max-height: 80vh;
|
||||
cursor: grab;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
overflow: hidden; // 防止未缩放的大图表溢出
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
svg {
|
||||
transform-origin: center center; // SVG 的变换原点
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
// 渲染状态时的加载效果
|
||||
&.rendering {
|
||||
svg {
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全屏
|
||||
&:fullscreen {
|
||||
.mermaid-content {
|
||||
max-height: 100vh;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
&.dragging {
|
||||
.mermaid-content {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
&.zoom-limit {
|
||||
.mermaid-content {
|
||||
transform-origin: center center;
|
||||
}
|
||||
}
|
||||
|
||||
.mermaid-source-code {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #333;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
// 内容切换过渡 - 减少闪烁,优化为淡入淡出
|
||||
.content-enter-active {
|
||||
transition: opacity 0.2s ease-out;
|
||||
}
|
||||
|
||||
.content-leave-active {
|
||||
transition: opacity 0.15s ease-in;
|
||||
}
|
||||
|
||||
.content-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.content-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
// 工具栏过渡
|
||||
.toolbar-enter-active,
|
||||
.toolbar-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toolbar-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.toolbar-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
80
Yi.Ai.Vue3/src/vue-element-plus-y/components/XMarkdownCore/components/Mermaid/types.d.ts
vendored
Normal file
80
Yi.Ai.Vue3/src/vue-element-plus-y/components/XMarkdownCore/components/Mermaid/types.d.ts
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
export interface MermaidToolbarConfig {
|
||||
showToolbar?: boolean;
|
||||
showFullscreen?: boolean;
|
||||
showZoomIn?: boolean;
|
||||
showZoomOut?: boolean;
|
||||
showReset?: boolean;
|
||||
showDownload?: boolean;
|
||||
toolbarStyle?: Record<string, any>;
|
||||
toolbarClass?: string;
|
||||
iconColor?: string;
|
||||
tabTextColor?: string;
|
||||
hoverBackgroundColor?: string;
|
||||
tabActiveBackgroundColor?: string;
|
||||
}
|
||||
|
||||
export interface MermaidToolbarProps extends MermaidToolbarConfig {}
|
||||
|
||||
export interface MermaidZoomControls {
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => void;
|
||||
reset: () => void;
|
||||
fullscreen: () => void;
|
||||
destroy: () => void;
|
||||
initialize: () => void;
|
||||
}
|
||||
|
||||
export interface UseMermaidZoomOptions {
|
||||
container: Ref<HTMLElement | null>;
|
||||
scaleStep?: number;
|
||||
minScale?: number;
|
||||
maxScale?: number;
|
||||
}
|
||||
|
||||
export interface MermaidToolbarEmits {
|
||||
onZoomIn: [];
|
||||
onZoomOut: [];
|
||||
onReset: [];
|
||||
onFullscreen: [];
|
||||
onEdit: [];
|
||||
onToggleCode: [];
|
||||
onCopyCode: [];
|
||||
onDownload: [];
|
||||
}
|
||||
|
||||
// Mermaid 组件暴露给插槽的方法接口
|
||||
export interface MermaidExposedMethods {
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => void;
|
||||
reset: () => void;
|
||||
fullscreen: () => void;
|
||||
toggleCode: () => void;
|
||||
copyCode: () => void;
|
||||
download: () => void;
|
||||
svg: import('vue').Ref<string>;
|
||||
showSourceCode: import('vue').Ref<boolean>;
|
||||
toolbarConfig: import('vue').ComputedRef<MermaidToolbarConfig>;
|
||||
rawContent: string;
|
||||
}
|
||||
|
||||
export interface MermaidExposeProps {
|
||||
showSourceCode: boolean;
|
||||
svg: string;
|
||||
rawContent: any;
|
||||
toolbarConfig: MermaidToolbarConfig;
|
||||
isLoading: boolean;
|
||||
|
||||
// 缩放控制方法
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => void;
|
||||
reset: () => void;
|
||||
fullscreen: () => void;
|
||||
|
||||
// 其他操作方法
|
||||
toggleCode: () => void;
|
||||
copyCode: () => Promise<void>;
|
||||
download: () => void;
|
||||
|
||||
// 原始 props(除了重复的 toolbarConfig)
|
||||
raw: any;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<template>
|
||||
<div class="dot-spinner">
|
||||
<div class="dot-spinner__dot" />
|
||||
<div class="dot-spinner__dot" />
|
||||
<div class="dot-spinner__dot" />
|
||||
<div class="dot-spinner__dot" />
|
||||
<div class="dot-spinner__dot" />
|
||||
<div class="dot-spinner__dot" />
|
||||
<div class="dot-spinner__dot" />
|
||||
<div class="dot-spinner__dot" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dot-spinner {
|
||||
--uib-size: 2.8rem;
|
||||
--uib-speed: 0.9s;
|
||||
--uib-color: var(--el-color-primary);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
height: var(--uib-size);
|
||||
width: var(--uib-size);
|
||||
}
|
||||
|
||||
.dot-spinner__dot {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dot-spinner__dot::before {
|
||||
content: '';
|
||||
height: 20%;
|
||||
width: 20%;
|
||||
border-radius: 50%;
|
||||
background-color: var(--uib-color);
|
||||
transform: scale(0);
|
||||
opacity: 0.5;
|
||||
animation: pulse0112 calc(var(--uib-speed) * 1.311) ease-in-out infinite;
|
||||
box-shadow: 0 0 20px rgba(18, 31, 53, 0.3);
|
||||
}
|
||||
|
||||
.dot-spinner__dot:nth-child(2) {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.dot-spinner__dot:nth-child(2)::before {
|
||||
animation-delay: calc(var(--uib-speed) * -0.875);
|
||||
}
|
||||
|
||||
.dot-spinner__dot:nth-child(3) {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.dot-spinner__dot:nth-child(3)::before {
|
||||
animation-delay: calc(var(--uib-speed) * -0.75);
|
||||
}
|
||||
|
||||
.dot-spinner__dot:nth-child(4) {
|
||||
transform: rotate(135deg);
|
||||
}
|
||||
|
||||
.dot-spinner__dot:nth-child(4)::before {
|
||||
animation-delay: calc(var(--uib-speed) * -0.625);
|
||||
}
|
||||
|
||||
.dot-spinner__dot:nth-child(5) {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dot-spinner__dot:nth-child(5)::before {
|
||||
animation-delay: calc(var(--uib-speed) * -0.5);
|
||||
}
|
||||
|
||||
.dot-spinner__dot:nth-child(6) {
|
||||
transform: rotate(225deg);
|
||||
}
|
||||
|
||||
.dot-spinner__dot:nth-child(6)::before {
|
||||
animation-delay: calc(var(--uib-speed) * -0.375);
|
||||
}
|
||||
|
||||
.dot-spinner__dot:nth-child(7) {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
|
||||
.dot-spinner__dot:nth-child(7)::before {
|
||||
animation-delay: calc(var(--uib-speed) * -0.25);
|
||||
}
|
||||
|
||||
.dot-spinner__dot:nth-child(8) {
|
||||
transform: rotate(315deg);
|
||||
}
|
||||
|
||||
.dot-spinner__dot:nth-child(8)::before {
|
||||
animation-delay: calc(var(--uib-speed) * -0.125);
|
||||
}
|
||||
|
||||
@keyframes pulse0112 {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum SELECT_OPTIONS_ENUM {
|
||||
'CODE' = '代码',
|
||||
'VIEW' = '预览'
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ElxRunCodeContentProps } from '../type';
|
||||
import DOMPurify from 'dompurify';
|
||||
import _ from 'lodash';
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import HighLightCode from '../../HighLightCode/index.vue';
|
||||
import { useMarkdownContext } from '../../MarkdownProvider';
|
||||
import CustomLoading from './custom-loading.vue';
|
||||
import { SELECT_OPTIONS_ENUM } from './options';
|
||||
|
||||
const props = defineProps<ElxRunCodeContentProps>();
|
||||
|
||||
const computedClass = computed(() => `pre-md ${props.preClass}`);
|
||||
|
||||
const codeClass = computed(() => `language-${props.lang || 'text'}`);
|
||||
|
||||
const iframeRef = ref<HTMLIFrameElement>();
|
||||
|
||||
const allHtml = computed(() => props.content);
|
||||
|
||||
const codeContainerRef = ref<HTMLElement>();
|
||||
|
||||
const isLoading = ref(false);
|
||||
|
||||
const context = useMarkdownContext();
|
||||
|
||||
const isSafeViewCode = computed(() => {
|
||||
return context.value.secureViewCode;
|
||||
});
|
||||
const enableCodeLineNumber = computed(() => {
|
||||
return context.value?.enableCodeLineNumber || false;
|
||||
});
|
||||
function doRenderIframe() {
|
||||
const iframe = iframeRef.value;
|
||||
if (!iframe)
|
||||
return;
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
const rawHtml = allHtml.value || '';
|
||||
let sanitizedHtml = rawHtml;
|
||||
|
||||
// 安全模式过滤
|
||||
if (isSafeViewCode.value) {
|
||||
sanitizedHtml = DOMPurify.sanitize(rawHtml, {
|
||||
WHOLE_DOCUMENT: true,
|
||||
FORBID_TAGS: ['script', 'iframe', 'object', 'embed'],
|
||||
FORBID_ATTR: ['onerror', 'onclick', 'onload', 'style']
|
||||
});
|
||||
}
|
||||
|
||||
// 检查 <head> 中是否有 UTF-8 charset
|
||||
let finalHtml = sanitizedHtml;
|
||||
const hasHead = /<head[^>]*>/i.test(sanitizedHtml);
|
||||
const hasUtf8Meta =
|
||||
/<head[^>]*>[\s\S]*?<meta\s[^>]*charset=["']?utf-8["']?/i.test(
|
||||
sanitizedHtml
|
||||
);
|
||||
|
||||
if (hasHead) {
|
||||
if (!hasUtf8Meta) {
|
||||
finalHtml = sanitizedHtml.replace(
|
||||
/<head[^>]*>/i,
|
||||
match => `${match}<meta charset="UTF-8">`
|
||||
);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// 没有 <head>,插入 <head><meta charset="UTF-8"></head> 到 <html> 或最前
|
||||
if (/<html[^>]*>/i.test(sanitizedHtml)) {
|
||||
finalHtml = sanitizedHtml.replace(
|
||||
/<html[^>]*>/i,
|
||||
match => `${match}<head><meta charset="UTF-8"></head>`
|
||||
);
|
||||
}
|
||||
else {
|
||||
// 甚至没有 <html>,包一层完整结构
|
||||
finalHtml = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="UTF-8"></head>
|
||||
<body>${sanitizedHtml}</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
const blob = new Blob([finalHtml], { type: 'text/html' });
|
||||
|
||||
if (iframe.src && iframe.src.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(iframe.src);
|
||||
}
|
||||
|
||||
iframe.src = URL.createObjectURL(blob);
|
||||
|
||||
const onLoad = () => {
|
||||
setTimeout(() => {
|
||||
isLoading.value = false;
|
||||
}, 300);
|
||||
iframe.removeEventListener('load', onLoad);
|
||||
};
|
||||
iframe.addEventListener('load', onLoad);
|
||||
}
|
||||
|
||||
const renderIframe = _.debounce(() => {
|
||||
doRenderIframe();
|
||||
}, 300);
|
||||
|
||||
function startRender() {
|
||||
if (props.nowView === SELECT_OPTIONS_ENUM.VIEW) {
|
||||
isLoading.value = true;
|
||||
renderIframe();
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.nowView, isSafeViewCode.value],
|
||||
() => {
|
||||
startRender();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
watch(
|
||||
() => props.code,
|
||||
() => {
|
||||
startRender();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
startRender();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-scrollbar
|
||||
ref="codeContainerRef"
|
||||
class="elx-run-code-content-scrollbar"
|
||||
:style="preStyle"
|
||||
>
|
||||
<div
|
||||
v-show="props.nowView === SELECT_OPTIONS_ENUM.CODE"
|
||||
class="elx-xmarkdown-container elx-run-code-content"
|
||||
>
|
||||
<pre>
|
||||
<div
|
||||
:class="computedClass"
|
||||
:style="preStyle"
|
||||
>
|
||||
<code
|
||||
class="elx-run-code-content-code"
|
||||
:class="codeClass"
|
||||
>
|
||||
<HighLightCode
|
||||
:enable-code-line-number="enableCodeLineNumber"
|
||||
:lang="props.lang"
|
||||
:code="props.code"
|
||||
/>
|
||||
</code>
|
||||
</div>
|
||||
</pre>
|
||||
</div>
|
||||
<div
|
||||
v-show="props.nowView === SELECT_OPTIONS_ENUM.VIEW"
|
||||
style="position: relative; width: 100%; height: 100%"
|
||||
class="elx-run-code-content-view"
|
||||
>
|
||||
<div v-if="isLoading" class="iframe-loading-mask">
|
||||
<CustomLoading />
|
||||
</div>
|
||||
<div v-show="!isLoading" class="elx-run-code-content-view-iframe">
|
||||
<iframe
|
||||
ref="iframeRef"
|
||||
sandbox="allow-scripts"
|
||||
style="border: 0; width: 100%; height: 79.5vh"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</template>
|
||||
|
||||
<style src="./style/index.scss"></style>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ElxRunCodeHeaderTypes } from '../type';
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { SELECT_OPTIONS_ENUM } from './options';
|
||||
|
||||
interface ElxRunCodeProps {
|
||||
value: ElxRunCodeHeaderTypes['options'];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ElxRunCodeProps>(), {});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'changeSelect', val: string): void;
|
||||
(e: 'update:value'): void;
|
||||
}>();
|
||||
|
||||
const options = Object.values(SELECT_OPTIONS_ENUM);
|
||||
|
||||
const selectValue = useVModel(props, 'value', emit);
|
||||
|
||||
function change(val: string) {
|
||||
emit('changeSelect', val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="custom-style">
|
||||
<el-segmented v-model="selectValue" :options="options" @change="change" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style src="./style/index.scss"></style>
|
||||
@@ -0,0 +1,61 @@
|
||||
.custom-style .el-segmented {
|
||||
--el-segmented-item-selected-color: white;
|
||||
--el-border-radius-base: var(--shiki-custom-brr);
|
||||
}
|
||||
|
||||
body.dark {
|
||||
.custom-style .el-segmented {
|
||||
--el-segmented-item-selected-bg-color: #409eff;
|
||||
--el-segmented-item-selected-color: white;
|
||||
--el-segmented-item-hover-bg-color: #4e4e4e;
|
||||
--el-segmented-item-active-bg-color: #4e4e4e;
|
||||
--el-fill-color-light: #39393a;
|
||||
.el-segmented__item-label {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
.elx-run-code-content-scrollbar {
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.elx-run-code-content-scrollbar {
|
||||
background-color: var(--shiki-bg) !important;
|
||||
}
|
||||
|
||||
.elx-run-code-content {
|
||||
width: 100%;
|
||||
height: 80vh !important;
|
||||
padding: 0 !important;
|
||||
.elx-run-code-content-code {
|
||||
overflow: visible !important;
|
||||
padding: 10px 0;
|
||||
}
|
||||
pre {
|
||||
white-space: nowrap !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
pre div.pre-md {
|
||||
width: 100%;
|
||||
height: 100% !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.code-line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.line-content {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.iframe-loading-mask {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 79.8vh;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ElxRunCodeHeaderTypes, ElxRunCodeProps } from './type';
|
||||
import { CloseBold } from '@element-plus/icons-vue';
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { computed, h, ref, toValue } from 'vue';
|
||||
import { useMarkdownContext } from '../MarkdownProvider';
|
||||
import { SELECT_OPTIONS_ENUM } from './components/options';
|
||||
import RunCodeContent from './components/run-code-content.vue';
|
||||
import RunCodeHeader from './components/run-code-header.vue';
|
||||
|
||||
const props = withDefaults(defineProps<ElxRunCodeProps>(), {
|
||||
code: () => [],
|
||||
lang: '',
|
||||
mode: 'drawer'
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible'): void;
|
||||
}>();
|
||||
const drawer = useVModel(props, 'visible', emit);
|
||||
|
||||
const selectValue = ref<ElxRunCodeHeaderTypes['options']>(
|
||||
SELECT_OPTIONS_ENUM.VIEW
|
||||
);
|
||||
|
||||
const isView = computed(() => selectValue.value === SELECT_OPTIONS_ENUM.VIEW);
|
||||
const context = useMarkdownContext();
|
||||
const { codeXSlot } = toValue(context) || {};
|
||||
|
||||
function changeSelectValue(val: ElxRunCodeHeaderTypes['options']) {
|
||||
selectValue.value = val;
|
||||
}
|
||||
|
||||
function close() {
|
||||
drawer.value = false;
|
||||
}
|
||||
|
||||
// 渲染插槽函数
|
||||
function renderSlot(slotName: string) {
|
||||
if (!codeXSlot) {
|
||||
return 'div';
|
||||
}
|
||||
const slotFn = codeXSlot[slotName];
|
||||
if (typeof slotFn === 'function') {
|
||||
return slotFn({
|
||||
...props,
|
||||
value: selectValue.value,
|
||||
close,
|
||||
changeSelectValue
|
||||
});
|
||||
}
|
||||
|
||||
return h(slotFn as any, {
|
||||
...props,
|
||||
value: selectValue.value,
|
||||
close,
|
||||
changeSelectValue
|
||||
});
|
||||
}
|
||||
|
||||
const RunCodeCloseBtnComputed = computed(() => {
|
||||
if (codeXSlot?.viewCodeCloseBtn) {
|
||||
return renderSlot('viewCodeCloseBtn');
|
||||
}
|
||||
return CloseBold;
|
||||
});
|
||||
const RunCodeHeaderComputed = computed(() => {
|
||||
if (codeXSlot?.viewCodeHeader) {
|
||||
return renderSlot('viewCodeHeader');
|
||||
}
|
||||
return RunCodeHeader;
|
||||
});
|
||||
|
||||
const RunCodeContentComputed = computed(() => {
|
||||
if (codeXSlot?.viewCodeContent) {
|
||||
return renderSlot('viewCodeContent');
|
||||
}
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-if="props.mode === 'dialog'"
|
||||
v-model="drawer"
|
||||
:class="`${props.customClass} ${isView ? 'elx-run-code-dialog-view' : ''}`"
|
||||
:close-on-click-modal="props.dialogOptions?.closeOnClickModal ?? true"
|
||||
:close-on-press-escape="props.dialogOptions?.closeOnPressEscape ?? true"
|
||||
:show-close="false"
|
||||
class="elx-run-code-dialog"
|
||||
align-center
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
>
|
||||
<template #header>
|
||||
<component :is="RunCodeHeaderComputed" v-model:value="selectValue" />
|
||||
<el-button class="view-code-close-btn" @click="close">
|
||||
<component :is="RunCodeCloseBtnComputed" />
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<component :is="RunCodeContentComputed" v-if="RunCodeContentComputed" />
|
||||
<RunCodeContent v-else v-bind="props" :now-view="selectValue" />
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-drawer
|
||||
v-if="props.mode === 'drawer'"
|
||||
v-model="drawer"
|
||||
:class="`${props.customClass} ${isView ? 'elx-run-code-drawer-view' : ''}`"
|
||||
:close-on-click-modal="props.drawerOptions?.closeOnClickModal ?? true"
|
||||
:close-on-press-escape="props.drawerOptions?.closeOnPressEscape ?? true"
|
||||
:show-close="false"
|
||||
class="elx-run-code-drawer"
|
||||
align-center
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
>
|
||||
<template #header>
|
||||
<component :is="RunCodeHeaderComputed" v-model:value="selectValue" />
|
||||
<el-button
|
||||
class="view-code-close-btn"
|
||||
:class="{ customCloseBtn: !!codeXSlot?.viewCodeCloseBtn }"
|
||||
@click="close"
|
||||
>
|
||||
<component :is="RunCodeCloseBtnComputed" />
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<component :is="RunCodeContentComputed" v-if="RunCodeContentComputed" />
|
||||
<RunCodeContent v-else v-bind="props" :now-view="selectValue" />
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<style src="./style.scss"></style>
|
||||
@@ -0,0 +1,86 @@
|
||||
.elx-run-code-dialog,
|
||||
.elx-run-code-drawer {
|
||||
width: 75% !important;
|
||||
background-color: var(--shiki-code-header-bg) !important;
|
||||
.el-dialog__body {
|
||||
overflow: auto;
|
||||
border-radius: var(--shiki-custom-brr);
|
||||
}
|
||||
.el-drawer__body {
|
||||
overflow: auto;
|
||||
border-radius: var(--shiki-custom-brr);
|
||||
}
|
||||
.el-drawer__header {
|
||||
position: relative;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
.el-dialog__headerbtn,
|
||||
.el-drawer__close-btn {
|
||||
color: var(--shiki-code-header-span-color);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 5px;
|
||||
box-sizing: border-box;
|
||||
right: 10px;
|
||||
top: 15px;
|
||||
font-size: 20px;
|
||||
border-radius: var(--shiki-custom-brr);
|
||||
&:hover {
|
||||
background-color: var(--shiki-code-header-btn-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.view-code-close-btn {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
span {
|
||||
color: var(--shiki-code-header-span-color);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
position: absolute;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 5px;
|
||||
box-sizing: border-box;
|
||||
right: 10px;
|
||||
top: 15px;
|
||||
font-size: 20px;
|
||||
border-radius: var(--shiki-custom-brr);
|
||||
&:hover {
|
||||
background-color: var(--shiki-code-header-btn-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.customCloseBtn {
|
||||
&:hover {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 媒体查询
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.elx-run-code-dialog,
|
||||
.elx-run-code-drawer {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.elx-run-code-content-view-iframe {
|
||||
height: 713px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.elx-run-code-dialog-view {
|
||||
.el-dialog__body {
|
||||
border: 1px solid transparent !important;
|
||||
}
|
||||
.el-drawer__body {
|
||||
border: 1px solid transparent !important;
|
||||
}
|
||||
}
|
||||
116
Yi.Ai.Vue3/src/vue-element-plus-y/components/XMarkdownCore/components/RunCode/type.d.ts
vendored
Normal file
116
Yi.Ai.Vue3/src/vue-element-plus-y/components/XMarkdownCore/components/RunCode/type.d.ts
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { SELECT_OPTIONS_ENUM } from './components/options';
|
||||
|
||||
export interface ElxRunCodeHeaderTypes {
|
||||
/**
|
||||
* 视图 code 代码 view 预览
|
||||
*/
|
||||
options: SELECT_OPTIONS_ENUM.CODE | SELECT_OPTIONS_ENUM.VIEW;
|
||||
}
|
||||
|
||||
export interface DialogOptions {
|
||||
/**
|
||||
* 点击遮罩层是否可以关闭
|
||||
*/
|
||||
closeOnClickModal?: boolean;
|
||||
/**
|
||||
* 是否可以通过按下 ESC 键关闭 Dialog
|
||||
*/
|
||||
closeOnPressEscape?: boolean;
|
||||
}
|
||||
|
||||
export interface DrawerOptions extends DialogOptions {
|
||||
/**
|
||||
* 抽屉的方向
|
||||
*/
|
||||
direction?: 'ltr' | 'rtl' | 'ttb' | 'btt';
|
||||
}
|
||||
|
||||
export interface ElxRunCodeProps {
|
||||
/**
|
||||
* 代码块内容(高亮后的代码块内容)
|
||||
*/
|
||||
code: string[];
|
||||
/**
|
||||
* 代码块内容(原文)
|
||||
*/
|
||||
content: string;
|
||||
/**
|
||||
* 高亮后pre标签的类名
|
||||
*/
|
||||
preClass: string;
|
||||
/**
|
||||
* 高亮后pre标签的样式
|
||||
*/
|
||||
preStyle: any;
|
||||
/**
|
||||
* 语言
|
||||
*/
|
||||
lang: string;
|
||||
/**
|
||||
* 是否可见
|
||||
*/
|
||||
visible: boolean;
|
||||
/**
|
||||
* 自定义类名
|
||||
*/
|
||||
customClass?: string;
|
||||
/**
|
||||
* 弹窗模式
|
||||
*/
|
||||
mode?: 'dialog' | 'drawer';
|
||||
/**
|
||||
* 弹窗主题(暂时不支持)
|
||||
*/
|
||||
theme?: string;
|
||||
/**
|
||||
* 弹窗选项
|
||||
*/
|
||||
dialogOptions?: DialogOptions;
|
||||
/**
|
||||
* 抽屉选项
|
||||
*/
|
||||
drawerOptions?: DrawerOptions;
|
||||
}
|
||||
|
||||
export type ElxRunCodeOptions = Pick<
|
||||
ElxRunCodeProps,
|
||||
'mode' | 'customClass' | 'dialogOptions' | 'drawerOptions'
|
||||
>;
|
||||
|
||||
export type OmitOfElxRunCodeContent = Omit<
|
||||
ElxRunCodeProps,
|
||||
'visible' | 'customClass' | 'dialogOptions' | 'drawerOptions'
|
||||
>;
|
||||
|
||||
export interface ElxRunCodeContentProps extends OmitOfElxRunCodeContent {
|
||||
/**
|
||||
* 当前内容区域显示的视图
|
||||
*/
|
||||
nowView: ElxRunCodeHeaderTypes['options'];
|
||||
}
|
||||
|
||||
export interface ElxRunCodeExposeProps extends ElxRunCodeProps {
|
||||
/**
|
||||
* 当前选中的视图
|
||||
*/
|
||||
value: ElxRunCodeHeaderTypes['options'];
|
||||
/**
|
||||
* 切换视图
|
||||
*/
|
||||
changeSelectValue: (value: ElxRunCodeHeaderTypes['options']) => void;
|
||||
}
|
||||
|
||||
export interface ElxRunCodeContentExposeProps extends ElxRunCodeContentProps {
|
||||
/**
|
||||
* 当前选中的视图
|
||||
*/
|
||||
value: ElxRunCodeHeaderTypes['options'];
|
||||
/**
|
||||
* 当前内容区域显示的视图
|
||||
*/
|
||||
nowView: ElxRunCodeHeaderTypes['options'];
|
||||
}
|
||||
|
||||
export interface ElxRunCodeCloseBtnExposeProps {
|
||||
close: () => void;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import CodeBlock from './CodeBlock/index.vue';
|
||||
import CodeLine from './CodeLine/index.vue';
|
||||
import CodeX from './CodeX/index.vue';
|
||||
import Mermaid from './Mermaid/index.vue';
|
||||
|
||||
export { CodeBlock, CodeLine, CodeX, Mermaid };
|
||||
23
Yi.Ai.Vue3/src/vue-element-plus-y/components/XMarkdownCore/components/types.d.ts
vendored
Normal file
23
Yi.Ai.Vue3/src/vue-element-plus-y/components/XMarkdownCore/components/types.d.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
// import type { CustomAttrs } from './core'
|
||||
|
||||
// export type * from './core/types'
|
||||
import type { BuiltinTheme } from 'shiki';
|
||||
import type { Component } from 'vue';
|
||||
|
||||
export interface MdComponent {
|
||||
raw: any;
|
||||
}
|
||||
export type codeXRenderer =
|
||||
| ((params: { language?: string; content: string }) => VNodeChild)
|
||||
| Component;
|
||||
export type codeXSlot = ((params: any) => VNodeChild) | Component;
|
||||
export interface HighlightProps {
|
||||
theme?: BuiltinTheme | null;
|
||||
isDark?: boolean;
|
||||
language?: string;
|
||||
content?: string;
|
||||
}
|
||||
// 定义颜色替换的类型
|
||||
export interface ColorReplacements {
|
||||
[theme: string]: Record<string, string>;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { Root } from 'hast';
|
||||
import type { Options as TRehypeOptions } from 'mdast-util-to-hast';
|
||||
import type { PluggableList } from 'unified';
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import type { CustomAttrs, SanitizeOptions, TVueMarkdown } from './types';
|
||||
import { defineComponent, shallowRef, toRefs, watch } from 'vue';
|
||||
// import { useMarkdownContext } from '../components/MarkdownProvider';
|
||||
import { render } from './hast-to-vnode';
|
||||
import { useMarkdownProcessor } from './useProcessor';
|
||||
|
||||
export type { CustomAttrs, SanitizeOptions, TVueMarkdown };
|
||||
|
||||
const sharedProps = {
|
||||
markdown: {
|
||||
type: String as PropType<string>,
|
||||
default: ''
|
||||
},
|
||||
customAttrs: {
|
||||
type: Object as PropType<CustomAttrs>,
|
||||
default: () => ({})
|
||||
},
|
||||
remarkPlugins: {
|
||||
type: Array as PropType<PluggableList>,
|
||||
default: () => []
|
||||
},
|
||||
rehypePlugins: {
|
||||
type: Array as PropType<PluggableList>,
|
||||
default: () => []
|
||||
},
|
||||
rehypeOptions: {
|
||||
type: Object as PropType<Omit<TRehypeOptions, 'file'>>,
|
||||
default: () => ({})
|
||||
},
|
||||
sanitize: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
sanitizeOptions: {
|
||||
type: Object as PropType<SanitizeOptions>,
|
||||
default: () => ({})
|
||||
}
|
||||
};
|
||||
const vueMarkdownImpl = defineComponent({
|
||||
name: 'VueMarkdown',
|
||||
props: sharedProps,
|
||||
setup(props, { slots, attrs }) {
|
||||
const {
|
||||
markdown,
|
||||
remarkPlugins,
|
||||
rehypePlugins,
|
||||
rehypeOptions,
|
||||
sanitize,
|
||||
sanitizeOptions,
|
||||
customAttrs
|
||||
} = toRefs(props);
|
||||
|
||||
const { processor } = useMarkdownProcessor({
|
||||
remarkPlugins,
|
||||
rehypePlugins,
|
||||
rehypeOptions,
|
||||
sanitize,
|
||||
sanitizeOptions
|
||||
});
|
||||
|
||||
return () => {
|
||||
const mdast = processor.value.parse(markdown.value);
|
||||
const hast = processor.value.runSync(mdast) as Root;
|
||||
return render(hast, attrs, slots, customAttrs.value);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const vueMarkdownAsyncImpl = defineComponent({
|
||||
name: 'VueMarkdownAsync',
|
||||
props: sharedProps,
|
||||
async setup(props, { slots, attrs }) {
|
||||
const {
|
||||
markdown,
|
||||
remarkPlugins,
|
||||
rehypePlugins,
|
||||
rehypeOptions,
|
||||
sanitize,
|
||||
sanitizeOptions,
|
||||
customAttrs
|
||||
} = toRefs(props);
|
||||
const { processor } = useMarkdownProcessor({
|
||||
remarkPlugins,
|
||||
rehypePlugins,
|
||||
rehypeOptions,
|
||||
sanitize,
|
||||
sanitizeOptions
|
||||
});
|
||||
|
||||
const hast = shallowRef<Root | null>(null);
|
||||
const process = async (): Promise<void> => {
|
||||
const mdast = processor.value.parse(markdown.value);
|
||||
hast.value = (await processor.value.run(mdast)) as Root;
|
||||
};
|
||||
|
||||
watch(() => [markdown.value, processor.value], process, { flush: 'sync' });
|
||||
|
||||
await process();
|
||||
|
||||
return () => {
|
||||
return hast.value
|
||||
? render(hast.value, attrs, slots, customAttrs.value)
|
||||
: null;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// export the public type for h/tsx inference
|
||||
// also to avoid inline import() in generated d.ts files
|
||||
export const VueMarkdown: TVueMarkdown = vueMarkdownImpl as any;
|
||||
|
||||
// export the public type for h/tsx inference
|
||||
// also to avoid inline import() in generated d.ts files
|
||||
export const VueMarkdownAsync: TVueMarkdown = vueMarkdownAsyncImpl as any;
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { Element, Root, RootContent, Text } from 'hast';
|
||||
import type { MaybeRefOrGetter, Slots, VNode, VNodeArrayChildren } from 'vue';
|
||||
import type {
|
||||
AliasList,
|
||||
Attributes,
|
||||
Context,
|
||||
CustomAttrs,
|
||||
CustomAttrsObjectResult
|
||||
} from './types';
|
||||
import { find, html, svg } from 'property-information';
|
||||
import { h, toValue } from 'vue';
|
||||
|
||||
export function render(
|
||||
hast: Root,
|
||||
attrs: Record<string, unknown>,
|
||||
slots?: Slots,
|
||||
customAttrs?: MaybeRefOrGetter<CustomAttrs>
|
||||
): VNode {
|
||||
return h(
|
||||
'div',
|
||||
attrs,
|
||||
renderChildren(
|
||||
hast.children,
|
||||
{ listDepth: -1, listOrdered: false, listItemIndex: -1, svg: false },
|
||||
hast,
|
||||
slots ?? {},
|
||||
toValue(customAttrs) ?? {}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function renderChildren(
|
||||
nodeList: (RootContent | Root)[],
|
||||
ctx: Context,
|
||||
parent: Element | Root,
|
||||
slots: Slots,
|
||||
customAttrs: CustomAttrs
|
||||
): VNodeArrayChildren {
|
||||
const keyCounter: {
|
||||
[key: string]: number;
|
||||
} = {};
|
||||
|
||||
return nodeList.map(node => {
|
||||
switch (node.type) {
|
||||
case 'text':
|
||||
return node.value;
|
||||
case 'raw':
|
||||
return node.value;
|
||||
case 'root':
|
||||
return renderChildren(node.children, ctx, parent, slots, customAttrs);
|
||||
case 'element': {
|
||||
const { attrs, context, aliasList, vnodeProps } = getVNodeInfos(
|
||||
node,
|
||||
parent,
|
||||
ctx,
|
||||
keyCounter,
|
||||
customAttrs
|
||||
);
|
||||
for (let i = aliasList.length - 1; i >= 0; i--) {
|
||||
const targetSlot = slots[aliasList[i]];
|
||||
if (typeof targetSlot === 'function') {
|
||||
return targetSlot({
|
||||
...vnodeProps,
|
||||
...attrs,
|
||||
children: () =>
|
||||
renderChildren(node.children, context, node, slots, customAttrs)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return h(
|
||||
node.tagName,
|
||||
attrs,
|
||||
renderChildren(node.children, context, node, slots, customAttrs)
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function getVNodeInfos(
|
||||
node: RootContent,
|
||||
parent: Element | Root,
|
||||
context: Context,
|
||||
keyCounter: Record<string, number>,
|
||||
customAttrs: CustomAttrs
|
||||
): {
|
||||
attrs: Record<string, unknown>;
|
||||
context: Context;
|
||||
aliasList: AliasList;
|
||||
vnodeProps: Record<string, any>;
|
||||
} {
|
||||
const aliasList: AliasList = [];
|
||||
|
||||
let attrs: Record<string, unknown> = {};
|
||||
const vnodeProps: Record<string, any> = {};
|
||||
const ctx = { ...context };
|
||||
|
||||
if (node.type === 'element') {
|
||||
aliasList.push(node.tagName);
|
||||
keyCounter[node.tagName] =
|
||||
node.tagName in keyCounter ? keyCounter[node.tagName] + 1 : 0;
|
||||
vnodeProps.key = `${node.tagName}-${keyCounter[node.tagName]}`;
|
||||
node.properties = node.properties || {};
|
||||
|
||||
if (node.tagName === 'svg') {
|
||||
ctx.svg = true;
|
||||
}
|
||||
|
||||
attrs = Object.entries(node.properties).reduce<Record<string, any>>(
|
||||
(acc, [hastKey, value]) => {
|
||||
const attrInfo = find(ctx.svg ? svg : html, hastKey);
|
||||
acc[attrInfo.attribute] = value;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
switch (node.tagName) {
|
||||
case 'h1':
|
||||
case 'h2':
|
||||
case 'h3':
|
||||
case 'h4':
|
||||
case 'h5':
|
||||
case 'h6':
|
||||
vnodeProps.level = Number.parseFloat(node.tagName.slice(1));
|
||||
aliasList.push('heading');
|
||||
break;
|
||||
// TODO: maybe use <pre> instead for customizing from <pre> not <code> ?
|
||||
case 'code':
|
||||
vnodeProps.languageOriginal = Array.isArray(attrs.class)
|
||||
? attrs.class.find(cls => cls.startsWith('language-'))
|
||||
: '';
|
||||
vnodeProps.language = vnodeProps.languageOriginal
|
||||
? vnodeProps.languageOriginal.replace('language-', '')
|
||||
: '';
|
||||
vnodeProps.inline = 'tagName' in parent && parent.tagName !== 'pre';
|
||||
|
||||
// when tagName is code, it definitely has children and the first child is text
|
||||
// https://github.com/syntax-tree/mdast-util-to-hast/blob/main/lib/handlers/code.js
|
||||
vnodeProps.content = (node.children[0] as unknown as Text)?.value ?? '';
|
||||
|
||||
aliasList.push(vnodeProps.inline ? 'inline-code' : 'block-code');
|
||||
break;
|
||||
case 'thead':
|
||||
case 'tbody':
|
||||
ctx.currentContext = node.tagName;
|
||||
break;
|
||||
case 'td':
|
||||
case 'th':
|
||||
case 'tr':
|
||||
vnodeProps.isHead = context.currentContext === 'thead';
|
||||
break;
|
||||
|
||||
case 'ul':
|
||||
case 'ol':
|
||||
ctx.listDepth = context.listDepth + 1;
|
||||
ctx.listOrdered = node.tagName === 'ol';
|
||||
ctx.listItemIndex = -1;
|
||||
vnodeProps.ordered = ctx.listOrdered;
|
||||
vnodeProps.depth = ctx.listDepth;
|
||||
|
||||
aliasList.push('list');
|
||||
break;
|
||||
|
||||
case 'li':
|
||||
ctx.listItemIndex++;
|
||||
|
||||
vnodeProps.ordered = ctx.listOrdered;
|
||||
vnodeProps.depth = ctx.listDepth;
|
||||
vnodeProps.index = ctx.listItemIndex;
|
||||
aliasList.push('list-item');
|
||||
|
||||
break;
|
||||
case 'slot':
|
||||
if (typeof node.properties['slot-name'] === 'string') {
|
||||
aliasList.push(`${node.properties['slot-name']}`);
|
||||
delete node.properties['slot-name'];
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
attrs = computeAttrs(
|
||||
node,
|
||||
aliasList,
|
||||
vnodeProps,
|
||||
{ ...attrs } as Attributes, // TODO: fix this
|
||||
customAttrs
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
attrs,
|
||||
context: ctx,
|
||||
aliasList,
|
||||
vnodeProps
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* @param node - hast node
|
||||
* @param aliasList - html tag list. The earlier alias has higher priority. ?
|
||||
* @param attrs - attrs
|
||||
* @param customAttrs - custom attrs object
|
||||
* @returns attrs
|
||||
*/
|
||||
function computeAttrs(
|
||||
node: Element,
|
||||
aliasList: AliasList,
|
||||
vnodeProps: Record<string, any>,
|
||||
attrs: Attributes,
|
||||
customAttrs: CustomAttrs
|
||||
): CustomAttrsObjectResult {
|
||||
const result: CustomAttrsObjectResult = {
|
||||
...attrs
|
||||
};
|
||||
for (let i = aliasList.length - 1; i >= 0; i--) {
|
||||
const name = aliasList[i];
|
||||
// console.log(Object.keys(customAttrs))
|
||||
if (name in customAttrs) {
|
||||
const customAttr = customAttrs[name];
|
||||
return {
|
||||
...result,
|
||||
...(typeof customAttr === 'function'
|
||||
? customAttr(node, { ...attrs, ...vnodeProps })
|
||||
: customAttr)
|
||||
};
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// shunnNet has the rights under the MIT license
|
||||
export { VueMarkdown, VueMarkdownAsync } from './components';
|
||||
export { getVNodeInfos, render, renderChildren } from './hast-to-vnode';
|
||||
export type * from './types';
|
||||
export { createProcessor, useMarkdownProcessor } from './useProcessor';
|
||||
389
Yi.Ai.Vue3/src/vue-element-plus-y/components/XMarkdownCore/core/types.d.ts
vendored
Normal file
389
Yi.Ai.Vue3/src/vue-element-plus-y/components/XMarkdownCore/core/types.d.ts
vendored
Normal file
@@ -0,0 +1,389 @@
|
||||
import type { Options as DeepMergeOptions } from 'deepmerge';
|
||||
import type { Element } from 'hast';
|
||||
import type { Options as TRehypeOptions } from 'mdast-util-to-hast';
|
||||
import type { Options } from 'rehype-sanitize';
|
||||
import type { PluggableList } from 'unified';
|
||||
import type {
|
||||
AllowedComponentProps,
|
||||
ComponentCustomProps,
|
||||
VNode,
|
||||
VNodeArrayChildren,
|
||||
VNodeProps
|
||||
} from 'vue';
|
||||
|
||||
export interface Context {
|
||||
listDepth: number;
|
||||
listOrdered: boolean;
|
||||
listItemIndex: number;
|
||||
currentContext?: string;
|
||||
svg: boolean;
|
||||
}
|
||||
export type Attributes = Record<string, string>;
|
||||
|
||||
interface TTableProps {
|
||||
/** whether it is in head */
|
||||
isHead: boolean;
|
||||
}
|
||||
|
||||
interface THeadingProps {
|
||||
/** heading level */
|
||||
level: number;
|
||||
}
|
||||
|
||||
interface TListProps {
|
||||
/** depth of the list */
|
||||
depth: number;
|
||||
/** whether it is ordered list */
|
||||
ordered: boolean;
|
||||
}
|
||||
|
||||
interface TCodeProps {
|
||||
/** language name original @example 'language-js' */
|
||||
languageOriginal: string;
|
||||
|
||||
/** language name @example 'js' */
|
||||
language: string;
|
||||
|
||||
/** code content */
|
||||
content: string;
|
||||
|
||||
/** whether it is inline code */
|
||||
inline: boolean;
|
||||
}
|
||||
|
||||
// https://www.google.com/search?q=record%3Cstring,+any%3E+vs+record%3Cstring,+unknown%3E&sourceid=chrome&ie=UTF-8
|
||||
export type CustomAttrsObjectResult = Record<string, unknown>;
|
||||
|
||||
type CustomAttrsFunctionValue<T> = (
|
||||
/**
|
||||
* hast node
|
||||
*
|
||||
* Please refer to the source code at the following URL to understand the possible attributes for each element.
|
||||
*
|
||||
* @see https://github.com/syntax-tree/mdast-util-to-hast/tree/main/lib/handlers
|
||||
*/
|
||||
node: Element,
|
||||
/**
|
||||
* Properties of the current element.
|
||||
*
|
||||
* Except for the basic properties provided from hast, it also includes custom properties such as `level`, `ordered`, `depth`, `index` etc.
|
||||
*/
|
||||
combinedAttrs: T | Attributes
|
||||
) => Record<string, unknown>;
|
||||
|
||||
type CustomAttrsValue<
|
||||
T extends Record<string, unknown> = Record<string, unknown>
|
||||
> = CustomAttrsObjectResult | CustomAttrsFunctionValue<T>;
|
||||
|
||||
type TBasicHTMLTagNames = keyof Omit<
|
||||
HTMLElementTagNameMap,
|
||||
| 'h1'
|
||||
| 'h2'
|
||||
| 'h3'
|
||||
| 'h4'
|
||||
| 'h5'
|
||||
| 'h6'
|
||||
| 'ul'
|
||||
| 'ol'
|
||||
| 'li'
|
||||
| 'code'
|
||||
| 'td'
|
||||
| 'th'
|
||||
| 'tr'
|
||||
>;
|
||||
export type CustomAttrs = {
|
||||
[key in TBasicHTMLTagNames]?: CustomAttrsValue; // << dynamic properties
|
||||
} & {
|
||||
[key: string]:
|
||||
| CustomAttrsValue
|
||||
| CustomAttrsValue<THeadingProps>
|
||||
| CustomAttrsValue<TListProps>
|
||||
| CustomAttrsValue<TCodeProps>
|
||||
| CustomAttrsValue<TTableProps>
|
||||
| undefined;
|
||||
['h1']?: CustomAttrsValue<THeadingProps>; // << static properties
|
||||
['h2']?: CustomAttrsValue<THeadingProps>;
|
||||
['h3']?: CustomAttrsValue<THeadingProps>;
|
||||
['h4']?: CustomAttrsValue<THeadingProps>;
|
||||
['h5']?: CustomAttrsValue<THeadingProps>;
|
||||
['h6']?: CustomAttrsValue<THeadingProps>;
|
||||
['heading']?: CustomAttrsValue<THeadingProps>;
|
||||
['ul']?: CustomAttrsValue<TListProps>;
|
||||
['ol']?: CustomAttrsValue<TListProps>;
|
||||
['list']?: CustomAttrsValue<TListProps>;
|
||||
['li']?: CustomAttrsValue<TListProps>;
|
||||
['list-item']?: CustomAttrsValue<TListProps>;
|
||||
['code']?: CustomAttrsValue<TCodeProps>;
|
||||
['inline-code']?: CustomAttrsValue<TCodeProps>;
|
||||
['block-code']?: CustomAttrsValue<TCodeProps>;
|
||||
['td']?: CustomAttrsValue<TTableProps>;
|
||||
['th']?: CustomAttrsValue<TTableProps>;
|
||||
['tr']?: CustomAttrsValue<TTableProps>;
|
||||
};
|
||||
|
||||
export type AliasList = string[];
|
||||
export type TagList = AliasList;
|
||||
|
||||
export interface SanitizeOptions {
|
||||
/**
|
||||
* Options for `rehype-sanitize`
|
||||
*
|
||||
* @see https://github.com/rehypejs/rehype-sanitize
|
||||
*/
|
||||
sanitizeOptions?: Options;
|
||||
/**
|
||||
* Options for `deepmerge`
|
||||
*/
|
||||
mergeOptions?: DeepMergeOptions;
|
||||
}
|
||||
|
||||
export interface TVueMarkdownProps {
|
||||
/**
|
||||
* Markdown content
|
||||
*
|
||||
* @default '''
|
||||
*/
|
||||
markdown: string;
|
||||
/**
|
||||
* You can set custom attributes for each element, such as `href`, `target`, `rel`, `lazyload`, etc.
|
||||
*
|
||||
* The key is the HTML tag name, and the value can either be an object or a function that returns an object.
|
||||
*
|
||||
* The value will be passed to Vue's `h` function. You can refer to Vue's official documentation to learn how to configure `h`.
|
||||
*
|
||||
* @see https://vuejs.org/guide/extras/render-function.html#render-functions-jsx
|
||||
*
|
||||
* @default {}
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* a: { target: '_blank', rel: 'noopener' },
|
||||
* img: { lazyload: true },
|
||||
* h1: (node, combinedAttrs) => {
|
||||
* return { class: ['title', 'mb-2'] }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
customAttrs?: CustomAttrs;
|
||||
/**
|
||||
* Remark plugins
|
||||
*
|
||||
* These plugins will be used between `remark-parse` and `remark-rehype`.
|
||||
*
|
||||
* @see https://github.com/remarkjs/remark?tab=readme-ov-file#plugins
|
||||
*
|
||||
* @default []
|
||||
*/
|
||||
remarkPlugins?: PluggableList;
|
||||
/**
|
||||
* rehype plugins
|
||||
*
|
||||
* These plugins will be used after `remark-rehype` but before `rehype-sanitize`.
|
||||
*
|
||||
* @see https://github.com/remarkjs/remark-rehype?tab=readme-ov-file#related
|
||||
*
|
||||
* @default []
|
||||
*/
|
||||
rehypePlugins?: PluggableList;
|
||||
/**
|
||||
* Whether to sanitize the HTML content. (use `rehype-sanitize`)
|
||||
*
|
||||
* You need disable this option if you want to render `<slot>` in markdown content.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
sanitize?: boolean;
|
||||
/**
|
||||
* Options for `rehype-sanitize`
|
||||
*
|
||||
* @see https://github.com/rehypejs/rehype-sanitize?tab=readme-ov-file#options
|
||||
*
|
||||
* @default { allowDangerousHtml: true }
|
||||
*/
|
||||
sanitizeOptions?: SanitizeOptions;
|
||||
|
||||
/**
|
||||
* Options for `rehype-parse`
|
||||
*
|
||||
* @see https://github.com/remarkjs/remark-rehype?tab=readme-ov-file#options
|
||||
*
|
||||
* @default {}
|
||||
*/
|
||||
rehypeOptions?: Omit<TRehypeOptions, 'file'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed version of the `VueMarkdown` component.
|
||||
*
|
||||
* Copy from vue-router
|
||||
*/
|
||||
export interface TVueMarkdown {
|
||||
new (): {
|
||||
$props: AllowedComponentProps &
|
||||
ComponentCustomProps &
|
||||
VNodeProps &
|
||||
TVueMarkdownProps;
|
||||
|
||||
$slots: TBaseSlots & {
|
||||
/**
|
||||
* Customize `<h1>` tag
|
||||
* @scope `level` heading level
|
||||
* @scope `children` Functional component, child elements.
|
||||
*/
|
||||
['h1']?: THeadingSlot;
|
||||
/**
|
||||
* Customize `<h2>` tag
|
||||
* @scope `level` heading level
|
||||
* @scope `children` Functional component, child elements.
|
||||
*/
|
||||
['h2']?: THeadingSlot;
|
||||
/**
|
||||
* Customize `<h3>` tag
|
||||
* @scope `level` heading level
|
||||
* @scope `children` Functional component, child elements.
|
||||
*/
|
||||
['h3']?: THeadingSlot;
|
||||
/**
|
||||
* Customize `<h4>` tag
|
||||
* @scope `level` heading level
|
||||
* @scope `children` Functional component, child elements.
|
||||
*/
|
||||
['h4']?: THeadingSlot;
|
||||
/**
|
||||
* Customize `<h5>` tag
|
||||
* @scope `level` heading level
|
||||
* @scope `children` Functional component, child elements.
|
||||
*/
|
||||
['h5']?: THeadingSlot;
|
||||
/**
|
||||
* Customize `<h6>` tag
|
||||
* @scope `level` heading level
|
||||
* @scope `children` Functional component, child elements.
|
||||
*/
|
||||
['h6']?: THeadingSlot;
|
||||
/**
|
||||
* Customize `<h1>` ~ `<h6>` tag
|
||||
* @scope `level` heading level
|
||||
* @scope `children` Functional component, child elements.
|
||||
*/
|
||||
['heading']?: THeadingSlot;
|
||||
|
||||
/**
|
||||
* Customize inline and block code.
|
||||
* @scope `languageOriginal` language name original
|
||||
* @scope `language` language name
|
||||
* @scope `content` code content
|
||||
* @scope `inline` whether it is inline code
|
||||
* @scope `children` Functional component, child elements.
|
||||
*/
|
||||
['code']?: TCodeSlot;
|
||||
/**
|
||||
* Customize inline code.
|
||||
* @scope `languageOriginal` language name original
|
||||
* @scope `language` language name
|
||||
* @scope `content` code content
|
||||
* @scope `inline` whether it is inline code
|
||||
* @scope `children` Functional component, child elements.
|
||||
*/
|
||||
['inline-code']?: TCodeSlot;
|
||||
/**
|
||||
* Customize block code.
|
||||
* @scope `languageOriginal` language name original
|
||||
* @scope `language` language name
|
||||
* @scope `content` code content
|
||||
* @scope `inline` whether it is inline code
|
||||
* @scope `children` Functional component, child elements.
|
||||
*/
|
||||
['block-code']?: TCodeSlot;
|
||||
|
||||
/**
|
||||
* Customize unordered list
|
||||
* @scope `depth` depth of the list
|
||||
* @scope `ordered` whether it is ordered list
|
||||
* @scope `children` Functional component, child elements.
|
||||
*/
|
||||
['ul']?: TListSlot;
|
||||
|
||||
/**
|
||||
* Customize ordered list
|
||||
* @scope `depth` depth of the list
|
||||
* @scope `ordered` whether it is ordered list
|
||||
* @scope `children` Functional component, child elements.
|
||||
*/
|
||||
['ol']?: TListSlot;
|
||||
/**
|
||||
* Customize ordered and unordered list
|
||||
* @scope `depth` depth of the list
|
||||
* @scope `ordered` whether it is ordered list
|
||||
* @scope `children` Functional component, child elements.
|
||||
*/
|
||||
['list']?: TListSlot;
|
||||
|
||||
/**
|
||||
* Customize list item
|
||||
* @scope `depth` depth of the list
|
||||
* @scope `ordered` whether it is ordered list
|
||||
* @scope `index` index of the list item
|
||||
* @scope `children` Functional component, child elements.
|
||||
*/
|
||||
['li']?: TListSlot;
|
||||
|
||||
/**
|
||||
* Customize list item
|
||||
* @scope `depth` depth of the list
|
||||
* @scope `ordered` whether it is ordered list
|
||||
* @scope `index` index of the list item
|
||||
* @scope `children` Functional component, child elements.
|
||||
*/
|
||||
['list-item']?: TListSlot;
|
||||
|
||||
/**
|
||||
* Customize table element: td
|
||||
*
|
||||
* @scope `isHead` whether it is in head
|
||||
* @scope `children` Functional component, child elements.
|
||||
*/
|
||||
['td']?: TTableElementSlot;
|
||||
|
||||
/**
|
||||
* Customize table element: th
|
||||
*
|
||||
* @scope `isHead` whether it is in head
|
||||
* @scope `children` Functional component, child elements.
|
||||
*/
|
||||
['th']?: TTableElementSlot;
|
||||
|
||||
/**
|
||||
* Customize table element: tr
|
||||
*
|
||||
* @scope `isHead` whether it is in head
|
||||
* @scope `children` Functional component, child elements.
|
||||
*/
|
||||
['tr']?: TTableElementSlot;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
type TTableElementSlot = TCustomSlot<TTableProps>;
|
||||
type TListSlot = TCustomSlot<TListProps>;
|
||||
type THeadingSlot = TCustomSlot<THeadingProps>;
|
||||
type TCodeSlot = TCustomSlot<TCodeProps>;
|
||||
|
||||
type HtmlTagNames = keyof HTMLElementTagNameMap;
|
||||
type TBaseSlots = {
|
||||
// An index signature parameter type cannot be a literal type or generic type. Consider using a mapped object type instead.ts(1337)
|
||||
// [key: HtmlTagNames]: (scope: Record<string, any>) => VNode[] | VNode
|
||||
[key in HtmlTagNames]?: (scope: TBaseSlotScope) => VNode[] | VNode;
|
||||
} & {
|
||||
[key: string]: (scope: TBaseSlotScope) => VNode[] | VNode;
|
||||
};
|
||||
|
||||
type TBaseSlotScope = TElementChild & Attributes;
|
||||
interface TElementChild {
|
||||
/** Functional component, child elements. */
|
||||
children: () => VNodeArrayChildren;
|
||||
}
|
||||
|
||||
type TCustomSlot<T> = (scope: TBaseSlotScope & T) => VNode[] | VNode;
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Root } from 'hast';
|
||||
import type { Root as MdastRoot } from 'mdast';
|
||||
import type { Options as TRehypeOptions } from 'mdast-util-to-hast';
|
||||
import type { PluggableList, Processor } from 'unified';
|
||||
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
import type { SanitizeOptions } from './types';
|
||||
import deepmerge from 'deepmerge';
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
||||
import remarkParse from 'remark-parse';
|
||||
import remarkRehype from 'remark-rehype';
|
||||
import { unified } from 'unified';
|
||||
import { computed, toValue } from 'vue';
|
||||
|
||||
export interface TUseMarkdownProcessorOptions {
|
||||
remarkPlugins?: MaybeRefOrGetter<PluggableList>;
|
||||
rehypePlugins?: MaybeRefOrGetter<PluggableList>;
|
||||
rehypeOptions?: MaybeRefOrGetter<Omit<TRehypeOptions, 'file'>>;
|
||||
sanitize?: MaybeRefOrGetter<boolean>;
|
||||
sanitizeOptions?: MaybeRefOrGetter<SanitizeOptions>;
|
||||
}
|
||||
export function useMarkdownProcessor(options?: TUseMarkdownProcessorOptions): {
|
||||
processor: ComputedRef<
|
||||
Processor<MdastRoot, MdastRoot, Root, undefined, undefined>
|
||||
>;
|
||||
} {
|
||||
const processor = computed(() => {
|
||||
return createProcessor({
|
||||
prePlugins: [remarkParse, ...(toValue(options?.remarkPlugins) ?? [])],
|
||||
rehypePlugins: toValue(options?.rehypePlugins),
|
||||
rehypeOptions: toValue(options?.rehypeOptions),
|
||||
sanitize: toValue(options?.sanitize),
|
||||
sanitizeOptions: toValue(options?.sanitizeOptions)
|
||||
});
|
||||
});
|
||||
return { processor };
|
||||
}
|
||||
|
||||
export function createProcessor(options?: {
|
||||
prePlugins?: PluggableList;
|
||||
rehypePlugins?: PluggableList;
|
||||
rehypeOptions?: Omit<TRehypeOptions, 'file'>;
|
||||
sanitize?: boolean;
|
||||
sanitizeOptions?: SanitizeOptions;
|
||||
// TODO: fix types
|
||||
}): Processor<any, any, any, any, any> {
|
||||
return unified()
|
||||
.use(options?.prePlugins ?? [])
|
||||
.use(remarkRehype, {
|
||||
allowDangerousHtml: true,
|
||||
...(options?.rehypeOptions || {})
|
||||
})
|
||||
.use(options?.rehypePlugins ?? [])
|
||||
.use(
|
||||
options?.sanitize
|
||||
? [
|
||||
[
|
||||
rehypeSanitize,
|
||||
deepmerge(
|
||||
defaultSchema,
|
||||
options?.sanitizeOptions?.sanitizeOptions || {},
|
||||
options?.sanitizeOptions?.mergeOptions || {}
|
||||
)
|
||||
]
|
||||
]
|
||||
: []
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './useComponents';
|
||||
export * from './useMarkdown';
|
||||
export * from './useMermaid';
|
||||
export * from './useMermaidZoom';
|
||||
export * from './usePlugins';
|
||||
export * from './useThemeMode';
|
||||
@@ -0,0 +1,11 @@
|
||||
import { h } from 'vue';
|
||||
import { CodeX } from '../components/index';
|
||||
|
||||
function useComponents() {
|
||||
const components = {
|
||||
code: (raw: any) => h(CodeX, { raw })
|
||||
};
|
||||
return components;
|
||||
}
|
||||
|
||||
export { useComponents };
|
||||
@@ -0,0 +1,42 @@
|
||||
import { flow } from 'lodash-es';
|
||||
|
||||
export function useProcessMarkdown(markdown: string) {
|
||||
return preprocessLaTeX(markdown);
|
||||
}
|
||||
|
||||
export function preprocessLaTeX(markdown: string) {
|
||||
if (typeof markdown !== 'string') return markdown;
|
||||
|
||||
const codeBlockRegex = /```[\s\S]*?```/g;
|
||||
const codeBlocks = markdown.match(codeBlockRegex) || [];
|
||||
const escapeReplacement = (str: string) => str.replace(/\$/g, '_ELX_DOLLAR_');
|
||||
let processedMarkdown = markdown.replace(
|
||||
codeBlockRegex,
|
||||
'ELX_CODE_BLOCK_PLACEHOLDER'
|
||||
);
|
||||
|
||||
processedMarkdown = flow([
|
||||
(str: string) =>
|
||||
str.replace(/\\\[(.*?)\\\]/g, (_, equation) => `$$${equation}$$`),
|
||||
(str: string) =>
|
||||
str.replace(/\\\[([\s\S]*?)\\\]/g, (_, equation) => `$$${equation}$$`),
|
||||
(str: string) =>
|
||||
str.replace(/\\\((.*?)\\\)/g, (_, equation) => `$$${equation}$$`),
|
||||
(str: string) =>
|
||||
str.replace(
|
||||
/(^|[^\\])\$(.+?)\$/g,
|
||||
(_, prefix, equation) => `${prefix}$${equation}$`
|
||||
)
|
||||
])(processedMarkdown);
|
||||
|
||||
codeBlocks.forEach(block => {
|
||||
processedMarkdown = processedMarkdown.replace(
|
||||
'ELX_CODE_BLOCK_PLACEHOLDER',
|
||||
escapeReplacement(block)
|
||||
);
|
||||
});
|
||||
|
||||
processedMarkdown = processedMarkdown.replace(/_ELX_DOLLAR_/g, '$');
|
||||
|
||||
return processedMarkdown;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Ref } from 'vue';
|
||||
import { throttle } from 'lodash-es';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
interface UseMermaidOptions {
|
||||
id?: string;
|
||||
theme?: 'default' | 'dark' | 'forest' | 'neutral' | string;
|
||||
config?: any;
|
||||
}
|
||||
|
||||
async function loadMermaid() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const mermaidModule = await import('mermaid');
|
||||
return mermaidModule.default;
|
||||
}
|
||||
|
||||
let mermaidContainer: HTMLElement | null = null;
|
||||
|
||||
function getMermaidContainer(): HTMLElement {
|
||||
if (!mermaidContainer) {
|
||||
mermaidContainer = document.querySelector(
|
||||
'.elx-markdown-mermaid-container'
|
||||
) as HTMLElement;
|
||||
if (!mermaidContainer) {
|
||||
mermaidContainer = document.createElement('div') as HTMLElement;
|
||||
mermaidContainer.ariaHidden = 'true';
|
||||
mermaidContainer.style.maxHeight = '0';
|
||||
mermaidContainer.style.opacity = '0';
|
||||
mermaidContainer.style.overflow = 'hidden';
|
||||
mermaidContainer.classList.add('elx-markdown-mermaid-container');
|
||||
document.body.append(mermaidContainer);
|
||||
}
|
||||
}
|
||||
return mermaidContainer;
|
||||
}
|
||||
|
||||
export function useMermaid(
|
||||
content: string | Ref<string>,
|
||||
options: UseMermaidOptions = {}
|
||||
) {
|
||||
const { id = 'mermaid', theme = 'default', config = {} } = options;
|
||||
const mermaidConfig = computed(() => ({
|
||||
suppressErrorRendering: true,
|
||||
startOnLoad: false,
|
||||
securityLevel: 'loose',
|
||||
theme,
|
||||
...config
|
||||
}));
|
||||
const data = ref('');
|
||||
const error = ref<unknown>(null);
|
||||
const throttledRender = throttle(
|
||||
async () => {
|
||||
const contentValue =
|
||||
typeof content === 'string' ? content : content.value;
|
||||
if (!contentValue?.trim()) {
|
||||
data.value = '';
|
||||
error.value = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 动态加载 mermaid 库
|
||||
const mermaidInstance = await loadMermaid();
|
||||
if (!mermaidInstance) {
|
||||
data.value = contentValue;
|
||||
error.value = null;
|
||||
return;
|
||||
}
|
||||
// 语法校验
|
||||
const isValid = await mermaidInstance.parse(contentValue.trim());
|
||||
if (!isValid) {
|
||||
console.log('Mermaid parse error: Invalid syntax');
|
||||
data.value = '';
|
||||
error.value = new Error('Mermaid parse error: Invalid syntax');
|
||||
return;
|
||||
}
|
||||
// 初始化 mermaid 配置
|
||||
mermaidInstance.initialize(mermaidConfig.value);
|
||||
const renderId = `${id}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const container = getMermaidContainer();
|
||||
const { svg } = await mermaidInstance.render(
|
||||
renderId,
|
||||
contentValue,
|
||||
container
|
||||
);
|
||||
data.value = svg;
|
||||
error.value = null;
|
||||
} catch (err) {
|
||||
console.log('Mermaid render error:', err);
|
||||
data.value = '';
|
||||
error.value = err;
|
||||
}
|
||||
},
|
||||
300,
|
||||
{ trailing: true, leading: true }
|
||||
);
|
||||
|
||||
// 监听内容变化,自动触发渲染
|
||||
watch(
|
||||
() => (typeof content === 'string' ? content : content.value),
|
||||
() => {
|
||||
throttledRender();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return {
|
||||
data,
|
||||
error
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import type {
|
||||
MermaidZoomControls,
|
||||
UseMermaidZoomOptions
|
||||
} from '../components/Mermaid/types';
|
||||
import { onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
export function useMermaidZoom(
|
||||
options: UseMermaidZoomOptions
|
||||
): MermaidZoomControls {
|
||||
const { container } = options;
|
||||
|
||||
const scale = ref(1);
|
||||
const posX = ref(0);
|
||||
const posY = ref(0);
|
||||
const isDragging = ref(false);
|
||||
|
||||
let removeEvents: (() => void) | null = null;
|
||||
|
||||
// 获取SVG元素
|
||||
const getSvg = () =>
|
||||
container.value?.querySelector('.mermaid-content svg') as HTMLElement;
|
||||
|
||||
// 更新变换
|
||||
const updateTransform = (svg: HTMLElement) => {
|
||||
svg.style.transformOrigin = 'center center';
|
||||
svg.style.transform = `translate(${posX.value}px, ${posY.value}px) scale(${scale.value})`;
|
||||
};
|
||||
|
||||
// 重置状态
|
||||
const resetState = () => {
|
||||
scale.value = 1;
|
||||
posX.value = 0;
|
||||
posY.value = 0;
|
||||
isDragging.value = false;
|
||||
};
|
||||
|
||||
// 添加拖拽事件
|
||||
const addDragEvents = (content: HTMLElement) => {
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
|
||||
const onStart = (clientX: number, clientY: number) => {
|
||||
isDragging.value = true;
|
||||
startX = clientX - posX.value;
|
||||
startY = clientY - posY.value;
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const onMove = (clientX: number, clientY: number) => {
|
||||
if (isDragging.value) {
|
||||
posX.value = clientX - startX;
|
||||
posY.value = clientY - startY;
|
||||
updateTransform(content);
|
||||
}
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
isDragging.value = false;
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
// 鼠标事件
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (e.button !== 0)
|
||||
return; // ⭐️ 只响应鼠标左键
|
||||
e.preventDefault();
|
||||
onStart(e.clientX, e.clientY);
|
||||
};
|
||||
const onMouseMove = (e: MouseEvent) => onMove(e.clientX, e.clientY);
|
||||
|
||||
// 触摸事件
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
if (e.touches.length === 1) {
|
||||
onStart(e.touches[0].clientX, e.touches[0].clientY);
|
||||
}
|
||||
};
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (e.touches.length === 1) {
|
||||
e.preventDefault();
|
||||
onMove(e.touches[0].clientX, e.touches[0].clientY);
|
||||
}
|
||||
};
|
||||
|
||||
// 绑定事件
|
||||
content.addEventListener('mousedown', onMouseDown);
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onEnd);
|
||||
content.addEventListener('touchstart', onTouchStart, { passive: false });
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', onEnd);
|
||||
|
||||
return () => {
|
||||
content.removeEventListener('mousedown', onMouseDown);
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onEnd);
|
||||
content.removeEventListener('touchstart', onTouchStart);
|
||||
document.removeEventListener('touchmove', onTouchMove);
|
||||
document.removeEventListener('touchend', onEnd);
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
};
|
||||
|
||||
// 缩放功能
|
||||
const zoomIn = () => {
|
||||
const svg = getSvg();
|
||||
if (svg) {
|
||||
scale.value = Math.min(scale.value + 0.2, 10);
|
||||
updateTransform(svg);
|
||||
}
|
||||
};
|
||||
|
||||
const zoomOut = () => {
|
||||
const svg = getSvg();
|
||||
if (svg) {
|
||||
scale.value = Math.max(scale.value - 0.2, 0.1);
|
||||
updateTransform(svg);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
const svg = getSvg();
|
||||
if (svg) {
|
||||
resetState();
|
||||
updateTransform(svg);
|
||||
}
|
||||
};
|
||||
|
||||
const fullscreen = () => {
|
||||
if (!container.value)
|
||||
return;
|
||||
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
else {
|
||||
container.value.requestFullscreen?.();
|
||||
}
|
||||
};
|
||||
|
||||
const initialize = () => {
|
||||
if (!container.value)
|
||||
return;
|
||||
|
||||
resetState();
|
||||
|
||||
const svg = getSvg();
|
||||
if (svg) {
|
||||
removeEvents = addDragEvents(svg);
|
||||
updateTransform(svg);
|
||||
}
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
removeEvents?.();
|
||||
removeEvents = null;
|
||||
resetState();
|
||||
};
|
||||
|
||||
// 监听容器变化
|
||||
watch(
|
||||
() => container.value,
|
||||
() => {
|
||||
destroy();
|
||||
resetState();
|
||||
}
|
||||
);
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(destroy);
|
||||
|
||||
return {
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
reset,
|
||||
fullscreen,
|
||||
destroy,
|
||||
initialize
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Pluggable } from 'unified';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import { computed, toRefs } from 'vue';
|
||||
import { rehypeAnimatedPlugin } from '../plugins/rehypePlugin';
|
||||
|
||||
function usePlugins(props: any) {
|
||||
const {
|
||||
allowHtml,
|
||||
enableAnimate,
|
||||
enableLatex,
|
||||
enableBreaks,
|
||||
rehypePlugins,
|
||||
remarkPlugins,
|
||||
rehypePluginsAhead,
|
||||
remarkPluginsAhead
|
||||
} = toRefs(props);
|
||||
|
||||
const rehype = computed(() => {
|
||||
return [
|
||||
...(rehypePluginsAhead.value as Pluggable[]),
|
||||
allowHtml.value && rehypeRaw,
|
||||
enableLatex.value && rehypeKatex,
|
||||
enableAnimate.value && rehypeAnimatedPlugin,
|
||||
...(rehypePlugins.value as Pluggable[])
|
||||
].filter(Boolean) as Pluggable[];
|
||||
});
|
||||
|
||||
const remark = computed(() => {
|
||||
const base: (Pluggable | { plugins: Pluggable[] })[] = [
|
||||
enableLatex.value && remarkMath,
|
||||
enableBreaks.value && remarkBreaks
|
||||
].filter(Boolean) as (Pluggable | { plugins: Pluggable[] })[];
|
||||
|
||||
return [
|
||||
[remarkGfm, { singleTilde: false }],
|
||||
...(remarkPluginsAhead.value as (Pluggable | { plugins: Pluggable[] })[]),
|
||||
...base,
|
||||
...(remarkPlugins.value as (Pluggable | { plugins: Pluggable[] })[])
|
||||
];
|
||||
});
|
||||
|
||||
return {
|
||||
rehypePlugins: rehype,
|
||||
remarkPlugins: remark
|
||||
};
|
||||
}
|
||||
export { usePlugins };
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { Root } from 'hast';
|
||||
import type {
|
||||
BundledHighlighterOptions,
|
||||
CodeToHastOptions,
|
||||
CodeToTokensBaseOptions,
|
||||
CodeToTokensOptions,
|
||||
CodeToTokensWithThemesOptions,
|
||||
GrammarState,
|
||||
HighlighterGeneric,
|
||||
RequireKeys,
|
||||
ThemedToken,
|
||||
ThemedTokenWithVariants,
|
||||
TokensResult
|
||||
} from 'shiki';
|
||||
import { GLOBAL_SHIKI_KEY } from '@components/XMarkdownCore/shared';
|
||||
import {
|
||||
createdBundledHighlighter,
|
||||
createOnigurumaEngine,
|
||||
createSingletonShorthands
|
||||
} from 'shiki';
|
||||
import { onUnmounted, provide, ref } from 'vue';
|
||||
import { languageLoaders, themeLoaders } from '../../../hooks/shiki-loader';
|
||||
|
||||
export interface GlobalShiki {
|
||||
codeToHtml: (
|
||||
code: string,
|
||||
options: CodeToHastOptions<string, string>
|
||||
) => Promise<string>;
|
||||
codeToHast: (
|
||||
code: string,
|
||||
options: CodeToHastOptions<string, string>
|
||||
) => Promise<Root>;
|
||||
codeToTokensBase: (
|
||||
code: string,
|
||||
options: RequireKeys<
|
||||
CodeToTokensBaseOptions<string, string>,
|
||||
'theme' | 'lang'
|
||||
>
|
||||
) => Promise<ThemedToken[][]>;
|
||||
codeToTokens: (
|
||||
code: string,
|
||||
options: CodeToTokensOptions<string, string>
|
||||
) => Promise<TokensResult>;
|
||||
codeToTokensWithThemes: (
|
||||
code: string,
|
||||
options: RequireKeys<
|
||||
CodeToTokensWithThemesOptions<string, string>,
|
||||
'lang' | 'themes'
|
||||
>
|
||||
) => Promise<ThemedTokenWithVariants[][]>;
|
||||
getSingletonHighlighter: (
|
||||
options?: Partial<BundledHighlighterOptions<string, string>>
|
||||
) => Promise<HighlighterGeneric<string, string>>;
|
||||
getLastGrammarState:
|
||||
| ((element: ThemedToken[][] | Root) => GrammarState)
|
||||
| ((
|
||||
code: string,
|
||||
options: CodeToTokensBaseOptions<string, string>
|
||||
) => Promise<GrammarState>);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Shiki 管理器(单例 + 懒初始化)
|
||||
*/
|
||||
class ShikiManager {
|
||||
private static instance: ShikiManager | null = null;
|
||||
|
||||
private shikiInstance: GlobalShiki | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): ShikiManager {
|
||||
if (!ShikiManager.instance) {
|
||||
ShikiManager.instance = new ShikiManager();
|
||||
}
|
||||
return ShikiManager.instance;
|
||||
}
|
||||
|
||||
public getShiki(): GlobalShiki {
|
||||
if (this.shikiInstance) return this.shikiInstance;
|
||||
|
||||
const highlighterFactory = createdBundledHighlighter({
|
||||
langs: languageLoaders,
|
||||
themes: themeLoaders,
|
||||
engine: () => createOnigurumaEngine(import('shiki/wasm'))
|
||||
});
|
||||
|
||||
const {
|
||||
codeToHtml,
|
||||
codeToHast,
|
||||
codeToTokensBase,
|
||||
codeToTokens,
|
||||
codeToTokensWithThemes,
|
||||
getSingletonHighlighter,
|
||||
getLastGrammarState
|
||||
} = createSingletonShorthands(highlighterFactory);
|
||||
|
||||
this.shikiInstance = {
|
||||
codeToHtml,
|
||||
codeToHast,
|
||||
codeToTokensBase,
|
||||
codeToTokens,
|
||||
codeToTokensWithThemes,
|
||||
getSingletonHighlighter,
|
||||
getLastGrammarState
|
||||
};
|
||||
return this.shikiInstance;
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.shikiInstance = null;
|
||||
ShikiManager.instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 全局状态管理
|
||||
let globalShikiInstance: GlobalShiki | undefined;
|
||||
let globalShikiManager: ShikiManager | undefined;
|
||||
let referenceCount = 0;
|
||||
|
||||
const shikiIsCreated = ref(false);
|
||||
const shikiInstance = ref<GlobalShiki>();
|
||||
const shikiManager = ref<ShikiManager>();
|
||||
|
||||
/**
|
||||
* @description 在 Vue 中提供 Shiki 实例(支持多组件实例)
|
||||
*/
|
||||
export function useShiki(): GlobalShiki {
|
||||
// 增加引用计数
|
||||
referenceCount++;
|
||||
|
||||
// ✅ 注册 onUnmounted 钩子
|
||||
onUnmounted(() => {
|
||||
referenceCount--;
|
||||
console.log(`shiki reference count: ${referenceCount}`);
|
||||
|
||||
// 只有当所有组件都卸载时才清理
|
||||
if (referenceCount === 0) {
|
||||
console.log('shiki destroyed - all references removed');
|
||||
shikiIsCreated.value = false;
|
||||
shikiInstance.value = undefined;
|
||||
shikiManager.value?.dispose();
|
||||
globalShikiManager?.dispose();
|
||||
globalShikiInstance = undefined;
|
||||
globalShikiManager = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
// ✅ 仅在首次时初始化
|
||||
if (!globalShikiInstance) {
|
||||
console.log('shiki created');
|
||||
globalShikiManager = ShikiManager.getInstance();
|
||||
globalShikiInstance = globalShikiManager.getShiki();
|
||||
|
||||
shikiManager.value = globalShikiManager;
|
||||
shikiInstance.value = globalShikiInstance;
|
||||
|
||||
provide(GLOBAL_SHIKI_KEY, shikiInstance);
|
||||
shikiIsCreated.value = true;
|
||||
} else {
|
||||
// 为后续组件实例提供相同的实例
|
||||
shikiManager.value = globalShikiManager;
|
||||
shikiInstance.value = globalShikiInstance;
|
||||
provide(GLOBAL_SHIKI_KEY, shikiInstance);
|
||||
}
|
||||
|
||||
return globalShikiInstance;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
BundledLanguage,
|
||||
BundledTheme,
|
||||
HighlighterGeneric,
|
||||
ThemeRegistrationResolved
|
||||
} from 'shiki';
|
||||
import type { InitShikiOptions } from '../shared';
|
||||
import { createHighlighter } from 'shiki';
|
||||
import { shikiThemeDefault } from '../shared';
|
||||
import { useDarkModeWatcher } from './useThemeMode';
|
||||
|
||||
interface UseShikiOptions {
|
||||
themes?: InitShikiOptions['themes'];
|
||||
}
|
||||
|
||||
const highlighter =
|
||||
shallowRef<HighlighterGeneric<BundledLanguage, BundledTheme>>();
|
||||
const shikiThemeColor = ref<ThemeRegistrationResolved>();
|
||||
const hasCreated = ref(false);
|
||||
const oldThemes = ref<InitShikiOptions['themes']>();
|
||||
|
||||
export function useGlobalShikiHighlighter(options?: UseShikiOptions) {
|
||||
const { isDark } = useDarkModeWatcher();
|
||||
|
||||
const themeArr = computed(() => {
|
||||
if (options?.themes) {
|
||||
return Object.keys(options.themes).map(key => options.themes![key]);
|
||||
}
|
||||
return [shikiThemeDefault.light, shikiThemeDefault.dark];
|
||||
});
|
||||
|
||||
const updateThemeColor = () => {
|
||||
if (!highlighter.value || !hasCreated.value)
|
||||
return;
|
||||
|
||||
const themeName = isDark.value ? themeArr.value[1] : themeArr.value[0];
|
||||
|
||||
shikiThemeColor.value = highlighter.value.getTheme(themeName as any);
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
if (
|
||||
hasCreated.value &&
|
||||
JSON.stringify(oldThemes.value) === JSON.stringify(options?.themes)
|
||||
) {
|
||||
updateThemeColor();
|
||||
return;
|
||||
}
|
||||
|
||||
const themes = [...themeArr.value];
|
||||
if (!themes.length)
|
||||
return;
|
||||
|
||||
const newHighlighter = await createHighlighter({
|
||||
themes: themes as any[],
|
||||
langs: []
|
||||
});
|
||||
|
||||
highlighter.value?.dispose?.();
|
||||
highlighter.value = newHighlighter;
|
||||
oldThemes.value = options?.themes;
|
||||
hasCreated.value = true;
|
||||
|
||||
updateThemeColor();
|
||||
};
|
||||
|
||||
watch(isDark, updateThemeColor, { immediate: true });
|
||||
|
||||
const destroy = () => {
|
||||
hasCreated.value = false;
|
||||
highlighter.value?.dispose?.();
|
||||
};
|
||||
|
||||
return {
|
||||
highlighter,
|
||||
shikiThemeColor,
|
||||
isDark,
|
||||
init,
|
||||
destroy
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
export function useDarkModeWatcher() {
|
||||
const isDark = ref(document.body.classList.contains('dark'));
|
||||
|
||||
let observer: MutationObserver;
|
||||
|
||||
onMounted(() => {
|
||||
observer = new MutationObserver(() => {
|
||||
isDark.value = document.body.classList.contains('dark');
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'] // 只监听 class 变化
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
observer && observer.disconnect();
|
||||
});
|
||||
|
||||
return {
|
||||
isDark
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './core';
|
||||
export * from './MarkdownRender';
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
import type { Element, ElementContent, Root } from 'hast';
|
||||
import type { BuildVisitor } from 'unist-util-visit';
|
||||
import { visit } from 'unist-util-visit';
|
||||
|
||||
export function rehypeAnimatedPlugin() {
|
||||
return (tree: Root) => {
|
||||
visit(tree, 'element', ((node: Element) => {
|
||||
if (
|
||||
[
|
||||
'p',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'li',
|
||||
'strong',
|
||||
'th',
|
||||
'td'
|
||||
].includes(node.tagName) &&
|
||||
node.children
|
||||
) {
|
||||
const newChildren: Array<ElementContent> = [];
|
||||
for (const child of node.children) {
|
||||
if (child.type === 'text') {
|
||||
// @ts-expect-error Segmenter is not available in all environments
|
||||
const segmenter = new Intl.Segmenter('zh', { granularity: 'word' });
|
||||
const segments = segmenter.segment(child.value);
|
||||
const words = [...segments]
|
||||
.map(segment => segment.segment)
|
||||
.filter(Boolean);
|
||||
words.forEach((word: string) => {
|
||||
newChildren.push({
|
||||
children: [{ type: 'text', value: word }],
|
||||
properties: {
|
||||
className: 'x-markdown-animated-word'
|
||||
},
|
||||
tagName: 'span',
|
||||
type: 'element'
|
||||
});
|
||||
});
|
||||
} else {
|
||||
newChildren.push(child);
|
||||
}
|
||||
}
|
||||
node.children = newChildren;
|
||||
}
|
||||
}) as BuildVisitor<Root, 'element'>);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { GlobalShiki } from '@components/XMarkdownCore/hooks/useShiki';
|
||||
import type { CodeXProps } from '@components/XMarkdownCore/shared/types';
|
||||
import type { BuiltinTheme } from 'shiki';
|
||||
import type { PluggableList } from 'unified';
|
||||
import type { MermaidToolbarConfig } from '../components/Mermaid/types';
|
||||
import type { ElxRunCodeOptions } from '../components/RunCode/type';
|
||||
import type { CustomAttrs, SanitizeOptions } from '../core';
|
||||
import type { InitShikiOptions } from './shikiHighlighter';
|
||||
|
||||
export const shikiThemeDefault: InitShikiOptions['themes'] = {
|
||||
light: 'vitesse-light',
|
||||
dark: 'vitesse-dark'
|
||||
};
|
||||
|
||||
export const DEFAULT_PROPS = {
|
||||
markdown: '',
|
||||
allowHtml: false,
|
||||
enableLatex: true,
|
||||
enableAnimate: false,
|
||||
enableBreaks: true,
|
||||
codeXProps: () => ({}),
|
||||
codeXRender: () => ({}),
|
||||
codeXSlot: () => ({}),
|
||||
codeHighlightTheme: null,
|
||||
customAttrs: () => ({}),
|
||||
remarkPlugins: () => [],
|
||||
remarkPluginsAhead: () => [],
|
||||
rehypePlugins: () => [],
|
||||
rehypePluginsAhead: () => [],
|
||||
rehypeOptions: () => ({}),
|
||||
sanitize: false,
|
||||
sanitizeOptions: () => ({}),
|
||||
mermaidConfig: () => ({}),
|
||||
langs: () => [],
|
||||
defaultThemeMode: '' as 'light' | 'dark',
|
||||
themes: () => ({ ...shikiThemeDefault }),
|
||||
colorReplacements: () => ({}),
|
||||
needViewCodeBtn: true,
|
||||
secureViewCode: false,
|
||||
viewCodeModalOptions: () => ({})
|
||||
};
|
||||
|
||||
export const MARKDOWN_CORE_PROPS = {
|
||||
markdown: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
allowHtml: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
enableLatex: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
enableAnimate: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
enableBreaks: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
codeXProps: {
|
||||
type: Object as PropType<CodeXProps>,
|
||||
default: () => ({
|
||||
enableCodePreview: false, // 启动代码预览功能
|
||||
enableCodeCopy: true, // 启动代码复制功能
|
||||
enableThemeToggle: false, // 启动主题切换
|
||||
enableCodeLineNumber: false
|
||||
})
|
||||
},
|
||||
codeXRender: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
codeXSlot: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
codeHighlightTheme: {
|
||||
type: Object as PropType<BuiltinTheme | null>,
|
||||
default: () => null
|
||||
},
|
||||
customAttrs: {
|
||||
type: Object as PropType<CustomAttrs>,
|
||||
default: () => ({})
|
||||
},
|
||||
remarkPlugins: {
|
||||
type: Array as PropType<PluggableList>,
|
||||
default: () => []
|
||||
},
|
||||
remarkPluginsAhead: {
|
||||
type: Array as PropType<PluggableList>,
|
||||
default: () => []
|
||||
},
|
||||
rehypePlugins: {
|
||||
type: Array as PropType<PluggableList>,
|
||||
default: () => []
|
||||
},
|
||||
rehypePluginsAhead: {
|
||||
type: Array as PropType<PluggableList>,
|
||||
default: () => []
|
||||
},
|
||||
rehypeOptions: {
|
||||
type: Object as PropType<Record<string, any>>,
|
||||
default: () => ({})
|
||||
},
|
||||
sanitize: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
sanitizeOptions: {
|
||||
type: Object as PropType<SanitizeOptions>,
|
||||
default: () => ({})
|
||||
},
|
||||
mermaidConfig: {
|
||||
type: Object as PropType<Partial<MermaidToolbarConfig>>,
|
||||
default: () => ({})
|
||||
},
|
||||
langs: {
|
||||
type: Array as PropType<InitShikiOptions['langs']>,
|
||||
default: () => []
|
||||
},
|
||||
defaultThemeMode: {
|
||||
type: String as PropType<'light' | 'dark'>,
|
||||
default: 'light'
|
||||
},
|
||||
themes: {
|
||||
type: Object as PropType<InitShikiOptions['themes']>,
|
||||
default: () =>
|
||||
({
|
||||
...shikiThemeDefault
|
||||
}) satisfies InitShikiOptions['themes']
|
||||
},
|
||||
colorReplacements: {
|
||||
type: Object as PropType<InitShikiOptions['colorReplacements']>,
|
||||
default: () => ({})
|
||||
},
|
||||
needViewCodeBtn: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
secureViewCode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
viewCodeModalOptions: {
|
||||
type: Object as PropType<ElxRunCodeOptions>,
|
||||
default: () => ({})
|
||||
},
|
||||
isDark: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
globalShiki: {
|
||||
type: Object as PropType<GlobalShiki>,
|
||||
default: () => ({})
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './markdownProvider';
|
||||
// export * from './markdownRenderer';
|
||||
export * from './shikiHighlighter';
|
||||
@@ -0,0 +1,6 @@
|
||||
const MARKDOWN_PROVIDER_KEY = Symbol('vue-element-plus-x-markdown-provider');
|
||||
const GLOBAL_SHIKI_KEY = Symbol('vue-element-plus-x-markdown-shiki-provider');
|
||||
|
||||
const MERMAID_CACHE_KEY_LENGTH = 10000;
|
||||
|
||||
export { GLOBAL_SHIKI_KEY, MARKDOWN_PROVIDER_KEY, MERMAID_CACHE_KEY_LENGTH };
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { BuiltinTheme } from 'shiki';
|
||||
import type { PluggableList } from 'unified';
|
||||
import type { MermaidToolbarConfig } from '../components/Mermaid/types';
|
||||
import type { CustomAttrs, SanitizeOptions } from '../core';
|
||||
import type { InitShikiOptions } from './shikiHighlighter';
|
||||
import { shikiThemeDefault } from './shikiHighlighter';
|
||||
|
||||
const MarkdownProps = {
|
||||
markdown: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
allowHtml: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
enableCodeLineNumber: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
enableLatex: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
enableAnimate: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
enableBreaks: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
codeXRender: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
codeXSlot: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
codeHighlightTheme: {
|
||||
type: Object as PropType<BuiltinTheme | null>,
|
||||
default: () => null
|
||||
},
|
||||
customAttrs: {
|
||||
type: Object as PropType<CustomAttrs>,
|
||||
default: () => ({})
|
||||
},
|
||||
remarkPlugins: {
|
||||
type: Array as PropType<PluggableList>,
|
||||
default: () => []
|
||||
},
|
||||
remarkPluginsAhead: {
|
||||
type: Array as PropType<PluggableList>,
|
||||
default: () => []
|
||||
},
|
||||
rehypePlugins: {
|
||||
type: Array as PropType<PluggableList>,
|
||||
default: () => []
|
||||
},
|
||||
rehypePluginsAhead: {
|
||||
type: Array as PropType<PluggableList>,
|
||||
default: () => []
|
||||
},
|
||||
rehypeOptions: {
|
||||
type: Object as PropType<Record<string, any>>,
|
||||
default: () => ({})
|
||||
},
|
||||
sanitize: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
sanitizeOptions: {
|
||||
type: Object as PropType<SanitizeOptions>,
|
||||
default: () => ({})
|
||||
},
|
||||
mermaidConfig: {
|
||||
type: Object as PropType<Partial<MermaidToolbarConfig>>,
|
||||
default: () => ({})
|
||||
},
|
||||
langs: {
|
||||
type: Array as PropType<InitShikiOptions['langs']>,
|
||||
default: () => []
|
||||
},
|
||||
defaultThemeMode: {
|
||||
type: String as PropType<'light' | 'dark'>,
|
||||
default: 'light'
|
||||
},
|
||||
themes: {
|
||||
type: Object as PropType<InitShikiOptions['themes']>,
|
||||
default: () =>
|
||||
({
|
||||
...shikiThemeDefault
|
||||
}) satisfies InitShikiOptions['themes']
|
||||
}
|
||||
};
|
||||
export { MarkdownProps };
|
||||
@@ -0,0 +1,270 @@
|
||||
import type {
|
||||
BundledLanguage,
|
||||
BundledTheme,
|
||||
LanguageInput,
|
||||
StringLiteralUnion,
|
||||
ThemeRegistrationAny
|
||||
} from 'shiki';
|
||||
|
||||
// 初始化Shiki 高亮器配置
|
||||
export interface InitShikiOptions {
|
||||
// 语言列表
|
||||
langs: Array<LanguageInput | BundledLanguage> | undefined;
|
||||
// 主题列表
|
||||
themes: Partial<
|
||||
Record<
|
||||
string | 'light' | 'dark',
|
||||
ThemeRegistrationAny | StringLiteralUnion<BundledTheme, string>
|
||||
>
|
||||
>;
|
||||
/**
|
||||
* 自定义当前主题下的代码颜色配置
|
||||
*
|
||||
* 一个颜色名称到新颜色值的映射表。
|
||||
*
|
||||
* 注意: 颜色的键必须以 `#` 开头,并且应为小写格式 ,否则不生效。
|
||||
*
|
||||
* 如果主题本身也定义了 `colorReplacements`,这个映射会与其合并。
|
||||
*
|
||||
* 最好和当前主题对应着修改
|
||||
*
|
||||
* @template
|
||||
* ```typescript
|
||||
* {
|
||||
* "vitesse-light": {
|
||||
* "#ab5959": "#ff66ff"
|
||||
* },
|
||||
* "vitesse-dark": {
|
||||
* "#cb7676": "#ff0066"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
colorReplacements: Record<string, string | Record<string, string>>;
|
||||
}
|
||||
|
||||
export const shikiThemeDefault: InitShikiOptions['themes'] = {
|
||||
light: 'vitesse-light',
|
||||
dark: 'vitesse-dark'
|
||||
};
|
||||
|
||||
export const SHIKI_SUPPORT_LANGS = [
|
||||
'abap',
|
||||
'actionscript-3',
|
||||
'ada',
|
||||
'apache',
|
||||
'apex',
|
||||
'apl',
|
||||
'applescript',
|
||||
'ara',
|
||||
'asm',
|
||||
'astro',
|
||||
'awk',
|
||||
'ballerina',
|
||||
'bat',
|
||||
'beancount',
|
||||
'berry',
|
||||
'bibtex',
|
||||
'bicep',
|
||||
'blade',
|
||||
'c',
|
||||
'cadence',
|
||||
'clarity',
|
||||
'clojure',
|
||||
'cmake',
|
||||
'cobol',
|
||||
'codeql',
|
||||
'coffee',
|
||||
'cpp',
|
||||
'crystal',
|
||||
'csharp',
|
||||
'css',
|
||||
'cue',
|
||||
'cypher',
|
||||
'd',
|
||||
'dart',
|
||||
'dax',
|
||||
'diff',
|
||||
'docker',
|
||||
'dream-maker',
|
||||
'elixir',
|
||||
'elm',
|
||||
'erb',
|
||||
'erlang',
|
||||
'fish',
|
||||
'fsharp',
|
||||
'gdresource',
|
||||
'gdscript',
|
||||
'gdshader',
|
||||
'gherkin',
|
||||
'git-commit',
|
||||
'git-rebase',
|
||||
'glimmer-js',
|
||||
'glimmer-ts',
|
||||
'glsl',
|
||||
'gnuplot',
|
||||
'go',
|
||||
'graphql',
|
||||
'groovy',
|
||||
'hack',
|
||||
'haml',
|
||||
'handlebars',
|
||||
'haskell',
|
||||
'hcl',
|
||||
'hjson',
|
||||
'hlsl',
|
||||
'html',
|
||||
'http',
|
||||
'imba',
|
||||
'ini',
|
||||
'java',
|
||||
'javascript',
|
||||
'jinja-html',
|
||||
'jison',
|
||||
'json',
|
||||
'json5',
|
||||
'jsonc',
|
||||
'jsonl',
|
||||
'jsonnet',
|
||||
'jssm',
|
||||
'jsx',
|
||||
'julia',
|
||||
'kotlin',
|
||||
'kusto',
|
||||
'latex',
|
||||
'less',
|
||||
'liquid',
|
||||
'lisp',
|
||||
'logo',
|
||||
'lua',
|
||||
'make',
|
||||
'markdown',
|
||||
'marko',
|
||||
'matlab',
|
||||
'mdc',
|
||||
'mdx',
|
||||
'mermaid',
|
||||
'mojo',
|
||||
'narrat',
|
||||
'nextflow',
|
||||
'nginx',
|
||||
'nim',
|
||||
'nix',
|
||||
'objective-c',
|
||||
'objective-cpp',
|
||||
'ocaml',
|
||||
'pascal',
|
||||
'perl',
|
||||
'php',
|
||||
'plsql',
|
||||
'postcss',
|
||||
'powerquery',
|
||||
'powershell',
|
||||
'prisma',
|
||||
'prolog',
|
||||
'proto',
|
||||
'pug',
|
||||
'puppet',
|
||||
'purescript',
|
||||
'python',
|
||||
'r',
|
||||
'raku',
|
||||
'razor',
|
||||
'reg',
|
||||
'rel',
|
||||
'riscv',
|
||||
'rst',
|
||||
'ruby',
|
||||
'rust',
|
||||
'sas',
|
||||
'sass',
|
||||
'scala',
|
||||
'scheme',
|
||||
'scss',
|
||||
'shaderlab',
|
||||
'shellscript',
|
||||
'shellsession',
|
||||
'smalltalk',
|
||||
'solidity',
|
||||
'sparql',
|
||||
'splunk',
|
||||
'sql',
|
||||
'ssh-config',
|
||||
'stata',
|
||||
'stylus',
|
||||
'svelte',
|
||||
'swift',
|
||||
'system-verilog',
|
||||
'tasl',
|
||||
'tcl',
|
||||
'tex',
|
||||
'toml',
|
||||
'tsx',
|
||||
'turtle',
|
||||
'twig',
|
||||
'typescript',
|
||||
'v',
|
||||
'vb',
|
||||
'verilog',
|
||||
'vhdl',
|
||||
'viml',
|
||||
'vue',
|
||||
'vue-html',
|
||||
'vyper',
|
||||
'wasm',
|
||||
'wenyan',
|
||||
'wgsl',
|
||||
'wolfram',
|
||||
'xml',
|
||||
'xsl',
|
||||
'yaml',
|
||||
'zenscript',
|
||||
'zig',
|
||||
'bash',
|
||||
'batch',
|
||||
'be',
|
||||
'c#',
|
||||
'cdc',
|
||||
'clj',
|
||||
'cmd',
|
||||
'console',
|
||||
'cql',
|
||||
'cs',
|
||||
'dockerfile',
|
||||
'erl',
|
||||
'f#',
|
||||
'fs',
|
||||
'fsl',
|
||||
'gjs',
|
||||
'gts',
|
||||
'hbs',
|
||||
'hs',
|
||||
'jade',
|
||||
'js',
|
||||
'kql',
|
||||
'makefile',
|
||||
'md',
|
||||
'nar',
|
||||
'nf',
|
||||
'objc',
|
||||
'perl6',
|
||||
'properties',
|
||||
'ps',
|
||||
'ps1',
|
||||
'py',
|
||||
'ql',
|
||||
'rb',
|
||||
'rs',
|
||||
'sh',
|
||||
'shader',
|
||||
'shell',
|
||||
'spl',
|
||||
'styl',
|
||||
'ts',
|
||||
'vim',
|
||||
'vimscript',
|
||||
'vy',
|
||||
'yml',
|
||||
'zsh',
|
||||
'文言'
|
||||
] as const;
|
||||
42
Yi.Ai.Vue3/src/vue-element-plus-y/components/XMarkdownCore/shared/types.d.ts
vendored
Normal file
42
Yi.Ai.Vue3/src/vue-element-plus-y/components/XMarkdownCore/shared/types.d.ts
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { TVueMarkdownProps } from '@components/XMarkdownCore';
|
||||
import type { CodeBlockHeaderExpose } from '@components/XMarkdownCore/components/CodeBlock/shiki-header';
|
||||
import type { ElxRunCodeOptions } from '../components/RunCode/type';
|
||||
import type { InitShikiOptions } from './shikiHighlighter';
|
||||
|
||||
export type MarkdownProps = {
|
||||
allowHtml?: boolean;
|
||||
enableLatex?: boolean;
|
||||
enableAnimate?: boolean;
|
||||
enableBreaks?: boolean;
|
||||
codeXProps?: CodeXProps;
|
||||
codeXRender?: Record<string, any>;
|
||||
codeXSlot?: CodeBlockHeaderExpose & Record<string, any>;
|
||||
codeHighlightTheme?: BuiltinTheme | null;
|
||||
remarkPluginsAhead?: PluggableList;
|
||||
rehypePluginsAhead?: PluggableList;
|
||||
defaultThemeMode?: 'light' | 'dark';
|
||||
needViewCodeBtn?: boolean;
|
||||
secureViewCode?: boolean;
|
||||
viewCodeModalOptions?: ElxRunCodeOptions;
|
||||
mermaidConfig?: Partial<MermaidToolbarConfig>;
|
||||
} & Partial<Pick<InitShikiOptions, 'langs' | 'themes' | 'colorReplacements'>> &
|
||||
Pick<
|
||||
TVueMarkdownProps,
|
||||
| 'markdown'
|
||||
| 'customAttrs'
|
||||
| 'remarkPlugins'
|
||||
| 'rehypePlugins'
|
||||
| 'sanitize'
|
||||
| 'sanitizeOptions'
|
||||
| 'rehypeOptions'
|
||||
>;
|
||||
|
||||
export type MarkdownProviderProps = Omit<MarkdownProps, 'markdown'> &
|
||||
Partial<Pick<MarkdownProps, 'markdown'>>;
|
||||
|
||||
export interface CodeXProps {
|
||||
enableCodePreview?: boolean; // 启动代码预览功能
|
||||
enableCodeCopy?: boolean; // 启动代码复制功能
|
||||
enableThemeToggle?: boolean; // 启动主题切换
|
||||
enableCodeLineNumber?: boolean; // 开启行号
|
||||
}
|
||||
12
Yi.Ai.Vue3/src/vue-element-plus-y/components/XMarkdownCore/style/animate.scss
vendored
Normal file
12
Yi.Ai.Vue3/src/vue-element-plus-y/components/XMarkdownCore/style/animate.scss
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.x-markdown-animated-word {
|
||||
animation: fadeIn 1s ease-in-out;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@import url('./katex.scss');
|
||||
@import url('./animate.scss');
|
||||
@@ -0,0 +1,7 @@
|
||||
.katex-error {
|
||||
color: var(--el-text-color-secondary) !important;
|
||||
}
|
||||
.katex-html {
|
||||
overflow: auto hidden;
|
||||
padding: 3px;
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// // 媒体查询:当用户系统主题为暗色时 基于浏览器的主题色
|
||||
// @media (prefers-color-scheme: light) {
|
||||
// body {
|
||||
// --shiki-custom-brr-mini: 3px;
|
||||
// --shiki-custom-brr: 5px;
|
||||
// --shiki-custom-blur: 10px;
|
||||
// --shiki-code-header-bg: #fafafa;
|
||||
// --shiki-code-header-span-color: #575757;
|
||||
// }
|
||||
// }
|
||||
|
||||
// // 媒体查询:当用户系统主题为暗色时
|
||||
// @media (prefers-color-scheme: dark) {
|
||||
// body {
|
||||
// --shiki-custom-brr-mini: 3px;
|
||||
// --shiki-custom-brr: 5px;
|
||||
// --shiki-custom-blur: 10px;
|
||||
// --shiki-code-header-bg: #272727;
|
||||
// --shiki-code-header-span-color: #fafafa;
|
||||
// }
|
||||
|
||||
// .shiki,
|
||||
// .shiki span {
|
||||
// color: var(--shiki-dark) !important;
|
||||
// background-color: var(--shiki-dark-bg) !important;
|
||||
// /* 可选,用于定义字体样式 */
|
||||
// font-style: var(--shiki-dark-font-style) !important;
|
||||
// font-weight: var(--shiki-dark-font-weight) !important;
|
||||
// text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||
// }
|
||||
// }
|
||||
|
||||
body {
|
||||
--shiki-custom-brr-mini: 3px;
|
||||
--shiki-custom-brr: 5px;
|
||||
--shiki-custom-blur: 10px;
|
||||
--shiki-code-header-bg: #fafafa;
|
||||
--shiki-code-header-span-color: #575757;
|
||||
--shiki-code-header-btn-bg: #ebedf0;
|
||||
}
|
||||
|
||||
body.dark {
|
||||
--shiki-custom-brr-mini: 3px;
|
||||
--shiki-custom-brr: 5px;
|
||||
--shiki-custom-blur: 10px;
|
||||
--shiki-code-header-bg: #272727;
|
||||
--shiki-code-header-span-color: #fafafa;
|
||||
--shiki-code-header-btn-bg: #3b3b3b;
|
||||
}
|
||||
|
||||
body.dark .shiki,
|
||||
body.dark .shiki span {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
/* 可选,用于定义字体样式 */
|
||||
font-style: var(--shiki-dark-font-style) !important;
|
||||
font-weight: var(--shiki-dark-font-weight) !important;
|
||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||
}
|
||||
|
||||
.shiki {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.elx-xmarkdown-container {
|
||||
// background-color: var(--el-fill-color);
|
||||
color: black;
|
||||
padding: 5px 10px;
|
||||
border-radius: var(--shiki-custom-brr);
|
||||
|
||||
/* 表格整体边框 */
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
/* 表头样式 */
|
||||
th {
|
||||
background-color: white;
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 表格行样式 */
|
||||
td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr:nth-child(even) {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
tr:nth-child(odd) {
|
||||
background-color: var(--el-fill-color-lighter);
|
||||
}
|
||||
}
|
||||
|
||||
pre code {
|
||||
text-shadow: none !important;
|
||||
// 设置代码字体样式
|
||||
span {
|
||||
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
text-align: left;
|
||||
// white-space: pre-wrap;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
word-wrap: normal;
|
||||
tab-size: 4;
|
||||
-webkit-hyphens: none;
|
||||
-moz-hyphens: none;
|
||||
-ms-hyphens: none;
|
||||
hyphens: none;
|
||||
}
|
||||
}
|
||||
|
||||
pre div.pre-md {
|
||||
position: relative;
|
||||
border-radius: var(--shiki-custom-brr-mini);
|
||||
border: 1px solid var(--el-border-color);
|
||||
min-width: 180px !important;
|
||||
.markdown-elxLanguage-header-div {
|
||||
box-sizing: content-box !important;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 5px 5px 5px 8px;
|
||||
border-radius: var(--shiki-custom-brr-mini);
|
||||
-webkit-backdrop-filter: blur(var(--shiki-custom-blur));
|
||||
backdrop-filter: blur(var(--shiki-custom-blur));
|
||||
margin: 0;
|
||||
background-color: var(--shiki-code-header-bg);
|
||||
z-index: 1;
|
||||
span {
|
||||
box-shadow: none !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.markdown-elxLanguage-header-span {
|
||||
font-size: 14px;
|
||||
font-family: sans-serif;
|
||||
color: var(--shiki-code-header-span-color) !important;
|
||||
font-weight: bold !important;
|
||||
background-color: transparent !important;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
&:hover {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-elxLanguage-header-space-start,
|
||||
.markdown-elxLanguage-header-space {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.markdown-elxLanguage-header-space-start {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.shiki-header-button {
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--shiki-custom-brr);
|
||||
background-color: transparent;
|
||||
width: fit-content !important;
|
||||
padding: 0px 3px;
|
||||
height: 24px;
|
||||
opacity: 1;
|
||||
transition: color 0.3s ease-in-out;
|
||||
cursor: pointer;
|
||||
.el-icon {
|
||||
font-size: 15px !important;
|
||||
}
|
||||
svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
.el-icon,
|
||||
span {
|
||||
background-color: transparent !important;
|
||||
color: var(--shiki-code-header-span-color) !important;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--shiki-code-header-btn-bg);
|
||||
}
|
||||
}
|
||||
// 按钮图标的大小
|
||||
.shiki-header-button {
|
||||
span {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
.shiki-header-button-expand {
|
||||
span {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-elxLanguage-header-toggle-expand {
|
||||
margin: 2px 0 0 0;
|
||||
transition: transform 0.3s ease-in-out;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.copied {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
flex: 1;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
pre div.is-expanded {
|
||||
height: auto !important;
|
||||
code {
|
||||
padding: 8px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
.markdown-elxLanguage-header-toggle-expand {
|
||||
transform: rotate(0deg) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user