划词组件搭配富文本渲染使用

工具类准备:
  • 准备通用工具类库:"lodash-es":"^4.17.21",
  • 自定义工具类导入 @utils/common.ts
export const sleep = (timeoutMs: number) => {
  return new Promise((resolve) => {
    setTimeout(resolve, timeoutMs)
  })
}
export const getScrollElement = (child: HTMLElement | null): HTMLElement | null => {
  let $scrollElement: HTMLElement | null | undefined = child
  do {
    if (!$scrollElement) break
    const overflowY = getComputedStyle($scrollElement).overflowY
    if (overflowY === 'auto' || overflowY === 'scroll') {
      break
    }
  } while (($scrollElement = $scrollElement.parentElement))

  return $scrollElement || null
}
export function createUUID(a?): string {
  return a
    ? (a ^ ((Math.random() * 16) >> (a / 4))).toString(16)
    : (([1e7] as unknown as string) + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, createUUID)
}

整体demo如下:

子组件AuditComment不做展示根据需求自行封装

export interface OnlineUsers {
  reading: OnlineUserState[]
  editing: OnlineUserState[]
}

export interface BaseModel {
  id: number
  username: string
  create_time: string
  update_time: string
  user: number
}

export interface LatestDocument<T extends object> {
  versionChanged: boolean
  document: T
  content: string
}

export interface BasePatchReview<T extends BaseReview = BaseReview> {
  content?: string
  is_resolved?: boolean
  marker_data?: MarkerData
  marker_ref?: string
  parent?: number | null
  username?: string
  create_time?: string
  children?: Array<T>
  original_selection?: string
}

export interface BaseReview extends BaseModel {
  content: string
  is_resolved: boolean
  marker_data: MarkerData
  marker_ref: string
  parent: number | null
  username: string
  create_time: string
  children: BaseReview[]
  original_selection: string
  showEditor?: boolean
}

export interface MarkerData {
  id: string
  endMeta: {}
  startMeta: {}
  text: string
  parentElementText?: string
}

export interface AddCommentOptions {
  docId: number
  comment: string
  marker_data: MarkerData | null
  marker_ref: string
  original_selection: string
  parent?: number
}
<script setup lang="tsx" generic="T extends object">
import { usePageTranslate } from './use-page-translate'
import { useMessage } from '@/hooks/useMessage'
import { MessageOutlined } from '@ant-design/icons-vue'
import { sleep, createUUID, getScrollElement } from '@/utils/common'
import { AddCommentOptions, BasePatchReview, BaseReview, LatestDocument, MarkerData, OnlineUsers  } from './mark'
import { isEmpty } from 'lodash-es'
import AuditComment from '@/components/YAuditComment/index.vue'
import { removeOrphanMark } from './remove-orphan-mark'
import { syncMark } from '../content-diff/sync-mark'
import { smoothScroll } from '@/utils/smooth-scroll'

const auditComment = ref<InstanceType<typeof AuditComment>>()
const { createMessage, createConfirm } = useMessage()
const { isPageTranslated } = usePageTranslate()

const showIcon = ref(false)
const showCommentWithHighlightId = ref('')
const iconPosition = ref({ top: 0, left: 0 })
const commentPosition = ref({ top: 0 })
const reviews = ref<BaseReview[]>([])
const validReviews = ref<BaseReview[]>([])
const currentReview = ref<BaseReview>({} as BaseReview)

let $scrollElement: HTMLElement
let $renderer: HTMLElement
let selectionRange: Range | null = null

