划词组件搭配富文本渲染使用
工具类准备:
- 准备通用工具类库:
"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
},
}
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。