|  | 
|  | 1 | +--- | 
|  | 2 | +title: 使用 turndown 踩坑记录 | 
|  | 3 | +date: 2024-12-15T15:56:59Z | 
|  | 4 | +slug: post-34 | 
|  | 5 | +author: chaseFunny:https://github.com/chaseFunny | 
|  | 6 | +tags: ["踩坑记录","前端开发"] | 
|  | 7 | +--- | 
|  | 8 | + | 
|  | 9 | +最近开发中,遇到一个需求,需要自定义处理 bytemd 的粘贴逻辑,也就是: | 
|  | 10 | + | 
|  | 11 | +在 React 项目中使用了 bytemd 编辑器 (https://github.com/pd4d10/bytemd) ,我也没有做过类似的需求,最开始的实现思路就是: | 
|  | 12 | + | 
|  | 13 | +1)找到 bytemd 在哪里处理粘贴逻辑 | 
|  | 14 | + | 
|  | 15 | +2)阻止默认的粘贴逻辑 | 
|  | 16 | + | 
|  | 17 | +3)拿到粘贴的内容 | 
|  | 18 | + | 
|  | 19 | +4)返回预期的内容 | 
|  | 20 | + | 
|  | 21 | + | 
|  | 22 | + | 
|  | 23 | +## 寻找 bytemd 的自定义粘贴逻辑入口 | 
|  | 24 | + | 
|  | 25 | +通过查阅源码得知:插件也就是一个函数,用于扩展 Bytemd 编辑器和查看器的功能,返回指定的对象类型,返回对象类型已经定义好了,是 BytemdPlugin 。它包含五个属性: | 
|  | 26 | + | 
|  | 27 | +- remark:自定义 Markdown 解析 | 
|  | 28 | +- rehype:HTML 解析 | 
|  | 29 | +- actions:注册操作,也就是定义我们编辑框上面哪些小图标的 | 
|  | 30 | +- editorEffect :编辑器副作用 | 
|  | 31 | +- viewerEffect:查看器副作用 | 
|  | 32 | + | 
|  | 33 | +现在我们需要的是 editorEffect ,它是接受一个函数,函数中会给我们 `ctx` 也就是编辑器上下文,在这里我们可以通过 `cxt.editor.on('paste',fn(cm,e){e.preventDefault();})` 来实现禁止默认粘贴逻辑,当我们这样写后,并且把这个插件注册到编辑器,我们粘贴就失效了,我们现在已经找到了粘贴逻辑的地方,下面我们来实现自定义粘贴的逻辑 | 
|  | 34 | + | 
|  | 35 | +## 自定义粘贴逻辑 | 
|  | 36 | + | 
|  | 37 | +我们需要先拿到用户粘贴的内容,也很简单: | 
|  | 38 | + | 
|  | 39 | +```ts | 
|  | 40 | + editor.on('paste', async (cm: any, e: ClipboardEvent) => { | 
|  | 41 | +   const clipboardData = e.clipboardData; | 
|  | 42 | +   const text = clipboardData.getData('text/plain'); // 文本内容 | 
|  | 43 | +   const html = clipboardData.getData('text/html'); // 原始 html 内容 | 
|  | 44 | + } | 
|  | 45 | +``` | 
|  | 46 | +
 | 
|  | 47 | +现在我们需要对 html 内容进行解析,转为 md 格式,然后返回 | 
|  | 48 | +
 | 
|  | 49 | +通过调研,发现好用的 turndown 库:用 JavaScript 编写的 HTML 到 Markdown 转换器,下面我们就使用 turndown 来实现 html => markdown 。直接看代码: | 
|  | 50 | +
 | 
|  | 51 | +```ts | 
|  | 52 | +// ... | 
|  | 53 | +const TurndownService = require('turndown') | 
|  | 54 | +const turndownService = new TurndownService() | 
|  | 55 | +const mdText = turndownService.turndown(html) | 
|  | 56 | +// ... | 
|  | 57 | +``` | 
|  | 58 | +
 | 
|  | 59 | +但是这样写,会发现有很多问题: | 
|  | 60 | +
 | 
