edit.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. document.addEventListener('DOMContentLoaded', () => {
  2. const zoom = 0.25// From the pdf2htmlex parameter --zoom
  3. const zoomRatio = 1 / zoom
  4. const saveButton = document.getElementById('saveButton')
  5. const whiteoutButton = document.getElementById('whiteoutButton')
  6. const editTextButton = document.getElementById('editTextButton')
  7. const saveForm = document.getElementById('saveForm')
  8. const htmlInput = document.getElementById('htmlInput')
  9. const pageContainer = document.getElementById('page-container')
  10. let host = null
  11. let mode = null
  12. const toggleMode = newMode => {
  13. if (mode === newMode) newMode = null
  14. switch (mode) {
  15. case 'whiteout': disableWhiteout(); break;
  16. case 'editText': disableEditText(); break;
  17. }
  18. switch (newMode) {
  19. case 'whiteout': enableWhiteout(); break;
  20. case 'editText': enableEditText(); break;
  21. }
  22. mode = newMode
  23. }
  24. document.querySelectorAll('#page-container img').forEach(image => {
  25. image.addEventListener('click', () => {
  26. const selection = getSelection()
  27. selection.removeAllRanges()
  28. const range = document.createRange()
  29. range.selectNode(image)
  30. selection.addRange(range)
  31. })
  32. })
  33. saveButton.addEventListener('click', () => {
  34. htmlInput.value = ""
  35. htmlInput.value = document.documentElement.outerHTML
  36. saveForm.submit()
  37. htmlInput.value = ""
  38. })
  39. const save = () => new Promise((resolve, reject) => {
  40. document.documentElement.classList.add('saving')
  41. const req = new XMLHttpRequest()
  42. req.open('POST', 'save', true)
  43. req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
  44. req.responseType = 'arraybuffer'
  45. htmlInput.value = ""
  46. const body = `html=${encodeURIComponent(document.documentElement.outerHTML)}`
  47. req.onload = () => {
  48. if (req.status > 299) {
  49. reject(new Error(req.statusText))
  50. } else {
  51. resolve(req.response)
  52. }
  53. }
  54. req.onerror = (ev) => {
  55. reject(ev.error)
  56. }
  57. req.send(body)
  58. }).finally(() => {
  59. document.documentElement.classList.remove('saving')
  60. })
  61. // Get screen to print ratio
  62. /*
  63. The editing process deals with documents in several different dimensions and units of distance:
  64. - Paper size (A4, Legal, etc)
  65. - Print size (Font size in points, applied using the '@media print' rule)
  66. - Screen size (Font size in pixels, applied using the '@media screen' rule)
  67. - Zoom (Display transformation of the document preview, applied using transform-matrix)
  68. - Browser size (1:1 ratio for the browser itself)
  69. The following code reads evidence of these different values from the CSS, and determines how to transform from
  70. browser vector space, to preview vector space, to print vector space, so our edits and the preview are accurately
  71. represented in the resulting PDF.
  72. */
  73. const localStyleSheets = Array.from(document.styleSheets).filter(x => !x.href)
  74. const pageOnePixelWidth = localStyleSheets.map(ss =>
  75. Array.from(ss.rules)
  76. .find(rule => rule.selectorText === '.w0' && rule.style.width.endsWith('px'))
  77. ).find(x => x).style.width.split('px')[0]
  78. const pageOnePointWidth = localStyleSheets.map(ss =>
  79. Array.from(ss.rules)
  80. .filter(rule => rule instanceof CSSMediaRule && rule.conditionText === 'print')
  81. .map(mediaRule =>
  82. Array.from(mediaRule.cssRules)
  83. .filter(rule => rule.selectorText === '.w0' && rule.style.width.endsWith('pt'))
  84. .find(x => x)
  85. ).find(x => x)
  86. ).find(x => x).style.width.split('pt')[0]
  87. const pixelsPerPoint = 1.3333333333333333
  88. const pageOnePrintPixelWidth = pageOnePointWidth * pixelsPerPoint
  89. const screenToPrintRatio = pageOnePrintPixelWidth / pageOnePixelWidth
  90. const printToScreenRatio = pageOnePixelWidth / pageOnePrintPixelWidth
  91. // Replacements are constructed in print vector space, so they must be scaled down in screen.
  92. const myStyle = document.createElement('style')
  93. myStyle.type = 'text/css'
  94. myStyle.innerHTML = `
  95. @media screen {
  96. .replacement {
  97. zoom: ${printToScreenRatio};
  98. }
  99. }
  100. `
  101. document.head.appendChild(myStyle)
  102. // Whiteout
  103. let start = null
  104. const box = document.createElement('div')
  105. box.className = 'whiteout-box'
  106. whiteoutButton.addEventListener('click', () => toggleMode('whiteout'))
  107. const enableWhiteout = () => {
  108. whiteoutButton.classList.add('active')
  109. pageContainer.classList.add('whiteout')
  110. pageContainer.addEventListener('mousedown', whiteoutMouseDown)
  111. }
  112. const disableWhiteout = () => {
  113. whiteoutButton.classList.remove('active')
  114. pageContainer.classList.remove('whiteout')
  115. pageContainer.removeEventListener('mousedown', whiteoutMouseDown)
  116. }
  117. const whiteoutMouseDown = event => {
  118. start = {x: event.clientX, y: event.clientY}
  119. document.body.appendChild(box)
  120. drawBox(start, start)
  121. window.addEventListener('mousemove', whiteoutMouseMove)
  122. window.addEventListener('mouseup', whiteoutMouseUp)
  123. }
  124. const whiteoutMouseMove = event => {
  125. let end = {x: event.clientX, y: event.clientY}
  126. drawBox(rect(start, end))
  127. }
  128. const whiteoutMouseUp = event => {
  129. let end = {x: event.clientX, y: event.clientY}
  130. document.body.removeChild(box)
  131. const selection = rect(start, end)
  132. console.log('whiteout', selection)
  133. whiteout(selection)
  134. window.removeEventListener('mousemove', whiteoutMouseMove)
  135. window.removeEventListener('mouseup', whiteoutMouseUp)
  136. }
  137. const rect = (a, b) => {
  138. const left = Math.min(a.x, b.x)
  139. const top = Math.min(a.y, b.y)
  140. const right = Math.max(a.x, b.x)
  141. const bottom = Math.max(a.y, b.y)
  142. return new DOMRect(left, top, right - left, bottom - top)
  143. }
  144. const drawBox = rect => {
  145. box.style.cssText = `
  146. top: ${rect.top - 1}px;
  147. left: ${rect.left - 1}px;
  148. width: ${rect.width}px;
  149. height: ${rect.height}px;
  150. `
  151. }
  152. const intersects = (r1, r2) =>
  153. !(r2.left > r1.right ||
  154. r2.right < r1.left ||
  155. r2.top > r1.bottom ||
  156. r2.bottom < r1.top)
  157. /*
  158. The common whiteout approach of drawing an opaque white box over sensitive content is quite ineffective.
  159. A user can simply select the text from behind the whiteout, and copy & paste it into another program.
  160. This method finds every text character within the selected rectangle, and replaces it with a placeholder.
  161. It also modifies the background image by drawing a white box, replacing the pixels within the rectangle.
  162. */
  163. const whiteout = rect => {
  164. const elements = []
  165. const walk = (element) => {
  166. let elementRect
  167. if (element instanceof HTMLImageElement) {
  168. elementRect = element.getBoundingClientRect()
  169. } else {
  170. const range = document.createRange()
  171. range.selectNodeContents(element)
  172. elementRect = range.getBoundingClientRect()
  173. }
  174. if (intersects(rect, elementRect)) {
  175. if (element.childNodes && element.childNodes.length) {
  176. Array.from(element.childNodes).forEach(walk)
  177. } else if (element instanceof Text && element.textContent.length > 1) {
  178. while (element.textContent.length) {
  179. const next = element.splitText(1)
  180. walk(element)
  181. element = next
  182. }
  183. } else {
  184. elements.push(element)
  185. }
  186. }
  187. }
  188. walk(pageContainer)
  189. elements.forEach(element => {
  190. if (element instanceof Text) {
  191. const range = document.createRange()
  192. range.selectNodeContents(element)
  193. const elementRect = range.getBoundingClientRect()
  194. const replacement = document.createElement('span')
  195. replacement.className = 'replacement'
  196. replacement.style.cssText = `
  197. width: ${elementRect.width * zoomRatio * screenToPrintRatio}px;
  198. display: inline-block;
  199. position: relative;
  200. `
  201. element.replaceWith(replacement)
  202. } else if (element instanceof HTMLImageElement) {
  203. const canvas = document.createElement('canvas')
  204. canvas.width = element.offsetWidth * zoomRatio
  205. canvas.height = element.offsetHeight * zoomRatio
  206. const ctx = canvas.getContext('2d')
  207. ctx.fillStyle = 'white'
  208. ctx.drawImage(element, 0, 0, element.offsetWidth * zoomRatio, element.offsetHeight * zoomRatio)
  209. const elementRect = element.getBoundingClientRect()
  210. ctx.fillRect(
  211. (rect.x - elementRect.x) * zoomRatio,
  212. (rect.y - elementRect.y) * zoomRatio,
  213. rect.width * zoomRatio,
  214. rect.height * zoomRatio
  215. )
  216. element.src = canvas.toDataURL()
  217. }
  218. })
  219. window.editor.changed = true
  220. }
  221. // Edit Text
  222. editTextButton.addEventListener('click', () => toggleMode('editText'))
  223. const enableEditText = () => {
  224. editTextButton.classList.add('active')
  225. pageContainer.classList.add('editText')
  226. document.querySelectorAll('.pc').forEach(page => {
  227. page.setAttribute('contenteditable', 'true')
  228. })
  229. window.editor.changed = true
  230. }
  231. const disableEditText = () => {
  232. editTextButton.classList.remove('active')
  233. pageContainer.classList.remove('editText')
  234. document.querySelectorAll('.pc').forEach(page => {
  235. page.setAttribute('contenteditable', 'false')
  236. })
  237. }
  238. const attach = hostController => {
  239. host = hostController
  240. }
  241. window.editor = {
  242. attach,
  243. save,
  244. changed: false
  245. }
  246. })