Vue3实现简易typora

自我介绍

大家好,我是 思哲Lee 一名前端开发人员,从业快两年,编程学习探索之路已四年有余,主要在公司写vue

前言声明

本项目是对typora的简易实现, 在markdown解析方面包含h1-h6,有序列表(ol),无序列表(ul),在快捷键方面完成了h1-h6标签的快捷键和复制粘贴快捷键的实现,在视图方面,完成了大纲树的构建和点击节点树后的视图导航

这里简单介绍一下typora是什么,我的理解是他是一个markdown语法的解析和编辑器,它可以高效的处理md和日常文章记录等,如果你对typora或markdown语法不了解,可以读一下这边文章 juejin.cn/post/725410...

本项目是在去年完成,因此在编码的风格上可能存在问题,虽然那个时候已经有了设计模式和代码设计原则的基础(因为编码是在去年5月进行的,重构和代码整洁之道这两本关于代码整洁之道是在8-12月读的),因此源码整洁度和性能相关的问题可能没有特别完善,请读者包涵

由于我的经验和水平有限,可能这篇文章对于你不会有太大帮助,不是因为这篇文章水,而是你的能力已经远超了我的水平极限,请理性讨论,不要带有言语攻击,谢谢

为什么会复刻typora

复刻typora的想法是去年开始和完成的,那时正逢劳动节,起因是看到b站鱼皮有一个复刻typora简易实现的挑战,由于我对网页编辑行为的好奇心,如正火的语雀笔记管理工具等,加剧了我接受挑战的欲望,因此开始了我和此小项目的缘分之路,经过重重踩坑和学习,最终突破自我,完成了简易实现

正文

精彩效果预览

你将了解到什么

  • 了解如何在浏览器中不借助输入框的情况下完成编辑行为的
  • 了解如何完成快捷键和对应标签行为解析的
  • 了解大纲树是如何建立关系和跳转的
  • 得到笔者的源码一份(调皮一下haha)

核心问题解决

编辑视图

div有一个属性contenteditable 可以将div升级为一个可编辑的视图块,在这个区域内完成对应输入即可自动解析为对应的标签,不过存在副作用,标签的原始样式似乎失效了,需要重写一遍对应标签的样式

在原始行为中,对应div中编辑的行为会被自动转换为div标签,为了区分对应的标签,职责和功能这里我参考了语雀的处理方式,将不同的标签进行分类如 p,h1-h6,ul,ol标签

标签解析

上一部分是对视图的编辑行为的开端,那这一部分便是文章的精华,现在我们已经拥有了视图的编辑权限和操作权限,那这一部分便是完成标签转换行为,我们要完成视图的标签转换首要获取的就是当前光标所处操作行的dom标签,那么当前dom标签要如何获取呢,window对象上给我们提供了api能够让我们获取当前光标所处的上下文(dom树上下文结构)

typescript 复制代码
/**
 * @description: 得到当前光标所处行的元素
 * @return {Node | null | undefined}
 */
export function getCurSelectedElement(): HTMLElement | null | undefined {
  // 获取选中文本所在的范围对象
  // getAllSelectNode()
  const range = window.getSelection()?.getRangeAt(0)
  // 获取包含选中文本的最小共同祖先元素
  const container = range?.commonAncestorContainer
  // 如果选中文本不在元素内,则返回null
  const selectedElement: any = container?.nodeType === 1 ? container : container?.parentNode
  return selectedElement
}

以下是对于在首次输入时,没有生成标签时,自动插入p标签和对应内容的方法

现在我们能够获取当前光标所处的元素了,知道上级节点的类型了,就可以完成标签转换,上面我介绍了如何将默认的div标签转换为p标签,下面我将介绍p标签是如何升级 转换为其他功能标签的,这里我提到了 升级 , 这里我采用了一个策略处理标签转换的场景,p标签为标准标签,它可以通过特殊处理转换为其他标签(如h1 h2等),我称其为 升级 ,反之成为 降级 由功能标签变为原始标签如(h1->p),如下图所示

p标签升级要怎么做到呢? 监听用户输入和获取光标所处的dom标签即可,通过判断对应的行标签和输入是否符合我的语法要求来决定是否完成标签的升级流程

