Electron-vite【实战】MD 编辑器 -- 编辑区(含工具条、自定义右键快捷菜单、快捷键编辑、拖拽打开文件等)

最终效果

页面

src/renderer/src/App.vue

c 复制代码
    <div class="editorPanel">
      <div class="btnBox">
        <div
          v-for="(config, key) in actionDic"
          :key="key"
          class="btnItem"
          :title="config.label"
          @click="config.action"
        >
          <Icon :icon="config.icon" />
        </div>
      </div>
      <textarea
        ref="editorRef"
        v-model="markdownContent"
        spellcheck="false"
        class="editor"
        :class="{ dragging: isDragging }"
        placeholder="请输入内容 ( Markdown语法 ) ..."
        @keydown="handleKeyDown"
        @contextmenu.prevent="show_edite_contextMenu"
        @dragover="handleDragOver"
        @dragleave="handleDragLeave"
        @drop="handleDrop"
      ></textarea>
    </div>

相关样式

css 复制代码
.editorPanel {
  flex: 1;
  border: 1px solid gray;
  border-left: none;
  display: flex;
  flex-direction: column;
  width: 620px;
}
.editor {
  padding: 10px;
  border: none;
  box-sizing: border-box;
  flex: 1;
  word-break: break-all;
  resize: none;
  font-family: SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
  font-size: 14px;
  line-height: 1.5;
  outline: none;
}
/* 滚动条样式优化 */
.editor,
.preview,
.outlineListBox {
  scrollbar-width: thin;
  scrollbar-color: #c1c1c1 #ffffff;
  scrollbar-gutter: stable;
}
.btnBox {
  display: flex;
  justify-content: space-evenly;
  background-color: #f0f0f0;
  height: 34px;
}
.btnItem {
  cursor: pointer;
  background-color: #f0f0f0;
  padding: 6px;
  font-size: 20px;
  display: inline-block;
}
.btnItem:hover {
  background-color: #e0e0e0;
}

相关依赖

实现图标

c 复制代码
npm i --save-dev @iconify/vue

导入使用

c 复制代码
import { Icon } from '@iconify/vue'

搜索图标
https://icon-sets.iconify.design/?query=home

相关变量

c 复制代码
const editorRef = ref<HTMLTextAreaElement>()
const markdownContent = ref('')

工具条

所有的功能都是插入 markdown 语法

c 复制代码
      <div class="btnBox">
        <div
          v-for="(config, key) in actionDic"
          :key="key"
          class="btnItem"
          :title="config.label"
          @click="config.action"
        >
          <Icon :icon="config.icon" />
        </div>
      </div>
