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:
2026-01-12 13:23:55 +08:00
parent 49f264b53b
commit 4de3dfdd8d
2 changed files with 484 additions and 82 deletions

View File

@@ -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>

View File

@@ -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 += `![${node.alt || ''}](${node.src})`
}
// 处理段落和换行
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>