基于tiptap编辑的富文本内容展示
主要涉及一些常用的功能及兼容(部分展示与富文本编辑时有出入)
- 代码高亮
- 表格宽度自适应
- 表头固定
- 目录
- 富文本内容划词评论
需要安装图片展示的插件:"v-viewer":"^3.0.10",
demo如下:
<script lang="ts" setup>
// 插件里使用了element-plus的复选框组件
import 'element-plus/es/components/checkbox/style/css.mjs'
import { toRefs } from 'vue'
import { api as viewImage } from 'v-viewer'
import { useTableEnhancer } from './use-table-enhancer'
import { useTableColumnWidth } from './use-table-column-width'
import { useHighlightSyntax } from '@/components/YEditorRenderer/use-highlight-syntax'
import { useDirectory } from '@/components/YEditorRenderer/use-directory'
const props = withDefaults(
defineProps<{
/**
* @deprecated
*/
id?: string
content: string
enableFixedHeader?: boolean
/**
* 是否需要目录。默认值:`false`
*/
enableDirectory?: boolean
rendererClass?: string
}>(),
{
id: 'demand-editor',
enableFixedHeader: true,
enableDirectory: false,
rendererClass: '',
},
)
const { content, id, enableFixedHeader, enableDirectory, rendererClass } = toRefs(props)
const emits = defineEmits(['update:editorContent', 'onClick'])
const uniqueId = computed(() => 'editor-render-' + Date.now() + Math.random())
const editorSelector = computed(() => `[data-id="${uniqueId.value}"]`)
const handleEditorClick = (e: any) => {
if (e.target.tagName === 'IMG') {
viewImage({ images: [e.target.src] })
return
}
emits('onClick', e)
}
useHighlightSyntax(editorSelector.value + ' pre', content)
useTableColumnWidth(content, editorSelector.value)
useTableEnhancer(editorSelector.value, { enableFixedHeader })
useDirectory(editorSelector.value, content, { enableDirectory })
</script>
<template>
<div class="wrapper">
<div
:id="id"
:data-id="uniqueId"
class="custom-editor el-tiptap-editor__content render-editor-content"
:class="rendererClass"
@click="handleEditorClick"
v-html="content"
></div>
<slot name="mark" :selector="editorSelector" :content="content"></slot>
</div>
</template>
<style lang="scss" scoped>
.wrapper {
position: relative;
display: flex;
flex-direction: row;
width: 100%;
margin-top: 8px;
// 请勿开启overflow-y=auto,否则无法动态寻找滚动父节点
overflow: hidden;
}
:deep(pre) {
code:not(.highlight-code) {
color: transparent !important;
&::selection {
color: transparent !important;
}
}
// pre下只有一个code标签则说明是因为某种原因导致高亮逻辑未被触发
code:only-child {
color: #fff !important;
padding: 10px !important;
}
> .code-language {
color: #aaa;
position: absolute;
right: 0px;
top: 0px;
font-size: 12px;
background-color: #aaa;
color: #0d0d0d;
display: inline-block;
padding: 0px 5px;
line-height: 14px;
border-radius: 6px;
pointer-events: none;
}
> code.highlight-code {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
pointer-events: none;
color: #fff;
}
}
.render-editor-content {
flex: 1;
min-height: 50px;
height: 100%;
// 请勿开启overflow-y=auto,否则无法动态寻找滚动父节点
overflow: hidden;
:deep(.custom-editor-directory) {
a:hover {
text-decoration: underline;
}
}
}
</style>
其中的表头固定、自适应表格宽度、高亮代码、生成目录分别封装成hooks
工具函数 @utils/common.ts
需要准备插件 "highlight.js":"^11.9.0"
export const EditorDocumentDirectory = 'data-editor-document-directory'
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 const injectCodeLineNumbers = ($lineNumbers: Element, lineCount: number) => {
let spans: string = ''
for (let i = 1; i <= lineCount; ++i) {
spans += `<span>${i}</span>`
}
$lineNumbers.innerHTML = spans
$lineNumbers.classList.add('code-line-numbers')
$lineNumbers.classList.remove('size3x', 'size4x', 'size5x')
if (lineCount > 9999) $lineNumbers.classList.add('size5x')
else if (lineCount > 999) $lineNumbers.classList.add('size4x')
else if (lineCount > 99) $lineNumbers.classList.add('size3x')
}
import 'highlight.js/styles/androidstudio.css'
import hljs from 'highlight.js'
import c from 'highlight.js/lib/languages/c'
import cpp from 'highlight.js/lib/languages/cpp'
import python from 'highlight.js/lib/languages/python'
import shell from 'highlight.js/lib/languages/shell'
import typescript from 'highlight.js/lib/languages/typescript'
import java from 'highlight.js/lib/languages/java'
import kotlin from 'highlight.js/lib/languages/kotlin'
import json from 'highlight.js/lib/languages/json'
import php from 'highlight.js/lib/languages/php'
import go from 'highlight.js/lib/languages/go'
import rust from 'highlight.js/lib/languages/rust'
import sql from 'highlight.js/lib/languages/sql'
import diff from 'highlight.js/lib/languages/diff'
import yaml from 'highlight.js/lib/languages/yaml'
import xml from 'highlight.js/lib/languages/xml'
import text from 'highlight.js/lib/languages/plaintext'
import nginx from 'highlight.js/lib/languages/nginx'
import dockerfile from 'highlight.js/lib/languages/dockerfile'
import lua from 'highlight.js/lib/languages/lua'
import css from 'highlight.js/lib/languages/css'
export const highlightLanguages = {
c,
cpp,
java,
kotlin,
go,
rust,
python,
php,
lua,
javascript: typescript,
css,
json,
xml,
yaml,
nginx,
dockerfile,
shell,
diff,
sql,
text,
}
export const highlight = hljs
Object.entries(highlightLanguages).forEach(([language, fn]) => {
hljs.registerLanguage(language, fn)
})
表头固定:use-table-enhancer.ts
import { Ref } from 'vue'
import { getScrollElement } from '@/utils/common'
export function useTableEnhancer(selector: string, options: { enableFixedHeader: Ref<boolean> }) {
onMounted(() => {
const $scrollElement = getScrollElement(document.querySelector<HTMLElement>(selector))
$scrollElement && registerEvents($scrollElement)
})
const registerEvents = ($scrollElement: HTMLElement) => {
// console.log('Register table listeners', $scrollElement)
const $renderer = document.querySelector<HTMLElement>(selector)!
const listenScrollbar = createScrollbarListener($renderer, $scrollElement)
const listenFixedHeader = createFixedHeaderListener(
$renderer,
$scrollElement,
options.enableFixedHeader,
)
// table的横向滚动条也会被监听到
$scrollElement.addEventListener('scroll', listenScrollbar, true)
$scrollElement.addEventListener('scroll', listenFixedHeader, true)
const observer = new ResizeObserver(() => {
listenScrollbar(undefined, true)
listenFixedHeader(undefined, true)
})
observer.observe($scrollElement)
// 划词会挤压内容
observer.observe($renderer)
watch(
() => options.enableFixedHeader.value,
() => {
$scrollElement.dispatchEvent(new Event('scroll'))
},
)
// Vue在update dom的时候可能会用到节点作为参考节点(insert Before),在onMounted的时候删除则没问题
let effectNodes: HTMLElement[] = []
onBeforeUnmount(() => {
// console.log('unmount table listeners')
observer.disconnect()
$scrollElement.removeEventListener('scroll', listenFixedHeader, true)
$scrollElement.removeEventListener('scroll', listenScrollbar, true)
const rendererDataId = document.querySelector<HTMLElement>(selector)?.dataset.id
if (rendererDataId) {
effectNodes = (Array.from($scrollElement.children) as HTMLElement[]).filter(
($item) =>
$item.dataset.rendererId === rendererDataId &&
($item.classList?.contains('table-fixed-header-global') ||
$item.classList?.contains('table-scrollbar-global')),
)
} else {
effectNodes = []
}
})
onUnmounted(() => {
effectNodes.forEach(($node) => $node.remove())
})
}
}
const createScrollbarListener = ($renderer: HTMLElement, $scrollElement: HTMLElement) => {
let $scrollingWrapper: HTMLElement | null = null
const rendererDataId = $renderer.dataset.id
const $virtualScrollbar = document.createElement('div')
$virtualScrollbar.className = 'table-scrollbar-global'
$virtualScrollbar.dataset.rendererId = rendererDataId
$virtualScrollbar.appendChild(document.createElement('div'))
$scrollElement.appendChild($virtualScrollbar)
$renderer
.getAttributeNames()
.filter((attr) => attr.indexOf('data-v') === 0)
.forEach((attr) => {
$virtualScrollbar.setAttribute(attr, '')
})
$virtualScrollbar.addEventListener(
'scroll',
() => {
$scrollingWrapper && ($scrollingWrapper.scrollLeft = $virtualScrollbar!.scrollLeft)
},
true,
)
return (e?: Event, resizing: boolean = false) => {
if (
e?.type === 'scroll' &&
(e.target === $virtualScrollbar || (e.target as Element).classList.contains('tableWrapper'))
) {
// 各个表格的横向滚动条也会触发该事件
return
}
const tables = $renderer.querySelectorAll<HTMLTableElement>(
// 防止子table
`.tableWrapper > table`,
)
const scrollElementRect = $scrollElement.getBoundingClientRect()
let selectedIndex = -1
for (let index = 0; index < tables.length; ++index) {
const table = tables[index]!
const $tableWrapper = table.parentElement!
const tableRect = table.getBoundingClientRect()
// 表头在容器可见区域下方,后面的表格也无需再看了
if (tableRect.top > scrollElementRect.bottom) break
// 整个表格都在容器可见区域上方
if (tableRect.bottom < scrollElementRect.top) continue
// 表格滚动条已经出现在容器可见区域
if (tableRect.bottom < scrollElementRect.bottom) continue
const tableWrapperRect = $tableWrapper.getBoundingClientRect()
// 虚拟滚动条绑定当前表格
// 表格宽度小于容器,无需出现滚动条
if (tableRect.width <= tableWrapperRect.width) break
selectedIndex = index
const recreating = $scrollingWrapper !== $tableWrapper
$scrollingWrapper = $tableWrapper
$virtualScrollbar.scrollLeft = $tableWrapper.scrollLeft
if (recreating || resizing || Math.random() > 0.85) {
// 移除隐藏样式,才能获得真实位置
$virtualScrollbar.classList.remove('hidden')
$virtualScrollbar.style.width = `${tableWrapperRect.width}px`
$virtualScrollbar.style.left = `${tableWrapperRect.left}px`
$virtualScrollbar.style.top = scrollElementRect.bottom - 30 + 'px'
;($virtualScrollbar.firstChild as HTMLDivElement).style.width = tableRect.width + 'px'
fixPosition($virtualScrollbar)
}
break
}
if (selectedIndex === -1) {
$scrollingWrapper = null
$virtualScrollbar.classList.add('hidden')
}
}
}
/**
* 手动触发
element.dispatchEvent(
new CustomEvent('scroll', {
detail: { updateFixedHeader: true }
})
)
*/
const createFixedHeaderListener = (
$renderer: HTMLElement,
$scrollElement: HTMLElement,
enableFixedHeader: Ref<boolean>,
) => {
return (e?: Event, resizing?: boolean) => {
const rendererDataId = $renderer.dataset.id
let $globalFixedHeader =
(Array.from($scrollElement.children) as HTMLElement[]).find(
($item) =>
$item.classList?.contains('table-fixed-header-global') &&
$item.dataset.rendererId === rendererDataId,
) || null
if (!enableFixedHeader.value) {
$globalFixedHeader?.remove()
return
}
if (e instanceof CustomEvent && e.detail.updateFixedHeader) {
$globalFixedHeader?.remove()
$globalFixedHeader = null
}
const tables = $renderer.querySelectorAll<HTMLTableElement>(
// 防止子table
`.tableWrapper > table`,
)
const scrollElementOffsetTop = $scrollElement.getBoundingClientRect().top
let hasFixedHeader = false
tableLoop: for (let index = 0; index < tables.length; ++index) {
const table = tables[index]!
const $tableWrapper = table.parentElement!
const firstTr = table.querySelector('tr')
if (!firstTr) continue
const firstThs = firstTr.children
// 横向表头,纵向表头(不固定),没有表头(不固定)
for (let i = 0; i < firstThs.length; ++i) {
if (firstThs[i].tagName !== 'TH') {
continue tableLoop
}
}
const trs: HTMLTableRowElement[] = [firstTr]
{
let rowSpan = 1
for (let i = 0; i < firstThs.length; ++i) {
rowSpan = Math.max(Number((firstThs.item(i) as HTMLTableCellElement).rowSpan), rowSpan)
}
for (let i = 1; i < rowSpan; ++i) {
const tr = firstTr.parentElement!.children.item(i) as HTMLTableRowElement
tr && trs.push(tr)
}
}
const tableRect = table.getBoundingClientRect()
const headerHeight = trs.reduce((summary, $tr) => summary + $tr.offsetHeight, 0)
// 还没到顶
if (tableRect.top > scrollElementOffsetTop) break
// 只有表头
if (headerHeight === tableRect.height) continue
const hidingHeight = scrollElementOffsetTop - tableRect.top
const showingHeight = tableRect.height - hidingHeight
// 已经过顶
if (showingHeight <= 0) continue
// 固定表头绑定当前表格
// 即将过顶,固定表格和表格尾部开始重叠
if (showingHeight < headerHeight) break
let $clonedTable!: HTMLTableElement
let clonedTrs: HTMLTableRowElement[] = []
let recreating = false
if ($globalFixedHeader) {
$clonedTable = $globalFixedHeader.firstChild as HTMLTableElement
clonedTrs = Array.from(
$globalFixedHeader.querySelector('tr')!.parentElement!.children,
) as HTMLTableRowElement[]
if (
clonedTrs.length !== trs.length ||
clonedTrs.some(($clonedTr, i) => {
return $clonedTr.children.length !== trs[i].children.length
})
) {
clonedTrs = []
$globalFixedHeader?.remove()
$globalFixedHeader = null
}
}
if (!$globalFixedHeader) {
recreating = true
$globalFixedHeader = document.createElement('div')
$renderer
.getAttributeNames()
.filter((item) => item === 'class' || item === 'style' || item.indexOf('data-v') === 0)
.forEach((attr) => {
$globalFixedHeader!.setAttribute(attr, $renderer.getAttribute(attr)!)
})
$globalFixedHeader.classList.add('table-fixed-header-global')
$globalFixedHeader.dataset.rendererId = rendererDataId
$clonedTable = table.cloneNode(false) as HTMLTableElement
{
// 已知:table-layout=fixed 与 width 一起使用时,列宽只根据col或者第一行的列宽渲染。
// 场景:在表头超过一行的情况下,如果第一行列宽有合并列,则导致后面行的列无法按照指定宽度渲染。
// 方案:设置minWidth为真实表格宽度(原minWidth是后期设置的,不准确),删除width。
$clonedTable.style.minWidth = tableRect.width + 'px'
$clonedTable.style.width = ''
}
clonedTrs = trs.map(($tr) => {
const $clonedTr = $tr.cloneNode(true) as HTMLTableRowElement
$clonedTable.appendChild($clonedTr)
return $clonedTr
})
$globalFixedHeader.appendChild($clonedTable)
// 不能设置在renderer里面,因为划词评审的时候会把所有内容传到服务器
$scrollElement.appendChild($globalFixedHeader)
}
const tableWrapperScrollLeft = $tableWrapper.scrollLeft
$globalFixedHeader.style.top = scrollElementOffsetTop + 'px'
$clonedTable.style.marginLeft = -tableWrapperScrollLeft + 'px'
if (recreating || resizing || Math.random() > 0.75) {
$globalFixedHeader.style.width = Math.min($tableWrapper.offsetWidth, tableRect.width) + 'px'
$globalFixedHeader.style.left = tableRect.left + tableWrapperScrollLeft + 'px'
clonedTrs.forEach(($clonedTr, index) => {
const ths = trs[index]!.children
const clonedThs = $clonedTr.children
for (let i = 0; i < ths.length; ++i) {
const $clonedTh = clonedThs.item(i) as HTMLTableCellElement
// 不能使用offsetWidth,因为它拿到的是整数,小数点去掉有可能导致文字换行
const width = (ths.item(i) as HTMLTableCellElement).getBoundingClientRect().width
if (width === 0) {
$clonedTh.style.display = 'none'
} else {
$clonedTh.style.display = ''
$clonedTh.style.width = width + 'px'
}
}
})
fixPosition($globalFixedHeader)
}
hasFixedHeader = true
break
}
if ($globalFixedHeader && !hasFixedHeader) {
$globalFixedHeader.remove()
}
}
}
// 在绝对定位(比如弹窗、抽屉)中,fixed的位置是相对于带 position=absolute|fixed 的祖先元素的
const fixPosition = ($node: HTMLElement) => {
const fixedHeaderRect = $node.getBoundingClientRect()
if (
fixedHeaderRect.left - $node.offsetLeft > 2 /* 误差 */ ||
fixedHeaderRect.top - $node.offsetTop > 2 /* 误差 */
) {
const offset = getRelativeOffset($node.parentElement)
$node.style.left = fixedHeaderRect.left + offset.left + 'px'
$node.style.top = fixedHeaderRect.top + offset.top + 'px'
}
}
const getRelativeOffset = ($start: Element | null) => {
const offset = { top: 0, left: 0 }
let $current: Element | null = $start
while ($current) {
const style = window.getComputedStyle($current)
if (style.position === 'absolute' || style.position === 'fixed') {
const rect = $current.getBoundingClientRect()
offset.top -= rect.top
offset.left -= rect.left
}
$current = $current.parentElement
}
return offset
}
自适应表格宽度:use-table-column-width.ts
import type { Ref } from 'vue'
export const useTableColumnWidth = (content: Ref<string>, selector: string) => {
let width = 0
const observer = new ResizeObserver(([entry]) => {
const nextWidth = entry.target.clientWidth
if (width !== nextWidth) {
width = nextWidth
setWidth(selector)
}
})
onMounted(() => {
observer.observe(document.querySelector(selector)!)
})
onUnmounted(() => {
observer.disconnect()
})
watch(
() => content.value,
() => {
nextTick(() => {
setWidth(selector)
})
},
)
}
const isTableInTable = ($table: HTMLElement) => {
let $parent = $table.parentNode
do {
if (!$parent) return false
if ($parent.nodeName === 'TR') return true
} while (($parent = $parent.parentNode))
}
const splitter = /,\s?/
const setWidth = (selector: string) => {
// console.info('Setting col width by selector: ' + selector)
const editor = document.querySelector(selector)
const tables = Array.from(editor?.querySelectorAll('table') || [])
if (tables.length === 0) return tables
tables.forEach((table, index) => {
const enableWrapper = !isTableInTable(table)
// table的第一个tr,可能是表头th,也可能没有表头直接就是td
const firstTableRow = table.querySelector('tr')
const thsOrTds = Array.from(firstTableRow?.children || [])
const colWidthList: number[] = []
thsOrTds.forEach((thOrTd) => {
const colwidth = thOrTd.getAttribute('colwidth') || ''
const colspan = Number(thOrTd.getAttribute('colspan') || 1)
// "12,65" "12, 65" "12,,65" "12,, 65" "12"
const colwidths = colwidth.split(splitter).map(Number)
for (let i = 0; i < colspan; ++i) {
colWidthList.push(colwidths[i] || 0)
}
})
const colGroup = document.createElement('colgroup')
colGroup.innerHTML = colWidthList
.map((colWidth) => {
return colWidth !== 0 ? `<col style="width: ${colWidth}px" />` : `<col />`
})
.join('')
// querySelector匹配是使用深度优先先序遍历,从文档标记中的第一个元素开始,并按子节点的顺序依次遍历。
// table里可能有子table,querySelector有可能匹配到子table的colgroup
const historyColGroup = Array.from(table.children).find(
($child) => $child.nodeName === 'COLGROUP',
)
if (historyColGroup?.innerHTML !== colGroup.innerHTML) {
historyColGroup?.remove()
table.appendChild(colGroup)
}
// 设置表格宽度,防止文字重叠在一起
{
const tableWidth = colWidthList.reduce((sum, colWidth) => sum + colWidth, 0)
table.style.minWidth = tableWidth + 'px'
if (colWidthList.every((colWidth) => colWidth !== 0)) {
// 一些元素会莫名其妙地撑大单元格(已知:代码块),使得其他单元格被挤压。设置width可解决
table.style.width = tableWidth + 'px'
}
}
const $tableParent = table.parentElement
if (!enableWrapper) {
if ($tableParent?.classList.contains('tableWrapper')) {
$tableParent.replaceWith(table)
}
} else {
// 给table新加一层div
if (!$tableParent?.classList.contains('tableWrapper')) {
const $wrapper = document.createElement('div')
$wrapper.className = 'tableWrapper'
$wrapper.id = 'tableWrapper' + index
$tableParent?.replaceChild($wrapper, table)
$wrapper.appendChild(table)
} else {
$tableParent.id = 'tableWrapper' + index
}
}
})
return tables
}
高亮代码:use-highlight-syntax.ts
import { Ref } from 'vue'
import { injectCodeLineNumbers, highlight } from '@/utils/common'
/**
* 代码高亮和行号逻辑
* @param selector 节点内所有的 code 都会被高亮
* @param dependency 变更依赖,变更后重新设置高亮
*/
export const useHighlightSyntax = (selector: string, dependency: Ref<string>) => {
onMounted(() => {
watch(
() => dependency.value,
() => {
document.querySelectorAll<HTMLElement>(selector).forEach(($pre) => {
const $code = $pre.querySelector('code')
if (!$code) return
const lines = $code.innerHTML.split('\n')
lines[lines.length - 1] === '' && lines.pop()
const lineCount = lines.length
{
const $cloneCode = $code.cloneNode(true) as HTMLElement
$cloneCode.classList.add('highlight-code')
// 代码太长导致高亮卡顿
if (lineCount < 500) {
// 如果没有找到对应的language,
// 则不会生成 class=.hljs,也不会增加属性data-highlighted="true"
highlight.highlightElement($cloneCode)
}
$pre.appendChild($cloneCode)
$code.addEventListener('scroll', () => {
$cloneCode.scrollLeft = $code.scrollLeft
})
}
{
const matchedLanguage = ($code.getAttribute('class') || '').match(/language-([\S]+)/)
if (matchedLanguage) {
const $codeLanguage = document.createElement('span')
$codeLanguage.classList.add('code-language')
$codeLanguage.textContent = matchedLanguage[1]
$pre.appendChild($codeLanguage)
}
}
{
const $lineNumbers = document.createElement('div')
injectCodeLineNumbers($lineNumbers, lineCount)
$pre.prepend($lineNumbers)
}
})
},
{ flush: 'post', immediate: true },
)
})
}
生成目录:use-directory.ts
import { EditorDocumentDirectory } from '@/utils/common'
import { Ref } from 'vue'
/**
* 支持生成目录
* @param selector 渲染容器
* @param dependency 变更依赖
* @param options 开关控制
*/
export function useDirectory(
selector: string,
dependency: Ref<string>,
options: { enableDirectory: Ref<boolean> },
) {
onMounted(() => {
watch(
() => [dependency.value, options.enableDirectory.value],
async () => {
if (!dependency.value) return
await nextTick()
handleGenerateDirectory(options.enableDirectory.value, selector)
handleHash()
},
{ immediate: true },
)
})
}
export const handleGenerateDirectory = (isStick: boolean, selector: string) => {
try {
const dom = document.querySelector(selector)!
if (!isStick) {
//删除
dom.querySelector<HTMLElement>(`ul[${EditorDocumentDirectory}]`)?.remove()
dom.querySelectorAll<HTMLElement>('h1,h2,h3,h4,h5,h6').forEach(($hNode) => {
$hNode.removeAttribute('id')
})
return
}
//添加
const headNodes = dom.querySelectorAll('h1,h2,h3,h4,h5,h6')
if (headNodes.length <= 0) return //没有标题就不必再执行了
const directory = document.createElement('ul')
directory.style.userSelect = 'none' //禁止划词
directory.style.paddingBottom = '20px'
directory.setAttribute(EditorDocumentDirectory, '1')
directory.classList.add('custom-editor-directory') //自定义一个类名
let map = {}
for (let item of Array.from(headNodes)) {
const textContent = item.textContent
if (!textContent) continue //标题为空不要生成目录
const uniqueId = item.tagName + '-' + textContent
//解决出现重复标题
let count = map[uniqueId] || 0
item.id = count > 0 ? `${uniqueId}-${count}` : uniqueId
map[uniqueId] = ++count
const level = parseInt(item.tagName.charAt(1)) // 获取标题级别,如h1的级别为1
const li = document.createElement('li')
const link = document.createElement('a')
link.textContent = textContent
link.href = '#' + item.id // 使用标题的id作为锚点链接
// 根据标题级别设置缩进样式
li.style.marginLeft = (level - 1) * 20 + 'px'
// 将链接添加到目录项
li.appendChild(link)
directory.appendChild(li)
}
//将目录插入文档顶部
dom.insertAdjacentHTML('afterbegin', directory.outerHTML)
} catch (error) {
console.error(error, '动态生成目录报错了')
}
}
export const handleHash = () => {
const hash = decodeURIComponent(window.location.hash)
if (hash) {
const element = document.getElementById(hash.slice(1))
if (element) {
element.scrollIntoView({ behavior: 'auto' })
}
}
}
划词内容与富文本渲染没有直接耦合,所以不再此封装上说明
详情见mark.md
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。