c 复制代码
// 操作字典
const actionDic: {
  [key: string]: {
    icon: string
    label: string
    contextMenu?: boolean
    action: () => void
  }
} = {
  h1: {
    icon: 'codex:h1',
    label: '一级标题 Ctrl+1',
    action: () => {
      addTag('#')
    }
  },
  h2: {
    icon: 'codex:h2',
    label: '二级标题 Ctrl+2',
    action: () => {
      addTag('##')
    }
  },
  h3: {
    icon: 'codex:h3',
    label: '三级标题 Ctrl+3',
    action: () => {
      addTag('###')
    }
  },
  h4: {
    icon: 'codex:h4',
    label: '四级标题 Ctrl+4',
    action: () => {
      addTag('####')
    }
  },
  h5: {
    icon: 'codex:h5',
    label: '五级标题 Ctrl+5',
    action: () => {
      addTag('#####')
    }
  },
  h6: {
    icon: 'codex:h6',
    label: '六级标题 Ctrl+6',
    action: () => {
      addTag('######')
    }
  },
  p: {
    icon: 'codex:text',
    label: '正文 Ctrl+0',
    action: () => {
      setParagraph()
    }
  },
  code: {
    icon: 'codex:brackets',
    label: '代码块 Ctrl+Shift+K',
    contextMenu: true,
    action: () => {
      insertCode()
    }
  },
  link: {
    icon: 'codex:link',
    label: '超链接 Ctrl+L',
    contextMenu: true,
    action: () => {
      inserthyperlink()
    }
  },
  quote: {
    icon: 'codex:quote',
    label: '引用 Ctrl+Q',
    contextMenu: true,
    action: () => {
      addTag('>')
    }
  },
  b: {
    icon: 'codex:bold',
    label: '加粗 Ctrl+B',
    action: () => {
      addStyle('bold')
    }
  },
  i: {
    icon: 'codex:italic',
    label: '斜体 Ctrl+I',
    action: () => {
      addStyle('italic')
    }
  },
  d: {
    icon: 'codex:strikethrough',
    label: '删除线  Ctrl+D',
    action: () => {
      addStyle('delLine')
    }
  },
  ul: {
    icon: 'codex:list-bulleted',
    label: '无序列表 Ctrl+Shift+U',
    action: () => {
      addTag('-')
    }
  },
  ol: {
    icon: 'codex:list-numbered',
    label: '有序列表 Ctrl+Shift+O',
    action: () => {
      addTag('1.')
    }
  },
  todo: {
    icon: 'codex:checklist',
    label: '待办列表 Ctrl+Shift+D',
    action: () => {
      addTag('- [ ]')
    }
  },
  table: {
    icon: 'codex:table-with-headings',
    label: '表格 Ctrl+Shift+T',
    action: () => {
      insertTable()
    }
  },
  img: {
    icon: 'codex:picture',
    label: '图片 Ctrl+Shift+I',
    action: () => {
      insertImg()
    }
  },
  video: {
    icon: 'codex:play',
    label: '视频 Ctrl+Shift+V',
    action: () => {
      insertVideo()
    }
  }
}

公共方法

c 复制代码
// 根据新内容,重新渲染页面,并恢复光标位置和滚动位置
const restoreCursorAndScroll = async (
  newContent: string,
  newCursorPosition: number
): Promise<void> => {
  // 更新文本内容
  markdownContent.value = newContent
  if (!editorRef.value) return
  const textarea = editorRef.value
  // 记录当前编辑区的滚动位置
  const originalScrollTop = textarea.scrollTop
  // 等待 DOM 更新完成
  await nextTick()
  // 重新聚焦到 textarea
  textarea.focus()
  textarea.setSelectionRange(newCursorPosition, newCursorPosition)
  // 恢复编辑区的滚动位置
  textarea.scrollTop = originalScrollTop
}

标题

以一级标题为例