在生成完新标签时一定要调用标签的focus()方法来得到焦点(此时一定要保证有内容否则也无法正常被选中),否则此标签将无法再次被选中(踩坑点之一)

typescript 复制代码
/**
 * @description: 对表达式进行解析
 * @param {string} exp 传入的表达式
 * @param {HTMLElement} el 传入的目标元素
 * @return {*}
 */
export function expressParse(exp: string, el: HTMLElement) {
  const regexp = /^(.*) (.*)/
  const matchRes = exp.match(regexp)
  // 此事无需进行转换因为已经存在内容
  if (matchRes && matchRes[2]) return
  if (matchRes) {
    const operation = matchRes[1]
    switch (operation) {
      case '#':
        tagHandler(el, 'h1', { id: getRandId() })
        break

      case '##':
        tagHandler(el, 'h2', { id: getRandId() })
        break

      case '###':
        tagHandler(el, 'h3', { id: getRandId() })
        break
      case '####':
        tagHandler(el, 'h4', { id: getRandId() })
        break
      case '#####':
        tagHandler(el, 'h5', { id: getRandId() })
        break
      case '######':
        tagHandler(el, 'h6', { id: getRandId() })
        break

      case '+':
      case '-':
        tagHandler(el, 'ul', { class: 'li-circle' })
        break

      case '1.':
        tagHandler(el, 'ol', { class: 'li-number' })
        break
      default:
        break
    }
  }
}

export function tagHandler(
  el: HTMLElement,
  tag: string,
  props?: { class?: string; id?: string; content: string }
) {

  tagReplace(el, tag, props)
}


/**
 * @description: 将目标dom对象替换为指定的标签元素
 * @param {HTMLElement} el
 * @param {string} tag
 * @return {*}
 */
export function tagReplace(el: HTMLElement, tag: string, option?: { [prop: string]: any }) {
  const newEl = document.createElement(tag)
  if (option) {
    if (option.class) newEl.className = option.class
    if (option.id) newEl.id = option.id
    if (option.content) newEl.innerHTML = option.content
  }
  if (newEl.tagName === 'UL' || newEl.tagName === 'OL') {
    const liEl = document.createElement('li')

    newEl.appendChild(liEl)
  }
  el.replaceWith(newEl)
  moveCursorToParagraphEnd(newEl)
  // 有内容才能聚焦
  if (!newEl.innerHTML) newEl.innerHTML = ' '
  newEl.focus()
  // 将光标定位到该标签最后的位置
  moveCursorToParagraphEnd(newEl)
  executeAfterDefaultRender(() => {
    clearElStartSpace(newEl)
  })
  return newEl.tagName.toLowerCase()
}

至此我们就完成了基础p标签向功能标签的迁移了

快捷键映射

上一部分我们实现了基础标签向功能标签的升级 ,这个过程是通过监听光标选中当前标签和用户输入并命中对应的规则完成标签升级的,本部分我将介绍如何使用快捷键完成对应的标签快速转换行为,这次扩展是对已完成功能的再次复用,因此进需要进行边界处理即可,在本次简易实现中,我主要对h1-h6,ctrl+c,ctrl+v和ctrl+x 进行了实现和行为处理

html 复制代码
 <div class=" w-full h-full outline-none"
       cy-markdown
       style="padding:5vw"
       @input="markdownChangeHandler"
       @keydown.ctrl="markdownChangeHandler"
       @keydown.delete.exact="tagContentRemoveHandler"
       @keydown.enter.exact="inputEnterCreateEle"
       @keydown.ctrl.x.exact="cutTagHandler"
       @keydown.ctrl.v.exact="pasteTagHandler"
       @keydown.ctrl.1.prevent.exact="mapTagH('h1')"
       @keydown.ctrl.2.prevent.exact="mapTagH('h2')"
       @keydown.ctrl.3.prevent.exact="mapTagH('h3')"
       @keydown.ctrl.4.prevent.exact="mapTagH('h4')"
       @keydown.ctrl.5.prevent.exact="mapTagH('h5')"
       @keydown.ctrl.6.prevent.exact="mapTagH('h6')"
       contenteditable="true"
       ref="markdownContainerRef">

  </div>