const props = withDefaults(
  defineProps<{
    /**
     * 文档渲染标签选择器,从插槽中获取
     */
    editorSelector: string
    /**
     * 文档内容,从插槽中获取
     */
    documentContent: string
    /**
     * 在线用户(选填),有人编辑时需要风险提示
     */
    onlineUsers?: OnlineUsers
    /**
     * 文档编号
     */
    documentId: number
    /**
     * 模块名,提示时用到。比如 `文档`,`需求`
     */
    moduleName?: string
    /**
     * 获取划词列表
     */
    onFetchReviews(docId: number): Promise<BaseReview[]>
    /**
     * 获取最新文档,并确认最新文档是高于当前版本
     */
    onFetchLatestDocument(docId: number, highLightId: string): Promise<LatestDocument<T>>
    /**
     * 用户确认重载到最新文档
     */
    onResetToLatestDocument(document: T): void
    /**
     * 增加或者删除划词后的文档内容,用于保存到各自业务表
     */
    onSaveDocument(docId: number, content: string): Promise<void>
    /**
     * 是否显示划词图标
     */
    shouldShowIcon?: (range: Range) => boolean | Promise<boolean>
    /**
     * 划词图标显隐变化
     */
    onIconVisibleChanged?: (visible: boolean) => void
    /**
     * 划词弹窗显隐变化
     */
    onDialogVisibleChanged?: (visible: boolean) => void
    /**
     * 添加划词或者划词回复
     */
    onAddComment(options: AddCommentOptions, isReply: boolean): Promise<any>
    /**
     * 编辑划词内容
     */
    onEditComment(id: number, review: BasePatchReview): Promise<any>
    /**
     * 划词已解决
     */
    onResolveComment(commentId: number): Promise<any>
    /**
     * 删除划词
     */
    onDeleteComment(commentId: number, highlightId: string, isDeletingMark: boolean): Promise<any>
  }>(),
  {
    moduleName: '',
  },
)

const markOptions = props
const {
  documentId: docId,
  documentContent,
  onlineUsers,
  editorSelector,
  moduleName,
} = toRefs(props)

onMounted(async () => {
  $renderer = document.querySelector<HTMLDivElement>(editorSelector.value)!
  $scrollElement = getScrollElement($renderer)!

  document.addEventListener('click', handleDocumentClick)
  $renderer.addEventListener('mouseup', handleMouseUp)
  $renderer.addEventListener('selectstart', handleDocumentClick)
  $renderer.addEventListener('click', handleClickMarkNode)
})

onBeforeUnmount(() => {
  $renderer.removeEventListener('mouseup', handleMouseUp)
  $renderer.removeEventListener('selectstart', handleDocumentClick)
  $renderer.removeEventListener('click', handleClickMarkNode)
  document.removeEventListener('click', handleDocumentClick)
})

// 请求划词内容
watch(
  () => docId.value,
  async (id) => {
    showCommentWithHighlightId.value = ''
    showIcon.value = false
    auditComment.value?.removeCache()

    if (id) {
      reviews.value = await markOptions.onFetchReviews(id)
    } else {
      reviews.value = []
      currentReview.value = {} as BaseReview
    }
  },
  { immediate: true },
)

// 划词或者文档变化后,重新为划词标记着色
watch(
  () => [reviews.value, documentContent.value] as const,
  ([reviews]) => {
    if (!docId.value) return

    for (let i = 0; i < reviews.length; i++) {
      if (currentReview.value.id === reviews[i].id) {
        currentReview.value = reviews[i]
        break
      }
    }

    const marks = $renderer.querySelectorAll<HTMLSpanElement>('.inline-comment-marker')
    validReviews.value = []
    marks.forEach(($node) => {
      const highlightId = $node.dataset.highlightId
      if (!highlightId) return
      // 出现过 marker_data === null 的情况,但也许只有开发环境有
      const review = reviews.find((item) => item.marker_data?.id === highlightId)
      if (!review) return
      validReviews.value.push(review)

      if (review.is_resolved) {
        $node.classList.remove('valid')
        $node.classList.add('resolved')
      } else {
        $node.classList.add('valid')
      }
      if (review.marker_ref === currentReview.value.marker_ref) {
        $node.classList.add('active')
      }
    })
    validReviews.value = [...new Set(validReviews.value)]
  },
  { deep: true },
)

