输入链接创建视频 DOM 元素可以这么写 下面是一个插入 plyr 播放器播放视频链接的插件,这个是编辑器的插入效果\ 回渲到 DOM 上需要在 remark 时标记这个视频元素,然后在 DOM 挂载时将这个标记的元素替换为 plyr 播放器,这样就可以插入了 'use client' import { $command, $inputRule, $node, $remark } from '@milkdown/utils' import { Node } from '@milkdown/prose/model' import { InputRule } from '@milkdown/prose/inputrules' import { createRoot } from 'react-dom/client' import dynamic from 'next/dynamic' import directive from 'remark-directive' export const kunVideoRemarkDirective = $remark('kun-video', () => directive) const KunPlyr = dynamic(() => import('./Plyr').then((mod) => mod.KunPlyr), { ssr: false }) export const videoNode = $node('kun-video', () => ({ content: 'block+', group: 'block', selectable: true, draggable: true, atom: true, isolating: true, defining: true, marks: '', attrs: { src: { default: '' } }, parseDOM: [ { tag: 'div[data-video-player]', getAttrs: (dom) => ({ src: dom.getAttribute('data-src') }) } ], toDOM: (node: Node) => { const container = document.createElement('div') container.setAttribute('data-video-player', '') container.setAttribute('data-src', node.attrs.src) container.setAttribute('contenteditable', 'false') container.className = 'w-full my-4 overflow-hidden shadow-lg rounded-xl' const root = createRoot(container) root.render() return container }, parseMarkdown: { match: (node) => node.name === 'kun-video', runner: (state, node, type) => { state.addNode(type, { src: (node.attributes as { src: string }).src }) } }, toMarkdown: { match: (node) => node.type.name === 'kun-video', runner: (state, node) => { state.addNode('leafDirective', undefined, undefined, { name: 'kun-video', attributes: node.attrs }) } } })) interface InsertKunVideoCommandPayload { src: string } export const insertKunVideoCommand = $command( 'InsertKunVideo', (ctx) => (payload: InsertKunVideoCommandPayload = { src: '' }) => (state, dispatch) => { if (!dispatch) { return true } const { src = '' } = payload const node = videoNode.type(ctx).create({ src }) if (!node) { return true } dispatch(state.tr.replaceSelectionWith(node).scrollIntoView()) return true } ) export const videoInputRule = $inputRule( (ctx) => new InputRule( // Matches format: {{kun-video="video url"}} // eg: {{kun-video="https://img.touchgalstatic.org/2023/05/f15179024920231109233759.mp4"}} /{{kun-video="(?[^"]+)?"?\}}/, (state, match, start, end) => { const [matched, src = ''] = match const { tr } = state if (matched) { return tr.replaceWith( start - 1, end, videoNode.type(ctx).create({ src }) ) } return null } ) ) 论坛的这个是一个 TOC,实现代码在 这个代码的要点是生成了一个 heading list 以及使用 IntersectionObserver 监视这个 list,所以当页面滚动时对应的 TOC item 会变为高亮 这里使用了一个 composable ,如果你使用 React,这类似于 hooks import { ref } from 'vue' interface TOCItem { id: string text: string level: number type: 'heading' | 'reply' } export const useTopicTOC = () => { const headings = ref([]) const activeId = ref('') let observer: IntersectionObserver | null = null const refreshTOC = () => { if (observer) { observer.disconnect() } const elements = Array.from( document.querySelectorAll( '.kun-master h1, .kun-master h2, .kun-master h3, .kun-reply' ) ) headings.value = elements.map((element) => { if (element.matches('.kun-master h1, .kun-master h2, .kun-master h3')) { return { id: element.id, text: element.textContent || '', level: Number(element.tagName.charAt(1)), type: 'heading' as const } } else { const [floor, content] = element.id.split('.', 2) return { id: element.id, text: content ? ${floor}. ${content} : floor, level: 2, type: 'reply' as const } } }) observer = new IntersectionObserver( (entries) => { const visibleEntry = entries.find((entry) => entry.isIntersecting) if (visibleEntry) activeId.value = visibleEntry.target.id }, { rootMargin: '0px 0px -80% 0px' } ) elements.forEach((element) => observer?.observe(element)) } onMounted(() => { refreshTOC() }) onBeforeUnmount(() => { if (observer) { observer.disconnect() } }) return { headings, activeId, refreshTOC } } 点击滚动的逻辑在这里,这里是纯 DOM 操作,点击后会滚动到相应的位置并且添加高亮 export const scrollPage = throttle((rid: number) => { let timeout: NodeJS.Timeout | null = null const element = document.querySelector([id^="${rid}"]) as HTMLElement if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'center' }) element.classList.add( 'outline-2', 'outline-offset-2', 'outline-primary', 'rounded-lg' ) if (timeout !== null) { clearTimeout(timeout) } timeout = setTimeout(() => { element.classList.remove( 'outline-2', 'outline-offset-2', 'outline-primary', 'rounded-lg' ) }, 3000) } else { useMessage(10215, 'info') } }, 1000) export const scrollToTOCElement = (id: string) => { let timeout: NodeJS.Timeout | null = null const element = document.getElementById(id) if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'center' }) element.classList.add( 'outline-2', 'outline-offset-2', 'outline-primary', 'rounded-lg' ) if (timeout !== null) { clearTimeout(timeout) } timeout = setTimeout(() => { element.classList.remove( 'outline-2', 'outline-offset-2', 'outline-primary', 'rounded-lg' ) }, 3000) } } 如果还有任何不懂的问题欢迎继续提问,也可以加入开发群组 或者闲聊群组 以讨论任何关于全栈开发以及 CS 相关的技术问题