由于和浏览器的切换Tabs快捷键存在冲突,因此需要手动阻止触发浏览器的默认行为,使用 prevent 修饰符进行处理,这里说一下 exact 此修饰符的作用是只有精准的按下ctrl.1等前缀按键时才会进行事件触发,默认是只要包含这两个按键就能触发

下面的函数主要处理mapH标签时的行为,同时会进行一些边界处理

typescript 复制代码
/**
 * @description: 快捷键映射
 * @param {string} tagName
 * @return {*}
 */
export function mapTagH(tagName: string) {
  const focusEl = getCurSelectedElement()
  if (!focusEl) return
  // 过滤掉 editor 作为focusEl元素的情况
  // debugger
  if (focusEl.contentEditable === 'true') {
    const tagP = document.createElement('p')
    focusEl.appendChild(tagP)
    shortcutMap(tagName, tagP)
  } else shortcutMap(tagName, focusEl)
}


/**
 * @description: 快捷键映射触发转换后的标签
 * @param {string} tag
 * @return {*}
 */
export function shortcutMap(tag: string, el: HTMLElement) {
  if (tag.includes('h')) {
    tagHandler(el, tag, { id: getRandId(), content: el.innerHTML })
  }
}

export function tagHandler(
  el: HTMLElement,
  tag: string,
  props?: { class?: string; id?: string; content: string }
) {

  tagReplace(el, tag, props)
}

/**
 * @description: 将目标dom对象替换为指定的标签元素
 * @param {HTMLElement} el
 * @param {string} tag
 * @return {*}
 */
export function tagReplace(el: HTMLElement, tag: string, option?: { [prop: string]: any }) {
  const newEl = document.createElement(tag)
  if (option) {
    if (option.class) newEl.className = option.class
    if (option.id) newEl.id = option.id
    if (option.content) newEl.innerHTML = option.content
  }
  if (newEl.tagName === 'UL' || newEl.tagName === 'OL') {
    const liEl = document.createElement('li')
    // liEl.innerHTML = '&nbsp;'
    newEl.appendChild(liEl)
  }
  el.replaceWith(newEl)
  moveCursorToParagraphEnd(newEl)
  // 有内容才能聚焦
  if (!newEl.innerHTML) newEl.innerHTML = '&nbsp;'
  newEl.focus()
  // 将光标定位到该标签最后的位置
  moveCursorToParagraphEnd(newEl)
  executeAfterDefaultRender(() => {
    clearElStartSpace(newEl)
  })
  return newEl.tagName.toLowerCase()
}

对于ctrl+c,v,x等行为的处理就非常简单了,只需要判断对应的边界即可,因为浏览器默认支持这些功能,并且复制或者剪切的部分在 拥有 contenteditable="true" 时,可以自动解析复制的标签进行视图渲染

typescript 复制代码
/**
 * @description: 在使用ctrl + x 剪切时调用,用于规避剪切时的标签遗留3
 * @param {KeyboardEvent} e
 * @return {*}
 */
export function cutTagHandler(e: KeyboardEvent) {
  const selectNodes = getAllSelectNode()
  if (!selectNodes[0]) return
  const firstEle: HTMLElement = selectNodes[0]
  // 需要比默认行为慢 , 级剪切行为
  executeAfterDefaultRender(() => {
    if (firstEle.tagName !== 'P' && !tagContentCleaner(firstEle.innerHTML)) {
      // 将目标标签映射为P标签
      tagReplace(firstEle, 'p')
      if (firstEle.tagName.includes('H')) {
        const el = document.querySelector('[contenteditable]')
        contentParse(el.innerHTML)
      }
    }
  })
}

/**
 * @description: 当 ctrl+v复制元素时调用,删除掉当前站位的p元素
 * @param {KeyboardEvent} e
 * @return {*}
 */
export function pasteTagHandler(e: KeyboardEvent) {
  const allEl = getAllSelectNode()
  const el: any = allEl?.[0]
  if (!el) return
  // function调用默认谁调用this指向谁,但使用了箭头函数后,this和当前作用域一致
  executeAfterDefaultRender(() => {
    if (el.tagName === 'P') {
      el.remove()
    }
  })
}