// 控制icon位置
watch(
  () => showIcon.value,
  (showing) => {
    markOptions?.onIconVisibleChanged?.(showing)
    if (!showing) return

    const rects = Array.from(selectionRange!.getClientRects())
    const firstTop = rects[0].top
    let mostLeft = Infinity
    let mostRight = -Infinity
    rects
      .filter((rect) => rect.top === firstTop)
      .forEach((rect) => {
        mostLeft = Math.min(rect.left, mostLeft)
        mostRight = Math.max(rect.right, mostRight)
      })
    const wrapperRect = $renderer.parentElement!.getBoundingClientRect()
    let iconTop = firstTop - wrapperRect.top
    if (firstTop - wrapperRect.top < 30) {
      iconTop += rects[0].height + 10
    } else {
      iconTop -= 40
    }
    iconPosition.value = {
      top: iconTop,
      left: (mostLeft + mostRight) / 2 - wrapperRect.left - 16,
    }
  },
)

// 控制弹窗显隐和位置
watch(
  () => currentReview.value,
  async (review, prevReview) => {
    if (review.marker_ref && review.marker_ref === prevReview.marker_ref) {
      return
    }

    // 关闭划词
    if (isEmpty(review) && !showCommentWithHighlightId.value) {
      await resetScrollbar()
      inactivateMarkNodes(prevReview)
      return
    }

    // 新保存的划词
    if (!isEmpty(review) && isEmpty(prevReview)) {
      const nodes = getActiveMarkNodes()
      if (nodes.length && nodes[0].dataset.highlightId === review.marker_ref) {
        return
      }
    }

    // 新打开的划词 | 切换划词
    inactivateMarkNodes(prevReview)
    const highlightId = review.marker_ref || showCommentWithHighlightId.value
    showCommentWithHighlightId.value = highlightId
    activateMarkNodes(highlightId)
    setDialogPosition()
  },
)

// 控制body标签的滚动条行为
watchEffect(() => {
  document.body.classList[showCommentWithHighlightId.value ? 'add' : 'remove'](
    'hide-body-overflow-when-marking',
  )
})

// 浏览器调整尺寸、侧边栏开关。导致划词弹窗和划词位置不统一
{
  const observer = new ResizeObserver(() => {
    const $node = getActiveMarkNodes()[0]
    if (!$node) return

    const scrollRect = $scrollElement.getBoundingClientRect()
    const nodeRect = $node.getBoundingClientRect()
    commentPosition.value.top = nodeRect.top - $renderer.getBoundingClientRect().top

    if (nodeRect.top < scrollRect.top || nodeRect.bottom > scrollRect.bottom) {
      $scrollElement.scrollTop +=
        (nodeRect.top + nodeRect.bottom) / 2 - ((scrollRect.top + scrollRect.bottom) * 2) / 5
    }
  })
  watch(
    () => showCommentWithHighlightId.value,
    () => {
      if (showCommentWithHighlightId.value) {
        observer.observe($renderer)
      } else {
        observer.unobserve($renderer)
      }
    },
  )

  onBeforeUnmount(() => {
    observer.disconnect()
  })
}

const getActiveMarkNodes = () => {
  return $renderer.querySelectorAll<HTMLSpanElement>('.inline-comment-marker.active')
}

const formatDomNode = ($node: Node, offset: number) => {
  return [3, 4, 8].includes($node.nodeType)
    ? { $node, offset }
    : { $node: $node.childNodes[offset], offset: 0 }
}

const handleClickMarkNode = (e: MouseEvent) => {
  const target = e.target as HTMLSpanElement
  const highlightId = target.dataset.highlightId
  if (highlightId) {
    const review = reviews.value.find((item) => item.marker_ref === highlightId)
    if (review) {
      currentReview.value = review
    }
  }
}

