|
@@ -0,0 +1,301 @@
|
|
|
+<template>
|
|
|
+ <div class="czr-markdown-main">
|
|
|
+ <div class="czr-markdown-main-content" :class="`layout-${layout}`">
|
|
|
+ <div class="editor">
|
|
|
+ <textarea
|
|
|
+ ref="ref_textarea"
|
|
|
+ v-model="markdownValue"
|
|
|
+ @input="updateMarkdown"
|
|
|
+ ></textarea>
|
|
|
+ </div>
|
|
|
+ <div class="preview" v-html="markdownHtmlCpt"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+defineOptions({
|
|
|
+ name: 'CzrMarkdown',
|
|
|
+})
|
|
|
+import { ref, computed, onMounted, reactive } from 'vue'
|
|
|
+import { marked } from 'marked'
|
|
|
+import DOMPurify from 'dompurify'
|
|
|
+
|
|
|
+const renderer: any = new marked.Renderer()
|
|
|
+renderer.link = ({ href, text }) => {
|
|
|
+ return `<a href="${href}">${text}</a>`
|
|
|
+}
|
|
|
+renderer.text = ({ text }) => {
|
|
|
+ return text.replace(/\$\{([^}]+)\}/g, (match, p1) => {
|
|
|
+ return `<span class='border-1 bg-red-400'>${state.optionsMap.get(p1)?.label}</span>`
|
|
|
+ })
|
|
|
+}
|
|
|
+DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
|
|
+ if (node.tagName === 'A') {
|
|
|
+ node.setAttribute('target', '_blank')
|
|
|
+ node.setAttribute('rel', 'noopener noreferrer')
|
|
|
+ }
|
|
|
+})
|
|
|
+const emit = defineEmits(['update:modelValue'])
|
|
|
+const props = defineProps({
|
|
|
+ modelValue: {
|
|
|
+ type: String,
|
|
|
+ default: '',
|
|
|
+ },
|
|
|
+ layout: {
|
|
|
+ type: String,
|
|
|
+ default: 'x',
|
|
|
+ },
|
|
|
+ rendererFunc: <any>{},
|
|
|
+})
|
|
|
+const state = reactive({
|
|
|
+ optionsMap: new Map([
|
|
|
+ ['123', { label: '变量1', id: '123', type: 'String' }],
|
|
|
+ ['223', { label: '变量变量变量变量变量2', id: '223', type: 'String' }],
|
|
|
+ [
|
|
|
+ '333',
|
|
|
+ {
|
|
|
+ label: '变量变量变量变量变量变量变量变量变量变量变量3',
|
|
|
+ id: '333',
|
|
|
+ type: 'Number',
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ ]),
|
|
|
+})
|
|
|
+const Menus: any = [
|
|
|
+ {
|
|
|
+ icon: 'czr_md-title',
|
|
|
+ title: '标题',
|
|
|
+ options: [
|
|
|
+ { title: '一级标题', start: '# ', end: '' },
|
|
|
+ { title: '二级标题', start: '## ', end: '' },
|
|
|
+ { title: '三级标题', start: '### ', end: '' },
|
|
|
+ { title: '四级标题', start: '#### ', end: '' },
|
|
|
+ { title: '五级标题', start: '##### ', end: '' },
|
|
|
+ {
|
|
|
+ label: 'H6',
|
|
|
+ title: '六级标题',
|
|
|
+ start: '###### ',
|
|
|
+ end: '',
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ { icon: 'czr_md-bold', title: '加粗', start: '**', end: '**' },
|
|
|
+ { icon: 'czr_md-em', title: '斜体', start: '*', end: '*' },
|
|
|
+ { icon: 'czr_md-bold-em', title: '加粗斜体', start: '***', end: '***' },
|
|
|
+ {
|
|
|
+ icon: 'czr_md-s',
|
|
|
+ title: '删除线',
|
|
|
+ start: '~~',
|
|
|
+ end: '~~',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ icon: 'czr_md-u',
|
|
|
+ title: '下划线',
|
|
|
+ start: '<u>',
|
|
|
+ end: '</u>',
|
|
|
+ split: true,
|
|
|
+ },
|
|
|
+ { icon: 'czr_md-code', title: '代码块', start: '\n```\n', end: '\n```' },
|
|
|
+ {
|
|
|
+ icon: 'czr_md-link',
|
|
|
+ title: '链接',
|
|
|
+ start: '[链接](',
|
|
|
+ end: ')',
|
|
|
+ },
|
|
|
+ { icon: 'czr_md-img', title: '图片', start: '' },
|
|
|
+]
|
|
|
+const ref_textarea = ref(null)
|
|
|
+const markdownValue = ref(props.modelValue)
|
|
|
+
|
|
|
+const markdownHtmlCpt = computed(() => {
|
|
|
+ return DOMPurify.sanitize(marked(markdownValue.value) as any)
|
|
|
+})
|
|
|
+
|
|
|
+const updateMarkdown = () => {
|
|
|
+ emit('update:modelValue', markdownValue.value)
|
|
|
+}
|
|
|
+
|
|
|
+const insertText = (before, after) => {
|
|
|
+ const dom: any = ref_textarea.value
|
|
|
+ if (dom) {
|
|
|
+ const start = dom.selectionStart
|
|
|
+ const end = dom.selectionEnd
|
|
|
+ const selectedText = markdownValue.value.substring(start, end)
|
|
|
+ markdownValue.value =
|
|
|
+ markdownValue.value.substring(0, start) +
|
|
|
+ before +
|
|
|
+ selectedText +
|
|
|
+ after +
|
|
|
+ markdownValue.value.substring(end)
|
|
|
+ // 移动光标位置
|
|
|
+ setTimeout(() => {
|
|
|
+ dom.selectionStart = start + before.length
|
|
|
+ dom.selectionEnd = end + before.length
|
|
|
+ dom.focus()
|
|
|
+ }, 0)
|
|
|
+ emit('update:modelValue', markdownValue.value)
|
|
|
+ }
|
|
|
+}
|
|
|
+onMounted(() => {
|
|
|
+ marked.setOptions({
|
|
|
+ renderer: renderer,
|
|
|
+ breaks: true,
|
|
|
+ gfm: true,
|
|
|
+ })
|
|
|
+ console.log(props)
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+.czr-markdown-main {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
|
|
|
+ .czr-markdown-main-menus {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
|
|
|
+ padding: 10px;
|
|
|
+ .split {
|
|
|
+ width: 1px;
|
|
|
+ height: 16px;
|
|
|
+ background-color: rgba(0, 0, 0, 0.3);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .czr-markdown-main-content {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ overflow: hidden;
|
|
|
+ &.layout-y {
|
|
|
+ flex-direction: column;
|
|
|
+ .editor {
|
|
|
+ border-bottom: 1px solid rgba(0, 0, 0, 0.15);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ &.layout-x {
|
|
|
+ .editor {
|
|
|
+ border-right: 1px solid rgba(0, 0, 0, 0.15);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .editor,
|
|
|
+ .preview {
|
|
|
+ flex: 1;
|
|
|
+ padding: 14px;
|
|
|
+ }
|
|
|
+ .editor {
|
|
|
+ textarea {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ resize: none;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ :deep(.preview) {
|
|
|
+ overflow: auto;
|
|
|
+ /* 通用标题样式 */
|
|
|
+ h1,
|
|
|
+ h2,
|
|
|
+ h3,
|
|
|
+ h4,
|
|
|
+ h5,
|
|
|
+ h6 {
|
|
|
+ font-family:
|
|
|
+ -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica,
|
|
|
+ Arial, sans-serif;
|
|
|
+ font-weight: 600;
|
|
|
+ line-height: 1.25;
|
|
|
+ margin-top: 1.5em;
|
|
|
+ margin-bottom: 0.5em;
|
|
|
+ color: #222;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* H1 样式 */
|
|
|
+ h1 {
|
|
|
+ font-size: 2em;
|
|
|
+ border-bottom: 1px solid #eaecef;
|
|
|
+ padding-bottom: 0.3em;
|
|
|
+ margin-top: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* H2 样式 */
|
|
|
+ h2 {
|
|
|
+ font-size: 1.5em;
|
|
|
+ border-bottom: 1px solid #eaecef;
|
|
|
+ padding-bottom: 0.3em;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* H3 样式 */
|
|
|
+ h3 {
|
|
|
+ font-size: 1.25em;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* H4 样式 */
|
|
|
+ h4 {
|
|
|
+ font-size: 1em;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* H5 样式 */
|
|
|
+ h5 {
|
|
|
+ font-size: 0.875em;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* H6 样式 */
|
|
|
+ h6 {
|
|
|
+ font-size: 0.85em;
|
|
|
+ color: #6a737d;
|
|
|
+ }
|
|
|
+ /* 默认链接样式 */
|
|
|
+ a {
|
|
|
+ color: #0366d6; /* 链接颜色 */
|
|
|
+ text-decoration: none; /* 去掉下划线 */
|
|
|
+ transition: color 0.2s; /* 颜色过渡效果 */
|
|
|
+ /* 鼠标悬停样式 */
|
|
|
+ &:hover {
|
|
|
+ color: #0550a8; /* 悬停颜色 */
|
|
|
+ text-decoration: underline; /* 显示下划线 */
|
|
|
+ }
|
|
|
+ /* 已访问链接 */
|
|
|
+ &:visited {
|
|
|
+ color: #5a32a3; /* 紫色表示已访问 */
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 活动链接 (点击时) */
|
|
|
+ &:active {
|
|
|
+ color: #d63384;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /*
|
|
|
+ * 暗色系代码块样式
|
|
|
+ * 适用于夜间/暗色模式的代码展示
|
|
|
+ */
|
|
|
+
|
|
|
+ // 基础代码块样式
|
|
|
+ pre {
|
|
|
+ background-color: #1e1e1e; // 深灰背景
|
|
|
+ border-radius: 6px; // 圆角
|
|
|
+ padding: 1.25rem; // 内边距
|
|
|
+ margin: 1.5rem 0; // 外边距
|
|
|
+ overflow: auto; // 溢出滚动
|
|
|
+ border: 1px solid #333; // 边框
|
|
|
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); // 阴影效果
|
|
|
+
|
|
|
+ // 代码内容样式
|
|
|
+ code {
|
|
|
+ display: block;
|
|
|
+ color: #d4d4d4; // 主文本颜色
|
|
|
+ font-family:
|
|
|
+ 'Fira Code', 'Consolas', 'Monaco', monospace; // 等宽字体栈
|
|
|
+ font-size: 0.95em;
|
|
|
+ line-height: 1.5;
|
|
|
+ text-shadow: none;
|
|
|
+ white-space: pre;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|