|  | 61 | +1. 代码块没有检测出来,没有高亮 | 
|  | 62 | +1. 行内代码块没有展示 | 
|  | 63 | +1. 代码中会有额外的转义斜杠 | 
|  | 64 | +1. 编程语言未识别 | 
|  | 65 | +1. 表格和删除线也不生效 | 
|  | 66 | +
 | 
|  | 67 | +等等问题,所以我们需要进行额外的配置,才能正确的把 HTML 转为 markdown 格式 | 
|  | 68 | +
 | 
|  | 69 | +下面经过我不断测试,最终得到的代码为: | 
|  | 70 | +
 | 
|  | 71 | +```ts | 
|  | 72 | +import turndownService from 'turndown'; | 
|  | 73 | +import { gfm, strikethrough, tables } from 'turndown-plugin-gfm'; | 
|  | 74 | + | 
|  | 75 | +/** | 
|  | 76 | + * 配置并返回 turndown 实例 | 
|  | 77 | + */ | 
|  | 78 | +export function configureTurndown() { | 
|  | 79 | +  const turndownServiceObj = new turndownService({ | 
|  | 80 | +    codeBlockStyle: 'fenced', | 
|  | 81 | +  }); | 
|  | 82 | + | 
|  | 83 | +  turndownServiceObj.use(gfm); | 
|  | 84 | +  turndownServiceObj.use([tables, strikethrough]); | 
|  | 85 | + | 
|  | 86 | +  // 添加自定义规则 | 
|  | 87 | +  addCustomRules(turndownServiceObj); | 
|  | 88 | + | 
|  | 89 | +  return turndownServiceObj; | 
|  | 90 | +} | 
|  | 91 | + | 
|  | 92 | +/** | 
|  | 93 | + * 为 turndown 添加自定义规则 | 
|  | 94 | + */ | 
|  | 95 | +function addCustomRules(turndownServiceObj: turndownService) { | 
|  | 96 | +  // 删除线 | 
|  | 97 | +  turndownServiceObj.addRule('strikethrough', { | 
|  | 98 | +    filter: ['del', 's', 'strike'] as string[], | 
|  | 99 | +    replacement: (content) => `~~${content}~~`, | 
|  | 100 | +  }); | 
|  | 101 | + | 
|  | 102 | +  // 代码块 | 
|  | 103 | +  turndownServiceObj.addRule('pre', { | 
|  | 104 | +    filter: ['pre'], | 
|  | 105 | +    replacement: (content, node: any) => { | 
|  | 106 | +      const code = node.querySelector('code'); | 
|  | 107 | +      let language = ''; | 
|  | 108 | + | 
|  | 109 | +      if (node.getAttribute('lang')) { | 
|  | 110 | +        language = node.getAttribute('lang'); | 
|  | 111 | +      } else if (code?.className) { | 
|  | 112 | +        const langMatch = code.className.match(/language-(\S+)/); | 
|  | 113 | +        language = langMatch?.[1] || ''; | 
|  | 114 | +      } else if (node.className) { | 
|  | 115 | +        const mdFencesMatch = node.className.match(/md-fences|language-(\S+)/); | 
|  | 116 | +        language = mdFencesMatch?.[1] || ''; | 
|  | 117 | +      } | 
|  | 118 | + | 
|  | 119 | +      let codeContent = code ? code.textContent.trim() : content.trim(); | 
|  | 120 | +      codeContent = codeContent.replace(/\\([^\\])/g, '$1'); | 
|  | 121 | +      language = language.toLowerCase().replace(/[^a-z0-9+#]+/g, ''); | 
|  | 122 | + | 
|  | 123 | +      return `\`\`\`${language}\n${codeContent}\n\`\`\`\n`; | 
|  | 124 | +    }, | 
|  | 125 | +  }); | 
|  | 126 | + | 
|  | 127 | +  // 行内代码 | 
|  | 128 | +  turndownServiceObj.addRule('inlineCode', { | 
|  | 129 | +    filter: (node) => node.nodeName === 'CODE' && node.parentNode?.nodeName !== 'PRE', | 
|  | 130 | +    replacement: (content) => `\`${content}\``, | 
|  | 131 | +  }); | 
|  | 132 | + | 
|  | 133 | +  // 表格 | 
|  | 134 | +  turndownServiceObj.addRule('table', { | 
|  | 135 | +    filter: 'table', | 
|  | 136 | +    replacement: function (content, node) { | 
|  | 137 | +      const table = node as HTMLTableElement; | 
|  | 138 | +      const rows = Array.from(table.rows); | 
|  | 139 | + | 
|  | 140 | +      const headers = Array.from(rows[0]?.cells || []) | 
|  | 141 | +        .map((cell) => cell.textContent?.trim() || '') | 
|  | 142 | +        .join(' | '); | 
|  | 143 | + | 
|  | 144 | +      const separator = Array.from(rows[0]?.cells || []) | 
|  | 145 | +        .map(() => '---') | 
|  | 146 | +        .join(' | '); | 
|  | 147 | + | 
|  | 148 | +      const data = rows | 
|  | 149 | +        .slice(1) | 
|  | 150 | +        .map((row) => | 
|  | 151 | +          Array.from(row.cells) | 
|  | 152 | +            .map((cell) => cell.textContent?.trim() || '') | 
|  | 153 | +            .join(' | '), | 
|  | 154 | +        ) | 
|  | 155 | +        .join('\n'); | 
|  | 156 | + | 
|  | 157 | +      return `\n| ${headers} |\n| ${separator} |\n${data ? `| ${data} |` : ''}\n\n`; | 
|  | 158 | +    }, | 
|  | 159 | +  }); | 
|  | 160 | +} | 
|  | 161 | + | 
|  | 162 | +/** | 
|  | 163 | + * 处理粘贴的内容 | 
|  | 164 | + */ | 
|  | 165 | +export async function handlePastedContent(html: string, text: string) { | 
|  | 166 | +  const turndownServiceObj = configureTurndown(); | 
|  | 167 | +  const parser = new DOMParser(); | 
|  | 168 | +  const doc = parser.parseFromString(html, 'text/html'); | 
|  | 169 | +  const images: HTMLImageElement[] = Array.from(doc.getElementsByTagName('img')); | 
|  | 170 | + | 
|  | 171 | +  // 转为 markdown 文本 | 
|  | 172 | +  const mdContent = turndownServiceObj.turndown(html); | 
|  | 173 | + | 
|  | 174 | +  // 如果没有图片,直接返回处理后的文本 | 
|  | 175 | +  if (images.length === 0) { | 
|  | 176 | +    return mdContent || text; | 
|  | 177 | +  } | 
|  | 178 | + | 
|  | 179 | +  // 处理图片上传 | 
|  | 180 | +  return await processImages(images, mdContent); | 
|  | 181 | +} | 
|  | 182 | + | 
|  | 183 | +/** | 
|  | 184 | + * 处理图片上传 | 
|  | 185 | + * @param images 图片元素数组 | 
|  | 186 | + * @param mdContent markdown 文本 | 
|  | 187 | + * @returns 处理图片上传后的 markdown 文本 | 
|  | 188 | + */ | 
|  | 189 | +async function processImages(images: HTMLImageElement[], mdContent: string) { | 
|  | 190 | +  // 自定义图片上传的逻辑,通过上传后的图地址替换源地址,得到新的内容 processedText | 
|  | 191 | +  return processedText; | 
|  | 192 | +} | 
|  | 193 | + | 
|  | 194 | +``` | 
|  | 195 | +
 | 
|  | 196 | +这样得到的结果就是正常的了!以上的过程得到了 AI 的极大帮助,帮我定位问题,分析问题,找到解决思路等等 | 
|  | 197 | +
 | 
|  | 198 | +
 | 
|  | 199 | +
 | 
|  | 200 | +--- | 
|  | 201 | +此文自动发布于:<a href="https://github.com/coderPerseus/blog/issues/34" target="_blank">github issues</a> | 
0 commit comments