const getSelectedTextNodes = () => {
  const nodes: Text[] = []
  if (!selectionRange) return nodes
  const start = formatDomNode(selectionRange.startContainer, selectionRange.startOffset)
  const end = formatDomNode(selectionRange.endContainer, selectionRange.endOffset)

  const travelNodes = [selectionRange.commonAncestorContainer]
  let currentNode: Node | undefined
  let inRange = false
  while ((currentNode = travelNodes.shift())) {
    // 深度优先
    travelNodes.unshift(...Array.from(currentNode.childNodes))

    if (currentNode === start.$node) {
      if (currentNode.nodeType === 3) {
        const reminder = (currentNode as Text).splitText(start.offset)
        nodes.push(reminder)
        if (currentNode === end.$node) {
          reminder.splitText(end.offset - start.offset)
          break
        } else {
          inRange = true
        }
      }
    } else if (currentNode === end.$node) {
      if (currentNode.nodeType === 3) {
        // https://worklink.yealink.com/issues/bug/1241322
        // 三击选中情况下,选区包含下一段文字,但是end.offset其实是0,即最后得到的是空字符串
        nodes.push((currentNode as Text).splitText(end.offset).previousSibling as Text)
      }
      break
    } else if (inRange && currentNode.nodeType === 3) {
      nodes.push(currentNode as Text)
    }
  }

  return nodes.filter((node) => !!node.nodeValue)
}

const handleMouseUp = async () => {
  // mouseup之后选中节点才进行取消选中操作
  await sleep(2)
  const selection = window.getSelection()
  if (!selection || selection.isCollapsed) {
    selectionRange = null
    showIcon.value = false
  } else {
    selectionRange = selection.getRangeAt(0)
    if (
      selectionRange.endOffset === 0 &&
      selectionRange.startOffset === selectionRange.startContainer.textContent?.length
    ) {
      // 双击没有选中字符串
      return
    }

    if (
      selectionRange &&
      (!markOptions.shouldShowIcon || (await markOptions.shouldShowIcon(selectionRange)))
    ) {
      showIcon.value = true
    }
  }
}

const handleDocumentClick = () => {
  showIcon.value = false
}

const handleShowCommentContent = () => {
  if (isPageTranslated()) return
  showIcon.value = false

  const nodes = getSelectedTextNodes()
  if (nodes.length === 0) return

  for (let i = 0; i < nodes.length; ++i) {
    const parent = nodes[i].parentElement
    if (
      parent &&
      parent.nodeName === 'SPAN' &&
      (parent as HTMLSpanElement).classList.contains('inline-comment-marker')
    ) {
      const highlightId = parent.dataset.highlightId
      for (let i = 0; i < reviews.value.length; ++i) {
        if (highlightId === reviews.value[i].marker_ref) {
          currentReview.value = reviews.value[i]
          $renderer.normalize()
          return
        }
      }
    }
  }

  const uuid = createUUID()
  nodes.forEach(($node) => {
    const $span = document.createElement('span')
    $span.classList.add('inline-comment-marker', 'valid')
    $span.dataset.highlightId = uuid
    $span.appendChild($node.cloneNode(true))
    $node.replaceWith($span)
  })
  currentReview.value = {} as BaseReview
  showCommentWithHighlightId.value = uuid
  $renderer.normalize()
}

const handleSwitchReview = async (review: BaseReview) => {
  const $node = $renderer.querySelector<HTMLSpanElement>(
    `span[data-highlight-id="${review.marker_ref}"]`,
  )
  if (!$node) return
  const scrollRect = $scrollElement.getBoundingClientRect()
  const nodeRect = $node.getBoundingClientRect()
  await smoothScroll(
    $scrollElement,
    $scrollElement.scrollTop +
      (nodeRect.top + nodeRect.bottom) / 2 -
      ((scrollRect.top + scrollRect.bottom) * 2) / 5,
  )
  currentReview.value = review
}

const setDialogPosition = async () => {
  const $firstTag = getActiveMarkNodes()[0]
  const firstTop = $firstTag.getBoundingClientRect().top
  await nextTick(() => {
    const latestTop = $firstTag.getBoundingClientRect().top
    // span > span 时, offsetTop无效
    commentPosition.value.top = latestTop - $renderer.getBoundingClientRect().top
    $scrollElement.scrollTop += latestTop - firstTop
    markOptions.onDialogVisibleChanged?.(true)
    // 评论太长超过屏幕时,滚动条会跳到输入框的位置,因此只在新增划词时获得焦点
    if (isEmpty(currentReview.value)) {
      auditComment.value?.inputFocus()
    }
  })
}

