paste-html.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. import { EditorView } from '@codemirror/view'
  2. import { EditorSelection, Prec } from '@codemirror/state'
  3. export const pasteHtml = Prec.highest(
  4. EditorView.domEventHandlers({
  5. paste(event, view) {
  6. const { clipboardData } = event
  7. if (!clipboardData) {
  8. return false
  9. }
  10. // allow pasting an image to create a figure
  11. if (clipboardData.files.length > 0) {
  12. return false
  13. }
  14. // only handle pasted HTML
  15. if (!clipboardData.types.includes('text/html')) {
  16. return false
  17. }
  18. const html = clipboardData.getData('text/html').trim()
  19. if (html.length === 0) {
  20. return false
  21. }
  22. // convert the HTML to LaTeX
  23. try {
  24. const latex = htmlToLaTeX(html)
  25. view.dispatch(
  26. view.state.changeByRange(range => {
  27. return {
  28. range: EditorSelection.cursor(range.from + latex.length),
  29. changes: { from: range.from, to: range.to, insert: latex },
  30. }
  31. })
  32. )
  33. return true
  34. } catch (error) {
  35. console.error(error)
  36. // fall back to the default paste handler
  37. return false
  38. }
  39. },
  40. })
  41. )
  42. const removeUnwantedElements = (
  43. documentElement: HTMLElement,
  44. selector: string
  45. ) => {
  46. for (const element of documentElement.querySelectorAll(selector)) {
  47. element.remove()
  48. }
  49. }
  50. const htmlToLaTeX = (html: string) => {
  51. const parser = new DOMParser()
  52. const { documentElement } = parser.parseFromString(html, 'text/html')
  53. // remove style elements
  54. removeUnwantedElements(documentElement, 'style')
  55. // pre-process table elements
  56. processTables(documentElement)
  57. // protect special characters in non-LaTeX text nodes
  58. protectSpecialCharacters(documentElement)
  59. processMatchedElements(documentElement)
  60. const text = documentElement.textContent
  61. if (!text) {
  62. return ''
  63. }
  64. // normalise multiple newlines
  65. return text.replaceAll(/\n{2,}/g, '\n\n')
  66. }
  67. const protectSpecialCharacters = (documentElement: HTMLElement) => {
  68. for (const element of documentElement.childNodes) {
  69. const text = element.textContent
  70. if (text) {
  71. // if there are no code blocks, use backslash as an indicator of LaTeX code that shouldn't be protected
  72. if (
  73. element instanceof HTMLElement &&
  74. !element.querySelector('code') &&
  75. text.includes('\\')
  76. ) {
  77. continue
  78. }
  79. const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
  80. for (let node = walker.nextNode(); node; node = walker.nextNode()) {
  81. const text = node.textContent
  82. if (text) {
  83. // leave text that's in a code block
  84. if (node.parentElement?.closest('code')) {
  85. continue
  86. }
  87. // replace non-backslash-prefixed characters
  88. node.textContent = text.replaceAll(
  89. /(^|[^\\])([#$%&~_^\\{}])/g,
  90. (_match, prefix: string, char: string) => `${prefix}\\${char}`
  91. )
  92. }
  93. }
  94. }
  95. }
  96. }
  97. const processMatchedElements = (documentElement: HTMLElement) => {
  98. for (const item of selectors) {
  99. for (const element of documentElement.querySelectorAll<any>(
  100. item.selector
  101. )) {
  102. if (!item.match || item.match(element)) {
  103. // start the markup
  104. if (item.start) {
  105. const start = document.createTextNode(item.start(element))
  106. if (item.inside) {
  107. element.prepend(start)
  108. } else {
  109. element.before(start)
  110. }
  111. }
  112. // end the markup
  113. if (item.end) {
  114. const end = document.createTextNode(item.end(element))
  115. if (item.inside) {
  116. element.append(end)
  117. } else {
  118. element.after(end)
  119. }
  120. }
  121. }
  122. }
  123. }
  124. }
  125. const matchingParents = (element: HTMLElement, selector: string) => {
  126. const matches = []
  127. for (
  128. let ancestor = element.parentElement?.closest(selector);
  129. ancestor;
  130. ancestor = ancestor.parentElement?.closest(selector)
  131. ) {
  132. matches.push(ancestor)
  133. }
  134. return matches
  135. }
  136. const processTables = (element: HTMLElement) => {
  137. for (const table of element.querySelectorAll('table')) {
  138. // create a wrapper element for the table and the caption
  139. const container = document.createElement('div')
  140. container.className = 'ol-table-wrap'
  141. table.after(container)
  142. // move the caption (if it exists) into the container before the table
  143. const caption = table.querySelector('caption')
  144. if (caption) {
  145. container.append(caption)
  146. }
  147. // move the table into the container
  148. container.append(table)
  149. }
  150. }
  151. const tabular = (element: HTMLTableElement) => {
  152. const options = []
  153. // NOTE: only analysing cells in the first row
  154. const row = element.querySelector('tr')
  155. if (row) {
  156. // TODO: look for horizontal borders and insert \hline (or \toprule, \midrule, \bottomrule etc)?
  157. const cells = [...row.childNodes].filter(
  158. element => element.nodeName === 'TD' || element.nodeName === 'TH'
  159. ) as Array<HTMLTableCellElement | HTMLTableHeaderCellElement>
  160. for (const cell of cells) {
  161. const { borderLeft, textAlign, borderRight } = cell.style
  162. if (borderLeft && borderLeft !== 'none') {
  163. // avoid duplicating when both left and right borders are defined
  164. if (options.length === 0 || options[options.length - 1] !== '|') {
  165. options.push('|')
  166. }
  167. }
  168. options.push(
  169. textAlign === 'left' ? 'l' : textAlign === 'right' ? 'r' : 'c'
  170. )
  171. if (borderRight && borderRight !== 'none') {
  172. options.push('|')
  173. }
  174. }
  175. }
  176. return options.join(' ')
  177. }
  178. const listDepth = (
  179. element: HTMLOListElement | HTMLUListElement | HTMLLIElement
  180. ): number => Math.max(0, matchingParents(element, 'ul,ol').length - 1)
  181. const listIndent = (
  182. element: HTMLOListElement | HTMLUListElement | HTMLLIElement
  183. ): string => '\t'.repeat(listDepth(element))
  184. type ElementSelector<T extends string, E extends HTMLElement = HTMLElement> = {
  185. selector: T
  186. match?: (element: E) => boolean
  187. start?: (element: E) => string
  188. end?: (element: E) => string
  189. inside?: boolean
  190. }
  191. const createSelector = <
  192. T extends string,
  193. E extends HTMLElement = T extends keyof HTMLElementTagNameMap
  194. ? HTMLElementTagNameMap[T]
  195. : HTMLElement
  196. >({
  197. selector,
  198. ...elementSelector
  199. }: ElementSelector<T, E>) => ({
  200. selector,
  201. ...elementSelector,
  202. })
  203. const headings = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6']
  204. const isHeading = (element: HTMLElement | null) => {
  205. return element && headings.includes(element.nodeName)
  206. }
  207. const hasContent = (element: HTMLElement): boolean => {
  208. return Boolean(element.textContent && element.textContent.trim().length > 0)
  209. }
  210. export const selectors = [
  211. createSelector({
  212. selector: 'b',
  213. match: element =>
  214. element.style.fontWeight !== 'normal' &&
  215. !isHeading(element.parentElement) &&
  216. hasContent(element),
  217. start: () => '\\textbf{',
  218. end: () => '}',
  219. }),
  220. createSelector({
  221. selector: '*',
  222. match: element =>
  223. parseInt(element.style.fontWeight) > 400 && hasContent(element),
  224. start: () => '\\textbf{',
  225. end: () => '}',
  226. inside: true,
  227. }),
  228. createSelector({
  229. selector: 'i',
  230. match: element =>
  231. element.style.fontStyle !== 'normal' && hasContent(element),
  232. start: () => '\\textit{',
  233. end: () => '}',
  234. }),
  235. createSelector({
  236. selector: '*',
  237. match: element =>
  238. element.style.fontStyle === 'italic' && hasContent(element),
  239. start: () => '\\textit{',
  240. end: () => '}',
  241. }),
  242. createSelector({
  243. selector: 'sup',
  244. match: element => hasContent(element),
  245. start: () => '\\textsuperscript{',
  246. end: () => '}',
  247. }),
  248. createSelector({
  249. selector: 'span',
  250. match: element =>
  251. element.style.verticalAlign === 'super' && hasContent(element),
  252. start: () => '\\textsuperscript{',
  253. end: () => '}',
  254. }),
  255. createSelector({
  256. selector: 'sub',
  257. match: element => hasContent(element),
  258. start: () => '\\textsubscript{',
  259. end: () => '}',
  260. }),
  261. createSelector({
  262. selector: 'span',
  263. match: element =>
  264. element.style.verticalAlign === 'sub' && hasContent(element),
  265. start: () => '\\textsubscript{',
  266. end: () => '}',
  267. }),
  268. createSelector({
  269. selector: 'a',
  270. match: element => !!element.href && hasContent(element),
  271. start: (element: HTMLAnchorElement) => `\\href{${element.href}}{`,
  272. end: element => `}`,
  273. }),
  274. createSelector({
  275. selector: 'h1',
  276. match: element => !element.closest('table') && hasContent(element),
  277. start: () => `\n\n\\section{`,
  278. end: () => `}\n\n`,
  279. }),
  280. createSelector({
  281. selector: 'h2',
  282. match: element => !element.closest('table') && hasContent(element),
  283. start: () => `\n\n\\subsection{`,
  284. end: () => `}\n\n`,
  285. }),
  286. createSelector({
  287. selector: 'h3',
  288. match: element => !element.closest('table') && hasContent(element),
  289. start: () => `\n\n\\subsubsection{`,
  290. end: () => `}\n\n`,
  291. }),
  292. createSelector({
  293. selector: 'h4',
  294. match: element => !element.closest('table') && hasContent(element),
  295. start: () => `\n\n\\paragraph{`,
  296. end: () => `}\n\n`,
  297. }),
  298. createSelector({
  299. selector: 'h5',
  300. match: element => !element.closest('table') && hasContent(element),
  301. start: () => `\n\n\\subparagraph{`,
  302. end: () => `}\n\n`,
  303. }),
  304. // TODO: h6?
  305. createSelector({
  306. selector: 'br',
  307. match: element => element.parentElement?.nodeName !== 'TD', // TODO: why?
  308. start: () => `\n\n`,
  309. }),
  310. createSelector({
  311. selector: 'code',
  312. match: element =>
  313. element.parentElement?.nodeName !== 'PRE' && hasContent(element),
  314. start: () => `\\verb|`,
  315. end: () => `|`,
  316. }),
  317. createSelector({
  318. selector: 'pre > code',
  319. match: element => hasContent(element),
  320. start: () => `\n\n\\begin{verbatim}\n`,
  321. end: () => `\n\\end{verbatim}\n\n`,
  322. }),
  323. createSelector({
  324. selector: '.ol-table-wrap',
  325. start: element => `\n\n\\begin{table}\n\\centering\n`,
  326. end: () => `\n\\end{table}\n\n`,
  327. }),
  328. createSelector({
  329. selector: 'table',
  330. start: element => `\n\\begin{tabular}{${tabular(element)}}\n`,
  331. end: () => `\n\\end{tabular}\n`,
  332. }),
  333. createSelector({
  334. selector: 'thead',
  335. start: () => `\n`,
  336. end: () => `\n`,
  337. }),
  338. createSelector({
  339. selector: 'tfoot',
  340. start: () => `\n`,
  341. end: () => `\n`,
  342. }),
  343. createSelector({
  344. selector: 'tbody',
  345. start: () => `\n`,
  346. end: () => `\n`,
  347. }),
  348. createSelector({
  349. selector: 'tr',
  350. match: element => element.nextElementSibling?.nodeName === 'TR',
  351. end: () => `\n`,
  352. }),
  353. createSelector({
  354. selector: 'tr > td:not(:last-child), tr > th:not(:last-child)',
  355. start: element => {
  356. const colspan = element.getAttribute('colspan')
  357. return colspan ? `\\multicolumn{${Number(colspan)}}{` : ''
  358. },
  359. end: element => {
  360. const colspan = element.getAttribute('colspan')
  361. return colspan ? `} & ` : ` & `
  362. },
  363. }),
  364. createSelector({
  365. selector: 'tr > td:last-child, tr > th:last-child',
  366. start: element => {
  367. const colspan = element.getAttribute('colspan')
  368. return colspan ? `\\multicolumn{${Number(colspan)}}{` : ''
  369. },
  370. end: element => {
  371. const colspan = element.getAttribute('colspan')
  372. return colspan ? `} \\\\` : ` \\\\`
  373. },
  374. }),
  375. createSelector({
  376. selector: 'caption',
  377. start: () => `\n\n\\caption{`,
  378. end: () => `}\n\n`,
  379. }),
  380. createSelector({
  381. selector: 'ul',
  382. start: element => `\n\n${listIndent(element)}\\begin{itemize}`,
  383. end: element => `\n${listIndent(element)}\\end{itemize}\n`,
  384. }),
  385. createSelector({
  386. selector: 'ol',
  387. start: element => `\n\n${listIndent(element)}\\begin{enumerate}`,
  388. end: element => `\n${listIndent(element)}\\end{enumerate}\n`,
  389. }),
  390. createSelector({
  391. selector: 'li',
  392. start: element => `\n${listIndent(element)}\t\\item `,
  393. }),
  394. createSelector({
  395. selector: 'p',
  396. match: element =>
  397. element.nextElementSibling?.nodeName === 'P' && hasContent(element),
  398. end: () => '\n\n',
  399. }),
  400. ]