大纲树构建

上一部分我说明了标签是如何完成转换的,快捷键是如何映射上的和如何完成处理的,那么这部分我将介绍大纲树如何构建的,大纲视图对于标题的差异化渲染是如何完成的,以及如何完成标签导航的

首先哪些类型的节点需要被大纲树收集呢?没错h1-h6标签,最简单实现的数据结构就是数组,不过使用数组存储h1-h6标题肯定是不合适的,因为这体现不了层次结构和折叠效果,因此只能使用树来进行大纲树的构建,由于这棵树的挂载逻辑比较复杂,因为涉及到多层级树查找挂载节点问题,因此必须要有对应的策略,对于的挂载策略如下

  1. 这是一个多根节点的树结构,在没有树节点时,挂载的第一个节点无论是什么层级的那它应该都是根节点
  2. 当大纲树中已经存在节点,则它需要查找到离其最近的一颗树节点,收集其的全路径
  3. 获得最近一棵树的全路径后,递归的去查看当前节点层级是否能够完成挂载(通过树的层级去做判断),如果可以则去查找对应的挂载点,如果不行则需要创建一个新的树节点

对应实现代码如下

typescript 复制代码
import { useOutlineStore } from "@/stores"
import type {TreeNode} from '@/layouts/component/outline/types'
import { tagContentCleaner } from "@/utils"

/**
 * @description: 匹配对应标签,并以标签进行分组返回数据
 * @param {string} htmlStr
 * @return {*}
 */
export function contentParse(htmlStr: string) {
  const el = getCurSelectedElement()
  if (!(el?.tagName.includes('H'))) return
  const patternHTagReg = /<h.*?>(.*?)<\/h.*?>/g
  const arr: Array<string> = [...htmlStr.matchAll(patternHTagReg)].map((hTag) => hTag[0])
  const hArr: Array<string> = []
  arr.forEach((hTag: string) => {
    const hPattern = /id=(\d+)/
    const matchRes = hTag.match(hPattern)
    const id = matchRes?.[1]
    const target = hArr.find((h: string) => h.includes(id))
    target || hArr.push(hTag)
  })

  buildOutlineTree(hArr)
}

/**
 * @description: 构建大纲树,结点上需要挂载 id(唯一标识),el(对应dom实例),label(对应dom实例的内容),tagName(对应dom实例的标签)四个属性
 * @param {string[]} arr 存储所有匹配到的h标签,用于构建大纲树
 * @param {HTMLElement} el 当前操作的元素,如果是h时,则进行树的构建且引用对应关联的dom标签
 * @return {*}
 */
export function buildOutlineTree(arr: string[]) {
  const outlineStore = useOutlineStore()
  if (JSON.stringify(arr) === '[]') {
    // 没有H了,大纲为空清空大纲
    outlineStore.tree = []

    return
  }

  const tree: Array<TreeNode> = []
  for (let i = 0; i < arr.length; i++) {
    // 根结点
    if (tree.length === 0) {
      tree.push(treeNodeWrapper(arr[i]))
      continue
    }
    // 深度递归查找父结点所处位置找出父结点在 树中所处的位置
    const nodePath = searParentTreeNodePath(tree, treeNodeWrapper(arr[i - 1]))
    if (!nodePath) {
      return
    }
    // 当前结点和上一个结点比较,如果比他大则直接挂载到其children下,并赋予layer属性
    if (priorCompare(treeNodeWrapper(arr[i]).tagName, treeNodeWrapper(arr[i - 1]).tagName)) {
      // 当前结点比上一个结点大,需要挂载到其后
      const lastNodeIndex = nodePath.length - 1
      const lastNode = nodePath[lastNodeIndex]

      const node = treeNodeWrapper(arr[i])
      const layer = lastNodeIndex + 1
      //  最终包裹层级为搜索路径的长度,如果有一个父级结点,则层级为1,两个父级结点则成绩为2...
      
      mountNodeToParentNode(lastNode, node, layer)

      continue
    }

    // 当前结点比上一个结点小,则需要从低到搞顺延查找其路径下的挂载点位
    findMountParentNode(nodePath, treeNodeWrapper(arr[i]), tree)
  }
  outlineStore.tree = tree
}