const resetScrollbar = async () => {
  const $firstTag = getActiveMarkNodes()[0]
  const firstTop = $firstTag.getBoundingClientRect().top
  await nextTick(() => {
    $scrollElement.scrollTop += $firstTag.getBoundingClientRect().top - firstTop
    markOptions.onDialogVisibleChanged?.(false)
  })
}

const activateMarkNodes = (highlightId: string) => {
  $renderer
    .querySelectorAll<HTMLSpanElement>(`span[data-highlight-id="${highlightId}"]`)
    .forEach(($node) => {
      $node.classList.add('active')
    })
}

const inactivateMarkNodes = (review: BaseReview) => {
  getActiveMarkNodes().forEach(($node) => {
    if (review.marker_ref) {
      $node.classList.remove('active')
    } else {
      $node.replaceWith($node.firstChild!.cloneNode())
    }
  })
  $renderer.normalize()
}

const handleCloseComment = () => {
  currentReview.value = {} as BaseReview
  showCommentWithHighlightId.value = ''
}

const handleAddComment = async (comment: string) => {
  if (isPageTranslated()) return

  if (comment === '') {
    createMessage.warning('内容不能为空')
    return
  }

  const {
    versionChanged,
    document,
    content: latestContent,
  } = await markOptions.onFetchLatestDocument(docId.value, showCommentWithHighlightId.value)

  if (versionChanged) {
    createConfirm({
      iconType: 'warning',
      okText: `更新${moduleName.value}`,
      title: `批注失败,${moduleName.value}内容已变更,请更新${moduleName.value}后再进行批注(批注内容已缓存)`,
      onOk: async () => {
        currentReview.value = {} as BaseReview
        showCommentWithHighlightId.value = ''
        markOptions.onResetToLatestDocument(document)
        reviews.value = await markOptions.onFetchReviews(docId.value)
      },
    })
    return
  }
  const isReply = !isEmpty(currentReview.value)

  const markerData: MarkerData = {
    id: showCommentWithHighlightId.value,
    startMeta: {},
    endMeta: {},
    text: '',
  }
  if (!isReply) {
    const nodes = getActiveMarkNodes()
    markerData.parentElementText = nodes[0].parentElement?.innerText
    nodes.forEach(($node) => {
      markerData.text += $node.textContent
    })
  }

  await markOptions.onAddComment(
    {
      docId: docId.value,
      comment,
      marker_data: isReply ? null : markerData,
      marker_ref: isReply ? currentReview.value.marker_ref : showCommentWithHighlightId.value,
      original_selection: isReply ? currentReview.value.original_selection : markerData.text,
      parent: isReply ? currentReview.value.id : undefined,
    },
    isReply,
  )
  createMessage.info('评论成功')
  auditComment.value?.removeCache()
  reviews.value = await markOptions.onFetchReviews(docId.value)
  currentReview.value = reviews.value.find((item) => item.marker_ref === markerData.id)!

  if (!isReply) {
    const mergedContent = syncMark(
      removeOrphanMark(latestContent, reviews.value),
      removeOrphanMark($renderer.innerHTML, reviews.value),
    )
    await markOptions.onSaveDocument(docId.value, mergedContent)
  }
}

const handleEditComment = async (id: number, review: BasePatchReview) => {
  await markOptions.onEditComment(id, review)
  createMessage.info('修改评审内容成功')
  reviews.value = await markOptions.onFetchReviews(docId.value)
}

const handleResolveComment = async () => {
  const result = await markOptions.onResolveComment(currentReview.value.id)
  if (result) {
    createMessage.info('评审内容标记已解决')
    reviews.value = await markOptions.onFetchReviews(docId.value)
  }
}

/**
 * 不带id说明是删除整个划词。
 * 带id说明是删除回复
 */
