feat(editor): enhance markdown editor with contenteditable interface and modals
- Replace textarea with contenteditable div for WYSIWYG-like editing experience - Add code block editing modal for inline code editing functionality - Add image preview modal for zoom functionality on clicked images - Implement keyboard shortcuts (Ctrl+1-6 for headings, Ctrl+B bold, Ctrl+I italic, Ctrl+K links) - Improve syntax highlighting with Prism.js integration and language detection - Add debounced rendering to optimize performance during typing - Enhance styling with hover effects and improved code block indicators
This commit is contained in:
30
src/App.vue
30
src/App.vue
@@ -6,12 +6,36 @@ const html = ref('')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-container">
|
||||
<MarkdownEditor @update:html="html = $event" class="editor" />
|
||||
<div class="typora-container">
|
||||
<MarkdownEditor @update:html="html = $event" />
|
||||
<div class="preview" v-html="html"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
.typora-container {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
background: #f9f9f9;
|
||||
border-left: 1px solid #ddd;
|
||||
}
|
||||
|
||||
/* 图片点击放大 */
|
||||
.preview :deep(img) {
|
||||
max-width: 100%;
|
||||
cursor: zoom-in;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.preview :deep(img:hover) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,53 @@
|
||||
<template>
|
||||
<!-- Textarea for markdown input -->
|
||||
<textarea
|
||||
v-model="markdown"
|
||||
@keydown="handleKeydown"
|
||||
placeholder="Enter markdown..."
|
||||
rows="10"
|
||||
style="width: 100%;"
|
||||
></textarea>
|
||||
<div class="editor-wrapper" ref="wrapperRef">
|
||||
<!-- 单栏编辑器 -->
|
||||
<div
|
||||
ref="editorRef"
|
||||
contenteditable="true"
|
||||
class="editor"
|
||||
:class="{ 'editing-code': editingCodeBlock }"
|
||||
@input="onInput"
|
||||
@keydown="handleKeydown"
|
||||
@click="onEditorClick"
|
||||
@paste="handlePaste"
|
||||
spellcheck="false"
|
||||
></div>
|
||||
|
||||
<!-- Plugin host (no UI) -->
|
||||
<PluginHost />
|
||||
<!-- 代码块编辑弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="editingCodeBlock" class="code-modal" @click.self="closeCodeBlock">
|
||||
<div class="code-editor">
|
||||
<textarea
|
||||
ref="codeTextareaRef"
|
||||
v-model="codeBlockContent"
|
||||
placeholder="Enter code..."
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<button class="save-btn" @click="saveCodeBlock">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- 图片预览弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="expandedImage" class="image-modal" @click="expandedImage = null">
|
||||
<img :src="expandedImage.src" :alt="expandedImage.alt" />
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- 插件挂载点 -->
|
||||
<PluginHost />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { ref, watch, onMounted, nextTick } from 'vue'
|
||||
import { plugins } from '../plugins/index'
|
||||
import PluginHost from './PluginHost.vue'
|
||||
import markdownIt from 'markdown-it'
|
||||
import Prism from 'prismjs'
|
||||
// 按需加载常用语言
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/components/prism-python'
|
||||
import 'prismjs/components/prism-css'
|
||||
|
||||
const emit = defineEmits(['update:html'])
|
||||
|
||||
/* ---------- 插件挂载点 ---------- */
|
||||
const pluginContext = {}
|
||||
@@ -31,91 +57,443 @@ onMounted(() => {
|
||||
})
|
||||
})
|
||||
|
||||
/* ---------- markdown 解析 ---------- */
|
||||
/* ---------- Markdown 解析器 ---------- */
|
||||
const md = markdownIt({
|
||||
highlight: (code, lang) => {
|
||||
if (lang && Prism.languages[lang]) {
|
||||
return `<pre class="language-${lang}"><code>${Prism.highlight(
|
||||
code,
|
||||
Prism.languages[lang],
|
||||
lang
|
||||
)}</code></pre>`
|
||||
return `<pre class="language-${lang}" data-code="${encodeURIComponent(code)}"><code>${Prism.highlight(code, Prism.languages[lang], lang)}</code></pre>`
|
||||
}
|
||||
return `<pre class="language-${lang}"><code>${md.utils.escapeHtml(
|
||||
code
|
||||
)}</code></pre>`
|
||||
return `<pre class="language-text" data-code="${encodeURIComponent(code)}"><code>${md.utils.escapeHtml(code)}</code></pre>`
|
||||
}
|
||||
})
|
||||
|
||||
/* ---------- 编辑器状态 ---------- */
|
||||
const editorRef = ref(null)
|
||||
const codeTextareaRef = ref(null)
|
||||
const wrapperRef = ref(null)
|
||||
const markdown = ref('')
|
||||
const renderedHtml = ref('')
|
||||
let debounceTimer = null
|
||||
|
||||
/* ---------- 解析后钩子 & emit ---------- */
|
||||
watch(
|
||||
markdown,
|
||||
(newVal) => {
|
||||
let html = md.render(newVal)
|
||||
/* ---------- 编辑状态标记 ---------- */
|
||||
const isEditing = ref(false)
|
||||
|
||||
// onAfterParse 插件
|
||||
const afterPayload = { markdown: newVal, html }
|
||||
plugins.forEach(p => {
|
||||
if (p.onAfterParse) {
|
||||
const res = p.onAfterParse(afterPayload)
|
||||
if (res && res.html) afterPayload.html = res.html
|
||||
}
|
||||
})
|
||||
/* ---------- 代码块编辑状态 ---------- */
|
||||
const editingCodeBlock = ref(false)
|
||||
const codeBlockContent = ref('')
|
||||
const currentCodeElement = ref(null)
|
||||
|
||||
// onBeforeRender 插件
|
||||
const beforePayload = { html: afterPayload.html }
|
||||
plugins.forEach(p => {
|
||||
if (p.onBeforeRender) {
|
||||
const res = p.onBeforeRender(beforePayload)
|
||||
if (res && res.html) beforePayload.html = res.html
|
||||
}
|
||||
})
|
||||
/* ---------- 图片预览 ---------- */
|
||||
const expandedImage = ref(null)
|
||||
|
||||
emit('update:html', beforePayload.html)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
/* ---------- 键盘快捷键 ---------- */
|
||||
function insertAtCursor(text) {
|
||||
const el = document.activeElement
|
||||
if (el && el.selectionStart !== undefined) {
|
||||
const start = el.selectionStart
|
||||
const end = el.selectionEnd
|
||||
const before = markdown.value.slice(0, start)
|
||||
const after = markdown.value.slice(end)
|
||||
markdown.value = `${before}${text}${after}`
|
||||
// 将光标放在插入文本后
|
||||
const pos = start + text.length
|
||||
nextTick(() => {
|
||||
el.setSelectionRange(pos, pos)
|
||||
})
|
||||
/* ---------- 光标位置管理 ---------- */
|
||||
function saveSelection() {
|
||||
const sel = window.getSelection()
|
||||
if (!sel.rangeCount) return null
|
||||
const range = sel.getRangeAt(0)
|
||||
|
||||
// 检查是否在代码块内
|
||||
const codeBlock = range.closest('pre')
|
||||
if (codeBlock) {
|
||||
return { type: 'code', element: codeBlock }
|
||||
}
|
||||
|
||||
return { type: 'text', range: range.cloneRange() }
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.ctrlKey && !e.shiftKey) {
|
||||
if (e.key === 'b' || e.key === 'B') {
|
||||
e.preventDefault()
|
||||
insertAtCursor('**粗体**')
|
||||
} else if (e.key === 'i' || e.key === 'I') {
|
||||
e.preventDefault()
|
||||
insertAtCursor('_斜体_')
|
||||
} else if (e.key === '`') {
|
||||
e.preventDefault()
|
||||
insertAtCursor('```\n代码块\n```')
|
||||
function restoreSelection(saved) {
|
||||
if (!saved || !editorRef.value) return
|
||||
|
||||
const sel = window.getSelection()
|
||||
sel.removeAllRanges()
|
||||
|
||||
if (saved.type === 'code') {
|
||||
// 代码块不需要恢复光标
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
sel.addRange(saved.range)
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
/* ---------- 防抖渲染 ---------- */
|
||||
function renderMarkdown() {
|
||||
let html = md.render(markdown.value)
|
||||
|
||||
const afterPayload = { markdown: markdown.value, html }
|
||||
plugins.forEach(p => {
|
||||
if (p.onAfterParse) {
|
||||
const res = p.onAfterParse(afterPayload)
|
||||
if (res && res.html) afterPayload.html = res.html
|
||||
}
|
||||
})
|
||||
|
||||
const beforePayload = { html: afterPayload.html }
|
||||
plugins.forEach(p => {
|
||||
if (p.onBeforeRender) {
|
||||
const res = p.onBeforeRender(beforePayload)
|
||||
if (res && res.html) beforePayload.html = res.html
|
||||
}
|
||||
})
|
||||
|
||||
renderedHtml.value = beforePayload.html
|
||||
|
||||
nextTick(() => {
|
||||
bindImageClick()
|
||||
emit('update:html', beforePayload.html)
|
||||
})
|
||||
}
|
||||
|
||||
function onInput(e) {
|
||||
isEditing.value = true
|
||||
// 获取纯文本内容(排除 HTML 标签)
|
||||
const content = getPlainText()
|
||||
markdown.value = content
|
||||
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
renderMarkdown()
|
||||
isEditing.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
/* ---------- 从 contenteditable 获取纯文本 ---------- */
|
||||
function getPlainText() {
|
||||
if (!editorRef.value) return ''
|
||||
|
||||
let text = ''
|
||||
const walker = document.createTreeWalker(
|
||||
editorRef.value,
|
||||
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
|
||||
null,
|
||||
false
|
||||
)
|
||||
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
text += node.textContent
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const tag = node.tagName.toLowerCase()
|
||||
|
||||
// 处理代码块
|
||||
if (tag === 'pre') {
|
||||
const code = decodeURIComponent(node.dataset.code || '')
|
||||
text += `\`\`\`\n${code}\n\`\`\`\n`
|
||||
}
|
||||
// 处理标题
|
||||
else if (tag.startsWith('h') && tag.length === 2) {
|
||||
const level = parseInt(tag[1])
|
||||
text += '#'.repeat(level) + ' ' + node.textContent + '\n'
|
||||
}
|
||||
// 处理粗体
|
||||
else if (tag === 'strong' || tag === 'b') {
|
||||
text += `**${node.textContent}**`
|
||||
}
|
||||
// 处理斜体
|
||||
else if (tag === 'em' || tag === 'i') {
|
||||
text += `_${node.textContent}_`
|
||||
}
|
||||
// 处理删除线
|
||||
else if (tag === 'del' || tag === 's') {
|
||||
text += `~~${node.textContent}~~`
|
||||
}
|
||||
// 处理行内代码
|
||||
else if (tag === 'code' && !node.closest('pre')) {
|
||||
text += `\`${node.textContent}\``
|
||||
}
|
||||
// 处理链接
|
||||
else if (tag === 'a') {
|
||||
text += `[${node.textContent}](${node.href})`
|
||||
}
|
||||
// 处理图片
|
||||
else if (tag === 'img') {
|
||||
text += ``
|
||||
}
|
||||
// 处理段落和换行
|
||||
else if (['p', 'div', 'blockquote'].includes(tag)) {
|
||||
text += '\n'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return text.trim()
|
||||
}
|
||||
|
||||
/* ---------- 图片点击预览 ---------- */
|
||||
function bindImageClick() {
|
||||
const editor = editorRef.value
|
||||
if (!editor) return
|
||||
|
||||
editor.onclick = (e) => {
|
||||
if (e.target.tagName === 'IMG') {
|
||||
expandedImage.value = { src: e.target.src, alt: e.target.alt }
|
||||
} else if (e.target.tagName === 'PRE' && !editingCodeBlock.value) {
|
||||
// 点击代码块进入编辑模式
|
||||
openCodeBlock(e.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onEditorClick(e) {
|
||||
bindImageClick()
|
||||
}
|
||||
|
||||
/* ---------- 代码块编辑 ---------- */
|
||||
function openCodeBlock(preElement) {
|
||||
isEditing.value = true
|
||||
currentCodeElement.value = preElement
|
||||
codeBlockContent.value = decodeURIComponent(preElement.dataset.code || '')
|
||||
editingCodeBlock.value = true
|
||||
|
||||
nextTick(() => {
|
||||
if (codeTextareaRef.value) {
|
||||
codeTextareaRef.value.focus()
|
||||
codeTextareaRef.value.select()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function closeCodeBlock() {
|
||||
editingCodeBlock.value = false
|
||||
isEditing.value = false
|
||||
currentCodeElement.value = null
|
||||
}
|
||||
|
||||
function saveCodeBlock() {
|
||||
isEditing.value = true
|
||||
if (!currentCodeElement) return
|
||||
|
||||
const newHtml = md.options.highlight(codeBlockContent.value, '')
|
||||
currentCodeElement.innerHTML = newHtml.replace(/<pre class="[^"]*"><code>.*<\/code><\/pre>/,
|
||||
`<code>${md.utils.escapeHtml(codeBlockContent.value)}</code>`)
|
||||
currentCodeElement.dataset.code = encodeURIComponent(codeBlockContent.value)
|
||||
|
||||
// 更新 markdown 内容
|
||||
const codeMatch = markdown.value.match(/```[\s\S]*?```/)
|
||||
if (codeMatch) {
|
||||
markdown.value = markdown.value.replace(codeMatch[0], `\`\`\`\n${codeBlockContent.value}\n\`\`\``)
|
||||
}
|
||||
|
||||
closeCodeBlock()
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
/* ---------- 粘贴处理 ---------- */
|
||||
function handlePaste(e) {
|
||||
e.preventDefault()
|
||||
const text = (e.clipboardData || window.clipboardData).getData('text/plain')
|
||||
|
||||
document.execCommand('insertText', false, text)
|
||||
}
|
||||
|
||||
/* ---------- 快捷键 ---------- */
|
||||
function insertAtCursor(text) {
|
||||
const sel = window.getSelection()
|
||||
if (!sel.rangeCount) return
|
||||
|
||||
const range = sel.getRangeAt(0)
|
||||
range.deleteContents()
|
||||
const textNode = document.createTextNode(text)
|
||||
range.insertNode(textNode)
|
||||
|
||||
range.setStartAfter(textNode)
|
||||
range.collapse(true)
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(range)
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
// 代码块编辑模式下不处理快捷键
|
||||
if (editingCodeBlock.value) return
|
||||
|
||||
if (e.ctrlKey && !e.shiftKey) {
|
||||
const key = e.key.toLowerCase()
|
||||
|
||||
if (key >= '1' && key <= '6') {
|
||||
e.preventDefault()
|
||||
insertAtCursor('#'.repeat(parseInt(key)) + ' ')
|
||||
onInput()
|
||||
return
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case 'b':
|
||||
e.preventDefault()
|
||||
insertAtCursor('**粗体**')
|
||||
break
|
||||
case 'i':
|
||||
e.preventDefault()
|
||||
insertAtCursor('_斜体_')
|
||||
break
|
||||
case 'k':
|
||||
e.preventDefault()
|
||||
insertAtCursor('[链接文本](url)')
|
||||
break
|
||||
}
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
insertAtCursor(' ')
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 初始化 ---------- */
|
||||
onMounted(() => {
|
||||
const initialMarkdown = '# Welcome to Markdown Editor\n\nStart typing...'
|
||||
markdown.value = initialMarkdown
|
||||
renderMarkdown()
|
||||
})
|
||||
|
||||
/* ---------- 插件钩子 ---------- */
|
||||
watch(markdown, () => {
|
||||
if (!editingCodeBlock.value) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
renderMarkdown()
|
||||
}, 100)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
/* ---------- 渲染结果应用到编辑器 ---------- */
|
||||
watch(renderedHtml, (newHtml) => {
|
||||
if (editorRef.value && newHtml && !isEditing.value) {
|
||||
editorRef.value.innerHTML = newHtml
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem;
|
||||
.editor-wrapper {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 1.5rem 2rem;
|
||||
outline: none;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* 代码块样式 */
|
||||
.editor :deep(pre) {
|
||||
background: #f5f5f5;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor :deep(pre):hover::after {
|
||||
content: '点击编辑';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
background: rgba(255,255,255,0.9);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.editor :deep(code) {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 行内代码 */
|
||||
.editor :deep(p > code),
|
||||
.editor :deep(a > code) {
|
||||
background: #f0f0f0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
/* 图片样式 */
|
||||
.editor :deep(img) {
|
||||
max-width: 100%;
|
||||
cursor: zoom-in;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.editor :deep(a) {
|
||||
color: #0066cc;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.editor :deep(blockquote) {
|
||||
border-left: 4px solid #ddd;
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 代码块编辑弹窗 */
|
||||
.code-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.code-editor {
|
||||
background: #1e1e1e;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
width: 80%;
|
||||
max-width: 800px;
|
||||
height: 60vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.code-editor textarea {
|
||||
flex: 1;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
margin-top: 0.5rem;
|
||||
padding: 8px 16px;
|
||||
background: #0066cc;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
/* 图片弹窗 */
|
||||
.image-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.image-modal img {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user