/**
 * @description: 将一个string包装为树结点对象
 * @param {string} tagString 是一个标签的字符串形式,型如  <h1>content</h1>
 * @return {Object} 包装为虚拟结点后的值
 */
function treeNodeWrapper(tagString: string): TreeNode {
  const node: TreeNode = { el: null, label: '', tagName: '' }

  const contentPattern = /<(.*?)\s*id=(.*?)>(.*?)<\/.*?>/

  const matchRes = contentPattern.exec(tagString)

  if (matchRes) {
    // 处理错误匹配dom元素问题
    node.tagName = matchRes[1]
    const id = matchRes[2].split(' ')[0]
    let content = ''
    if (id) {
      node.id = id
      node.el = document.querySelector(`[id=${id}]`)
    }
    if (node.el) {
      // 内容断言
      const childEle = node.el?.firstElementChild
      
      if (childEle) {
        content = childEle.innerHTML
      }
      if(content){
        node.el.innerHTML = content 
      }
    }
    if (content) node.label = tagContentCleaner(content)
    else node.label = tagContentCleaner(matchRes[3])
  }
  return node
}



/**
 * @description: 在一颗树中查找出一个结点的父结点,返回该结点下所有的路径结点,如果都无法满足,则将其挂载到根结点下
 * @param {Object[]} tree 当前构建的一颗树
 * @param {string} tagStr
 * @return {*} 返回查找后的路径数组,每个索引上依次存储祖先结点,父级结点,直接父级结点.. ,方便我们挂载到对应结点上
 */
function searParentTreeNodePath(
  tree: TreeNode[],
  tagNode: TreeNode,
  treePath: TreeNode[] = []
): any {
  // 深度递归查找目标结点,将查找记录存储路径数组中

  for (let i = 0; i < tree.length; i++) {
    const node = tree[i]
    // 尝试在此路径下查找
    treePath.push(node)

    // 找到正确的路径即刻退出
    if (node.id === tagNode.id) {
      return treePath
    }

    if (node.children) {
      const path = searParentTreeNodePath(node.children, tagNode, treePath)
      if (path) return path
    }
    // 此不是正确的路径将其进行删除
    treePath.pop()
  }
}

/**
 * @description: 对两个标签的优先级进行比较
 * @param {string} tagStr1 第一个比较的值
 * @param {string} tagStr2 第二个比较的值
 * @return {boolean} 如果第一个比第二个则返回true,反之   ,如 <h6> 和 <h5> 比较,此时返回true , h6挂载到h5下
 */
function priorCompare(tagStr1: string, tagStr2: string) {
  const prior1Match = tagStr1.match(/h(\d)/)
  const prior2Match = tagStr2.match(/h(\d)/)
  if (prior1Match && prior2Match && prior1Match[1] && prior2Match[1]) {
    const prior1 = prior1Match[1]
    const prior2 = prior2Match[1]
    return Number(prior1) > Number(prior2)
  }

  return false
}



/**
 * @description:  将当前结点挂载到父结点下
 * @param {TreeNode} parentNode
 * @param {TreeNode} node
 * @return {void}
 */
function mountNodeToParentNode(parentNode: TreeNode, node: TreeNode, layer: number) {
  if (!parentNode.children) parentNode.children = []
  // 最终包裹层级为搜索路径的长度,如果有一个父级结点,则层级为1,两个父级结点则成绩为2...
  node.layer = layer
  parentNode.children.push(node)
}


/**
 * @description: 找出父级结点,并返回树的挂载
 * @param {TreeNode[]} nodePath 当前父结点的依赖路径
 * @param {TreeNode} node 当前需要挂载的结点
 * @param {TreeNode[]} tree 当前树根结点,如果树结点上都没有需要挂载的结点位置,则该结点应该挂载到根结点上
 * @return {void}
 */