const handleDeleteComment = (id?: number) => {
  if (!id && isPageTranslated()) return

  createConfirm({
    iconType: 'warning',
    title: () => <span style="color: #f04134;">是否删除评审内容?</span>,
    onOk: async () => {
      const {
        versionChanged,
        document,
        content: latestContent,
      } = await markOptions.onFetchLatestDocument(docId.value, showCommentWithHighlightId.value)

      if (versionChanged) {
        createConfirm({
          iconType: 'warning',
          okText: '更新描述',
          title: `删除评审失败,描述已被修改,点击更新描述后再进行评审`,
          onOk: async () => {
            showCommentWithHighlightId.value = ''
            markOptions.onResetToLatestDocument(document)
            reviews.value = await markOptions.onFetchReviews(docId.value)
            // $renderer.innerHTML = content
          },
        })
        return
      }
      await markOptions.onDeleteComment(
        id || currentReview.value.id,
        showCommentWithHighlightId.value,
        !id,
      )
      createMessage.info('删除成功')
      if (!id) {
        currentReview.value = {} as BaseReview
        showCommentWithHighlightId.value = ''
      }
      reviews.value = await markOptions.onFetchReviews(docId.value)
      if (!id) {
        const mergedContent = syncMark(
          removeOrphanMark(latestContent, reviews.value),
          removeOrphanMark($renderer.innerHTML, reviews.value),
        )
        await markOptions.onSaveDocument(docId.value, mergedContent)
      }
    },
  })
}
</script>

<template>
  <y-audit-comment
    v-if="showCommentWithHighlightId"
    ref="auditComment"
    :review="currentReview"
    :top="commentPosition.top"
    :valid-reviews="validReviews"
    @update:review="handleSwitchReview"
    @close-comment="handleCloseComment"
    @add-comment="handleAddComment"
    @edit-comment="handleEditComment"
    @resolve-comment="handleResolveComment"
    @delete-comment="handleDeleteComment"
  ></y-audit-comment>
  <div
    v-show="showIcon"
    class="message-icon"
    :style="{
      top: iconPosition.top + 'px',
      left: iconPosition.left + 'px',
    }"
    @click="handleShowCommentContent"
  >
    <a-tooltip>
      <template #title>评审</template>
      <message-outlined class="msg-btn" />
    </a-tooltip>
  </div>
  <editor-tips
    v-if="showCommentWithHighlightId"
    :enable="!!onlineUsers && onlineUsers.editing.length >= 2"
    tips="其他人正在编辑,您的评审位置可能会被修改,导致评审无效。请沟通后再操作。"
    :never-show-on-manual-close="false"
  ></editor-tips>
</template>

<style lang="less" scoped>
.message-icon {
  position: absolute;
  font-size: 18px;
  z-index: 3000;
  width: 32px;
  background-color: #fff;
  cursor: pointer;
  box-shadow:
    0 8px 12px rgb(0 0 0 / 8%),
    0 0 4px rgb(0 0 0 / 8%);
  border-radius: 8px;
  padding: 2px 8px;

  .msg-btn {
    color: @primary-color;
  }

  .close-btn {
    color: #f04134;
    margin-left: 4px;
  }

  :global(body.hide-body-overflow-when-marking) {
    // https://worklink.yealink.com/issues/bug/1242220
    // 长文本从下往上平滑滚动时body可能出现滚动条导致重新计算了表格宽度,从而阻止了平滑滚动
    overflow: hidden !important;
  }
}
</style>

补充所需相关hook

* 移除标记:
import { BaseReview } from './mark'
export const EditorDocumentDirectory = 'data-editor-document-directory'