c 复制代码
addTag('#')
c 复制代码
// 光标所在行前添加标记
const addTag = async (type: string): Promise<void> => {
  if (!editorRef.value) return
  const content = markdownContent.value
  const selectionStart = editorRef.value.selectionStart
  // 找到光标所在行的起始和结束位置
  let lineStart = content.lastIndexOf('\n', selectionStart - 1) + 1
  let lineEnd = content.indexOf('\n', selectionStart)
  if (lineEnd === -1) {
    lineEnd = content.length
  }
  // 获取当前行的文本
  let lineText = content.slice(lineStart, lineEnd)
  // 移除行首原有标记
  lineText = lineText.replace(/^[#>-]+\s*/, '')
  // 添加新的标记
  lineText = `${type} ${lineText.trimStart()}`
  // 构造新的内容
  const newContent = content.slice(0, lineStart) + lineText + content.slice(lineEnd)
  // 设置新的光标位置
  const newCursorPosition = lineStart + lineText.length
  restoreCursorAndScroll(newContent, newCursorPosition)
}

段落

c 复制代码
setParagraph()
c 复制代码
// 将光标所在行的文本设置为段落
const setParagraph = async (): Promise<void> => {
  if (!editorRef.value) return
  const content = markdownContent.value
  const selectionStart = editorRef.value.selectionStart
  // 找到光标所在行的起始和结束位置
  let lineStart = content.lastIndexOf('\n', selectionStart - 1) + 1
  let lineEnd = content.indexOf('\n', selectionStart)
  if (lineEnd === -1) {
    lineEnd = content.length
  }
  // 获取当前行的文本
  let lineText = content.slice(lineStart, lineEnd)
  // 移除行首的标题和引用标记(#>)
  lineText = lineText.replace(/^[#>]+\s*/, '')
  // 构造新的内容
  const newContent = content.slice(0, lineStart) + lineText + content.slice(lineEnd)
  // 设置新的光标位置
  const newCursorPosition = lineStart + lineText.length
  restoreCursorAndScroll(newContent, newCursorPosition)
}

代码块

c 复制代码
insertCode()
c 复制代码
// 插入代码块
const insertCode = async (): Promise<void> => {
  if (!editorRef.value) return
  const start = editorRef.value.selectionStart
  const end = editorRef.value.selectionEnd
  const content = markdownContent.value
  const selectedText = content.slice(start, end)
  const newContent = `${content.slice(0, start)}\n${'```js'}\n${selectedText}\n${'```'}\n${content.slice(end)}`
  const newCursorPosition = start + 7 + selectedText.length
  restoreCursorAndScroll(newContent, newCursorPosition)
}

超链接

c 复制代码
inserthyperlink()
c 复制代码
// 在光标所在的位置插入超链接
const inserthyperlink = async (): Promise<void> => {
  if (!editorRef.value) return
  const textarea = editorRef.value
  const content = markdownContent.value
  const start = textarea.selectionStart
  const end = textarea.selectionEnd
  // 获取选中的文本,若未选中则默认显示 '链接文本'
  const selectedText = content.slice(start, end) || '链接文本'
  // 构造超链接的 Markdown 语法
  const hyperlink = `[${selectedText}]()`
  // 构造新的内容
  const newContent = `${content.slice(0, start)}${hyperlink}${content.slice(end)}`
  // 设置新的光标位置
  const newCursorPosition = start + hyperlink.length - 1
  restoreCursorAndScroll(newContent, newCursorPosition)
}

引用

c 复制代码
addTag('>')
c 复制代码
// 光标所在行前添加标记
const addTag = async (type: string): Promise<void> => {
  if (!editorRef.value) return
  const content = markdownContent.value
  const selectionStart = editorRef.value.selectionStart
  // 找到光标所在行的起始和结束位置
  let lineStart = content.lastIndexOf('\n', selectionStart - 1) + 1
  let lineEnd = content.indexOf('\n', selectionStart)
  if (lineEnd === -1) {
    lineEnd = content.length
  }
  // 获取当前行的文本
  let lineText = content.slice(lineStart, lineEnd)
  // 移除行首原有标记
  lineText = lineText.replace(/^[#>-]+\s*/, '')
  // 添加新的标记
  lineText = `${type} ${lineText.trimStart()}`
  // 构造新的内容
  const newContent = content.slice(0, lineStart) + lineText + content.slice(lineEnd)
  // 设置新的光标位置
  const newCursorPosition = lineStart + lineText.length
  restoreCursorAndScroll(newContent, newCursorPosition)
}

加粗,斜体,删除线

c 复制代码
addStyle('bold')
c 复制代码
addStyle('italic')
c 复制代码
addStyle('delLine')
c 复制代码
// 给所选内容添加样式
const addStyle = async (type: string): Promise<void> => {
  if (!editorRef.value) return
  const textarea = editorRef.value
  const content = markdownContent.value
  const start = textarea.selectionStart
  const end = textarea.selectionEnd
  // 获取选中的文本
  let selectedText = content.slice(start, end)
  let defaultText = ''
  let tag = ''
  switch (type) {
    case 'bold':
      defaultText = '加粗文本'
      tag = '**'
      break
    case 'italic':
      defaultText = '斜体文本'
      tag = '*'
      break
    case 'delLine':
      defaultText = '删除线文本'
      tag = '~~'
      break
    default:
  }
  if (!selectedText) {
    selectedText = defaultText
  }
  const newText = `${tag}${selectedText}${tag}`
  // 构造新的内容
  const newContent = `${content.slice(0, start)}${newText}${content.slice(end)}`
  // 设置新的光标位置
  const newCursorPosition = start + newText.length
  restoreCursorAndScroll(newContent, newCursorPosition)
}

无序列表,有序列表,待办列表

c 复制代码
addTag('-')
c 复制代码
addTag('1.')
c 复制代码
addTag('- [ ]')
c 复制代码
// 光标所在行前添加标记
const addTag = async (type: string): Promise<void> => {
  if (!editorRef.value) return
  const content = markdownContent.value
  const selectionStart = editorRef.value.selectionStart
  // 找到光标所在行的起始和结束位置
  let lineStart = content.lastIndexOf('\n', selectionStart - 1) + 1
  let lineEnd = content.indexOf('\n', selectionStart)
  if (lineEnd === -1) {
    lineEnd = content.length
  }
  // 获取当前行的文本
  let lineText = content.slice(lineStart, lineEnd)
  // 移除行首原有标记
  lineText = lineText.replace(/^[#>-]+\s*/, '')
  // 添加新的标记
  lineText = `${type} ${lineText.trimStart()}`
  // 构造新的内容
  const newContent = content.slice(0, lineStart) + lineText + content.slice(lineEnd)
  // 设置新的光标位置
  const newCursorPosition = lineStart + lineText.length
  restoreCursorAndScroll(newContent, newCursorPosition)
}

表格

c 复制代码
insertTable()
c 复制代码
// 插入表格
const insertTable = (): void => {
  const table = '|  |  |\n|:----:|:----:|\n|  |  |\n|  |  |\n'
  const editor = editorRef.value
  if (editor) {
    const start = editor.selectionStart
    const end = editor.selectionEnd
    const before = markdownContent.value.slice(0, start)
    const after = markdownContent.value.slice(end)
    restoreCursorAndScroll(before + table + after, start + 2)
  }
}

图片

c 复制代码
insertImg()
c 复制代码
// 在光标所在的位置插入图片
const insertImg = async (): Promise<void> => {
  if (!editorRef.value) return
  const textarea = editorRef.value
  const content = markdownContent.value
  const start = textarea.selectionStart
  const end = textarea.selectionEnd
  let selectedText = content.slice(start, end) || '图片'
  // 构造图片的 Markdown 语法
  const hyperlink = `![${selectedText}]()`
  // 构造新的内容
  const newContent = `${content.slice(0, start)}${hyperlink}${content.slice(end)}`
  // 设置新的光标位置
  const newCursorPosition = start + hyperlink.length - 1
  restoreCursorAndScroll(newContent, newCursorPosition)
}

视频

c 复制代码
insertVideo()
c 复制代码
// 在光标所在的位置插入视频
const insertVideo = async (): Promise<void> => {
  if (!editorRef.value) return
  const textarea = editorRef.value
  const content = markdownContent.value
  const start = textarea.selectionStart
  const end = textarea.selectionEnd
  // Markdown 语法无视频,可用html实现
  const hyperlink = `<video src="" controls width="100%">
  请升级浏览器以观看视频。
</video>`
  // 构造新的内容
  const newContent = `${content.slice(0, start)}${hyperlink}${content.slice(end)}`
  // 设置新的光标位置
  const newCursorPosition = start + 12
  restoreCursorAndScroll(newContent, newCursorPosition)
}

右键快捷菜单

c 复制代码
@contextmenu.prevent="show_edite_contextMenu"
c 复制代码
// 显示右键菜单 -- 编辑器
const show_edite_contextMenu = (event): void => {
  // 阻止默认右键菜单
  event.preventDefault()
  // 获取鼠标位置
  menuX.value = event.clientX
  menuY.value = event.clientY
  // 显示菜单
  isMenuVisible.value = true
}

构建页面

c 复制代码
    <!-- 右键快捷菜单--编辑器 -->
    <div
      v-if="isMenuVisible"
      class="context-menu"
      :style="{ left: `${menuX}px`, top: `${menuY}px` }"
    >
      <div class="context-menu-btnBox">
        <div class="context-menu-btn" @click="copySelectedText">
          <Icon icon="codex:copy" width="24" style="margin-right: 4px" />
          <span>复制</span>
        </div>
        <div class="context-menu-btn" @click="paste">
          <Icon icon="mingcute:paste-line" width="20" style="margin-right: 4px" />
          <span>粘贴</span>
        </div>
        <div class="context-menu-btn" @click="cutSelectedText">
          <Icon icon="tabler:cut" width="20" style="margin-right: 4px" />
          <span>剪切</span>
        </div>
      </div>
      <div class="context-menu-btnBox">
        <div class="context-menu-btn" @click="actionDic.h1.action">
          <Icon :icon="actionDic.h1.icon" width="20" />
        </div>
        <div class="context-menu-btn" @click="actionDic.h2.action">
          <Icon :icon="actionDic.h2.icon" width="20" />
        </div>
        <div class="context-menu-btn" @click="actionDic.h3.action">
          <Icon :icon="actionDic.h3.icon" width="20" />
        </div>
        <div class="context-menu-btn" @click="actionDic.p.action">
          <Icon :icon="actionDic.p.icon" width="20" />
        </div>
        <div class="context-menu-btn" @click="actionDic.b.action">
          <Icon :icon="actionDic.b.icon" width="20" />
        </div>
        <div class="context-menu-btn" @click="actionDic.d.action">
          <Icon :icon="actionDic.d.icon" width="20" />
        </div>
      </div>
      <ul>
        <template v-for="item in actionDic">
          <li v-if="item.contextMenu" :key="item.label" @click="item.action">
            <Icon :icon="item.icon" width="20" style="margin-right: 10px" />
            <span> {{ item.label }}</span>
          </li>
        </template>
      </ul>
      <div class="context-menu-btnBox">
        <div class="context-menu-btn" @click="actionDic.ul.action">
          <Icon :icon="actionDic.ul.icon" width="20" style="margin-right: 4px" />
          <span>无序</span>
        </div>
        <div class="context-menu-btn" @click="actionDic.ol.action">
          <Icon :icon="actionDic.ol.icon" width="20" style="margin-right: 4px" />
          <span>有序</span>
        </div>
        <div class="context-menu-btn" @click="actionDic.todo.action">
          <Icon :icon="actionDic.todo.icon" width="20" style="margin-right: 4px" />
          <span>待办</span>
        </div>
      </div>
      <div class="context-menu-btnBox">
        <div class="context-menu-btn" @click="actionDic.table.action">
          <Icon :icon="actionDic.table.icon" width="20" style="margin-right: 4px" />
          <span>表格</span>
        </div>
        <div class="context-menu-btn" @click="actionDic.img.action">
          <Icon :icon="actionDic.img.icon" width="20" style="margin-right: 4px" />
          <span>图片</span>
        </div>
        <div class="context-menu-btn" @click="actionDic.video.action">
          <Icon :icon="actionDic.video.icon" width="20" style="margin-right: 4px" />
          <span>视频</span>
        </div>
      </div>
    </div>

相关样式

css 复制代码
/* 编辑器-右键菜单样式 */
.context-menu {
  position: fixed;
  z-index: 1000;
  width: 200px;
  background-color: white;
  border-radius: 4px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
  padding: 4px 0;
  font-size: 12px;
}
.context-menu ul {
  list-style: none;
  margin: 0;
  padding: 0;
}
.context-menu li {
  padding: 8px 16px;
  cursor: pointer;
  display: flex;
  align-items: center;
}
.context-menu li:hover {
  background-color: #e0e0e0;
}
.context-menu li i {
  margin-right: 8px;
  text-align: center;
}
.context-menu-btnBox {
  display: flex;
}
.context-menu-btn {
  flex: 1;
  padding: 8px 4px;
  cursor: pointer;
  text-align: center;
  display: flex;
  align-items: center;
  justify-content: center;
}
.context-menu-btn:hover {
  background-color: #e0e0e0;
}

复制

c 复制代码
const copySelectedText = (): void => {
  if (!editorRef.value) return
  const start = editorRef.value.selectionStart
  const end = editorRef.value.selectionEnd
  const content = markdownContent.value
  const selectedText = content.slice(start, end)
  navigator.clipboard.writeText(selectedText)
}

剪切

c 复制代码
const cutSelectedText = (): void => {
  if (!editorRef.value) return
  const start = editorRef.value.selectionStart
  const end = editorRef.value.selectionEnd
  const content = markdownContent.value
  const selectedText = content.slice(start, end)
  navigator.clipboard.writeText(selectedText)
  const newContent = content.slice(0, start) + content.slice(end)
  restoreCursorAndScroll(newContent, start)
}

粘贴

c 复制代码
const paste = (): void => {
  if (!editorRef.value) return
  const start = editorRef.value.selectionStart
  const end = editorRef.value.selectionEnd
  const content = markdownContent.value
  navigator.clipboard.readText().then((text) => {
    const newContent = content.slice(0, start) + text + content.slice(end)
    restoreCursorAndScroll(newContent, start + text.length)
  })
}

其他快捷编辑相关的方法同工具栏

隐藏编辑器右键菜单

按ESC/点击鼠标时

onMounted 中

c 复制代码
  // 监听点击鼠标左键时隐藏编辑器右键菜单
  document.addEventListener('click', hide_editor_contextMenu)
  // 监听按下ESC键时隐藏编辑器右键菜单
  document.addEventListener('keydown', ESC_hide_editor_contextMenu)
c 复制代码
// 按ESC时隐藏编辑器右键菜单
const ESC_hide_editor_contextMenu = ({ key }): void => {
  if (key === 'Escape') {
    hide_editor_contextMenu()
  }
}

onBeforeUnmount 中

c 复制代码
document.removeEventListener('click', hide_editor_contextMenu)
document.removeEventListener('keydown', ESC_hide_editor_contextMenu)

显示其他快捷菜单时

c 复制代码
const showContextMenu = (filePath: string): void => {
  window.electron.ipcRenderer.send('showContextMenu', filePath)
  // 隐藏其他右键菜单 -- 不能同时有多个右键菜单显示
  hide_editor_contextMenu()
}

快捷键编辑

除了响应工具栏的快捷键,还需支持按下回车键时

  • 若当前行为无序列表且有内容,则下一行继续无序列表,若无内容,则不再继续无序列表
  • 若当前行为有序列表且有内容,则下一行继续有序列表,且序号加 1,若无内容,则不再继续有序列表
  • 若当前行为待办列表且有内容,则下一行继续待办列表,若无内容,则不再继续待办列表
  • 若当前行为引用,则下一行继续为引用
c 复制代码
@keydown="handleKeyDown"
c 复制代码
// 编辑器按下键盘事件
const handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
  // 同步预览滚动位置
  syncPreviewScroll()
  // 生成快捷键组合字符串
  const modifiers: string[] = []
  if (event.ctrlKey) modifiers.push('Ctrl')
  if (event.shiftKey) modifiers.push('Shift')
  if (event.altKey) modifiers.push('Alt')
  const key = event.key.toUpperCase()
  const shortcut = [...modifiers, key].join('+')
  // 检查是否有对应的快捷键处理函数
  if (shortcutMap[shortcut]) {
    event.preventDefault()
    await shortcutMap[shortcut]()
    return
  }
  if (event.key === 'Enter' && editorRef.value && !event.shiftKey) {
    const textarea = editorRef.value
    const content = markdownContent.value
    const cursorPosition = textarea.selectionStart
    // 找到当前行的起始位置
    const lineStart = content.lastIndexOf('\n', cursorPosition - 1) + 1
    const currentLine = content.slice(lineStart, cursorPosition)
    // 检查当前行是否为列表格式且不为空行
    const listMatch = currentLine.match(/^(\s*)([-*+]|\d+\.)\s/)
    const isLineEmpty =
      currentLine.trim().length === listMatch?.[0]?.trim().length || currentLine.trim() === ''
    // 检查当前行是否为引用格式
    const quoteMatch = currentLine.match(/^(\s*)>\s*/)
    // 检查当前行是否为待办列表格式
    const todoMatch = currentLine.match(/^(\s*)- \[[ xX]\]\s*/)
    const isTodoEmpty = currentLine.trim().length === todoMatch?.[0]?.trim().length
    if (listMatch && !isLineEmpty && !todoMatch) {
      event.preventDefault()
      const indentation = listMatch[1]
      const listMarker = listMatch[2]
      let newListMarker = listMarker
      // 若为有序列表,序号递增
      if (/^\d+\.$/.test(listMarker)) {
        const currentNumber = parseInt(listMarker.replace('.', ''), 10)
        newListMarker = `${currentNumber + 1}.`
      }
      const newContent = `${content.slice(0, cursorPosition)}\n${indentation}${newListMarker} ${content.slice(cursorPosition)}`
      // 设置新的光标位置
      const newCursorPosition = cursorPosition + indentation.length + newListMarker.length + 2
      textarea.setSelectionRange(newCursorPosition, newCursorPosition)
      restoreCursorAndScroll(newContent, newCursorPosition)
    } else if (quoteMatch) {
      event.preventDefault()
      const indentation = quoteMatch[1]
      const newContent =
        `${content.slice(0, cursorPosition)}\n${indentation}> ` + content.slice(cursorPosition)
      // 设置新的光标位置
      const newCursorPosition = cursorPosition + indentation.length + 3
      textarea.setSelectionRange(newCursorPosition, newCursorPosition)
      restoreCursorAndScroll(newContent, newCursorPosition)
    } else if (todoMatch && !isTodoEmpty) {
      event.preventDefault()
      const indentation = todoMatch[1]
      const newContent =
        `${content.slice(0, cursorPosition)}\n${indentation}- [ ] ` + content.slice(cursorPosition)
      // 设置新的光标位置
      const newCursorPosition = cursorPosition + indentation.length + 7
      textarea.setSelectionRange(newCursorPosition, newCursorPosition)
      restoreCursorAndScroll(newContent, newCursorPosition)
    }
  }
}

相关方法

c 复制代码
// 同步预览区滚动
const syncPreviewScroll = (): void => {
  // 点击大纲项时,不触发同步预览区滚动
  if (editorRef.value && previewRef.value && !ifClickOutLine.value) {
    const editor = editorRef.value
    const preview = previewRef.value
    const editorScrollRatio = editor.scrollTop / (editor.scrollHeight - editor.clientHeight)
    const previewScrollTop = editorScrollRatio * (preview.scrollHeight - preview.clientHeight)
    preview.scrollTop = previewScrollTop
  }
}

相关变量

c 复制代码
// 快捷键映射表
const shortcutMap = {
  'Ctrl+0': setParagraph,
  'Ctrl+1': () => addTag('#'),
  'Ctrl+2': () => addTag('##'),
  'Ctrl+3': () => addTag('###'),
  'Ctrl+4': () => addTag('####'),
  'Ctrl+5': () => addTag('#####'),
  'Ctrl+6': () => addTag('######'),
  'Ctrl+Shift+K': insertCode,
  'Ctrl+L': inserthyperlink,
  'Ctrl+Q': () => addTag('>'),
  'Ctrl+B': () => addStyle('bold'),
  'Ctrl+I': () => addStyle('italic'),
  'Ctrl+D': () => addStyle('delLine'),
  'Ctrl+Shift+U': () => addTag('-'),
  'Ctrl+Shift+O': () => addTag('1.'),
  'Ctrl+Shift+D': () => addTag('- [ ]'),
  'Ctrl+Shift+T': insertTable,
  'Ctrl+Shift+V': insertVideo,
  'Ctrl+Shift+I': insertImg
}

拖拽打开文件

响应拖拽

c 复制代码
:class="{ dragging: isDragging }"
c 复制代码
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
c 复制代码
// 文件拖拽到编辑器中时
const handleDragOver = (e: DragEvent): void => {
  e.preventDefault()
  if (e.dataTransfer) {
    e.dataTransfer.dropEffect = 'link'
  }
  dragMessage.value = '释放鼠标打开文件(仅支持 md 文件)'
  isDragging.value = true
}
c 复制代码
// 拖拽文件离开编辑器后
const handleDragLeave = (e): void => {
  // 确保是真正离开容器而不是子元素
  if (e.relatedTarget === null || !e.currentTarget.contains(e.relatedTarget)) {
    isDragging.value = false
  }
}
c 复制代码
// 拖拽文件到编辑器中松开鼠标后
const handleDrop = async (event: DragEvent): Promise<void> => {
  event.preventDefault()
  const files = event.dataTransfer?.files || []
  for (const file of files) {
    file.path = window.api.getDropFilePath(file)
    if (file.type === 'text/markdown' || file.name.endsWith('.md')) {
      try {
        const content = await file.text()
        markdownContent.value = content
        currentFilePath.value = file.path
        if (!isFileExists(file.path)) {
          fileList.value.unshift({
            content,
            fileName: file.name,
            filePath: file.path
          })
        }
        // 拖入文件后,立马打开文件
        openFile({
          content,
          fileName: file.name,
          filePath: file.path
        })
      } catch (error) {
        console.error('读取文件出错:', error)
      }
    }
  }
  isDragging.value = false
}

获取被拖拽文件的本地路径

通过预加载脚本实现

src/preload/index.ts

ts 复制代码
import { contextBridge, webUtils } from 'electron'
c 复制代码
const api = {
  getDropFilePath: (file) => {
    return webUtils.getPathForFile(file)
  }
}

src/preload/index.d.ts

ts 复制代码
  // 定义 api 的类型
  interface ApiType {
    getDropFilePath: (item: File) => string
  }

拖拽提示层

html 复制代码
    <!-- 拖拽提示层 -->
    <div v-if="isDragging" class="drag-overlay">
      <div class="drag-message">{{ dragMessage }}</div>
    </div>

相关样式

css 复制代码
.editor.dragging {
  border-color: #2196f3;
  background-color: #f5f8ff;
}
.drag-overlay {
  position: absolute;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  background-color: rgba(33, 150, 243, 0.1);
  border: 1px dashed #2196f3;
  border-radius: 4px;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none; /* 允许点击穿透到 textarea */
  box-sizing: border-box;
}
.drag-message {
  font-size: 16px;
  color: #2196f3;
  background-color: white;
  padding: 10px 20px;
  border-radius: 4px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
相关推荐
潇-xiao6 分钟前
vim的相关命令 + 三种模式(10)
linux·编辑器·vim
前端南玖39 分钟前
Vue3响应式核心:ref vs reactive深度对比
前端·javascript·vue.js
微笑边缘的金元宝1 小时前
svg实现3环进度图,可动态调节进度数值,(vue)
前端·javascript·vue.js·svg
程序猿小D1 小时前
第28节 Node.js 文件系统
服务器·前端·javascript·vscode·node.js·编辑器·vim
小妖6661 小时前
uni-app bitmap.load() 返回 code=-100
前端·javascript·uni-app
走,带你去玩1 小时前
uniapp 时钟
javascript·css·uni-app
野盒子1 小时前
前端面试题 微信小程序兼容性问题与组件适配策略
前端·javascript·面试·微信小程序·小程序·cocoa
Hilaku2 小时前
为什么我不再追流行,而是重新研究了 jQuery
前端·javascript·jquery
G等你下课2 小时前
JavaScript 中 Promise 的深度解析:异步编程的革新之路
前端·javascript
海天胜景2 小时前
vue3 数据过滤方法
前端·javascript·vue.js