function findMountParentNode(nodePath: TreeNode[], node: TreeNode, tree: TreeNode[]) {
  for (let i = nodePath.length - 1; i >= 0; i--) {
    if (priorCompare(node.tagName, nodePath[i].tagName)) {
      // 当前结点比上一个结点大,需要挂载到其后
      const layer = i + 1
      mountNodeToParentNode(nodePath[i], node, layer)
      return
    }
  }
  // 该结点需要挂载到根结点上
  tree.push(node)
}

现在大纲树完成了挂载,并且能够显示层级了,那现在就可以完成路由导航了,那路由导航如何实现呢?没错使用window的api即可完成,在挂载树节点的时候,我们通过id已经得到了dom树的引用,那么我们在点击某个树节点的时候,即可得到对应的对象,其中就可以拿到树节点关联的dom对象信息,在通过window.scrollTo即可完成对应页面的路由跳转,window.scrollTo可以快速移动到指定dom元素位置上,代码如下

vue 复制代码
<template>
  <div>
    <el-input v-model="outlineFilterText"
              placeholder="请输入" />

    <el-tree :data="outlineStore.tree"
             empty-text=""
             default-expand-all
             ref="treeRef"
             :filter-node-method="filterNode"
             :expand-on-click-node="false"
             style="background-color:#2e3033;color:white;--el-tree-node-hover-bg-color:#292524"
             @node-click="handleNodeClick">
      <template #default="{data}">

        <!-- <div :style="`padding-left:${retractConvert(data.el)}px`" -->
        <div :style="`padding-left:${retractConvert(data.tagName,data.layer)}px`"
             class="truncate">
          {{ data.label }}
        </div>
      </template>
    </el-tree>
  </div>
</template>

<script lang="ts" setup>
import { useOutlineStore } from '@/stores'
import { ref, watch } from 'vue'
import type { TreeNode } from './types'

const handleNodeClick = (data: TreeNode) => {
  const view = document.querySelector('#view-right')
  console.log('view :>> ', view)
  // 内容滑动
  view?.scrollTo({
    top: data.el.offsetTop,
    behavior: 'smooth'
  })
}
const outlineStore = useOutlineStore()

const outlineFilterText = ref<string>('')
const treeRef = ref()
watch(outlineFilterText, (text) => {
  treeRef.value.filter(text)
})
const filterNode = (value: string, data: TreeNode) => {
  if (!value) return true
  return data.label.includes(value)
}
/**
 * @description: 对不同层级的数据进行距离转换
 * @param {string} tag 标签的名称
 * @param {number} layer 当前元素标签所处的包裹(嵌套层级)层级
 * @thick   需要对不同层级间的样式进行处理,如果当前包裹层级,如3级标签在2级包裹里就为1 在,3级标签里就为0 , 因此最终公式为 标签等级-1-当前包裹层级
 * @return {*}
 */
function retractConvert(tag: string, layer: number | undefined) {
  layer = layer || 0
  if (tag.includes('h')) {
    const n = Number(tag.charAt(1))
    if (n - layer <= 0) return 0
    return (n - 1 - layer) * 18
  }
  return 0
}
</script>

<style lang="scss" scoped>
:deep(.el-tree-node__label) {
  @apply truncate;
}
:deep(.el-tree) {
  --el-tree-node-hover-bg-color: #161618;
}
</style>

实现效果

源码地址

we-del/easy_typora-typora (github.com)

最后

感谢你能看到这里,笔者能力有限在文章存在的逻辑或表达问题请多包涵,如果对某个细节点存在疑问欢迎评论区讨论,如果文章对你有帮助请用点赞,收藏和关注回应我 :)

相关推荐
浮华似水19 分钟前
简洁之道 - React Hook Form
前端
正小安2 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
_.Switch4 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光4 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   4 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发
长路 ㅤ   4 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d
Fan_web4 小时前
jQuery——事件委托
开发语言·前端·javascript·css·jquery
安冬的码畜日常4 小时前
【CSS in Depth 2 精译_044】第七章 响应式设计概述
前端·css·css3·html5·响应式设计·响应式
莹雨潇潇5 小时前
Docker 快速入门(Ubuntu版)
java·前端·docker·容器
Jiaberrr5 小时前
Element UI教程:如何将Radio单选框的圆框改为方框
前端·javascript·vue.js·ui·elementui