export const removeOrphanMark = (content: string, reviews: BaseReview[]) => {
  const $wrapper = document.createElement('div')
  $wrapper.innerHTML = content
  $wrapper.querySelectorAll<HTMLSpanElement>('span.inline-comment-marker').forEach(($mark) => {
    $mark.classList.remove('valid', 'active', 'resolved')

    const highlightId = $mark.dataset.highlightId
    if (!highlightId || reviews.every((review) => review.marker_ref !== highlightId)) {
      // $mark.children是Html Element集合,无法识别textNode
      if ($mark.childNodes.length) {
        $mark.replaceWith($mark.firstChild!.cloneNode(true))
      } else {
        $mark.remove()
      }
    }
  })

  // 代码高亮克隆了 新的code标签
  // 代码高亮使用了 行号
  // 代码高亮使用了 语言标签
  $wrapper
    .querySelectorAll(
      ['pre > code.highlight-code', 'pre > .code-line-numbers', 'pre > .code-language'].join(','),
    )
    .forEach(($node) => {
      $node.remove()
    })

  // 旧数据包含大量干扰导致划词同步失败
  $wrapper.querySelectorAll<HTMLSpanElement>('span[data-highlight-id]').forEach(($mark) => {
    const highlightId = $mark.dataset.highlightId
    if (!highlightId) {
      $mark.removeAttribute('data-highlight-id')
    }
  })

  $wrapper.querySelectorAll<HTMLTableColElement>('colgroup').forEach(($colgroup) => {
    $colgroup.remove()
  })

  $wrapper.querySelectorAll<HTMLDivElement>('div.tableWrapper').forEach(($tableWrapper) => {
    const child = $tableWrapper.firstChild
    if (child?.nodeName === 'TABLE') {
      $tableWrapper.replaceWith(child.cloneNode(true))
    }
  })

  //去掉动态生成目录、h标签id
  $wrapper.querySelector<HTMLElement>(`ul[${EditorDocumentDirectory}]`)?.remove()
  $wrapper.querySelectorAll<HTMLElement>('h1,h2,h3,h4,h5,h6').forEach(($hNode) => {
    $hNode.removeAttribute('id')
  })

  $wrapper.normalize()
  return $wrapper.innerHTML
}
* 同步标记(syncMark)

这块放文本对比中说明

* 平滑滚动(smoothScroll)
/**
 * 滚动条平滑地滚动到目标高度,并返回Promise
 */
export const smoothScroll = ($scrollElement: HTMLElement, top: number) => {
  top = Math.max(0, Math.floor(top))

  return new Promise((resolve, reject) => {
    if ($scrollElement.scrollTop === top) {
      resolve(undefined)
      return
    }

    const timer = setTimeout(() => {
      $scrollElement.removeEventListener('scroll', handle)
      reject(
        new Error('滚动条滚动失败,目标高度:' + top + ',当前高度:' + $scrollElement.scrollTop),
      )
    }, 4000)

    const handle = () => {
      const actualTop = $scrollElement.scrollTop
      // 浏览器缩放时,实际位置会有偏差
      const scrollFinished = Math.abs(actualTop - top) < 1

      if (scrollFinished) {
        clearTimeout(timer)
        $scrollElement.removeEventListener('scroll', handle)
        resolve(undefined)
      }
    }

    $scrollElement.addEventListener('scroll', handle)
    $scrollElement.scrollTo({ top, behavior: 'smooth' })
  })
}
  • 浏览器页面翻译(usePageTranslate)
import { useMessage } from '@/hooks/useMessage' // 此处使用ant-design消息组件即可

export const usePageTranslate = () => {
  const { createConfirm } = useMessage()
  const id = computed(() => 'translate-testing-' + Date.now())

  onMounted(() => {
    const element = document.createElement('span')
    element.id = id.value
    element.style.position = 'fixed'
    // 不能偏离屏幕太多,否则edge浏览器不会翻译
    element.style.top = '-20px'
    element.style.left = '0'
    element.innerHTML = '翻译'
    document.body.appendChild(element)
  })

  onUnmounted(() => {
    document.body.removeChild(document.getElementById(id.value)!)
  })

  return {
    isPageTranslated() {
      const word = document.getElementById(id.value)?.innerText
      if (word !== '翻译') {
        createConfirm({
          content: 'Marking words failed due to browser translation.',
          iconType: 'warning',
          okText: 'Refresh Page',
          cancelText: 'Cancel',
          onOk() {
            window.location.reload()
          },
        })
        return true
      }
      return false
    },
  }
}

愚者
12 声望3 粉丝