|
@@ -1,21 +1,55 @@
|
|
|
<template>
|
|
|
- <div class="markdown-container">
|
|
|
- <div class="toolbar">
|
|
|
- <button @click="insertText('**', '**')">B</button>
|
|
|
- <button @click="insertText('*', '*')">I</button>
|
|
|
- <button @click="insertText('# ', '')">H1</button>
|
|
|
- <button @click="insertText('## ', '')">H2</button>
|
|
|
- <button @click="insertText('[', '](url)')">Link</button>
|
|
|
- <button @click="insertText('')">Image</button>
|
|
|
+ <div class="czr-markdown-main">
|
|
|
+ <div class="czr-markdown-main-menus">
|
|
|
+ <template v-for="item in Menus">
|
|
|
+ <template v-if="item.options?.length > 0">
|
|
|
+ <el-dropdown>
|
|
|
+ <div
|
|
|
+ class="__hover button flex items-center gap-1"
|
|
|
+ :title="item.title"
|
|
|
+ >
|
|
|
+ <SvgIcon :name="item.icon" color="#000000" />
|
|
|
+ <SvgIcon
|
|
|
+ name="czr_arrow"
|
|
|
+ :rotate="90"
|
|
|
+ size="12"
|
|
|
+ color="#000000"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <template #dropdown>
|
|
|
+ <el-dropdown-menu>
|
|
|
+ <template v-for="op in item.options">
|
|
|
+ <el-dropdown-item @click="insertText(op.start, op.end)">{{
|
|
|
+ op.title
|
|
|
+ }}</el-dropdown-item>
|
|
|
+ </template>
|
|
|
+ </el-dropdown-menu>
|
|
|
+ </template>
|
|
|
+ </el-dropdown>
|
|
|
+ </template>
|
|
|
+ <template v-else>
|
|
|
+ <div
|
|
|
+ class="__hover button flex items-center gap-1"
|
|
|
+ @click="insertText(item.start, item.end)"
|
|
|
+ :title="item.title"
|
|
|
+ >
|
|
|
+ <SvgIcon :name="item.icon" color="#000000" />
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <template v-if="item.split">
|
|
|
+ <div class="split" />
|
|
|
+ </template>
|
|
|
+ </template>
|
|
|
</div>
|
|
|
- <div class="editor-wrapper">
|
|
|
- <textarea
|
|
|
- ref="textarea"
|
|
|
- v-model="markdownText"
|
|
|
- class="editor"
|
|
|
- @input="updateMarkdown"
|
|
|
- ></textarea>
|
|
|
- <div class="preview" v-html="compiledMarkdown"></div>
|
|
|
+ <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>
|
|
@@ -28,97 +62,260 @@ import { ref, computed, onMounted } 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>`
|
|
|
+}
|
|
|
+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',
|
|
|
+ },
|
|
|
})
|
|
|
-const textarea = ref(null)
|
|
|
-const markdownText = ref(props.modelValue)
|
|
|
+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 compiledMarkdown = computed(() => {
|
|
|
- return DOMPurify.sanitize(marked(markdownText.value))
|
|
|
+const markdownHtmlCpt = computed(() => {
|
|
|
+ return DOMPurify.sanitize(marked(markdownValue.value) as any)
|
|
|
})
|
|
|
|
|
|
-function updateMarkdown() {
|
|
|
- emit('update:modelValue', markdownText.value)
|
|
|
+const updateMarkdown = () => {
|
|
|
+ emit('update:modelValue', markdownValue.value)
|
|
|
}
|
|
|
|
|
|
-function insertText(before, after) {
|
|
|
- const textareaEl = textarea.value
|
|
|
- const start = textareaEl.selectionStart
|
|
|
- const end = textareaEl.selectionEnd
|
|
|
- const selectedText = markdownText.value.substring(start, end)
|
|
|
-
|
|
|
- markdownText.value =
|
|
|
- markdownText.value.substring(0, start) +
|
|
|
- before +
|
|
|
- selectedText +
|
|
|
- after +
|
|
|
- markdownText.value.substring(end)
|
|
|
-
|
|
|
- // 移动光标位置
|
|
|
- setTimeout(() => {
|
|
|
- textareaEl.selectionStart = start + before.length
|
|
|
- textareaEl.selectionEnd = end + before.length
|
|
|
- textareaEl.focus()
|
|
|
- }, 0)
|
|
|
-
|
|
|
- emit('update:modelValue', markdownText.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,
|
|
|
})
|
|
|
})
|
|
|
</script>
|
|
|
|
|
|
-<style>
|
|
|
-.markdown-container {
|
|
|
+<style scoped lang="scss">
|
|
|
+.czr-markdown-main {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
- height: 100%;
|
|
|
-}
|
|
|
+ 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;
|
|
|
+ }
|
|
|
|
|
|
-.toolbar {
|
|
|
- padding: 8px;
|
|
|
- background: #f5f5f5;
|
|
|
- border-bottom: 1px solid #ddd;
|
|
|
-}
|
|
|
+ /* H1 样式 */
|
|
|
+ h1 {
|
|
|
+ font-size: 2em;
|
|
|
+ border-bottom: 1px solid #eaecef;
|
|
|
+ padding-bottom: 0.3em;
|
|
|
+ margin-top: 0;
|
|
|
+ }
|
|
|
|
|
|
-.toolbar button {
|
|
|
- margin-right: 8px;
|
|
|
- padding: 4px 8px;
|
|
|
- background: white;
|
|
|
- border: 1px solid #ddd;
|
|
|
- border-radius: 4px;
|
|
|
- cursor: pointer;
|
|
|
-}
|
|
|
+ /* H2 样式 */
|
|
|
+ h2 {
|
|
|
+ font-size: 1.5em;
|
|
|
+ border-bottom: 1px solid #eaecef;
|
|
|
+ padding-bottom: 0.3em;
|
|
|
+ }
|
|
|
|
|
|
-.editor-wrapper {
|
|
|
- display: flex;
|
|
|
- flex: 1;
|
|
|
- overflow: hidden;
|
|
|
-}
|
|
|
+ /* H3 样式 */
|
|
|
+ h3 {
|
|
|
+ font-size: 1.25em;
|
|
|
+ }
|
|
|
|
|
|
-.editor {
|
|
|
- flex: 1;
|
|
|
- padding: 10px;
|
|
|
- border: none;
|
|
|
- border-right: 1px solid #ddd;
|
|
|
- resize: none;
|
|
|
- font-family: monospace;
|
|
|
- outline: none;
|
|
|
-}
|
|
|
+ /* 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); // 阴影效果
|
|
|
|
|
|
-.preview {
|
|
|
- flex: 1;
|
|
|
- padding: 10px;
|
|
|
- overflow-y: auto;
|
|
|
- background: white;
|
|
|
+ // 代码内容样式
|
|
|
+ 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>
|