paste-html.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  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. // ignore text/html from VS Code
  19. if (
  20. clipboardData.types.includes('application/vnd.code.copymetadata') ||
  21. clipboardData.types.includes('vscode-editor-data')
  22. ) {
  23. return false
  24. }
  25. const html = clipboardData.getData('text/html').trim()
  26. if (html.length === 0) {
  27. return false
  28. }
  29. // convert the HTML to LaTeX
  30. try {
  31. const parser = new DOMParser()
  32. const { documentElement } = parser.parseFromString(html, 'text/html')
  33. // if the only content is in a code block, use the plain text version
  34. if (onlyCode(documentElement)) {
  35. return false
  36. }
  37. const latex = htmlToLaTeX(documentElement)
  38. view.dispatch(
  39. view.state.changeByRange(range => {
  40. return {
  41. range: EditorSelection.cursor(range.from + latex.length),
  42. changes: { from: range.from, to: range.to, insert: latex },
  43. }
  44. })
  45. )
  46. return true
  47. } catch (error) {
  48. console.error(error)
  49. // fall back to the default paste handler
  50. return false
  51. }
  52. },
  53. })
  54. )
  55. const removeUnwantedElements = (
  56. documentElement: HTMLElement,
  57. selector: string
  58. ) => {
  59. for (const element of documentElement.querySelectorAll(selector)) {
  60. element.remove()
  61. }
  62. }
  63. // return true if the text content of the first <code> element
  64. // is the same as the text content of the whole document element
  65. const onlyCode = (documentElement: HTMLElement) =>
  66. documentElement.querySelector('code')?.textContent?.trim() ===
  67. documentElement.textContent?.trim()
  68. const htmlToLaTeX = (documentElement: HTMLElement) => {
  69. // remove style elements
  70. removeUnwantedElements(documentElement, 'style')
  71. // replace non-breaking spaces added by Chrome on copy
  72. processWhitespace(documentElement)
  73. // pre-process table elements
  74. processTables(documentElement)
  75. processMatchedElements(documentElement)
  76. const text = documentElement.textContent
  77. if (!text) {
  78. return ''
  79. }
  80. // normalise multiple newlines
  81. return text.replaceAll(/\n{2,}/g, '\n\n')
  82. }
  83. const processWhitespace = (documentElement: HTMLElement) => {
  84. const walker = document.createTreeWalker(
  85. documentElement,
  86. NodeFilter.SHOW_TEXT
  87. )
  88. for (let node = walker.nextNode(); node; node = walker.nextNode()) {
  89. if (node.textContent === ' ') {
  90. node.textContent = ' '
  91. }
  92. }
  93. }
  94. const processMatchedElements = (documentElement: HTMLElement) => {
  95. for (const item of selectors) {
  96. for (const element of documentElement.querySelectorAll<any>(
  97. item.selector
  98. )) {
  99. if (!item.match || item.match(element)) {
  100. // start the markup
  101. if (item.start) {
  102. const start = document.createTextNode(item.start(element))
  103. if (item.inside) {
  104. element.prepend(start)
  105. } else {
  106. element.before(start)
  107. }
  108. }
  109. // end the markup
  110. if (item.end) {
  111. const end = document.createTextNode(item.end(element))
  112. if (item.inside) {
  113. element.append(end)
  114. } else {
  115. element.after(end)
  116. }
  117. }
  118. }
  119. }
  120. }
  121. }
  122. const matchingParents = (element: HTMLElement, selector: string) => {
  123. const matches = []
  124. for (
  125. let ancestor = element.parentElement?.closest(selector);
  126. ancestor;
  127. ancestor = ancestor.parentElement?.closest(selector)
  128. ) {
  129. matches.push(ancestor)
  130. }
  131. return matches
  132. }
  133. const processTables = (element: HTMLElement) => {
  134. for (const table of element.querySelectorAll('table')) {
  135. // create a wrapper element for the table and the caption
  136. const container = document.createElement('div')
  137. container.className = 'ol-table-wrap'
  138. table.after(container)
  139. // move the caption (if it exists) into the container before the table
  140. const caption = table.querySelector('caption')
  141. if (caption) {
  142. container.append(caption)
  143. }
  144. // move the table into the container
  145. container.append(table)
  146. }
  147. }
  148. const cellAlignment = new Map([
  149. ['left', 'l'],
  150. ['center', 'c'],
  151. ['right', 'r'],
  152. ])
  153. const tabular = (element: HTMLTableElement) => {
  154. const definitions: Array<{
  155. alignment: string
  156. borderLeft: boolean
  157. borderRight: boolean
  158. }> = []
  159. const rows = element.querySelectorAll('tr')
  160. for (const row of rows) {
  161. const cells = [...row.childNodes].filter(
  162. element => element.nodeName === 'TD' || element.nodeName === 'TH'
  163. ) as Array<HTMLTableCellElement>
  164. let index = 0
  165. for (const cell of cells) {
  166. // NOTE: reading the alignment and borders from the first cell definition in each column
  167. if (definitions[index] === undefined) {
  168. const { textAlign, borderLeftStyle, borderRightStyle } = cell.style
  169. definitions[index] = {
  170. alignment: textAlign,
  171. borderLeft: visibleBorderStyle(borderLeftStyle),
  172. borderRight: visibleBorderStyle(borderRightStyle),
  173. }
  174. }
  175. index += Number(cell.getAttribute('colspan') ?? 1)
  176. }
  177. }
  178. for (let index = 0; index <= definitions.length; index++) {
  179. // fill in missing definitions
  180. const item = definitions[index] || {
  181. alignment: 'left',
  182. borderLeft: false,
  183. borderRight: false,
  184. }
  185. // remove left border if previous column had a right border
  186. if (item.borderLeft && index > 0 && definitions[index - 1]?.borderRight) {
  187. item.borderLeft = false
  188. }
  189. }
  190. return definitions
  191. .flatMap(definition => [
  192. definition.borderLeft ? '|' : '',
  193. cellAlignment.get(definition.alignment) ?? 'l',
  194. definition.borderRight ? '|' : '',
  195. ])
  196. .filter(Boolean)
  197. .join(' ')
  198. }
  199. const listDepth = (
  200. element: HTMLOListElement | HTMLUListElement | HTMLLIElement
  201. ): number => Math.max(0, matchingParents(element, 'ul,ol').length - 1)
  202. const listIndent = (
  203. element: HTMLOListElement | HTMLUListElement | HTMLLIElement
  204. ): string => '\t'.repeat(listDepth(element))
  205. type ElementSelector<T extends string, E extends HTMLElement = HTMLElement> = {
  206. selector: T
  207. match?: (element: E) => boolean
  208. start?: (element: E) => string
  209. end?: (element: E) => string
  210. inside?: boolean
  211. }
  212. const createSelector = <
  213. T extends string,
  214. E extends HTMLElement = T extends keyof HTMLElementTagNameMap
  215. ? HTMLElementTagNameMap[T]
  216. : HTMLElement
  217. >({
  218. selector,
  219. ...elementSelector
  220. }: ElementSelector<T, E>) => ({
  221. selector,
  222. ...elementSelector,
  223. })
  224. const headings = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6']
  225. const isHeading = (element: HTMLElement | null) => {
  226. return element && headings.includes(element.nodeName)
  227. }
  228. const hasContent = (element: HTMLElement): boolean => {
  229. return Boolean(element.textContent && element.textContent.trim().length > 0)
  230. }
  231. type BorderStyle =
  232. | 'borderTopStyle'
  233. | 'borderRightStyle'
  234. | 'borderBottomStyle'
  235. | 'borderLeftStyle'
  236. const visibleBorderStyle = (style: CSSStyleDeclaration[BorderStyle]): boolean =>
  237. !!style && style !== 'none' && style !== 'hidden'
  238. const rowHasBorderStyle = (
  239. element: HTMLTableRowElement,
  240. style: BorderStyle
  241. ): boolean => {
  242. if (visibleBorderStyle(element.style[style])) {
  243. return true
  244. }
  245. const cells = element.querySelectorAll<HTMLTableCellElement>('th,td')
  246. return [...cells].every(cell => visibleBorderStyle(cell.style[style]))
  247. }
  248. const isTableRowElement = (
  249. element: Element | null
  250. ): element is HTMLTableRowElement => element?.tagName === 'TR'
  251. const nextRowHasBorderStyle = (
  252. element: HTMLTableRowElement,
  253. style: BorderStyle
  254. ) => {
  255. const { nextElementSibling } = element
  256. return (
  257. isTableRowElement(nextElementSibling) &&
  258. rowHasBorderStyle(nextElementSibling, style)
  259. )
  260. }
  261. const startMulticolumn = (element: HTMLTableCellElement): string => {
  262. const colspan = element.getAttribute('colspan') ?? 1
  263. const alignment = cellAlignment.get(element.style.textAlign) ?? 'l'
  264. return `\\multicolumn{${Number(colspan)}}{${alignment}}{`
  265. }
  266. const selectors = [
  267. createSelector({
  268. selector: 'b',
  269. match: element =>
  270. element.style.fontWeight !== 'normal' &&
  271. !isHeading(element.parentElement) &&
  272. hasContent(element),
  273. start: () => '\\textbf{',
  274. end: () => '}',
  275. }),
  276. createSelector({
  277. selector: '*',
  278. match: element =>
  279. parseInt(element.style.fontWeight) > 400 && hasContent(element),
  280. start: () => '\\textbf{',
  281. end: () => '}',
  282. inside: true,
  283. }),
  284. createSelector({
  285. selector: 'strong',
  286. match: element => hasContent(element),
  287. start: () => '\\textbf{',
  288. end: () => '}',
  289. }),
  290. createSelector({
  291. selector: 'i',
  292. match: element =>
  293. element.style.fontStyle !== 'normal' && hasContent(element),
  294. start: () => '\\textit{',
  295. end: () => '}',
  296. }),
  297. createSelector({
  298. selector: '*',
  299. match: element =>
  300. element.style.fontStyle === 'italic' && hasContent(element),
  301. start: () => '\\textit{',
  302. end: () => '}',
  303. }),
  304. createSelector({
  305. selector: 'em',
  306. match: element => hasContent(element),
  307. start: () => '\\textit{',
  308. end: () => '}',
  309. }),
  310. createSelector({
  311. selector: 'sup',
  312. match: element => hasContent(element),
  313. start: () => '\\textsuperscript{',
  314. end: () => '}',
  315. }),
  316. createSelector({
  317. selector: 'span',
  318. match: element =>
  319. element.style.verticalAlign === 'super' && hasContent(element),
  320. start: () => '\\textsuperscript{',
  321. end: () => '}',
  322. }),
  323. createSelector({
  324. selector: 'sub',
  325. match: element => hasContent(element),
  326. start: () => '\\textsubscript{',
  327. end: () => '}',
  328. }),
  329. createSelector({
  330. selector: 'span',
  331. match: element =>
  332. element.style.verticalAlign === 'sub' && hasContent(element),
  333. start: () => '\\textsubscript{',
  334. end: () => '}',
  335. }),
  336. createSelector({
  337. selector: 'a',
  338. match: element => !!element.href && hasContent(element),
  339. start: (element: HTMLAnchorElement) => `\\href{${element.href}}{`,
  340. end: element => `}`,
  341. }),
  342. createSelector({
  343. selector: 'h1',
  344. match: element => !element.closest('table') && hasContent(element),
  345. start: () => `\n\n\\section{`,
  346. end: () => `}\n\n`,
  347. }),
  348. createSelector({
  349. selector: 'h2',
  350. match: element => !element.closest('table') && hasContent(element),
  351. start: () => `\n\n\\subsection{`,
  352. end: () => `}\n\n`,
  353. }),
  354. createSelector({
  355. selector: 'h3',
  356. match: element => !element.closest('table') && hasContent(element),
  357. start: () => `\n\n\\subsubsection{`,
  358. end: () => `}\n\n`,
  359. }),
  360. createSelector({
  361. selector: 'h4',
  362. match: element => !element.closest('table') && hasContent(element),
  363. start: () => `\n\n\\paragraph{`,
  364. end: () => `}\n\n`,
  365. }),
  366. createSelector({
  367. selector: 'h5',
  368. match: element => !element.closest('table') && hasContent(element),
  369. start: () => `\n\n\\subparagraph{`,
  370. end: () => `}\n\n`,
  371. }),
  372. // TODO: h6?
  373. createSelector({
  374. selector: 'br',
  375. match: element => element.parentElement?.nodeName !== 'TD', // TODO: why?
  376. start: () => `\n\n`,
  377. }),
  378. createSelector({
  379. selector: 'code',
  380. match: element =>
  381. element.parentElement?.nodeName !== 'PRE' && hasContent(element),
  382. start: () => `\\verb|`,
  383. end: () => `|`,
  384. }),
  385. createSelector({
  386. selector: 'pre > code',
  387. match: element => hasContent(element),
  388. start: () => `\n\n\\begin{verbatim}\n`,
  389. end: () => `\n\\end{verbatim}\n\n`,
  390. }),
  391. createSelector({
  392. selector: '.ol-table-wrap',
  393. start: () => `\n\n\\begin{table}\n\\centering\n`,
  394. end: () => `\n\\end{table}\n\n`,
  395. }),
  396. createSelector({
  397. selector: 'table',
  398. start: element => `\n\\begin{tabular}{${tabular(element)}}`,
  399. end: () => `\\end{tabular}\n`,
  400. }),
  401. createSelector({
  402. selector: 'thead',
  403. start: () => `\n`,
  404. end: () => `\n`,
  405. }),
  406. createSelector({
  407. selector: 'tfoot',
  408. start: () => `\n`,
  409. end: () => `\n`,
  410. }),
  411. createSelector({
  412. selector: 'tbody',
  413. start: () => `\n`,
  414. end: () => `\n`,
  415. }),
  416. createSelector({
  417. selector: 'tr',
  418. start: element => {
  419. const borderTop = rowHasBorderStyle(element, 'borderTopStyle')
  420. return borderTop ? '\\hline\n' : ''
  421. },
  422. end: element => {
  423. const borderBottom = rowHasBorderStyle(element, 'borderBottomStyle')
  424. return borderBottom && !nextRowHasBorderStyle(element, 'borderTopStyle')
  425. ? '\n\\hline\n'
  426. : '\n'
  427. },
  428. }),
  429. createSelector({
  430. selector: 'tr > td:not(:last-child), tr > th:not(:last-child)',
  431. start: (element: HTMLTableCellElement) => {
  432. const colspan = element.getAttribute('colspan')
  433. return colspan ? startMulticolumn(element) : ''
  434. },
  435. end: element => {
  436. const colspan = element.getAttribute('colspan')
  437. return colspan ? `} & ` : ` & `
  438. },
  439. }),
  440. createSelector({
  441. selector: 'tr > td:last-child, tr > th:last-child',
  442. start: (element: HTMLTableCellElement) => {
  443. const colspan = element.getAttribute('colspan')
  444. return colspan ? startMulticolumn(element) : ''
  445. },
  446. end: element => {
  447. const colspan = element.getAttribute('colspan')
  448. return colspan ? `} \\\\` : ` \\\\`
  449. },
  450. }),
  451. createSelector({
  452. selector: 'caption',
  453. start: () => `\n\n\\caption{`,
  454. end: () => `}\n\n`,
  455. }),
  456. createSelector({
  457. selector: 'ul',
  458. start: element => `\n\n${listIndent(element)}\\begin{itemize}`,
  459. end: element => `\n${listIndent(element)}\\end{itemize}\n`,
  460. }),
  461. createSelector({
  462. selector: 'ol',
  463. start: element => `\n\n${listIndent(element)}\\begin{enumerate}`,
  464. end: element => `\n${listIndent(element)}\\end{enumerate}\n`,
  465. }),
  466. createSelector({
  467. selector: 'li',
  468. start: element => `\n${listIndent(element)}\t\\item `,
  469. }),
  470. createSelector({
  471. selector: 'p',
  472. match: element =>
  473. element.nextElementSibling?.nodeName === 'P' && hasContent(element),
  474. end: () => '\n\n',
  475. }),
  476. ]