|
@@ -1,52 +1,41 @@
|
|
|
<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 class="flex flex-col rounded-xl bg-[#f2f4f7] px-3 py-2 shadow">
|
|
|
+ <div
|
|
|
+ class="flex items-center gap-2 py-1 text-xs font-semibold text-gray-700"
|
|
|
+ >
|
|
|
+ <div>回复</div>
|
|
|
+ <div class="ml-auto">字数</div>
|
|
|
+ <el-tooltip content="复制" placement="top">
|
|
|
+ <SvgIcon
|
|
|
+ class="__hover"
|
|
|
+ color="#364153"
|
|
|
+ name="copy"
|
|
|
+ size="16"
|
|
|
+ @click="onCopy(ref_textarea.innerText)"
|
|
|
+ />
|
|
|
+ </el-tooltip>
|
|
|
</div>
|
|
|
+ <div
|
|
|
+ ref="ref_textarea"
|
|
|
+ class="text-text-secondary flex-1 py-1 text-[13px] break-words whitespace-pre-wrap outline-none select-text"
|
|
|
+ contenteditable="true"
|
|
|
+ @input="updateMarkdown"
|
|
|
+ ></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'
|
|
|
+import { copy } from '@/utils/czr-util'
|
|
|
+import { ElMessage } from 'element-plus'
|
|
|
+import SvgIcon from '@/components/SvgIcon/index.vue'
|
|
|
|
|
|
-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([
|
|
@@ -62,240 +51,63 @@ const state = reactive({
|
|
|
],
|
|
|
]),
|
|
|
})
|
|
|
-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;
|
|
|
+const ref_textarea = ref()
|
|
|
+const updateMarkdown = (e) => {
|
|
|
+ const selection: any = window.getSelection()
|
|
|
+ const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null
|
|
|
+ let lastReplacedSpan: any = null
|
|
|
+
|
|
|
+ const regex = /\{\{#([^#]+)#\}\}/g
|
|
|
+ const walker = document.createTreeWalker(e.target, NodeFilter.SHOW_TEXT, null)
|
|
|
+
|
|
|
+ while (walker.nextNode()) {
|
|
|
+ const node: any = walker.currentNode
|
|
|
+ const text: any = node.nodeValue
|
|
|
+
|
|
|
+ if (regex.test(text)) {
|
|
|
+ regex.lastIndex = 0
|
|
|
+ const fragment = document.createDocumentFragment()
|
|
|
+ let lastIndex = 0
|
|
|
+ let match
|
|
|
+
|
|
|
+ while ((match = regex.exec(text)) !== null) {
|
|
|
+ if (match.index > lastIndex) {
|
|
|
+ fragment.appendChild(
|
|
|
+ document.createTextNode(text.substring(lastIndex, match.index)),
|
|
|
+ )
|
|
|
}
|
|
|
+ const span = document.createElement('span')
|
|
|
+ span.setAttribute('contenteditable', 'false')
|
|
|
+ span.className = 'border-1 bg-red-400'
|
|
|
+ const key = match[1]
|
|
|
+ span.textContent = state.optionsMap.get(key)?.label || ''
|
|
|
+ fragment.appendChild(span)
|
|
|
+ lastReplacedSpan = span
|
|
|
+ lastIndex = regex.lastIndex
|
|
|
}
|
|
|
|
|
|
- /*
|
|
|
- * 暗色系代码块样式
|
|
|
- * 适用于夜间/暗色模式的代码展示
|
|
|
- */
|
|
|
-
|
|
|
- // 基础代码块样式
|
|
|
- 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;
|
|
|
- }
|
|
|
+ if (lastIndex < text.length) {
|
|
|
+ fragment.appendChild(document.createTextNode(text.substring(lastIndex)))
|
|
|
}
|
|
|
+
|
|
|
+ node.parentNode.replaceChild(fragment, node)
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ if (lastReplacedSpan) {
|
|
|
+ const newRange = document.createRange()
|
|
|
+ newRange.setStartAfter(lastReplacedSpan)
|
|
|
+ newRange.collapse(true)
|
|
|
+ const newSelection: any = window.getSelection()
|
|
|
+ newSelection.removeAllRanges()
|
|
|
+ newSelection.addRange(newRange)
|
|
|
+ }
|
|
|
}
|
|
|
-</style>
|
|
|
+const onCopy = (text) => {
|
|
|
+ copy(text)
|
|
|
+ ElMessage.success('复制成功!')
|
|
|
+}
|
|
|
+onMounted(() => {})
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss"></style>
|