pdf-preview-context.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659
  1. import {
  2. createContext,
  3. useCallback,
  4. useContext,
  5. useEffect,
  6. useMemo,
  7. useState,
  8. } from 'react'
  9. import PropTypes from 'prop-types'
  10. import useScopeValue from '../../../shared/context/util/scope-value-hook'
  11. import { useProjectContext } from '../../../shared/context/project-context'
  12. import getMeta from '../../../utils/meta'
  13. import { deleteJSON, postJSON } from '../../../infrastructure/fetch-json'
  14. import usePersistedState from '../../../shared/hooks/use-persisted-state'
  15. import {
  16. buildLogEntryAnnotations,
  17. handleOutputFiles,
  18. } from '../util/output-files'
  19. import { debounce } from 'lodash'
  20. import { useIdeContext } from '../../../shared/context/ide-context'
  21. import {
  22. send,
  23. sendMB,
  24. sendMBSampled,
  25. } from '../../../infrastructure/event-tracking'
  26. import { useEditorContext } from '../../../shared/context/editor-context'
  27. import { isMainFile } from '../util/editor-files'
  28. import useAbortController from '../../../shared/hooks/use-abort-controller'
  29. const AUTO_COMPILE_MAX_WAIT = 5000
  30. // We add a 1 second debounce to sending user changes to server if they aren't
  31. // collaborating with anyone. This needs to be higher than that, and allow for
  32. // client to server latency, otherwise we compile before the op reaches the server
  33. // and then again on ack.
  34. const AUTO_COMPILE_DEBOUNCE = 2000
  35. const searchParams = new URLSearchParams(window.location.search)
  36. export const PdfPreviewContext = createContext(undefined)
  37. PdfPreviewProvider.propTypes = {
  38. children: PropTypes.any,
  39. }
  40. export default function PdfPreviewProvider({ children }) {
  41. const ide = useIdeContext()
  42. const { _id: projectId, rootDoc_id: rootDocId } = useProjectContext()
  43. const { hasPremiumCompile, isProjectOwner } = useEditorContext()
  44. // the URL for loading the PDF in the preview pane
  45. const [pdfUrl, setPdfUrl] = useScopeValue('pdf.url')
  46. // the URL for downloading the PDF
  47. const [pdfDownloadUrl, setPdfDownloadUrl] = useScopeValue('pdf.downloadUrl')
  48. // the log entries parsed from the compile output log
  49. const [logEntries, setLogEntries] = useScopeValue('pdf.logEntries')
  50. // the project is considered to be "uncompiled" if a doc has changed since the last compile started
  51. const [uncompiled, setUncompiled] = useScopeValue('pdf.uncompiled')
  52. // annotations for display in the editor, built from the log entries
  53. const [, setLogEntryAnnotations] = useScopeValue('pdf.logEntryAnnotations')
  54. // the id of the CLSI server which ran the compile
  55. const [clsiServerId, setClsiServerId] = useScopeValue('ide.clsiServerId')
  56. // the compile group (standard or priority)
  57. const [compileGroup, setCompileGroup] = useScopeValue('ide.compileGroup')
  58. // whether to display the editor and preview side-by-side or full-width ("flat")
  59. const [pdfLayout, setPdfLayout] = useScopeValue('ui.pdfLayout')
  60. // what to show in the "flat" view (editor or pdf)
  61. const [, setUiView] = useScopeValue('ui.view')
  62. // whether a compile is in progress
  63. const [compiling, setCompiling] = useState(false)
  64. // whether the project has been compiled yet
  65. const [compiledOnce, setCompiledOnce] = useState(false)
  66. // whether the cache is being cleared
  67. const [clearingCache, setClearingCache] = useState(false)
  68. // whether the logs should be visible
  69. const [showLogs, setShowLogs] = useState(false)
  70. // an error that occurred
  71. const [error, setError] = useState()
  72. // the list of files that can be downloaded
  73. const [fileList, setFileList] = useState()
  74. // the raw contents of the log file
  75. const [rawLog, setRawLog] = useState()
  76. // validation issues from CLSI
  77. const [validationIssues, setValidationIssues] = useState()
  78. // whether autocompile is switched on
  79. const [autoCompile, _setAutoCompile] = usePersistedState(
  80. `autocompile_enabled:${projectId}`,
  81. false,
  82. true
  83. )
  84. // whether the compile should run in draft mode
  85. const [draft, setDraft] = usePersistedState(`draft:${projectId}`, false, true)
  86. // whether compiling should be prevented if there are linting errors
  87. const [stopOnValidationError, setStopOnValidationError] = usePersistedState(
  88. `stop_on_validation_error:${projectId}`,
  89. true,
  90. true
  91. )
  92. // the id of the currently open document in the editor
  93. const [currentDocId] = useScopeValue('editor.open_doc_id')
  94. // the Document currently open in the editor?
  95. const [currentDoc] = useScopeValue('editor.sharejs_doc')
  96. // whether the PDF view is hidden
  97. const [pdfHidden] = useScopeValue('ui.pdfHidden')
  98. // whether the editor linter found errors
  99. const [hasLintingError, setHasLintingError] = useScopeValue('hasLintingError')
  100. // whether syntax validation is enabled globally
  101. const [syntaxValidation] = useScopeValue('settings.syntaxValidation')
  102. // the timestamp that a doc was last changed or saved
  103. const [changedAt, setChangedAt] = useState(0)
  104. const { signal } = useAbortController()
  105. // pass the "uncompiled" value up into the scope for use outside this context provider
  106. useEffect(() => {
  107. setUncompiled(changedAt > 0)
  108. }, [setUncompiled, changedAt])
  109. // record changes to the autocompile setting
  110. const setAutoCompile = useCallback(
  111. value => {
  112. _setAutoCompile(value)
  113. sendMB('autocompile-setting-changed', { value })
  114. },
  115. [_setAutoCompile]
  116. )
  117. // parse the text of the current doc in the editor
  118. // if it contains "\documentclass" then use this as the root doc
  119. const getRootDocOverrideId = useCallback(() => {
  120. if (currentDocId === rootDocId) {
  121. return null // no need to override when in the root doc itself
  122. }
  123. if (currentDoc) {
  124. const doc = currentDoc.getSnapshot()
  125. if (doc) {
  126. return isMainFile(doc)
  127. }
  128. }
  129. return null
  130. }, [currentDoc, currentDocId, rootDocId])
  131. // TODO: remove this?
  132. const sendCompileMetrics = useCallback(() => {
  133. if (compiledOnce && !error && !window.user.alphaProgram) {
  134. const metadata = {
  135. errors: logEntries.errors.length,
  136. warnings: logEntries.warnings.length,
  137. typesetting: logEntries.typesetting.length,
  138. newPdfPreview: true,
  139. }
  140. sendMBSampled('compile-result', metadata, 0.01)
  141. }
  142. }, [compiledOnce, error, logEntries])
  143. // handle the data returned from a compile request
  144. const handleCompileData = useCallback(
  145. (data, options) => {
  146. if (data.clsiServerId) {
  147. setClsiServerId(data.clsiServerId)
  148. }
  149. if (data.compileGroup) {
  150. setCompileGroup(data.compileGroup)
  151. }
  152. if (data.outputFiles) {
  153. handleOutputFiles(projectId, data).then(result => {
  154. setLogEntryAnnotations(
  155. buildLogEntryAnnotations(result.logEntries.all, ide.fileTreeManager)
  156. )
  157. setLogEntries(result.logEntries)
  158. setFileList(result.fileList)
  159. setPdfDownloadUrl(result.pdfDownloadUrl)
  160. setPdfUrl(result.pdfUrl)
  161. setRawLog(result.log)
  162. })
  163. }
  164. switch (data.status) {
  165. case 'success':
  166. setError(undefined)
  167. setShowLogs(false) // TODO: always?
  168. break
  169. case 'clsi-maintenance':
  170. case 'compile-in-progress':
  171. case 'exited':
  172. case 'failure':
  173. case 'project-too-large':
  174. case 'terminated':
  175. case 'too-recently-compiled':
  176. setError(data.status)
  177. break
  178. case 'timedout':
  179. setError('timedout')
  180. if (!hasPremiumCompile && isProjectOwner) {
  181. send(
  182. 'subscription-funnel',
  183. 'editor-click-feature',
  184. 'compile-timeout'
  185. )
  186. sendMB('paywall-prompt', {
  187. 'paywall-type': 'compile-timeout',
  188. })
  189. }
  190. break
  191. case 'autocompile-backoff':
  192. if (!options.isAutoCompileOnLoad) {
  193. setError('autocompile-disabled')
  194. setAutoCompile(false)
  195. sendMB('autocompile-rate-limited', { hasPremiumCompile })
  196. }
  197. break
  198. case 'unavailable':
  199. setError('clsi-unavailable')
  200. break
  201. case 'validation-problems':
  202. setError('validation-problems')
  203. setValidationIssues(data.validationProblems)
  204. break
  205. default:
  206. setError('error')
  207. break
  208. }
  209. },
  210. [
  211. hasPremiumCompile,
  212. ide.fileTreeManager,
  213. isProjectOwner,
  214. projectId,
  215. setAutoCompile,
  216. setClsiServerId,
  217. setCompileGroup,
  218. setLogEntries,
  219. setLogEntryAnnotations,
  220. setPdfDownloadUrl,
  221. setPdfUrl,
  222. ]
  223. )
  224. const buildCompileParams = useCallback(
  225. options => {
  226. const params = new URLSearchParams()
  227. if (clsiServerId) {
  228. params.set('clsiserverid', clsiServerId)
  229. }
  230. if (options.isAutoCompileOnLoad || options.isAutoCompileOnChange) {
  231. params.set('auto_compile', 'true')
  232. }
  233. if (getMeta('ol-enablePdfCaching')) {
  234. params.set('enable_pdf_caching', 'true')
  235. }
  236. if (searchParams.get('file_line_errors') === 'true') {
  237. params.file_line_errors = 'true'
  238. }
  239. return params
  240. },
  241. [clsiServerId]
  242. )
  243. // run a compile
  244. const recompile = useCallback(
  245. (options = {}) => {
  246. if (compiling) {
  247. return
  248. }
  249. sendMBSampled('editor-recompile-sampled', options)
  250. setChangedAt(0) // NOTE: this sets uncompiled to false
  251. setCompiling(true)
  252. setValidationIssues(undefined)
  253. window.dispatchEvent(new CustomEvent('flush-changes')) // TODO: wait for this?
  254. postJSON(`/project/${projectId}/compile?${buildCompileParams(options)}`, {
  255. body: {
  256. rootDoc_id: getRootDocOverrideId(),
  257. draft,
  258. check: 'silent', // NOTE: 'error' and 'validate' are possible, but unused
  259. // use incremental compile for all users but revert to a full compile
  260. // if there was previously a server error
  261. incrementalCompilesEnabled: !error,
  262. },
  263. signal,
  264. })
  265. .then(data => {
  266. handleCompileData(data, options)
  267. })
  268. .catch(error => {
  269. // console.error(error)
  270. setError(error.info?.statusCode === 429 ? 'rate-limited' : 'error')
  271. })
  272. .finally(() => {
  273. setCompiling(false)
  274. sendCompileMetrics()
  275. })
  276. },
  277. [
  278. compiling,
  279. projectId,
  280. buildCompileParams,
  281. getRootDocOverrideId,
  282. draft,
  283. error,
  284. handleCompileData,
  285. sendCompileMetrics,
  286. signal,
  287. ]
  288. )
  289. // switch to logs if there's an error
  290. useEffect(() => {
  291. if (error) {
  292. setShowLogs(true)
  293. }
  294. }, [error])
  295. // recompile on key press
  296. useEffect(() => {
  297. const listener = event => {
  298. recompile(event.detail)
  299. }
  300. window.addEventListener('pdf:recompile', listener)
  301. return () => {
  302. window.removeEventListener('pdf:recompile', listener)
  303. }
  304. }, [recompile])
  305. // always compile the PDF once, when joining the project
  306. useEffect(() => {
  307. const listener = () => {
  308. if (!compiledOnce) {
  309. setCompiledOnce(true)
  310. recompile({ isAutoCompileOnLoad: true })
  311. }
  312. }
  313. window.addEventListener('project:joined', listener)
  314. return () => {
  315. window.removeEventListener('project:joined', listener)
  316. }
  317. }, [compiledOnce, recompile])
  318. // whether there has been an autocompile linting error, if syntax validation is switched on
  319. const autoCompileLintingError = Boolean(
  320. autoCompile && syntaxValidation && hasLintingError
  321. )
  322. // the project has visible changes
  323. const hasChanges = Boolean(
  324. autoCompile &&
  325. uncompiled &&
  326. compiledOnce &&
  327. !(stopOnValidationError && autoCompileLintingError)
  328. )
  329. // the project is available for auto-compiling
  330. const canAutoCompile = Boolean(
  331. autoCompile &&
  332. !compiling &&
  333. !pdfHidden &&
  334. !(stopOnValidationError && autoCompileLintingError)
  335. )
  336. // a debounced wrapper around the recompile function, used for auto-compile
  337. const [debouncedAutoCompile] = useState(() => {
  338. return debounce(
  339. () => {
  340. recompile({ isAutoCompileOnChange: true })
  341. },
  342. AUTO_COMPILE_DEBOUNCE,
  343. {
  344. maxWait: AUTO_COMPILE_MAX_WAIT,
  345. }
  346. )
  347. })
  348. // call the debounced recompile function if the project is available for auto-compiling and it has changed
  349. useEffect(() => {
  350. if (canAutoCompile && changedAt > 0) {
  351. debouncedAutoCompile()
  352. } else {
  353. debouncedAutoCompile.cancel()
  354. }
  355. }, [canAutoCompile, debouncedAutoCompile, recompile, changedAt])
  356. // cancel debounced recompile on unmount
  357. useEffect(() => {
  358. return () => {
  359. debouncedAutoCompile.cancel()
  360. }
  361. }, [debouncedAutoCompile])
  362. // record doc changes when notified by the editor
  363. useEffect(() => {
  364. const listener = () => {
  365. setChangedAt(Date.now())
  366. }
  367. window.addEventListener('doc:changed', listener)
  368. window.addEventListener('doc:saved', listener)
  369. return () => {
  370. window.removeEventListener('doc:changed', listener)
  371. window.removeEventListener('doc:saved', listener)
  372. }
  373. }, [])
  374. // send a request to stop the current compile
  375. const stopCompile = useCallback(() => {
  376. // TODO: stoppingCompile state?
  377. const params = new URLSearchParams()
  378. if (clsiServerId) {
  379. params.set('clsiserverid', clsiServerId)
  380. }
  381. return postJSON(`/project/${projectId}/compile/stop?${params}`, { signal })
  382. .catch(error => {
  383. setError(error)
  384. })
  385. .finally(() => {
  386. setCompiling(false)
  387. })
  388. }, [projectId, clsiServerId, signal])
  389. const clearCache = useCallback(() => {
  390. setClearingCache(true)
  391. const params = new URLSearchParams()
  392. if (clsiServerId) {
  393. params.set('clsiserverid', clsiServerId)
  394. }
  395. return deleteJSON(`/project/${projectId}/output?${params}`, { signal })
  396. .catch(error => {
  397. console.error(error)
  398. setError('clear-cache')
  399. })
  400. .finally(() => {
  401. setClearingCache(false)
  402. })
  403. }, [clsiServerId, projectId, setError, signal])
  404. // clear the cache then run a compile, triggered by a menu item
  405. const recompileFromScratch = useCallback(() => {
  406. setClearingCache(true)
  407. clearCache().then(() => {
  408. setClearingCache(false)
  409. recompile()
  410. })
  411. }, [clearCache, recompile])
  412. // switch to either side-by-side or flat (full-width) layout
  413. const switchLayout = useCallback(() => {
  414. setPdfLayout(layout => {
  415. const newLayout = layout === 'sideBySide' ? 'flat' : 'sideBySide'
  416. setUiView(newLayout === 'sideBySide' ? 'editor' : 'pdf')
  417. setPdfLayout(newLayout)
  418. window.localStorage.setItem('pdf.layout', newLayout)
  419. })
  420. }, [setPdfLayout, setUiView])
  421. // the context value, memoized to minimize re-rendering
  422. const value = useMemo(() => {
  423. return {
  424. autoCompile,
  425. autoCompileLintingError,
  426. clearCache,
  427. clearingCache,
  428. clsiServerId,
  429. compileGroup,
  430. compiledOnce,
  431. compiling,
  432. draft,
  433. error,
  434. fileList,
  435. hasChanges,
  436. hasLintingError,
  437. logEntries,
  438. pdfDownloadUrl,
  439. pdfLayout,
  440. pdfUrl,
  441. rawLog,
  442. recompile,
  443. recompileFromScratch,
  444. setAutoCompile,
  445. setClsiServerId,
  446. setCompileGroup,
  447. setCompiledOnce,
  448. setDraft,
  449. setError,
  450. setHasLintingError, // for story
  451. setLogEntries,
  452. setPdfDownloadUrl,
  453. setPdfLayout,
  454. setPdfUrl,
  455. setShowLogs,
  456. setStopOnValidationError,
  457. setUiView,
  458. showLogs,
  459. stopCompile,
  460. stopOnValidationError,
  461. switchLayout,
  462. uncompiled,
  463. validationIssues,
  464. }
  465. }, [
  466. autoCompile,
  467. autoCompileLintingError,
  468. clearCache,
  469. clearingCache,
  470. clsiServerId,
  471. compileGroup,
  472. compiledOnce,
  473. compiling,
  474. draft,
  475. error,
  476. fileList,
  477. hasChanges,
  478. hasLintingError,
  479. logEntries,
  480. pdfDownloadUrl,
  481. pdfLayout,
  482. pdfUrl,
  483. rawLog,
  484. recompile,
  485. recompileFromScratch,
  486. setAutoCompile,
  487. setClsiServerId,
  488. setCompileGroup,
  489. setCompiledOnce,
  490. setDraft,
  491. setError,
  492. setHasLintingError,
  493. setLogEntries,
  494. setPdfDownloadUrl,
  495. setPdfLayout,
  496. setPdfUrl,
  497. setStopOnValidationError,
  498. setUiView,
  499. showLogs,
  500. stopCompile,
  501. stopOnValidationError,
  502. switchLayout,
  503. uncompiled,
  504. validationIssues,
  505. ])
  506. return (
  507. <PdfPreviewContext.Provider value={value}>
  508. {children}
  509. </PdfPreviewContext.Provider>
  510. )
  511. }
  512. PdfPreviewContext.Provider.propTypes = {
  513. value: PropTypes.shape({
  514. autoCompile: PropTypes.bool.isRequired,
  515. autoCompileLintingError: PropTypes.bool.isRequired,
  516. clearCache: PropTypes.func.isRequired,
  517. clearingCache: PropTypes.bool.isRequired,
  518. clsiServerId: PropTypes.string,
  519. compileGroup: PropTypes.string,
  520. compiledOnce: PropTypes.bool.isRequired,
  521. compiling: PropTypes.bool.isRequired,
  522. draft: PropTypes.bool.isRequired,
  523. error: PropTypes.string,
  524. fileList: PropTypes.object,
  525. hasChanges: PropTypes.bool.isRequired,
  526. hasLintingError: PropTypes.bool,
  527. logEntries: PropTypes.object,
  528. pdfDownloadUrl: PropTypes.string,
  529. pdfLayout: PropTypes.string,
  530. pdfUrl: PropTypes.string,
  531. rawLog: PropTypes.string,
  532. recompile: PropTypes.func.isRequired,
  533. recompileFromScratch: PropTypes.func.isRequired,
  534. setAutoCompile: PropTypes.func.isRequired,
  535. setClsiServerId: PropTypes.func.isRequired,
  536. setCompileGroup: PropTypes.func.isRequired,
  537. setCompiledOnce: PropTypes.func.isRequired,
  538. setDraft: PropTypes.func.isRequired,
  539. setError: PropTypes.func.isRequired,
  540. setHasLintingError: PropTypes.func.isRequired, // only for storybook
  541. setLogEntries: PropTypes.func.isRequired,
  542. setPdfDownloadUrl: PropTypes.func.isRequired,
  543. setPdfLayout: PropTypes.func.isRequired,
  544. setPdfUrl: PropTypes.func.isRequired,
  545. setShowLogs: PropTypes.func.isRequired,
  546. setStopOnValidationError: PropTypes.func.isRequired,
  547. setUiView: PropTypes.func.isRequired,
  548. showLogs: PropTypes.bool.isRequired,
  549. stopCompile: PropTypes.func.isRequired,
  550. stopOnValidationError: PropTypes.bool.isRequired,
  551. switchLayout: PropTypes.func.isRequired,
  552. uncompiled: PropTypes.bool,
  553. validationIssues: PropTypes.object,
  554. }),
  555. }
  556. export function usePdfPreviewContext() {
  557. const context = useContext(PdfPreviewContext)
  558. if (!context) {
  559. throw new Error(
  560. 'usePdfPreviewContext is only available inside PdfPreviewProvider'
  561. )
  562. }
  563. return context
  564. }