Milkdown技术求助

WebMilkdownWeb求助
浏览数 - 654发布于 - 2025-05-22 - 03:37
真实z
真实z

1148

最近在写一个静态的网页,其中Markdown也用到了神秘的Milkdown,对于写Milkdown的插件我的大脑已经完全宕机了,完全不是我能理解的,官网的教程也早已过时,使用Milkdown的项目也是少之又少。

所以我想询问大佬们有没有好的教程或者例子去自定义一个节点,比如我想要创建一个根据在线视频链接去创建一个iframe节点,不知道是如何实现的。

另外就是如何根据Markdown去生成页面的导航的,我看到论坛是通过给h节点添加user-content-${节点名} 的id去导航的,但是我不知道是在什么时候给这个h标签添加的id的,又是在什么时候把Markdown里所有的h标签提取出来的,我发这个话题也是想测试一下会不会出现同名标签不知道跳转哪个标签的情况

测试标签

测试标签

测试标签

正常标签

鲲

5889

#1

输入链接创建视频 DOM 元素可以这么写

https://github.com/KUN1007/kun-touchgal-next

https://github.com/KUN1007/kun-touchgal-next/tree/main/components/kun/milkdown/plugins/components/video

下面是一个插入 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(<KunPlyr src={node.attrs.src} />)

    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="(?<src>[^"]+)?"?\}}/,
      (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,实现代码在

https://github.com/KUN1007/kun-galgame-nuxt3/blob/master/components/topic/detail/TableOfContent.vue

https://github.com/KUN1007/kun-galgame-nuxt3/blob/master/composables/topic/useTopicTOC.ts

这个代码的要点是生成了一个 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<TOCItem[]>([])
  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 操作,点击后会滚动到相应的位置并且添加高亮

https://github.com/KUN1007/kun-galgame-nuxt3/blob/master/components/topic/_helper.ts

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)
  }
}

如果还有任何不懂的问题欢迎继续提问,也可以加入开发群组 https://t.me/KUNForum 或者闲聊群组 https://t.me/kungalgame 以讨论任何关于全栈开发以及 CS 相关的技术问题

2025-05-22 - 08:03 (已编辑于 2025-05-22 - 08:08)

评论

鲲
评论

对了,也可以直接去 Milkdown 的 repo 发 Discussion,Milkdown 的作者非常友善,他会清楚详细的解答问题,甚至还会为你编写 demo

#2

才发现我没进开发群,马上进行一个迅猛的加

2025-05-23 - 09:17

评论

鲲
评论ringyuki

好耶!

真实z
真实z

1148

#3

我是vue3的,太谢谢了已经成功在我的网站里跑起来了Sticker

2025-05-23 - 14:17
真实z
真实z

1148

#4

我用你这个试了后发现一个问题就是如果我用一个英文的引号后面随便跟一个字符的话就会报错。

例子如下:

//markdown输入
:1

//报错日志
MilkdownError: Cannot match target parser for node: {"type":"textDirective","name":"1","attributes":{},"children":[],"position":{"start":{"line":1,"column":1,"offset":0},"end":{"line":1,"column":3,"offset":2}}}.

Video插件的写法如下:

//https://github.com/zhenshiz/mcmodwiki/blob/master/src/components/milkdown/plugin/video.js
import { $command, $inputRule, $node, $remark } from '@milkdown/utils'
import { InputRule } from '@milkdown/prose/inputrules'
import { createApp, h } from 'vue'
import directive from 'remark-directive'
import Plyr from '@/components/milkdown/components/Plyr.vue'

export const videoRemarkDirective = $remark('video', () => directive)

export const videoNode = $node('video', () => ({
  content: '',
  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) => {
    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 app = createApp({
      render: () => h(Plyr, { src: node.attrs.src })
    })
    app.mount(container)

    return container
  },
  parseMarkdown: {
    match: (node) => node.name === 'video',
    runner: (state, node, type) => {
      state.addNode(type, { src: node.attributes.src })
    }
  },
  toMarkdown: {
    match: (node) => node.type.name === 'video',
    runner: (state, node) => {
      state.addNode('leafDirective', undefined, undefined, {
        name: 'video',
        attributes: node.attrs
      })
    }
  }
}))

export const insertVideoCommand = $command(
  'InsertVideo',
  (ctx) => (payload) => {
    console.log(payload)
    if (!payload) {
      return false
    }

    return (state, dispatch) => {
      if (!dispatch) {
        return false
      }
      const node = videoNode.type(ctx).create({ src: payload })
      if (!node) return true

      dispatch(state.tr.replaceSelectionWith(node).scrollIntoView())
      return true
    }
  }
)

export const videoInputRule = $inputRule((ctx) =>
  new InputRule(
    /{{video="(?<src>[^"]+)?"?}}/,
    (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
    }
  )
)

export const video = [
  videoRemarkDirective,
  videoNode,
  insertVideoCommand,
  videoInputRule
].flat()

我这个插件是能成功插入视频的但是同时也会导致:1这样的格式出错。

导入插件的编辑器:https://github.com/zhenshiz/mcmodwiki/blob/master/src/components/milkdown/Editor.vue

对此我的排查是这样的

1.禁用video插件
结果:无报错
判断:确实是video插件导致的问题
2.将video插件中`export const videoRemarkDirective = $remark('video', () => directive)` 这块注释掉
结果:无报错
判断:初步判断是$remark导致的问题,因为这个会将我在$inputRule定义的格式`{{video:"https://img.touchgalstatic.org/2023/05/f15179024920231109233759.mp4"}}`变成`::video{src=https://img.touchgalstatic.org/2023/05/f15179024920231109233759.mp4}`也是因为这个格式感觉是导致我用`:1`冲突的原因

如果大佬知道咋解决的话,求教谢谢🙏

2025-05-25 - 13:50
kohaku