first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
import Notification from '@/shared/components/notification'
import StartFreeTrialButton from '@/shared/components/start-free-trial-button'
import { useTranslation } from 'react-i18next'
import { FC } from 'react'
export const CompileTimeWarningUpgradePromptInner: FC<{
handleDismissWarning: () => void
}> = ({ handleDismissWarning }) => {
const { t } = useTranslation()
return (
<Notification
action={
<StartFreeTrialButton
variant="new-10s"
source="compile-time-warning"
buttonProps={{
variant: 'secondary',
}}
>
{t('start_free_trial_without_exclamation')}
</StartFreeTrialButton>
}
ariaLive="polite"
content={
<div>
<div>
<span>{t('your_project_near_compile_timeout_limit')}</span>
</div>
<strong>{t('upgrade_for_12x_more_compile_time')}</strong>
{'. '}
</div>
}
type="warning"
title={t('took_a_while')}
isActionBelowContent
isDismissible
onDismiss={handleDismissWarning}
/>
)
}

View File

@@ -0,0 +1,79 @@
import { memo, useCallback, useEffect, useState } from 'react'
import * as eventTracking from '@/infrastructure/event-tracking'
import { useDetachCompileContext } from '@/shared/context/detach-compile-context'
import usePersistedState from '@/shared/hooks/use-persisted-state'
import { CompileTimeWarningUpgradePromptInner } from '@/features/pdf-preview/components/compile-time-warning-upgrade-prompt-inner'
import getMeta from '@/utils/meta'
function CompileTimeWarningUpgradePrompt() {
const { isProjectOwner, deliveryLatencies, compiling, showLogs, error } =
useDetachCompileContext()
const [showWarning, setShowWarning] = useState(false)
const [dismissedUntilWarning, setDismissedUntilWarning] = usePersistedState<
Date | undefined
>(`has-dismissed-10s-compile-time-warning-until`)
const handleNewCompile = useCallback(
compileTime => {
setShowWarning(false)
if (compileTime > 10000) {
if (isProjectOwner) {
if (
!dismissedUntilWarning ||
new Date(dismissedUntilWarning) < new Date()
) {
setShowWarning(true)
eventTracking.sendMB('compile-time-warning-displayed', {
time: 10,
isProjectOwner,
})
}
}
}
},
[isProjectOwner, dismissedUntilWarning]
)
const handleDismissWarning = useCallback(() => {
eventTracking.sendMB('compile-time-warning-dismissed', {
time: 10,
isProjectOwner,
})
setShowWarning(false)
const until = new Date()
until.setDate(until.getDate() + 1) // 1 day
setDismissedUntilWarning(until)
}, [isProjectOwner, setDismissedUntilWarning])
useEffect(() => {
if (compiling || error || showLogs) return
handleNewCompile(deliveryLatencies.compileTimeServerE2E)
}, [compiling, error, showLogs, deliveryLatencies, handleNewCompile])
if (!getMeta('ol-ExposedSettings').enableSubscriptions) {
return null
}
if (compiling || error || showLogs) {
return null
}
if (!showWarning) {
return null
}
// if showWarning is true then the 10s warning is shown
return (
<div>
{showWarning && isProjectOwner && (
<CompileTimeWarningUpgradePromptInner
handleDismissWarning={handleDismissWarning}
/>
)}
</div>
)
}
export default memo(CompileTimeWarningUpgradePrompt)

View File

@@ -0,0 +1,15 @@
import { memo } from 'react'
import { useLayoutContext } from '../../../shared/context/layout-context'
import DetachCompileButton from './detach-compile-button'
function DetachCompileButtonWrapper() {
const { detachRole, detachIsLinked } = useLayoutContext()
if (detachRole !== 'detacher' || !detachIsLinked) {
return null
}
return <DetachCompileButton />
}
export default memo(DetachCompileButtonWrapper)

View File

@@ -0,0 +1,47 @@
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
import classnames from 'classnames'
import { useDetachCompileContext } from '../../../shared/context/detach-compile-context'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLButton from '@/features/ui/components/ol/ol-button'
const modifierKey = /Mac/i.test(navigator.platform) ? 'Cmd' : 'Ctrl'
function DetachCompileButton() {
const { t } = useTranslation()
const { compiling, startCompile, hasChanges } = useDetachCompileContext()
const tooltipElement = (
<>
{t('recompile_pdf')}{' '}
<span className="keyboard-shortcut">({modifierKey} + Enter)</span>
</>
)
return (
<div className="detach-compile-button-container ms-1">
<OLTooltip
id="detach-compile"
description={tooltipElement}
tooltipProps={{ className: 'keyboard-tooltip' }}
overlayProps={{ delay: { show: 500, hide: 0 } }}
>
<OLButton
variant="primary"
onClick={() => startCompile()}
disabled={compiling}
className={classnames('detach-compile-button', {
'btn-striped-animated': hasChanges,
'detach-compile-button-disabled': compiling,
})}
size="sm"
isLoading={compiling}
>
{t('recompile')}
</OLButton>
</OLTooltip>
</div>
)
}
export default memo(DetachCompileButton)

View File

@@ -0,0 +1,26 @@
import { useLayoutContext } from '../../../shared/context/layout-context'
import PdfSynctexControls from './pdf-synctex-controls'
export function DefaultSynctexControl() {
const { detachRole } = useLayoutContext()
if (!detachRole) {
return <PdfSynctexControls />
}
return null
}
export function DetacherSynctexControl() {
const { detachRole, detachIsLinked } = useLayoutContext()
if (detachRole === 'detacher' && detachIsLinked) {
return <PdfSynctexControls />
}
return null
}
export function DetachedSynctexControl() {
const { detachRole, detachIsLinked } = useLayoutContext()
if (detachRole === 'detached' && detachIsLinked) {
return <PdfSynctexControls />
}
return null
}

View File

@@ -0,0 +1,27 @@
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
function PdfClearCacheButton() {
const { compiling, clearCache, clearingCache } = useCompileContext()
const { t } = useTranslation()
return (
<OLButton
size="sm"
variant="danger"
className="logs-pane-actions-clear-cache"
onClick={() => clearCache()}
isLoading={clearingCache}
disabled={clearingCache || compiling}
leadingIcon="delete"
loadingLabel={t('clear_cached_files')}
>
<span>{t('clear_cached_files')}</span>
</OLButton>
)
}
export default memo(PdfClearCacheButton)

View File

@@ -0,0 +1,16 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import OLNotification from '@/features/ui/components/ol/ol-notification'
function PdfCodeCheckFailedNotice() {
const { t } = useTranslation()
return (
<OLNotification
type="error"
content={t('code_check_failed_explanation')}
className="m-0"
/>
)
}
export default memo(PdfCodeCheckFailedNotice)

View File

@@ -0,0 +1,235 @@
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
import classNames from 'classnames'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import { useStopOnFirstError } from '../../../shared/hooks/use-stop-on-first-error'
import * as eventTracking from '../../../infrastructure/event-tracking'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import {
DropdownToggleCustom,
Dropdown,
DropdownDivider,
DropdownHeader,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLButtonGroup from '@/features/ui/components/ol/ol-button-group'
import { useLayoutContext } from '@/shared/context/layout-context'
const modifierKey = /Mac/i.test(navigator.platform) ? 'Cmd' : 'Ctrl'
function sendEventAndSet<T extends boolean>(
value: T,
setter: (value: T) => void,
settingName: string
) {
eventTracking.sendMB('recompile-setting-changed', {
setting: settingName,
settingVal: value,
})
setter(value)
}
function PdfCompileButton() {
const {
animateCompileDropdownArrow,
autoCompile,
compiling,
draft,
hasChanges,
setAutoCompile,
setDraft,
setStopOnValidationError,
stopOnFirstError,
stopOnValidationError,
startCompile,
stopCompile,
recompileFromScratch,
} = useCompileContext()
const { enableStopOnFirstError, disableStopOnFirstError } =
useStopOnFirstError({ eventSource: 'dropdown' })
const { t } = useTranslation()
const { detachRole } = useLayoutContext()
const fromScratchWithEvent = () => {
eventTracking.sendMB('recompile-setting-changed', {
setting: 'from-scratch',
})
recompileFromScratch()
}
const tooltipElement = (
<>
{t('recompile_pdf')}{' '}
<span className="keyboard-shortcut">({modifierKey} + Enter)</span>
</>
)
const dropdownToggleClassName = classNames(
{
'detach-compile-button-animate': animateCompileDropdownArrow,
'btn-striped-animated': hasChanges,
},
'no-left-border',
'dropdown-button-toggle'
)
const buttonClassName = classNames(
'align-items-center py-0 no-left-radius px-3',
{
'btn-striped-animated': hasChanges,
}
)
return (
<Dropdown as={OLButtonGroup} className="compile-button-group">
<OLTooltip
description={tooltipElement}
id="compile"
tooltipProps={{ className: 'keyboard-tooltip' }}
overlayProps={{
delay: { show: 500, hide: 0 },
placement: detachRole === 'detached' ? 'bottom' : undefined,
}}
>
<OLButton
variant="primary"
disabled={compiling}
isLoading={compiling}
onClick={() => startCompile()}
className={buttonClassName}
loadingLabel={`${t('compiling')}`}
>
{t('recompile')}
</OLButton>
</OLTooltip>
<DropdownToggle
as={DropdownToggleCustom}
split
variant="primary"
id="pdf-recompile-dropdown"
size="sm"
aria-label={t('toggle_compile_options_menu')}
className={dropdownToggleClassName}
/>
<DropdownMenu>
<DropdownHeader>{t('auto_compile')}</DropdownHeader>
<li role="none">
<DropdownItem
as="button"
onClick={() =>
sendEventAndSet(true, setAutoCompile, 'auto-compile')
}
trailingIcon={autoCompile ? 'check' : null}
>
{t('on')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
onClick={() =>
sendEventAndSet(false, setAutoCompile, 'auto-compile')
}
trailingIcon={!autoCompile ? 'check' : null}
>
{t('off')}
</DropdownItem>
</li>
<DropdownDivider />
<DropdownHeader>{t('compile_mode')}</DropdownHeader>
<li role="none">
<DropdownItem
as="button"
onClick={() => sendEventAndSet(false, setDraft, 'compile-mode')}
trailingIcon={!draft ? 'check' : null}
>
{t('normal')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
onClick={() => sendEventAndSet(true, setDraft, 'compile-mode')}
trailingIcon={draft ? 'check' : null}
>
{t('fast')}&nbsp;<span className="subdued">[draft]</span>
</DropdownItem>
</li>
<DropdownDivider />
<DropdownHeader>Syntax Checks</DropdownHeader>
<li role="none">
<DropdownItem
as="button"
onClick={() =>
sendEventAndSet(true, setStopOnValidationError, 'syntax-check')
}
trailingIcon={stopOnValidationError ? 'check' : null}
>
{t('stop_on_validation_error')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
onClick={() =>
sendEventAndSet(false, setStopOnValidationError, 'syntax-check')
}
trailingIcon={!stopOnValidationError ? 'check' : null}
>
{t('ignore_validation_errors')}
</DropdownItem>
</li>
<DropdownDivider />
<DropdownHeader>{t('compile_error_handling')}</DropdownHeader>
<li role="none">
<DropdownItem
as="button"
onClick={enableStopOnFirstError}
trailingIcon={stopOnFirstError ? 'check' : null}
>
{t('stop_on_first_error')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
onClick={disableStopOnFirstError}
trailingIcon={!stopOnFirstError ? 'check' : null}
>
{t('try_to_compile_despite_errors')}
</DropdownItem>
</li>
<DropdownDivider />
<li role="none">
<DropdownItem
as="button"
onClick={() => stopCompile()}
disabled={!compiling}
aria-disabled={!compiling}
>
{t('stop_compile')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
onClick={fromScratchWithEvent}
disabled={compiling}
aria-disabled={compiling}
>
{t('recompile_from_scratch')}
</DropdownItem>
</li>
</DropdownMenu>
</Dropdown>
)
}
export default memo(PdfCompileButton)

View File

@@ -0,0 +1,37 @@
import {
Dropdown,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import PdfFileList from './pdf-file-list'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
function PdfDownloadFilesButton() {
const { compiling, fileList } = useCompileContext()
const { t } = useTranslation()
if (!fileList) {
return null
}
return (
<Dropdown drop="up">
<DropdownToggle
id="dropdown-files-logs-pane"
variant="secondary"
size="sm"
disabled={compiling || !fileList}
>
{t('other_logs_and_files')}
</DropdownToggle>
<DropdownMenu id="dropdown-files-logs-pane-list">
<PdfFileList fileList={fileList} />
</DropdownMenu>
</Dropdown>
)
}
export default memo(PdfDownloadFilesButton)

View File

@@ -0,0 +1,69 @@
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
import {
DropdownDivider,
DropdownHeader,
DropdownItem,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import { PdfFileData, PdfFileDataList } from '../util/types'
function PdfFileList({ fileList }: { fileList: PdfFileDataList }) {
const { t } = useTranslation()
if (!fileList) {
return null
}
function basename(file: PdfFileData) {
return file.path.split('/').pop()
}
return (
<>
<DropdownHeader>{t('other_output_files')}</DropdownHeader>
{fileList.top.map(file => (
<li key={file.path} role="menuitem">
<DropdownItem
role="link"
download={basename(file)}
href={file.downloadURL || file.url}
>
{file.path}
</DropdownItem>
</li>
))}
{fileList.other.length > 0 && fileList.top.length > 0 && (
<DropdownDivider />
)}
{fileList.other.map(file => (
<li key={file.path} role="menuitem">
<DropdownItem
role="link"
download={basename(file)}
href={file.downloadURL || file.url}
>
{file.path}
</DropdownItem>
</li>
))}
{fileList.archive?.fileCount !== undefined &&
fileList.archive?.fileCount > 0 && (
<li role="menuitem">
<DropdownItem
role="link"
download={basename(fileList.archive)}
href={fileList.archive.url}
>
{t('download_all')} ({fileList.archive.fileCount})
</DropdownItem>
</li>
)}
</>
)
}
export default memo(PdfFileList)

View File

@@ -0,0 +1,34 @@
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import OLButton from '@/features/ui/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
function PdfHybridCodeCheckButton() {
const { codeCheckFailed, error, toggleLogs } = useCompileContext()
const { t } = useTranslation()
const handleClick = useCallback(() => {
toggleLogs()
}, [toggleLogs])
if (!codeCheckFailed) {
return null
}
return (
<OLButton
variant="danger"
size="sm"
disabled={Boolean(error)}
className="btn-toggle-logs toolbar-item"
onClick={handleClick}
>
<MaterialIcon type="warning" />
<span className="toolbar-text">{t('code_check_failed')}</span>
</OLButton>
)
}
export default memo(PdfHybridCodeCheckButton)

View File

@@ -0,0 +1,58 @@
import { useTranslation } from 'react-i18next'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import { useProjectContext } from '@/shared/context/project-context'
import { sendMB, isSmallDevice } from '@/infrastructure/event-tracking'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLButton from '@/features/ui/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
function PdfHybridDownloadButton() {
const { pdfDownloadUrl } = useCompileContext()
const { _id: projectId } = useProjectContext()
const { t } = useTranslation()
const description = pdfDownloadUrl
? t('download_pdf')
: t('please_compile_pdf_before_download')
function handleOnClick(e: React.MouseEvent) {
const event = e as React.MouseEvent<HTMLAnchorElement>
if (event.currentTarget.dataset.disabled === 'true') {
event.preventDefault()
return
}
sendMB('download-pdf-button-click', {
projectId,
location: 'pdf-preview',
isSmallDevice,
})
}
return (
<OLTooltip
id="download-pdf"
description={description}
overlayProps={{ placement: 'bottom' }}
>
<OLButton
onClick={handleOnClick}
variant="link"
className="pdf-toolbar-btn"
draggable={false}
data-disabled={!pdfDownloadUrl}
disabled={!pdfDownloadUrl}
download
href={pdfDownloadUrl || '#'}
target="_blank"
style={{ pointerEvents: 'auto' }}
aria-label={t('download_pdf')}
>
<MaterialIcon type="download" />
</OLButton>
</OLTooltip>
)
}
export default PdfHybridDownloadButton

View File

@@ -0,0 +1,55 @@
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
import * as eventTracking from '@/infrastructure/event-tracking'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLBadge from '@/features/ui/components/ol/ol-badge'
function PdfHybridLogsButton() {
const { error, logEntries, toggleLogs, showLogs, stoppedOnFirstError } =
useCompileContext()
const { t } = useTranslation()
const handleClick = useCallback(() => {
// only send analytics on open
if (!showLogs) {
eventTracking.sendMB('logs-click')
}
toggleLogs()
}, [toggleLogs, showLogs])
const errorCount = Number(logEntries?.errors?.length)
const warningCount = Number(logEntries?.warnings?.length)
const totalCount = errorCount + warningCount
return (
<OLTooltip
id="logs-toggle"
description={t('logs_and_output_files')}
overlayProps={{ placement: 'bottom' }}
>
<OLButton
variant="link"
disabled={Boolean(error || stoppedOnFirstError)}
active={showLogs}
className="pdf-toolbar-btn toolbar-item log-btn"
onClick={handleClick}
style={{ position: 'relative' }}
aria-label={showLogs ? t('view_pdf') : t('view_logs')}
>
<MaterialIcon type="description" />
{!showLogs && totalCount > 0 && (
<OLBadge bg={errorCount === 0 ? 'warning' : 'danger'}>
{totalCount}
</OLBadge>
)}
</OLButton>
</OLTooltip>
)
}
export default memo(PdfHybridLogsButton)

View File

@@ -0,0 +1,513 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { debounce, throttle } from 'lodash'
import PdfViewerControlsToolbar from './pdf-viewer-controls-toolbar'
import { useProjectContext } from '../../../shared/context/project-context'
import usePersistedState from '../../../shared/hooks/use-persisted-state'
import { buildHighlightElement } from '../util/highlights'
import PDFJSWrapper from '../util/pdf-js-wrapper'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import PdfPreviewErrorBoundaryFallback from './pdf-preview-error-boundary-fallback'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import { captureException } from '../../../infrastructure/error-reporter'
import * as eventTracking from '../../../infrastructure/event-tracking'
import { getPdfCachingMetrics } from '../util/metrics'
import { debugConsole } from '@/utils/debugging'
import { usePdfPreviewContext } from '@/features/pdf-preview/components/pdf-preview-provider'
import usePresentationMode from '../hooks/use-presentation-mode'
import useMouseWheelZoom from '../hooks/use-mouse-wheel-zoom'
import { PDFJS } from '../util/pdf-js'
type PdfJsViewerProps = {
url: string
pdfFile: Record<string, any>
}
function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
const { _id: projectId } = useProjectContext()
const { setError, firstRenderDone, highlights, position, setPosition } =
useCompileContext()
const { setLoadingError } = usePdfPreviewContext()
// state values persisted in localStorage to restore on load
const [scale, setScale] = usePersistedState(
`pdf-viewer-scale:${projectId}`,
'page-width'
)
// rawScale is different from scale as it is always a number.
// This is relevant when scale is e.g. 'page-width'.
const [rawScale, setRawScale] = useState<number | null>(null)
const [page, setPage] = useState<number | null>(null)
const [totalPages, setTotalPages] = useState<number | null>(null)
// local state values
const [pdfJsWrapper, setPdfJsWrapper] = useState<PDFJSWrapper | null>()
const [initialised, setInitialised] = useState(false)
const handlePageChange = useCallback(
(newPage: number) => {
if (!totalPages || newPage < 1 || newPage > totalPages) {
return
}
setPage(newPage)
if (pdfJsWrapper?.viewer) {
pdfJsWrapper.viewer.currentPageNumber = newPage
}
},
[pdfJsWrapper, setPage, totalPages]
)
// create the viewer when the container is mounted
const handleContainer = useCallback(
parent => {
if (parent) {
try {
setPdfJsWrapper(new PDFJSWrapper(parent.firstChild))
} catch (error: any) {
setLoadingError(true)
captureException(error)
}
}
},
[setLoadingError]
)
useEffect(() => {
return () => {
setPdfJsWrapper(null)
}
}, [])
const [startFetch, setStartFetch] = useState(0)
// listen for events and trigger rendering.
// Do everything in one effect to mitigate de-sync between events.
useEffect(() => {
if (!pdfJsWrapper || !firstRenderDone) return
let timePDFFetched: number
let timePDFRendered: number
const submitLatencies = () => {
if (!timePDFFetched) {
// The pagerendered event was attached after pagesinit fired. :/
return
}
const latencyFetch = Math.ceil(timePDFFetched - startFetch)
let latencyRender
if (timePDFRendered) {
// The renderer does not yield in case the browser tab is hidden.
// It will yield when the browser tab is visible again.
// This will skew our performance metrics for rendering!
// We are omitting the render time in case we detect this state.
latencyRender = Math.ceil(timePDFRendered - timePDFFetched)
}
firstRenderDone({
latencyFetch,
latencyRender,
// Let the pdfCachingMetrics round trip to account for pdf-detach.
pdfCachingMetrics: getPdfCachingMetrics(),
})
}
const handlePagesinit = () => {
setInitialised(true)
timePDFFetched = performance.now()
if (document.hidden) {
// Rendering does not start in case we are hidden. See comment above.
submitLatencies()
}
}
const handleRendered = () => {
if (!document.hidden) {
// The render time is not accurate in case we are hidden. See above.
timePDFRendered = performance.now()
}
submitLatencies()
// Only get the times for the first page.
pdfJsWrapper.eventBus.off('pagerendered', handleRendered)
}
const handleRenderedInitialPageNumber = () => {
setPage(pdfJsWrapper.viewer.currentPageNumber)
// Only need to set the initial page number once.
pdfJsWrapper.eventBus.off('pagerendered', handleRenderedInitialPageNumber)
}
const handleScaleChanged = (scale: { scale: number }) => {
setRawScale(scale.scale)
}
// `pagesinit` fires when the data for rendering the first page is ready.
pdfJsWrapper.eventBus.on('pagesinit', handlePagesinit)
// `pagerendered` fires when a page was actually rendered.
pdfJsWrapper.eventBus.on('pagerendered', handleRendered)
// Once a page has been rendered we can set the initial current page number.
pdfJsWrapper.eventBus.on('pagerendered', handleRenderedInitialPageNumber)
pdfJsWrapper.eventBus.on('scalechanging', handleScaleChanged)
return () => {
pdfJsWrapper.eventBus.off('pagesinit', handlePagesinit)
pdfJsWrapper.eventBus.off('pagerendered', handleRendered)
pdfJsWrapper.eventBus.off('pagerendered', handleRenderedInitialPageNumber)
pdfJsWrapper.eventBus.off('scalechanging', handleScaleChanged)
}
}, [pdfJsWrapper, firstRenderDone, startFetch])
// load the PDF document from the URL
useEffect(() => {
if (pdfJsWrapper && url) {
setInitialised(false)
setError(undefined)
setStartFetch(performance.now())
const abortController = new AbortController()
const handleFetchError = (err: Error) => {
if (abortController.signal.aborted) return
// The error is already logged at the call-site with additional context.
if (err instanceof PDFJS.MissingPDFException) {
setError('rendering-error-expected')
} else {
setError('rendering-error')
}
}
pdfJsWrapper
.loadDocument({ url, pdfFile, abortController, handleFetchError })
.then(doc => {
if (doc) {
setTotalPages(doc.numPages)
}
})
.catch(error => {
if (abortController.signal.aborted) return
debugConsole.error(error)
setError('rendering-error')
})
return () => {
abortController.abort()
}
}
}, [pdfJsWrapper, url, pdfFile, setError, setStartFetch])
// listen for scroll events
useEffect(() => {
let storePositionTimer: number
if (initialised && pdfJsWrapper) {
if (!pdfJsWrapper.isVisible()) {
return
}
// store the scroll position in localStorage, for the synctex button
const storePosition = debounce(pdfViewer => {
// set position for "sync to code" button
try {
setPosition(pdfViewer.currentPosition)
} catch (error) {
// debugConsole.error(error)
}
}, 500)
storePositionTimer = window.setTimeout(() => {
storePosition(pdfJsWrapper)
}, 100)
const scrollListener = () => {
storePosition(pdfJsWrapper)
setPage(pdfJsWrapper.viewer.currentPageNumber)
}
pdfJsWrapper.container.addEventListener('scroll', scrollListener)
return () => {
pdfJsWrapper.container.removeEventListener('scroll', scrollListener)
if (storePositionTimer) {
window.clearTimeout(storePositionTimer)
}
storePosition.cancel()
}
}
}, [setPosition, pdfJsWrapper, initialised])
// listen for double-click events
useEffect(() => {
if (pdfJsWrapper) {
const handleTextlayerrendered = (textLayer: any) => {
// handle both versions for backwards-compatibility
const textLayerDiv =
textLayer.source.textLayerDiv ?? textLayer.source.textLayer.div
if (!textLayerDiv.dataset.listeningForDoubleClick) {
textLayerDiv.dataset.listeningForDoubleClick = true
const doubleClickListener = (event: MouseEvent) => {
const clickPosition = pdfJsWrapper.clickPosition(
event,
textLayerDiv.closest('.page').querySelector('canvas'),
textLayer.pageNumber - 1
)
if (clickPosition) {
eventTracking.sendMB('jump-to-location', {
direction: 'pdf-location-in-code',
method: 'double-click',
})
window.dispatchEvent(
new CustomEvent('synctex:sync-to-position', {
detail: clickPosition,
})
)
}
}
textLayerDiv.addEventListener('dblclick', doubleClickListener)
}
}
pdfJsWrapper.eventBus.on('textlayerrendered', handleTextlayerrendered)
return () =>
pdfJsWrapper.eventBus.off('textlayerrendered', handleTextlayerrendered)
}
}, [pdfJsWrapper])
const positionRef = useRef(position)
useEffect(() => {
positionRef.current = position
}, [position])
const scaleRef = useRef(scale)
useEffect(() => {
scaleRef.current = scale
}, [scale])
// restore the saved scale and scroll position
useEffect(() => {
if (initialised && pdfJsWrapper) {
if (!pdfJsWrapper.isVisible()) {
return
}
if (positionRef.current) {
// Typescript is incorrectly inferring the type of the scale argument to
// scrollToPosition from its default value. We can remove this ignore once
// pdfJsWrapper is converted to using tyepscript.
// @ts-ignore
pdfJsWrapper.scrollToPosition(positionRef.current, scaleRef.current)
} else {
pdfJsWrapper.viewer.currentScaleValue = scaleRef.current
}
}
}, [initialised, pdfJsWrapper, scaleRef, positionRef])
// transmit scale value to the viewer when it changes
useEffect(() => {
if (pdfJsWrapper) {
pdfJsWrapper.viewer.currentScaleValue = scale
}
}, [scale, pdfJsWrapper])
// when highlights are created, build the highlight elements
useEffect(() => {
const timers: number[] = []
let intersectionObserver: IntersectionObserver
if (pdfJsWrapper && highlights?.length) {
// watch for the highlight elements to scroll into view
intersectionObserver = new IntersectionObserver(
entries => {
for (const entry of entries) {
if (entry.isIntersecting) {
intersectionObserver.unobserve(entry.target)
const element = entry.target as HTMLElement
// fade the element in and out
element.style.opacity = '0.5'
timers.push(
window.setTimeout(() => {
element.style.opacity = '0'
}, 1100)
)
}
}
},
{
threshold: 1.0, // the whole element must be visible
}
)
const elements: HTMLDivElement[] = []
for (const highlight of highlights) {
try {
const element = buildHighlightElement(highlight, pdfJsWrapper.viewer)
elements.push(element)
intersectionObserver.observe(element)
} catch (error) {
// ignore invalid highlights
}
}
const [firstElement] = elements
if (firstElement) {
// scroll to the first highlighted element
// Briefly delay the scrolling after adding the element to the DOM.
timers.push(
window.setTimeout(() => {
firstElement.scrollIntoView({
block: 'center',
inline: 'start',
behavior: 'smooth',
})
}, 100)
)
}
return () => {
for (const timer of timers) {
window.clearTimeout(timer)
}
for (const element of elements) {
element.remove()
}
intersectionObserver?.disconnect()
}
}
}, [highlights, pdfJsWrapper])
// set the scale in response to zoom option changes
const setZoom = useCallback(
zoom => {
switch (zoom) {
case 'zoom-in':
if (pdfJsWrapper) {
setScale(
`${Math.min(pdfJsWrapper.viewer.currentScale * 1.25, 9.99)}`
)
}
break
case 'zoom-out':
if (pdfJsWrapper) {
setScale(
`${Math.max(pdfJsWrapper.viewer.currentScale / 1.25, 0.1)}`
)
}
break
default:
setScale(zoom)
}
},
[pdfJsWrapper, setScale]
)
// adjust the scale when the container is resized
useEffect(() => {
if (pdfJsWrapper && 'ResizeObserver' in window) {
const resizeListener = throttle(() => {
pdfJsWrapper.updateOnResize()
}, 250)
const resizeObserver = new ResizeObserver(resizeListener)
resizeObserver.observe(pdfJsWrapper.container)
window.addEventListener('resize', resizeListener)
return () => {
resizeObserver.disconnect()
window.removeEventListener('resize', resizeListener)
}
}
}, [pdfJsWrapper])
const handleKeyDown = useCallback(
event => {
if (!initialised || !pdfJsWrapper) {
return
}
if (event.metaKey || event.ctrlKey) {
switch (event.key) {
case '+':
case '=':
event.preventDefault()
setZoom('zoom-in')
pdfJsWrapper.container.focus()
break
case '-':
event.preventDefault()
setZoom('zoom-out')
pdfJsWrapper.container.focus()
break
case '0':
event.preventDefault()
setZoom('page-width')
pdfJsWrapper.container.focus()
break
case '9':
event.preventDefault()
setZoom('page-height')
pdfJsWrapper.container.focus()
break
}
}
},
[initialised, setZoom, pdfJsWrapper]
)
useMouseWheelZoom(pdfJsWrapper, setScale)
const requestPresentationMode = usePresentationMode(
pdfJsWrapper,
page,
handlePageChange,
scale,
setScale
)
// Don't render the toolbar until we have the necessary information
const toolbarInfoLoaded =
rawScale !== null && page !== null && totalPages !== null
/* eslint-disable jsx-a11y/no-noninteractive-tabindex */
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
return (
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */
<div
className="pdfjs-viewer pdfjs-viewer-outer"
ref={handleContainer}
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<div className="pdfjs-viewer-inner" tabIndex={0} role="tabpanel">
<div className="pdfViewer" />
</div>
{toolbarInfoLoaded && (
<PdfViewerControlsToolbar
requestPresentationMode={requestPresentationMode}
setZoom={setZoom}
rawScale={rawScale}
setPage={handlePageChange}
page={page}
totalPages={totalPages}
pdfContainer={pdfJsWrapper?.container}
/>
)}
</div>
)
}
export default withErrorBoundary(memo(PdfJsViewer), () => (
<PdfPreviewErrorBoundaryFallback type="pdf" />
))

View File

@@ -0,0 +1,55 @@
import { useTranslation } from 'react-i18next'
import PdfLogEntryRawContent from './pdf-log-entry-raw-content'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { LogEntry } from '../util/types'
import { ElementType } from 'react'
const pdfLogEntryComponents = importOverleafModules(
'pdfLogEntryComponents'
) as {
import: { default: ElementType }
path: string
}[]
export default function PdfLogEntryContent({
rawContent,
formattedContent,
extraInfoURL,
index,
logEntry,
}: {
rawContent?: string
formattedContent?: React.ReactNode
extraInfoURL?: string | null
index?: number
logEntry?: LogEntry
}) {
const { t } = useTranslation()
return (
<div className="log-entry-content">
{formattedContent && (
<div className="log-entry-formatted-content">{formattedContent}</div>
)}
{extraInfoURL && (
<div className="log-entry-content-link">
<a href={extraInfoURL} target="_blank" rel="noopener">
{t('log_hint_extra_info')}
</a>
</div>
)}
{logEntry &&
pdfLogEntryComponents.map(
({ import: { default: Component }, path }) => (
<Component key={path} index={index} logEntry={logEntry} />
)
)}
{rawContent && (
<PdfLogEntryRawContent rawContent={rawContent} collapsedSize={150} />
)}
</div>
)
}

View File

@@ -0,0 +1,68 @@
import { useCallback, useState } from 'react'
import { useResizeObserver } from '../../../shared/hooks/use-resize-observer'
import { useTranslation } from 'react-i18next'
import classNames from 'classnames'
import OLButton from '@/features/ui/components/ol/ol-button'
import Icon from '../../../shared/components/icon'
export default function PdfLogEntryRawContent({
rawContent,
collapsedSize = 0,
}: {
rawContent: string
collapsedSize?: number
}) {
const [expanded, setExpanded] = useState(false)
const [needsExpander, setNeedsExpander] = useState(true)
const { elementRef } = useResizeObserver(
useCallback(
element => {
if (element.scrollHeight === 0) return // skip update when logs-pane is closed
setNeedsExpander(element.scrollHeight > collapsedSize)
},
[collapsedSize]
)
)
const { t } = useTranslation()
return (
<div className="log-entry-content-raw-container">
<div
className="expand-collapse-container"
style={{
height: expanded || !needsExpander ? 'auto' : collapsedSize,
}}
>
<pre className="log-entry-content-raw" ref={elementRef}>
{rawContent.trim()}
</pre>
</div>
{needsExpander && (
<div
className={classNames('log-entry-content-button-container', {
'log-entry-content-button-container-collapsed': !expanded,
})}
>
<OLButton
variant="secondary"
size="sm"
onClick={() => setExpanded(value => !value)}
>
{expanded ? (
<>
<Icon type="angle-up" /> {t('collapse')}
</>
) : (
<>
<Icon type="angle-down" /> {t('expand')}
</>
)}
</OLButton>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,108 @@
import classNames from 'classnames'
import { memo, MouseEventHandler, useCallback } from 'react'
import PreviewLogEntryHeader from '../../preview/components/preview-log-entry-header'
import PdfLogEntryContent from './pdf-log-entry-content'
import HumanReadableLogsHints from '../../../ide/human-readable-logs/HumanReadableLogsHints'
import { sendMB } from '@/infrastructure/event-tracking'
import getMeta from '@/utils/meta'
import { ErrorLevel, LogEntry, SourceLocation } from '../util/types'
function PdfLogEntry({
ruleId,
headerTitle,
headerIcon,
rawContent,
logType,
formattedContent,
extraInfoURL,
level,
sourceLocation,
showSourceLocationLink = true,
showCloseButton = false,
entryAriaLabel = undefined,
customClass,
contentDetails,
onSourceLocationClick,
onClose,
index,
logEntry,
id,
}: {
headerTitle: string | React.ReactNode
level: ErrorLevel
ruleId?: string
headerIcon?: React.ReactElement
rawContent?: string
logType?: string
formattedContent?: React.ReactNode
extraInfoURL?: string | null
sourceLocation?: SourceLocation
showSourceLocationLink?: boolean
showCloseButton?: boolean
entryAriaLabel?: string
customClass?: string
contentDetails?: string[]
onSourceLocationClick?: (sourceLocation: SourceLocation) => void
onClose?: () => void
index?: number
logEntry?: LogEntry
id?: string
}) {
const showAiErrorAssistant = getMeta('ol-showAiErrorAssistant')
if (ruleId && HumanReadableLogsHints[ruleId]) {
const hint = HumanReadableLogsHints[ruleId]
formattedContent = hint.formattedContent(contentDetails)
extraInfoURL = hint.extraInfoURL
}
const handleLogEntryLinkClick: MouseEventHandler<HTMLButtonElement> =
useCallback(
event => {
event.preventDefault()
if (onSourceLocationClick && sourceLocation) {
onSourceLocationClick(sourceLocation)
const parts = sourceLocation?.file?.split('.')
const extension =
parts?.length && parts?.length > 1 ? parts.pop() : ''
sendMB('log-entry-link-click', { level, ruleId, extension })
}
},
[level, onSourceLocationClick, ruleId, sourceLocation]
)
return (
<div
className={classNames('log-entry', customClass)}
aria-label={entryAriaLabel}
data-ruleid={ruleId}
data-log-entry-id={id}
>
<PreviewLogEntryHeader
level={level}
sourceLocation={sourceLocation}
headerTitle={headerTitle}
headerIcon={headerIcon}
logType={logType}
showSourceLocationLink={showSourceLocationLink}
onSourceLocationClick={handleLogEntryLinkClick}
showCloseButton={showCloseButton}
onClose={onClose}
/>
{(rawContent || formattedContent || showAiErrorAssistant) && (
<PdfLogEntryContent
rawContent={rawContent}
formattedContent={formattedContent}
extraInfoURL={extraInfoURL}
index={index}
logEntry={logEntry}
/>
)}
</div>
)
}
export default memo(PdfLogEntry)

View File

@@ -0,0 +1,72 @@
import { ElementType, memo } from 'react'
import { useTranslation } from 'react-i18next'
import PreviewLogsPaneMaxEntries from '../../preview/components/preview-logs-pane-max-entries'
import PdfLogEntry from './pdf-log-entry'
import { useDetachCompileContext } from '../../../shared/context/detach-compile-context'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { LogEntry } from '../util/types'
const LOG_PREVIEW_LIMIT = 100
const pdfLogEntriesComponents = importOverleafModules(
'pdfLogEntriesComponents'
) as {
import: { default: ElementType }
path: string
}[]
function PdfLogsEntries({
entries,
hasErrors,
}: {
entries: LogEntry[]
hasErrors?: boolean
}) {
const { t } = useTranslation()
const { syncToEntry } = useDetachCompileContext()
const logEntries = entries.slice(0, LOG_PREVIEW_LIMIT)
return (
<>
{entries.length > LOG_PREVIEW_LIMIT && (
<PreviewLogsPaneMaxEntries
totalEntries={entries.length}
entriesShown={LOG_PREVIEW_LIMIT}
hasErrors={hasErrors}
/>
)}
{pdfLogEntriesComponents.map(
({ import: { default: Component }, path }) => (
<Component key={path} />
)
)}
{logEntries.map((logEntry, index) => (
<PdfLogEntry
key={logEntry.key}
index={index}
id={logEntry.key}
logEntry={logEntry}
ruleId={logEntry.ruleId}
headerTitle={logEntry.messageComponent ?? logEntry.message}
rawContent={logEntry.content}
logType={logEntry.type}
level={logEntry.level}
contentDetails={logEntry.contentDetails}
entryAriaLabel={t('log_entry_description', {
level: logEntry.level,
})}
sourceLocation={{
file: logEntry.file,
line: logEntry.line,
column: logEntry.column,
}}
onSourceLocationClick={syncToEntry}
/>
))}
</>
)
}
export default memo(PdfLogsEntries)

View File

@@ -0,0 +1,107 @@
import { useTranslation } from 'react-i18next'
import { memo, useState } from 'react'
import classnames from 'classnames'
import PdfValidationIssue from './pdf-validation-issue'
import StopOnFirstErrorPrompt from './stop-on-first-error-prompt'
import TimeoutUpgradePromptNew from './timeout-upgrade-prompt-new'
import PdfPreviewError from './pdf-preview-error'
import PdfClearCacheButton from './pdf-clear-cache-button'
import PdfDownloadFilesButton from './pdf-download-files-button'
import PdfLogsEntries from './pdf-logs-entries'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import PdfPreviewErrorBoundaryFallback from './pdf-preview-error-boundary-fallback'
import PdfCodeCheckFailedNotice from './pdf-code-check-failed-notice'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import PdfLogEntry from './pdf-log-entry'
import { usePdfPreviewContext } from '@/features/pdf-preview/components/pdf-preview-provider'
import TimeoutUpgradePaywallPrompt from './timeout-upgrade-paywall-prompt'
import getMeta from '@/utils/meta'
function PdfLogsViewer({ alwaysVisible = false }: { alwaysVisible?: boolean }) {
const {
codeCheckFailed,
error,
hasShortCompileTimeout,
logEntries,
rawLog,
validationIssues,
showLogs,
stoppedOnFirstError,
isProjectOwner,
} = useCompileContext()
const { loadingError } = usePdfPreviewContext()
const { t } = useTranslation()
const [
isShowingPrimaryCompileTimeoutPaywall,
setIsShowingPrimaryCompileTimeoutPaywall,
] = useState(false)
const isPaywallChangeCompileTimeoutEnabled = getMeta(
'ol-isPaywallChangeCompileTimeoutEnabled'
)
const isCompileTimeoutPaywallDisplay =
isProjectOwner && isPaywallChangeCompileTimeoutEnabled
return (
<div
className={classnames('logs-pane', {
hidden: !showLogs && !alwaysVisible && !loadingError,
})}
>
<div className="logs-pane-content">
{codeCheckFailed && <PdfCodeCheckFailedNotice />}
{stoppedOnFirstError && <StopOnFirstErrorPrompt />}
{loadingError && <PdfPreviewError error="pdf-viewer-loading-error" />}
{hasShortCompileTimeout && error === 'timedout' ? (
isCompileTimeoutPaywallDisplay ? (
<TimeoutUpgradePaywallPrompt
setIsShowingPrimary={setIsShowingPrimaryCompileTimeoutPaywall}
/>
) : (
<TimeoutUpgradePromptNew />
)
) : (
<>{error && <PdfPreviewError error={error} />}</>
)}
{validationIssues &&
Object.entries(validationIssues).map(([name, issue]) => (
<PdfValidationIssue key={name} name={name} issue={issue} />
))}
{logEntries?.all && (
<PdfLogsEntries
entries={logEntries.all}
hasErrors={logEntries.errors.length > 0}
/>
)}
{rawLog && (
<PdfLogEntry
headerTitle={t('raw_logs')}
rawContent={rawLog}
entryAriaLabel={t('raw_logs_description')}
level="raw"
/>
)}
{!isShowingPrimaryCompileTimeoutPaywall && (
<div className="logs-pane-actions">
<PdfClearCacheButton />
<PdfDownloadFilesButton />
</div>
)}
</div>
</div>
)
}
export default withErrorBoundary(memo(PdfLogsViewer), () => (
<PdfPreviewErrorBoundaryFallback type="logs" />
))

View File

@@ -0,0 +1,22 @@
import { useTranslation } from 'react-i18next'
import { memo, useCallback } from 'react'
import { buildUrlWithDetachRole } from '@/shared/utils/url-helper'
import { useLocation } from '@/shared/hooks/use-location'
import OLButton from '@/features/ui/components/ol/ol-button'
function PdfOrphanRefreshButton() {
const { t } = useTranslation()
const location = useLocation()
const redirect = useCallback(() => {
location.assign(buildUrlWithDetachRole(null).toString())
}, [location])
return (
<OLButton variant="primary" size="sm" onClick={redirect}>
{t('redirect_to_editor')}
</OLButton>
)
}
export default memo(PdfOrphanRefreshButton)

View File

@@ -0,0 +1,76 @@
import PDFToolbarButton from './pdf-toolbar-button'
import { useTranslation } from 'react-i18next'
import { useState, useEffect } from 'react'
import OLButtonGroup from '@/features/ui/components/ol/ol-button-group'
type PdfPageNumberControlProps = {
setPage: (page: number) => void
page: number
totalPages: number
}
function PdfPageNumberControl({
setPage,
page,
totalPages,
}: PdfPageNumberControlProps) {
const { t } = useTranslation()
const [pageInputValue, setPageInputValue] = useState(page.toString())
useEffect(() => {
setPageInputValue(page.toString())
}, [page])
const handleSubmit = (event: React.SyntheticEvent) => {
event.preventDefault()
const parsedValue = Number(pageInputValue)
if (parsedValue < 1) {
setPage(1)
setPageInputValue('1')
} else if (parsedValue > totalPages) {
setPage(totalPages)
setPageInputValue(`${totalPages}`)
} else {
setPage(parsedValue)
}
}
return (
<>
<OLButtonGroup className="pdfjs-toolbar-buttons">
<PDFToolbarButton
tooltipId="pdf-controls-previous-page-tooltip"
icon="keyboard_arrow_up"
label={t('previous_page')}
disabled={page === 1}
onClick={() => setPage(page - 1)}
/>
<PDFToolbarButton
tooltipId="pdf-controls-next-page-tooltip"
icon="keyboard_arrow_down"
label={t('next_page')}
disabled={page === totalPages}
onClick={() => setPage(page + 1)}
/>
</OLButtonGroup>
<div className="pdfjs-page-number-input">
<form onSubmit={handleSubmit}>
<input
inputMode="numeric"
value={pageInputValue}
onFocus={event => event.target.select()}
onBlur={handleSubmit}
onChange={event => {
const rawValue = event.target.value
setPageInputValue(rawValue.replace(/\D/g, ''))
}}
/>
</form>
<span>/ {totalPages}</span>
</div>
</>
)
}
export default PdfPageNumberControl

View File

@@ -0,0 +1,25 @@
import ReactDOM from 'react-dom'
import PdfPreview from './pdf-preview'
import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n'
import { ReactContextRoot } from '@/features/ide-react/context/react-context-root'
function PdfPreviewDetachedRoot() {
const { isReady } = useWaitForI18n()
if (!isReady) {
return null
}
return (
<ReactContextRoot>
<PdfPreview />
</ReactContextRoot>
)
}
export default PdfPreviewDetachedRoot // for testing
const element = document.getElementById('pdf-preview-detached-root')
if (element) {
ReactDOM.render(<PdfPreviewDetachedRoot />, element)
}

View File

@@ -0,0 +1,53 @@
import { Trans, useTranslation } from 'react-i18next'
import { ErrorBoundaryFallback } from '../../../shared/components/error-boundary-fallback'
function PdfPreviewErrorBoundaryFallback({
type,
}: {
type: 'preview' | 'pdf' | 'logs'
}) {
const { t } = useTranslation()
const showInfoLink = (
<Trans
i18nKey="try_recompile_project_or_troubleshoot"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content
<a
href="/learn/how-to/Resolving_access%2C_loading%2C_and_display_problems"
target="_blank"
key="troubleshooting-link"
/>,
]}
/>
)
switch (type) {
case 'pdf':
return (
<ErrorBoundaryFallback>
<p>{t('pdf_viewer_error')}</p>
<p>{showInfoLink}</p>
</ErrorBoundaryFallback>
)
case 'logs':
return (
<ErrorBoundaryFallback>
<p>{t('log_viewer_error')}</p>
<p>{showInfoLink}</p>
</ErrorBoundaryFallback>
)
case 'preview':
default:
return (
<ErrorBoundaryFallback>
<p>{t('pdf_preview_error')}</p>
<p>{showInfoLink}</p>
</ErrorBoundaryFallback>
)
}
}
export default PdfPreviewErrorBoundaryFallback

View File

@@ -0,0 +1,305 @@
import { useTranslation, Trans } from 'react-i18next'
import { memo, useCallback } from 'react'
import OLButton from '@/features/ui/components/ol/ol-button'
import PdfLogEntry from './pdf-log-entry'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import { useStopOnFirstError } from '../../../shared/hooks/use-stop-on-first-error'
import getMeta from '../../../utils/meta'
function PdfPreviewError({ error }: { error: string }) {
const { t } = useTranslation()
const { startCompile } = useCompileContext()
switch (error) {
case 'rendering-error-expected':
return (
<PdfLogEntry
headerTitle={t('pdf_rendering_error')}
formattedContent={
<>
<Trans
i18nKey="something_went_wrong_rendering_pdf_expected"
components={[
// eslint-disable-next-line react/jsx-key
<OLButton
variant="info"
size="sm"
onClick={() => startCompile()}
/>,
]}
/>
<br />
<br />
<Trans
i18nKey="last_resort_trouble_shooting_guide"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content
<a
href="/learn/how-to/Resolving_access%2C_loading%2C_and_display_problems"
target="_blank"
key="troubleshooting-link"
/>,
]}
/>
</>
}
level="warning"
/>
)
case 'rendering-error':
return (
<ErrorLogEntry title={t('pdf_rendering_error')}>
{t('something_went_wrong_rendering_pdf')}
&nbsp;
<Trans
i18nKey="try_recompile_project_or_troubleshoot"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content
<a
href="/learn/how-to/Resolving_access%2C_loading%2C_and_display_problems"
target="_blank"
key="troubleshooting-link"
/>,
]}
/>
{getMeta('ol-compilesUserContentDomain') && (
<>
<br />
<br />
<Trans
i18nKey="new_compile_domain_notice"
values={{
compilesUserContentDomain: new URL(
getMeta('ol-compilesUserContentDomain')
).hostname,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
<code key="domain" />,
/* eslint-disable-next-line jsx-a11y/anchor-has-content */
<a
href="/learn/how-to/Resolving_access%2C_loading%2C_and_display_problems"
target="_blank"
key="troubleshooting-link"
/>,
]}
/>
</>
)}
</ErrorLogEntry>
)
case 'clsi-maintenance':
return (
<ErrorLogEntry title={t('server_error')}>
{t('clsi_maintenance')}
</ErrorLogEntry>
)
case 'clsi-unavailable':
return (
<ErrorLogEntry title={t('server_error')}>
{t('clsi_unavailable')}
</ErrorLogEntry>
)
case 'too-recently-compiled':
return (
<ErrorLogEntry title={t('server_error')}>
{t('too_recently_compiled')}
</ErrorLogEntry>
)
case 'terminated':
return (
<ErrorLogEntry title={t('terminated')}>
{t('compile_terminated_by_user')}
</ErrorLogEntry>
)
case 'rate-limited':
return (
<ErrorLogEntry title={t('pdf_compile_rate_limit_hit')}>
{t('project_flagged_too_many_compiles')}
</ErrorLogEntry>
)
case 'compile-in-progress':
return (
<ErrorLogEntry title={t('pdf_compile_in_progress_error')}>
{t('pdf_compile_try_again')}
</ErrorLogEntry>
)
case 'autocompile-disabled':
return (
<ErrorLogEntry title={t('autocompile_disabled')}>
{t('autocompile_disabled_reason')}
</ErrorLogEntry>
)
case 'project-too-large':
return (
<ErrorLogEntry title={t('project_too_large')}>
{t('project_too_much_editable_text')}
</ErrorLogEntry>
)
case 'timedout':
return <TimedOutLogEntry />
case 'failure':
return (
<ErrorLogEntry title={t('no_pdf_error_title')}>
{t('no_pdf_error_explanation')}
<ul className="my-1 ps-3">
<li>{t('no_pdf_error_reason_unrecoverable_error')}</li>
<li>
<Trans
i18nKey="no_pdf_error_reason_no_content"
components={{ code: <code /> }}
/>
</li>
<li>
<Trans
i18nKey="no_pdf_error_reason_output_pdf_already_exists"
components={{ code: <code /> }}
/>
</li>
</ul>
</ErrorLogEntry>
)
case 'clear-cache':
return (
<ErrorLogEntry title={t('server_error')}>
{t('somthing_went_wrong_compiling')}
</ErrorLogEntry>
)
case 'pdf-viewer-loading-error':
return (
<ErrorLogEntry title={t('pdf_rendering_error')}>
<Trans
i18nKey="something_went_wrong_loading_pdf_viewer"
components={[
<strong key="strong-" />,
// eslint-disable-next-line jsx-a11y/anchor-has-content
<a
href="/learn/how-to/Resolving_access%2C_loading%2C_and_display_problems"
target="_blank"
key="troubleshooting-link"
/>,
// eslint-disable-next-line jsx-a11y/anchor-has-content
<a key="contact-link" target="_blank" href="/contact" />,
]}
/>
</ErrorLogEntry>
)
case 'validation-problems':
return null // handled elsewhere
case 'error':
default:
return (
<ErrorLogEntry title={t('server_error')}>
{t('somthing_went_wrong_compiling')}
</ErrorLogEntry>
)
}
}
export default memo(PdfPreviewError)
function ErrorLogEntry({
title,
headerIcon,
children,
}: {
title: string
headerIcon?: React.ReactElement
children: React.ReactNode
}) {
const { t } = useTranslation()
return (
<PdfLogEntry
headerTitle={title}
headerIcon={headerIcon}
formattedContent={children}
entryAriaLabel={t('compile_error_entry_description')}
level="error"
/>
)
}
function TimedOutLogEntry() {
const { t } = useTranslation()
const { enableStopOnFirstError } = useStopOnFirstError({
eventSource: 'timeout',
})
const { startCompile, lastCompileOptions, setAnimateCompileDropdownArrow } =
useCompileContext()
const handleEnableStopOnFirstErrorClick = useCallback(() => {
enableStopOnFirstError()
startCompile({ stopOnFirstError: true })
setAnimateCompileDropdownArrow(true)
}, [enableStopOnFirstError, startCompile, setAnimateCompileDropdownArrow])
return (
<ErrorLogEntry title={t('timedout')}>
<p>{t('project_timed_out_intro')}</p>
<ul>
<li>
<Trans
i18nKey="project_timed_out_optimize_images"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a href="https://www.overleaf.com/learn/how-to/Optimising_very_large_image_files" />,
]}
/>
</li>
<li>
<Trans
i18nKey="project_timed_out_fatal_error"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a href="https://www.overleaf.com/learn/how-to/Why_do_I_keep_getting_the_compile_timeout_error_message%3F#Fatal_compile_errors_blocking_the_compilation" />,
]}
/>
{!lastCompileOptions.stopOnFirstError && (
<>
{' '}
<Trans
i18nKey="project_timed_out_enable_stop_on_first_error"
components={[
// eslint-disable-next-line react/jsx-key
<OLButton
variant="info"
size="sm"
onClick={handleEnableStopOnFirstErrorClick}
/>,
]}
/>{' '}
</>
)}
</li>
</ul>
<p>
<Trans
i18nKey="project_timed_out_learn_more"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a href="https://www.overleaf.com/learn/how-to/Why_do_I_keep_getting_the_compile_timeout_error_message%3F" />,
]}
/>
</p>
</ErrorLogEntry>
)
}

View File

@@ -0,0 +1,104 @@
import { memo, useState, useEffect, useRef } from 'react'
import OlButtonToolbar from '@/features/ui/components/ol/ol-button-toolbar'
import { useTranslation } from 'react-i18next'
import { useLayoutContext } from '@/shared/context/layout-context'
import PdfCompileButton from './pdf-compile-button'
import SwitchToEditorButton from './switch-to-editor-button'
import PdfHybridLogsButton from './pdf-hybrid-logs-button'
import PdfHybridDownloadButton from './pdf-hybrid-download-button'
import PdfHybridCodeCheckButton from './pdf-hybrid-code-check-button'
import PdfOrphanRefreshButton from './pdf-orphan-refresh-button'
import { DetachedSynctexControl } from './detach-synctex-control'
import { Spinner } from 'react-bootstrap-5'
const ORPHAN_UI_TIMEOUT_MS = 5000
function PdfPreviewHybridToolbar() {
const { detachRole, detachIsLinked } = useLayoutContext()
const uiTimeoutRef = useRef<number>()
const [orphanPdfTabAfterDelay, setOrphanPdfTabAfterDelay] = useState(false)
const orphanPdfTab = !detachIsLinked && detachRole === 'detached'
useEffect(() => {
if (uiTimeoutRef.current) {
window.clearTimeout(uiTimeoutRef.current)
}
if (orphanPdfTab) {
uiTimeoutRef.current = window.setTimeout(() => {
setOrphanPdfTabAfterDelay(true)
}, ORPHAN_UI_TIMEOUT_MS)
} else {
setOrphanPdfTabAfterDelay(false)
}
}, [orphanPdfTab])
let ToolbarInner = null
if (orphanPdfTabAfterDelay) {
// when the detached tab has been orphan for a while
ToolbarInner = <PdfPreviewHybridToolbarOrphanInner />
} else if (orphanPdfTab) {
ToolbarInner = <PdfPreviewHybridToolbarConnectingInner />
} else {
// tab is not detached or not orphan
ToolbarInner = <PdfPreviewHybridToolbarInner />
}
return (
<OlButtonToolbar className="toolbar toolbar-pdf toolbar-pdf-hybrid">
{ToolbarInner}
</OlButtonToolbar>
)
}
function PdfPreviewHybridToolbarInner() {
return (
<>
<div className="toolbar-pdf-left">
<PdfCompileButton />
<PdfHybridLogsButton />
<PdfHybridDownloadButton />
</div>
<div className="toolbar-pdf-right">
<div className="toolbar-pdf-controls" id="toolbar-pdf-controls" />
<PdfHybridCodeCheckButton />
<SwitchToEditorButton />
<DetachedSynctexControl />
</div>
</>
)
}
function PdfPreviewHybridToolbarOrphanInner() {
const { t } = useTranslation()
return (
<>
<div className="toolbar-pdf-orphan">
{t('tab_no_longer_connected')}
<PdfOrphanRefreshButton />
</div>
</>
)
}
function PdfPreviewHybridToolbarConnectingInner() {
const { t } = useTranslation()
return (
<>
<div className="toolbar-pdf-orphan">
<Spinner
animation="border"
aria-hidden="true"
size="sm"
role="status"
/>
&nbsp;
{t('tab_connecting')}
</div>
</>
)
}
export default memo(PdfPreviewHybridToolbar)

View File

@@ -0,0 +1,5 @@
import { FC } from 'react'
export const PdfPreviewMessages: FC = ({ children }) => {
return <div className="pdf-preview-messages">{children}</div>
}

View File

@@ -0,0 +1,44 @@
import { memo, Suspense } from 'react'
import classNames from 'classnames'
import PdfLogsViewer from './pdf-logs-viewer'
import PdfViewer from './pdf-viewer'
import { FullSizeLoadingSpinner } from '../../../shared/components/loading-spinner'
import PdfHybridPreviewToolbar from './pdf-preview-hybrid-toolbar'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import { PdfPreviewMessages } from './pdf-preview-messages'
import CompileTimeWarningUpgradePrompt from './compile-time-warning-upgrade-prompt'
import { PdfPreviewProvider } from './pdf-preview-provider'
import PdfPreviewHybridToolbarNew from '@/features/ide-redesign/components/pdf-preview/pdf-preview-hybrid-toolbar'
import PdfErrorState from '@/features/ide-redesign/components/pdf-preview/pdf-error-state'
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
function PdfPreviewPane() {
const { pdfUrl, hasShortCompileTimeout } = useCompileContext()
const classes = classNames('pdf', 'full-size', {
'pdf-empty': !pdfUrl,
})
const newEditor = useIsNewEditorEnabled()
return (
<div className={classes}>
<PdfPreviewProvider>
{newEditor ? (
<PdfPreviewHybridToolbarNew />
) : (
<PdfHybridPreviewToolbar />
)}
<PdfPreviewMessages>
{hasShortCompileTimeout && <CompileTimeWarningUpgradePrompt />}
</PdfPreviewMessages>
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<div className="pdf-viewer">
<PdfViewer />
</div>
</Suspense>
{newEditor ? <PdfErrorState /> : <PdfLogsViewer />}
</PdfPreviewProvider>
</div>
)
}
export default memo(PdfPreviewPane)

View File

@@ -0,0 +1,34 @@
import { createContext, FC, useContext, useMemo, useState } from 'react'
const PdfPreviewContext = createContext<
| {
loadingError: boolean
setLoadingError: (value: boolean) => void
}
| undefined
>(undefined)
export const usePdfPreviewContext = () => {
const context = useContext(PdfPreviewContext)
if (!context) {
throw new Error(
'usePdfPreviewContext is only avalable inside PdfPreviewProvider'
)
}
return context
}
export const PdfPreviewProvider: FC = ({ children }) => {
const [loadingError, setLoadingError] = useState(false)
const value = useMemo(
() => ({ loadingError, setLoadingError }),
[loadingError]
)
return (
<PdfPreviewContext.Provider value={value}>
{children}
</PdfPreviewContext.Provider>
)
}

View File

@@ -0,0 +1,15 @@
import PdfPreviewPane from './pdf-preview-pane'
import { memo } from 'react'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import PdfPreviewErrorBoundaryFallback from './pdf-preview-error-boundary-fallback'
import { useLayoutContext } from '../../../shared/context/layout-context'
function PdfPreview() {
const { detachRole } = useLayoutContext()
if (detachRole === 'detacher') return null
return <PdfPreviewPane />
}
export default withErrorBoundary(memo(PdfPreview), () => (
<PdfPreviewErrorBoundaryFallback type="preview" />
))

View File

@@ -0,0 +1,459 @@
import classNames from 'classnames'
import { memo, useCallback, useEffect, useState, useRef, useMemo } from 'react'
import { useProjectContext } from '../../../shared/context/project-context'
import { getJSON } from '../../../infrastructure/fetch-json'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import { useLayoutContext } from '../../../shared/context/layout-context'
import useScopeValue from '../../../shared/hooks/use-scope-value'
import { useTranslation } from 'react-i18next'
import useIsMounted from '../../../shared/hooks/use-is-mounted'
import useAbortController from '../../../shared/hooks/use-abort-controller'
import useDetachState from '../../../shared/hooks/use-detach-state'
import useDetachAction from '../../../shared/hooks/use-detach-action'
import localStorage from '../../../infrastructure/local-storage'
import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
import useScopeEventListener from '../../../shared/hooks/use-scope-event-listener'
import * as eventTracking from '../../../infrastructure/event-tracking'
import { debugConsole } from '@/utils/debugging'
import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLButton from '@/features/ui/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
import { Spinner } from 'react-bootstrap-5'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import useEventListener from '@/shared/hooks/use-event-listener'
import { CursorPosition } from '@/features/ide-react/types/cursor-position'
import { isValidTeXFile } from '@/main/is-valid-tex-file'
import { PdfScrollPosition } from '@/shared/hooks/use-pdf-scroll-position'
import { Placement } from 'react-bootstrap-5/types'
const GoToCodeButton = memo(function GoToCodeButton({
syncToCode,
syncToCodeInFlight,
isDetachLayout,
}: {
syncToCode: ({ visualOffset }: { visualOffset: number }) => void
syncToCodeInFlight: boolean
isDetachLayout?: boolean
}) {
const { t } = useTranslation()
const buttonClasses = classNames('synctex-control', {
'detach-synctex-control': !!isDetachLayout,
})
let buttonIcon = null
if (syncToCodeInFlight) {
buttonIcon = (
<Spinner animation="border" aria-hidden="true" size="sm" role="status" />
)
} else if (!isDetachLayout) {
buttonIcon = (
<MaterialIcon type="arrow_left_alt" className="synctex-control-icon" />
)
}
const syncToCodeWithButton = useCallback(() => {
eventTracking.sendMB('jump-to-location', {
direction: 'pdf-location-in-code',
method: 'arrow',
})
syncToCode({ visualOffset: 72 })
}, [syncToCode])
const overlayProps = useMemo(
() => ({
placement: (isDetachLayout ? 'bottom' : 'right') as Placement,
}),
[isDetachLayout]
)
return (
<OLTooltip
id="sync-to-code"
description={t('go_to_pdf_location_in_code')}
overlayProps={overlayProps}
>
<OLButton
variant="secondary"
size="sm"
onClick={syncToCodeWithButton}
disabled={syncToCodeInFlight}
className={buttonClasses}
aria-label={t('go_to_pdf_location_in_code')}
>
{buttonIcon}
{isDetachLayout ? <span>&nbsp;{t('show_in_code')}</span> : ''}
</OLButton>
</OLTooltip>
)
})
const GoToPdfButton = memo(function GoToPdfButton({
syncToPdf,
syncToPdfInFlight,
isDetachLayout,
canSyncToPdf,
}: {
syncToPdf: () => void
syncToPdfInFlight: boolean
canSyncToPdf: boolean
isDetachLayout?: boolean
}) {
const { t } = useTranslation()
const tooltipPlacement = isDetachLayout ? 'bottom' : 'right'
const buttonClasses = classNames('synctex-control', {
'detach-synctex-control': !!isDetachLayout,
})
let buttonIcon = null
if (syncToPdfInFlight) {
buttonIcon = (
<Spinner animation="border" aria-hidden="true" size="sm" role="status" />
)
} else if (!isDetachLayout) {
buttonIcon = (
<MaterialIcon type="arrow_right_alt" className="synctex-control-icon" />
)
}
return (
<OLTooltip
id="sync-to-pdf"
description={t('go_to_code_location_in_pdf')}
overlayProps={{ placement: tooltipPlacement }}
>
<OLButton
variant="secondary"
size="sm"
onClick={syncToPdf}
disabled={syncToPdfInFlight || !canSyncToPdf}
className={buttonClasses}
aria-label={t('go_to_code_location_in_pdf')}
>
{buttonIcon}
{isDetachLayout ? <span>&nbsp;{t('show_in_pdf')}</span> : ''}
</OLButton>
</OLTooltip>
)
})
function PdfSynctexControls() {
const { _id: projectId, rootDocId } = useProjectContext()
const { detachRole } = useLayoutContext()
const {
clsiServerId,
pdfFile,
pdfUrl,
pdfViewer,
position,
setShowLogs,
setHighlights,
} = useCompileContext()
const { selectedEntities } = useFileTreeData()
const { findEntityByPath, dirname, pathInFolder } = useFileTreePathContext()
const { getCurrentDocumentId, openDocWithId, openDocName } =
useEditorManagerContext()
const [cursorPosition, setCursorPosition] = useState<CursorPosition | null>(
() => {
const position = localStorage.getItem(
`doc.position.${getCurrentDocumentId()}`
)
return position ? position.cursorPosition : null
}
)
const isMounted = useIsMounted()
const { signal } = useAbortController()
useEventListener(
'cursor:editor:update',
useCallback(event => setCursorPosition(event.detail), [])
)
const [syncToPdfInFlight, setSyncToPdfInFlight] = useState(false)
const [syncToCodeInFlight, setSyncToCodeInFlight] = useDetachState(
'sync-to-code-inflight',
false,
'detacher',
'detached'
)
const [, setSynctexError] = useScopeValue('sync_tex_error')
const getCurrentFilePath = useCallback(() => {
const docId = getCurrentDocumentId()
if (!docId || !rootDocId) {
return null
}
let path = pathInFolder(docId)
if (!path) {
return null
}
// If the root file is folder/main.tex, then synctex sees the path as folder/./main.tex
const rootDocDirname = dirname(rootDocId)
if (rootDocDirname) {
path = path.replace(RegExp(`^${rootDocDirname}`), `${rootDocDirname}/.`)
}
return path
}, [dirname, getCurrentDocumentId, pathInFolder, rootDocId])
const goToCodeLine = useCallback(
(file, line) => {
if (file) {
const doc = findEntityByPath(file)?.entity
if (!doc) {
debugConsole.warn(`Document with path ${file} not found`)
return
}
openDocWithId(doc._id, {
gotoLine: line,
})
} else {
setSynctexError(true)
window.setTimeout(() => {
if (isMounted.current) {
setSynctexError(false)
}
}, 4000)
}
},
[findEntityByPath, openDocWithId, isMounted, setSynctexError]
)
const goToPdfLocation = useCallback(
params => {
setSyncToPdfInFlight(true)
if (clsiServerId) {
params += `&clsiserverid=${clsiServerId}`
}
if (pdfFile?.editorId) params += `&editorId=${pdfFile.editorId}`
if (pdfFile?.build) params += `&buildId=${pdfFile.build}`
getJSON(`/project/${projectId}/sync/code?${params}`, { signal })
.then(data => {
setShowLogs(false)
setHighlights(data.pdf)
})
.catch(debugConsole.error)
.finally(() => {
if (isMounted.current) {
setSyncToPdfInFlight(false)
}
})
},
[
pdfFile,
clsiServerId,
isMounted,
projectId,
setShowLogs,
setHighlights,
setSyncToPdfInFlight,
signal,
]
)
const cursorPositionRef = useRef(cursorPosition)
useEffect(() => {
cursorPositionRef.current = cursorPosition
}, [cursorPosition])
const syncToPdf = useCallback(() => {
const file = getCurrentFilePath()
if (cursorPositionRef.current) {
const { row, column } = cursorPositionRef.current
const params = new URLSearchParams({
file: file ?? '',
line: String(row + 1),
column: String(column),
}).toString()
eventTracking.sendMB('jump-to-location', {
direction: 'code-location-in-pdf',
method: 'arrow',
})
goToPdfLocation(params)
}
}, [getCurrentFilePath, goToPdfLocation])
useScopeEventListener(
'cursor:editor:syncToPdf',
useCallback(() => {
syncToPdf()
}, [syncToPdf])
)
const positionRef = useRef(position)
useEffect(() => {
positionRef.current = position
}, [position])
const _syncToCode = useCallback(
({
position = positionRef.current,
visualOffset = 0,
}: {
position?: PdfScrollPosition
visualOffset?: number
}) => {
if (!position) {
return
}
setSyncToCodeInFlight(true)
// FIXME: this actually works better if it's halfway across the
// page (or the visible part of the page). Synctex doesn't
// always find the right place in the file when the point is at
// the edge of the page, it sometimes returns the start of the
// next paragraph instead.
const h = position.offset.left
// Compute the vertical position to pass to synctex, which
// works with coordinates increasing from the top of the page
// down. This matches the browser's DOM coordinate of the
// click point, but the pdf position is measured from the
// bottom of the page so we need to invert it.
let v = 0
if (position.pageSize?.height) {
v += position.pageSize.height - position.offset.top // measure from pdf point (inverted)
} else {
v += position.offset.top // measure from html click position
}
v += visualOffset
const params = new URLSearchParams({
page: position.page + 1,
h: h.toFixed(2),
v: v.toFixed(2),
})
if (clsiServerId) {
params.set('clsiserverid', clsiServerId)
}
if (pdfFile?.editorId) params.set('editorId', pdfFile.editorId)
if (pdfFile?.build) params.set('buildId', pdfFile.build)
getJSON(`/project/${projectId}/sync/pdf?${params}`, { signal })
.then(data => {
const [{ file, line }] = data.code
goToCodeLine(file, line)
})
.catch(debugConsole.error)
.finally(() => {
if (isMounted.current) {
setSyncToCodeInFlight(false)
}
})
},
[
pdfFile,
clsiServerId,
projectId,
signal,
isMounted,
setSyncToCodeInFlight,
goToCodeLine,
]
)
const syncToCode = useDetachAction(
'sync-to-code',
_syncToCode,
'detached',
'detacher'
)
useEventListener(
'synctex:sync-to-position',
useCallback(event => syncToCode({ position: event.detail }), [syncToCode])
)
const [hasSingleSelectedDoc, setHasSingleSelectedDoc] = useDetachState(
'has-single-selected-doc',
false,
'detacher',
'detached'
)
useEffect(() => {
if (selectedEntities.length !== 1) {
setHasSingleSelectedDoc(false)
return
}
if (selectedEntities[0].type !== 'doc') {
setHasSingleSelectedDoc(false)
return
}
setHasSingleSelectedDoc(true)
}, [selectedEntities, setHasSingleSelectedDoc])
if (!position) {
return null
}
if (!pdfUrl || pdfViewer === 'native') {
return null
}
const canSyncToPdf: boolean =
hasSingleSelectedDoc &&
cursorPosition &&
openDocName &&
isValidTeXFile(openDocName)
if (detachRole === 'detacher') {
return (
<GoToPdfButton
syncToPdf={syncToPdf}
syncToPdfInFlight={syncToPdfInFlight}
isDetachLayout
canSyncToPdf={canSyncToPdf}
/>
)
} else if (detachRole === 'detached') {
return (
<GoToCodeButton
syncToCode={syncToCode}
syncToCodeInFlight={syncToCodeInFlight}
isDetachLayout
/>
)
} else {
return (
<>
<GoToPdfButton
syncToPdf={syncToPdf}
syncToPdfInFlight={syncToPdfInFlight}
canSyncToPdf={canSyncToPdf}
/>
<GoToCodeButton
syncToCode={syncToCode}
syncToCodeInFlight={syncToCodeInFlight}
/>
</>
)
}
}
export default memo(PdfSynctexControls)

View File

@@ -0,0 +1,44 @@
import MaterialIcon from '@/shared/components/material-icon'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLButton from '@/features/ui/components/ol/ol-button'
type PDFToolbarButtonProps = {
tooltipId: string
icon: string
label: string
onClick: () => void
shortcut?: string
disabled?: boolean
}
export default function PDFToolbarButton({
tooltipId,
disabled,
label,
icon,
onClick,
shortcut,
}: PDFToolbarButtonProps) {
return (
<OLTooltip
id={tooltipId}
description={
<>
<div>{label}</div>
{shortcut && <div>{shortcut}</div>}
</>
}
overlayProps={{ placement: 'bottom' }}
>
<OLButton
variant="ghost"
className="pdf-toolbar-btn pdfjs-toolbar-button"
disabled={disabled}
onClick={onClick}
aria-label={label}
>
<MaterialIcon type={icon} />
</OLButton>
</OLTooltip>
)
}

View File

@@ -0,0 +1,67 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import PdfLogEntry from './pdf-log-entry'
function PdfValidationIssue({ issue, name }: { issue: any; name: string }) {
const { t } = useTranslation()
switch (name) {
case 'sizeCheck':
return (
<PdfLogEntry
headerTitle={t('project_too_large')}
formattedContent={
<>
<div>{t('project_too_large_please_reduce')}</div>
<ul className="list-no-margin-bottom">
{issue.resources.map(
(resource: { path: string; kbSize: number }) => (
<li key={resource.path}>
{resource.path} &mdash; {resource.kbSize}
kb
</li>
)
)}
</ul>
</>
}
entryAriaLabel={t('validation_issue_entry_description')}
level="error"
/>
)
case 'conflictedPaths':
return (
<PdfLogEntry
headerTitle={t('conflicting_paths_found')}
formattedContent={
<>
<div>{t('following_paths_conflict')}</div>
<ul className="list-no-margin-bottom">
{issue.map((detail: { path: string }) => (
<li key={detail.path}>/{detail.path}</li>
))}
</ul>
</>
}
entryAriaLabel={t('validation_issue_entry_description')}
level="error"
/>
)
case 'mainFile':
return (
<PdfLogEntry
headerTitle={t('main_file_not_found')}
formattedContent={t('please_set_main_file')}
entryAriaLabel={t('validation_issue_entry_description')}
level="error"
/>
)
default:
return null
}
}
export default memo(PdfValidationIssue)

View File

@@ -0,0 +1,82 @@
import { useRef } from 'react'
import PdfPageNumberControl from './pdf-page-number-control'
import PdfZoomButtons from './pdf-zoom-buttons'
import { useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
import useDropdown from '@/shared/hooks/use-dropdown'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLOverlay from '@/features/ui/components/ol/ol-overlay'
import OLPopover from '@/features/ui/components/ol/ol-popover'
type PdfViewerControlsMenuButtonProps = {
setZoom: (zoom: string) => void
setPage: (page: number) => void
page: number
totalPages: number
pdfContainer?: HTMLDivElement
}
export default function PdfViewerControlsMenuButton({
setZoom,
setPage,
page,
totalPages,
pdfContainer,
}: PdfViewerControlsMenuButtonProps) {
const { t } = useTranslation()
const {
open: popoverOpen,
onToggle: togglePopover,
ref: popoverRef,
} = useDropdown()
const targetRef = useRef<HTMLButtonElement | null>(null)
return (
<>
<OLTooltip
id="pdf-controls-menu-tooltip"
description={t('view_options')}
overlayProps={{ placement: 'bottom' }}
>
<OLButton
variant="ghost"
className="pdf-toolbar-btn pdfjs-toolbar-popover-button"
onClick={togglePopover}
ref={targetRef}
>
<MaterialIcon type="more_horiz" />
</OLButton>
</OLTooltip>
<OLOverlay
show={popoverOpen}
target={targetRef.current}
placement="bottom"
container={pdfContainer}
containerPadding={0}
transition
rootClose
onHide={() => togglePopover(false)}
>
<OLPopover
className="pdfjs-toolbar-popover"
id="pdf-toolbar-popover-menu"
ref={popoverRef}
>
<PdfPageNumberControl
setPage={setPage}
page={page}
totalPages={totalPages}
/>
<div className="pdfjs-zoom-controls">
<PdfZoomButtons setZoom={setZoom} />
</div>
</OLPopover>
</OLOverlay>
</>
)
}

View File

@@ -0,0 +1,173 @@
import { memo, useCallback, useState } from 'react'
import { createPortal } from 'react-dom'
import PdfPageNumberControl from './pdf-page-number-control'
import PdfZoomButtons from './pdf-zoom-buttons'
import PdfZoomDropdown from './pdf-zoom-dropdown'
import { useResizeObserver } from '@/shared/hooks/use-resize-observer'
import PdfViewerControlsMenuButton from './pdf-viewer-controls-menu-button'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider'
import { useTranslation } from 'react-i18next'
type PdfViewerControlsToolbarProps = {
requestPresentationMode: () => void
setZoom: (zoom: string) => void
rawScale: number
setPage: (page: number) => void
page: number
totalPages: number
pdfContainer?: HTMLDivElement
}
function PdfViewerControlsToolbar({
requestPresentationMode,
setZoom,
rawScale,
setPage,
page,
totalPages,
pdfContainer,
}: PdfViewerControlsToolbarProps) {
const { t } = useTranslation()
const { showLogs } = useCompileContext()
const toolbarControlsElement = document.querySelector('#toolbar-pdf-controls')
const [availableWidth, setAvailableWidth] = useState<number>(1000)
const handleResize = useCallback(
element => {
setAvailableWidth(element.offsetWidth)
},
[setAvailableWidth]
)
const { elementRef: pdfControlsRef } = useResizeObserver(handleResize)
useCommandProvider(
() => [
{
id: 'view-pdf-presentation-mode',
label: t('presentation_mode'),
handler: requestPresentationMode,
},
{
id: 'view-pdf-zoom-in',
label: t('zoom_in'),
handler: () => setZoom('zoom-in'),
},
{
id: 'view-pdf-zoom-out',
label: t('zoom_out'),
handler: () => setZoom('zoom-out'),
},
{
id: 'view-pdf-fit-width',
label: t('fit_to_width'),
handler: () => setZoom('page-width'),
},
{
id: 'view-pdf-fit-height',
label: t('fit_to_height'),
handler: () => setZoom('page-height'),
},
],
[t, requestPresentationMode, setZoom]
)
if (!toolbarControlsElement) {
return null
}
if (showLogs) {
return null
}
const InnerControlsComponent =
availableWidth >= 300
? PdfViewerControlsToolbarFull
: PdfViewerControlsToolbarSmall
return createPortal(
<div className="pdfjs-viewer-controls" ref={pdfControlsRef}>
<InnerControlsComponent
requestPresentationMode={requestPresentationMode}
setZoom={setZoom}
rawScale={rawScale}
setPage={setPage}
page={page}
totalPages={totalPages}
pdfContainer={pdfContainer}
/>
</div>,
toolbarControlsElement
)
}
type InnerControlsProps = {
requestPresentationMode: () => void
setZoom: (zoom: string) => void
rawScale: number
setPage: (page: number) => void
page: number
totalPages: number
// eslint-disable-next-line react/no-unused-prop-types
pdfContainer?: HTMLDivElement
}
function PdfViewerControlsToolbarFull({
requestPresentationMode,
setZoom,
rawScale,
setPage,
page,
totalPages,
}: InnerControlsProps) {
return (
<>
<PdfPageNumberControl
setPage={setPage}
page={page}
totalPages={totalPages}
/>
<div className="pdfjs-zoom-controls">
<PdfZoomButtons setZoom={setZoom} />
<PdfZoomDropdown
requestPresentationMode={requestPresentationMode}
rawScale={rawScale}
setZoom={setZoom}
/>
</div>
</>
)
}
function PdfViewerControlsToolbarSmall({
requestPresentationMode,
setZoom,
rawScale,
setPage,
page,
totalPages,
pdfContainer,
}: InnerControlsProps) {
return (
<div className="pdfjs-viewer-controls-small">
<PdfZoomDropdown
requestPresentationMode={requestPresentationMode}
rawScale={rawScale}
setZoom={setZoom}
/>
<PdfViewerControlsMenuButton
setZoom={setZoom}
setPage={setPage}
page={page}
totalPages={totalPages}
pdfContainer={pdfContainer}
/>
</div>
)
}
export default memo(PdfViewerControlsToolbar)

View File

@@ -0,0 +1,25 @@
import { lazy, memo } from 'react'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
const PdfJsViewer = lazy(
() => import(/* webpackChunkName: "pdf-js-viewer" */ './pdf-js-viewer')
)
function PdfViewer() {
const { pdfUrl, pdfFile, pdfViewer } = useCompileContext()
if (!pdfUrl) {
return null
}
switch (pdfViewer) {
case 'native':
return <iframe title="PDF Preview" src={pdfUrl} />
case 'pdfjs':
default:
return <PdfJsViewer url={pdfUrl} pdfFile={pdfFile} />
}
}
export default memo(PdfViewer)

View File

@@ -0,0 +1,36 @@
import PDFToolbarButton from './pdf-toolbar-button'
import { useTranslation } from 'react-i18next'
import OLButtonGroup from '@/features/ui/components/ol/ol-button-group'
import { isMac } from '@/shared/utils/os'
type PdfZoomButtonsProps = {
setZoom: (zoom: string) => void
}
function PdfZoomButtons({ setZoom }: PdfZoomButtonsProps) {
const { t } = useTranslation()
const zoomInShortcut = isMac ? '⌘+' : 'Ctrl +'
const zoomOutShortcut = isMac ? '⌘-' : 'Ctrl -'
return (
<OLButtonGroup className="pdfjs-toolbar-buttons">
<PDFToolbarButton
tooltipId="pdf-controls-zoom-out-tooltip"
label={t('zoom_out')}
icon="remove"
onClick={() => setZoom('zoom-out')}
shortcut={zoomOutShortcut}
/>
<PDFToolbarButton
tooltipId="pdf-controls-zoom-in-tooltip"
label={t('zoom_in')}
icon="add"
onClick={() => setZoom('zoom-in')}
shortcut={zoomInShortcut}
/>
</OLButtonGroup>
)
}
export default PdfZoomButtons

View File

@@ -0,0 +1,190 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import classNames from 'classnames'
import {
Dropdown,
DropdownDivider,
DropdownHeader,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import FormControl from '@/features/ui/components/bootstrap-5/form/form-control'
import { isMac } from '@/shared/utils/os'
const shortcuts = isMac
? {
'zoom-in': ['⌘', '+'],
'zoom-out': ['⌘', '-'],
'fit-to-width': ['⌘', '0'],
'fit-to-height': ['⌘', '9'],
}
: {
'zoom-in': ['Ctrl', '+'],
'zoom-out': ['Ctrl', '-'],
'fit-to-width': ['Ctrl', '0'],
'fit-to-height': ['Ctrl', '9'],
}
type PdfZoomDropdownProps = {
requestPresentationMode: () => void
setZoom: (zoom: string) => void
rawScale: number
}
const zoomValues = ['0.5', '0.75', '1', '1.5', '2', '4']
const rawScaleToPercentage = (rawScale: number) => {
return `${Math.round(rawScale * 100)}%`
}
function PdfZoomDropdown({
requestPresentationMode,
setZoom,
rawScale,
}: PdfZoomDropdownProps) {
const { t } = useTranslation()
const [customZoomValue, setCustomZoomValue] = useState<string>(
rawScaleToPercentage(rawScale)
)
useEffect(() => {
setCustomZoomValue(rawScaleToPercentage(rawScale))
}, [rawScale])
const showPresentOption = document.fullscreenEnabled
return (
<Dropdown
onSelect={eventKey => {
if (eventKey === 'custom-zoom') {
return
}
if (eventKey === 'present') {
requestPresentationMode()
return
}
setZoom(eventKey)
}}
align="end"
>
<DropdownToggle
id="pdf-zoom-dropdown"
variant="link"
className="pdf-toolbar-btn pdfjs-zoom-dropdown-button small"
>
{rawScaleToPercentage(rawScale)}
</DropdownToggle>
<DropdownMenu className="pdfjs-zoom-dropdown-menu">
<li role="none">
<DropdownItem
disabled
as="div"
className="pdfjs-custom-zoom-menu-item"
eventKey="custom-zoom"
>
<FormControl
onFocus={event => event.target.select()}
value={customZoomValue}
onKeyDown={event => {
if (event.key === 'Enter') {
const zoom = Number(customZoomValue.replace('%', '')) / 100
// Only allow zoom values between 10% and 999%
if (zoom < 0.1) {
setZoom('0.1')
} else if (zoom > 9.99) {
setZoom('9.99')
} else {
setZoom(`${zoom}`)
}
}
}}
onChange={event => {
const rawValue = event.target.value
const parsedValue = rawValue.replace(/[^0-9%]/g, '')
setCustomZoomValue(parsedValue)
}}
/>
</DropdownItem>
</li>
<DropdownDivider />
<li role="none">
<DropdownItem
as="button"
eventKey="zoom-in"
trailingIcon={<Shortcut keys={shortcuts['zoom-in']} />}
>
{t('zoom_in')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
eventKey="zoom-out"
trailingIcon={<Shortcut keys={shortcuts['zoom-out']} />}
>
{t('zoom_out')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
eventKey="page-width"
trailingIcon={<Shortcut keys={shortcuts['fit-to-width']} />}
>
{t('fit_to_width')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
eventKey="page-height"
trailingIcon={<Shortcut keys={shortcuts['fit-to-height']} />}
>
{t('fit_to_height')}
</DropdownItem>
</li>
{showPresentOption && <DropdownDivider />}
{showPresentOption && (
<li role="none">
<DropdownItem as="button" eventKey="present">
{t('presentation_mode')}
</DropdownItem>
</li>
)}
<DropdownDivider />
<DropdownHeader aria-hidden="true">{t('zoom_to')}</DropdownHeader>
{zoomValues.map(value => (
<li role="none" key={value}>
<DropdownItem as="button" eventKey={value}>
{rawScaleToPercentage(Number(value))}
</DropdownItem>
</li>
))}
</DropdownMenu>
</Dropdown>
)
}
function Shortcut({ keys }: { keys: string[] }) {
return (
<span className="pull-right">
{keys.map((key, idx) => (
<span
className={classNames({
'pdfjs-zoom-dropdown-mac-shortcut-char': key.length === 1,
})}
key={`${key}${idx}`}
>
{key}
</span>
))}
</span>
)
}
export default PdfZoomDropdown

View File

@@ -0,0 +1,39 @@
import { useCallback } from 'react'
import { useTranslation, Trans } from 'react-i18next'
import OLButton from '@/features/ui/components/ol/ol-button'
import PdfLogEntry from './pdf-log-entry'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import { useStopOnFirstError } from '../../../shared/hooks/use-stop-on-first-error'
export default function StopOnFirstErrorPrompt() {
const { t } = useTranslation()
const { startCompile, setAnimateCompileDropdownArrow } = useCompileContext()
const { disableStopOnFirstError } = useStopOnFirstError({
eventSource: 'logs-pane',
})
const handleDisableButtonClick = useCallback(() => {
disableStopOnFirstError()
startCompile({ stopOnFirstError: false })
setAnimateCompileDropdownArrow(true)
}, [disableStopOnFirstError, startCompile, setAnimateCompileDropdownArrow])
return (
<PdfLogEntry
headerTitle={t('stop_on_first_error_enabled_title')}
formattedContent={
<>
<Trans
i18nKey="stop_on_first_error_enabled_description"
// eslint-disable-next-line react/jsx-key
components={[<strong />]}
/>{' '}
<OLButton variant="info" size="sm" onClick={handleDisableButtonClick}>
{t('disable_stop_on_first_error')}
</OLButton>
</>
}
level="info"
/>
)
}

View File

@@ -0,0 +1,34 @@
import { useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useLayoutContext } from '../../../shared/context/layout-context'
function SwitchToEditorButton() {
const { pdfLayout, setView, detachRole } = useLayoutContext()
const { t } = useTranslation()
if (detachRole) {
return null
}
if (pdfLayout === 'sideBySide') {
return null
}
function handleClick() {
setView('editor')
window.setTimeout(() => {
window.dispatchEvent(new Event('editor:focus'))
})
}
return (
<OLButton variant="secondary" size="sm" onClick={handleClick}>
<MaterialIcon type="code" />
{t('switch_to_editor')}
</OLButton>
)
}
export default SwitchToEditorButton

View File

@@ -0,0 +1,197 @@
import getMeta from '@/utils/meta'
import { Trans, useTranslation } from 'react-i18next'
import { memo, useCallback, useEffect } from 'react'
import { useDetachCompileContext } from '@/shared/context/detach-compile-context'
import StartFreeTrialButton from '@/shared/components/start-free-trial-button'
import MaterialIcon from '@/shared/components/material-icon'
import { useStopOnFirstError } from '@/shared/hooks/use-stop-on-first-error'
import * as eventTracking from '@/infrastructure/event-tracking'
import PdfLogEntry from './pdf-log-entry'
function TimeoutMessageAfterPaywallDismissal() {
const {
startCompile,
lastCompileOptions,
setAnimateCompileDropdownArrow,
isProjectOwner,
} = useDetachCompileContext()
const { enableStopOnFirstError } = useStopOnFirstError({
eventSource: 'timeout-new',
})
const handleEnableStopOnFirstErrorClick = useCallback(() => {
enableStopOnFirstError()
startCompile({ stopOnFirstError: true })
setAnimateCompileDropdownArrow(true)
}, [enableStopOnFirstError, startCompile, setAnimateCompileDropdownArrow])
return (
<div className="website-redesign timeout-upgrade-paywall-prompt">
<CompileTimeout isProjectOwner={isProjectOwner} />
{getMeta('ol-ExposedSettings').enableSubscriptions && (
<PreventTimeoutHelpMessage
handleEnableStopOnFirstErrorClick={handleEnableStopOnFirstErrorClick}
lastCompileOptions={lastCompileOptions}
isProjectOwner={isProjectOwner}
/>
)}
</div>
)
}
type CompileTimeoutProps = {
isProjectOwner: boolean
}
const CompileTimeout = memo(function CompileTimeout({
isProjectOwner,
}: CompileTimeoutProps) {
const { t } = useTranslation()
useEffect(() => {
eventTracking.sendMB('paywall-prompt', {
'paywall-type': 'compile-timeout',
'paywall-version': 'secondary',
})
}, [])
function onPaywallClick() {
eventTracking.sendMB('paywall-click', {
'paywall-type': 'compile-timeout',
'paywall-version': 'secondary',
})
}
return (
<PdfLogEntry
headerTitle={t('project_failed_to_compile')}
headerIcon={
<MaterialIcon
type="error"
className="log-entry-header-title"
size="2x"
unfilled
/>
}
formattedContent={
getMeta('ol-ExposedSettings').enableSubscriptions && (
<>
<p className="compile-timeout-message">
{isProjectOwner ? (
<div>
<p>{t('your_project_need_more_time_to_compile')}</p>
<p>{t('upgrade_to_unlock_more_time')}</p>
</div>
) : (
<div>
<p>{t('this_project_need_more_time_to_compile')}</p>
<p>{t('upgrade_to_unlock_more_time')}</p>
</div>
)}
</p>
{isProjectOwner === false && (
<Trans
i18nKey="tell_the_project_owner_and_ask_them_to_upgrade"
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
)}
{isProjectOwner && (
<div className="log-entry-cta-container">
<StartFreeTrialButton
source="compile-timeout"
buttonProps={{ variant: 'secondary' }}
handleClick={onPaywallClick}
>
{t('try_for_free')}
</StartFreeTrialButton>
</div>
)}
</>
)
}
// @ts-ignore
entryAriaLabel={t('your_compile_timed_out')}
level="error"
/>
)
})
type PreventTimeoutHelpMessageProps = {
lastCompileOptions: any
handleEnableStopOnFirstErrorClick: () => void
isProjectOwner: boolean
}
const PreventTimeoutHelpMessage = memo(function PreventTimeoutHelpMessage({
lastCompileOptions,
handleEnableStopOnFirstErrorClick,
isProjectOwner,
}: PreventTimeoutHelpMessageProps) {
const { t } = useTranslation()
return (
<PdfLogEntry
headerTitle={t('other_causes_of_compile_timeouts')}
formattedContent={
<>
<ul>
<li>
{t('large_or_high_resolution_images_taking_too_long_to_process')}
</li>
<li>
<Trans
i18nKey="a_fatal_compile_error_that_completely_blocks_compilation"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a
href="/learn/how-to/Fixing_and_preventing_compile_timeouts#Step_3:_Assess_your_project_for_time-consuming_tasks_and_fatal_errors"
rel="noopener noreferrer"
target="_blank"
/>,
]}
/>
{!lastCompileOptions.stopOnFirstError && (
<>
{' '}
<Trans
i18nKey="enable_stop_on_first_error_under_recompile_dropdown_menu_v2"
components={[
// eslint-disable-next-line react/jsx-key
<strong className="log-bold-text" />,
// eslint-disable-next-line react/jsx-key
<strong className="log-bold-text" />,
]}
/>{' '}
</>
)}
</li>
</ul>
<p className="mb-0">
<Trans
i18nKey="learn_more_about_compile_timeouts"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a
href="/learn/how-to/Fixing_and_preventing_compile_timeouts"
rel="noopener noreferrer"
target="_blank"
/>,
]}
/>
</p>
</>
}
// @ts-ignore
entryAriaLabel={t('reasons_for_compile_timeouts')}
level="raw"
/>
)
})
export default memo(TimeoutMessageAfterPaywallDismissal)

View File

@@ -0,0 +1,86 @@
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react'
import getMeta from '@/utils/meta'
import * as eventTracking from '@/infrastructure/event-tracking'
import TimeoutMessageAfterPaywallDismissal from './timeout-message-after-paywall-dismissal'
import { UpgradePrompt } from '@/shared/components/upgrade-prompt'
const studentRoles = [
'High-school student',
'Undergraduate student',
"Master's student (e.g. MSc, MA)",
'Doctoral student (e.g. PhD, MD, EngD)',
]
type Segmentation = Record<
string,
string | number | boolean | undefined | unknown | any
>
interface TimeoutUpgradePaywallPromptProps {
setIsShowingPrimary: Dispatch<SetStateAction<boolean>>
}
function TimeoutUpgradePaywallPrompt({
setIsShowingPrimary,
}: TimeoutUpgradePaywallPromptProps) {
const odcRole = getMeta('ol-odcRole')
const planPrices = getMeta('ol-paywallPlans')
const isStudent = useMemo(() => studentRoles.includes(odcRole), [odcRole])
const [isPaywallDismissed, setIsPaywallDismissed] = useState<boolean>(false)
function sendPaywallEvent(event: string, segmentation?: Segmentation) {
eventTracking.sendMB(event, {
'paywall-type': 'compile-timeout',
'paywall-version': 'primary',
...segmentation,
})
}
function onClose() {
sendPaywallEvent('paywall-dismiss')
setIsPaywallDismissed(true)
setIsShowingPrimary(false)
}
function onClickInfoLink() {
sendPaywallEvent('paywall-info-click', { content: 'plans' })
}
function onClickPaywall() {
sendPaywallEvent('paywall-click', {
plan: isStudent ? 'student' : 'collaborator',
})
}
useEffect(() => {
sendPaywallEvent('paywall-prompt', {
plan: isStudent ? 'student' : 'collaborator',
})
setIsShowingPrimary(true)
}, [isStudent, setIsShowingPrimary])
return (
<div>
{!isPaywallDismissed ? (
<UpgradePrompt
title="Unlock more compile time"
summary="Your project took too long to compile and timed out."
onClose={onClose}
planPricing={{
student: planPrices?.student,
standard: planPrices?.collaborator,
}}
itmCampaign="compile-timeout"
isStudent={isStudent}
onClickInfoLink={onClickInfoLink}
onClickPaywall={onClickPaywall}
/>
) : (
<TimeoutMessageAfterPaywallDismissal />
)}
</div>
)
}
export default TimeoutUpgradePaywallPrompt

View File

@@ -0,0 +1,223 @@
import { Trans, useTranslation } from 'react-i18next'
import { useDetachCompileContext } from '../../../shared/context/detach-compile-context'
import StartFreeTrialButton from '../../../shared/components/start-free-trial-button'
import { memo, useCallback } from 'react'
import PdfLogEntry from './pdf-log-entry'
import { useStopOnFirstError } from '../../../shared/hooks/use-stop-on-first-error'
import OLButton from '@/features/ui/components/ol/ol-button'
import * as eventTracking from '../../../infrastructure/event-tracking'
import getMeta from '@/utils/meta'
function TimeoutUpgradePromptNew() {
const {
startCompile,
lastCompileOptions,
setAnimateCompileDropdownArrow,
isProjectOwner,
} = useDetachCompileContext()
const { enableStopOnFirstError } = useStopOnFirstError({
eventSource: 'timeout-new',
})
const handleEnableStopOnFirstErrorClick = useCallback(() => {
enableStopOnFirstError()
startCompile({ stopOnFirstError: true })
setAnimateCompileDropdownArrow(true)
}, [enableStopOnFirstError, startCompile, setAnimateCompileDropdownArrow])
return (
<>
<CompileTimeout isProjectOwner={isProjectOwner} />
{getMeta('ol-ExposedSettings').enableSubscriptions && (
<PreventTimeoutHelpMessage
handleEnableStopOnFirstErrorClick={handleEnableStopOnFirstErrorClick}
lastCompileOptions={lastCompileOptions}
isProjectOwner={isProjectOwner}
/>
)}
</>
)
}
type CompileTimeoutProps = {
isProjectOwner: boolean
}
const CompileTimeout = memo(function CompileTimeout({
isProjectOwner,
}: CompileTimeoutProps) {
const { t } = useTranslation()
return (
<PdfLogEntry
headerTitle={t('your_compile_timed_out')}
formattedContent={
getMeta('ol-ExposedSettings').enableSubscriptions && (
<>
<p>
{isProjectOwner
? t('your_project_exceeded_compile_timeout_limit_on_free_plan')
: t('this_project_exceeded_compile_timeout_limit_on_free_plan')}
</p>
{isProjectOwner ? (
<p>
<strong>{t('upgrade_for_12x_more_compile_time')}</strong>{' '}
{t(
'plus_additional_collaborators_document_history_track_changes_and_more'
)}
</p>
) : (
<Trans
i18nKey="tell_the_project_owner_and_ask_them_to_upgrade"
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
)}
{isProjectOwner && (
<p className="text-center">
<StartFreeTrialButton
source="compile-timeout"
buttonProps={{ variant: 'primary', className: 'w-100' }}
>
{t('start_a_free_trial')}
</StartFreeTrialButton>
</p>
)}
</>
)
}
// @ts-ignore
entryAriaLabel={t('your_compile_timed_out')}
level="error"
/>
)
})
type PreventTimeoutHelpMessageProps = {
lastCompileOptions: any
handleEnableStopOnFirstErrorClick: () => void
isProjectOwner: boolean
}
const PreventTimeoutHelpMessage = memo(function PreventTimeoutHelpMessage({
lastCompileOptions,
handleEnableStopOnFirstErrorClick,
isProjectOwner,
}: PreventTimeoutHelpMessageProps) {
const { t } = useTranslation()
function sendInfoClickEvent() {
eventTracking.sendMB('paywall-info-click', {
'paywall-type': 'compile-timeout',
content: 'blog',
})
}
const compileTimeoutChangesBlogLink = (
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
<a
aria-label={t('read_more_about_free_compile_timeouts_servers')}
href="/blog/changes-to-free-compile-timeouts-and-servers"
rel="noopener noreferrer"
target="_blank"
onClick={sendInfoClickEvent}
/>
)
return (
<PdfLogEntry
headerTitle={t('reasons_for_compile_timeouts')}
formattedContent={
<>
<p>{t('common_causes_of_compile_timeouts_include')}:</p>
<ul>
<li>
<Trans
i18nKey="large_or_high-resolution_images_taking_too_long"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a
href="/learn/how-to/Optimising_very_large_image_files"
rel="noopener noreferrer"
target="_blank"
/>,
]}
/>
</li>
<li>
<Trans
i18nKey="a_fatal_compile_error_that_completely_blocks_compilation"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a
href="/learn/how-to/Fixing_and_preventing_compile_timeouts#Step_3:_Assess_your_project_for_time-consuming_tasks_and_fatal_errors"
rel="noopener noreferrer"
target="_blank"
/>,
]}
/>
{!lastCompileOptions.stopOnFirstError && (
<>
{' '}
<Trans
i18nKey="enable_stop_on_first_error_under_recompile_dropdown_menu"
components={[
// eslint-disable-next-line react/jsx-key
<OLButton
variant="link"
className="btn-inline-link fw-bold"
size="sm"
onClick={handleEnableStopOnFirstErrorClick}
/>,
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>{' '}
</>
)}
</li>
</ul>
<p>
<Trans
i18nKey="learn_more_about_other_causes_of_compile_timeouts"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a
href="/learn/how-to/Fixing_and_preventing_compile_timeouts"
rel="noopener noreferrer"
target="_blank"
/>,
]}
/>
</p>
<p>
<em>
<>
{isProjectOwner ? (
<Trans
i18nKey="weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_your_project"
components={[compileTimeoutChangesBlogLink]}
/>
) : (
<Trans
i18nKey="weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_this_project"
components={[compileTimeoutChangesBlogLink]}
/>
)}
</>
</em>
</p>
</>
}
// @ts-ignore
entryAriaLabel={t('reasons_for_compile_timeouts')}
level="raw"
/>
)
})
export default memo(TimeoutUpgradePromptNew)

View File

@@ -0,0 +1,67 @@
import { useCallback, useEffect } from 'react'
import useEventListener from '../../../shared/hooks/use-event-listener'
import useDetachAction from '../../../shared/hooks/use-detach-action'
export const startCompileKeypress = event => {
if (event.shiftKey || event.altKey) {
return false
}
if (event.ctrlKey) {
// Ctrl+s / Ctrl+Enter / Ctrl+.
if (event.key === 's' || event.key === 'Enter' || event.key === '.') {
return true
}
// Ctrl+s with Caps-Lock on
if (event.key === 'S' && !event.shiftKey) {
return true
}
} else if (event.metaKey) {
// Cmd+s / Cmd+Enter
if (event.key === 's' || event.key === 'Enter') {
return true
}
// Cmd+s with Caps-Lock on
if (event.key === 'S' && !event.shiftKey) {
return true
}
}
}
export default function useCompileTriggers(startCompile, setChangedAt) {
const handleKeyDown = useCallback(
event => {
if (startCompileKeypress(event)) {
event.preventDefault()
startCompile()
}
},
[startCompile]
)
const handleStartCompile = useCallback(() => {
startCompile()
}, [startCompile])
useEventListener('pdf:recompile', handleStartCompile)
useEffect(() => {
document.body.addEventListener('keydown', handleKeyDown)
return () => {
document.body.removeEventListener('keydown', handleKeyDown)
}
}, [handleKeyDown])
// record doc changes when notified by the editor
const setOrTriggerChangedAt = useDetachAction(
'set-changed-at',
setChangedAt,
'detacher',
'detached'
)
const setChangedAtHandler = useCallback(() => {
setOrTriggerChangedAt(Date.now())
}, [setOrTriggerChangedAt])
useEventListener('doc:changed', setChangedAtHandler)
}

View File

@@ -0,0 +1,63 @@
import { useEffect } from 'react'
import { useLayoutContext } from '@/shared/context/layout-context'
/**
* This hook adds an event listener for events dispatched from the editor to the compile logs pane
*/
export const useLogEvents = (setShowLogs: (show: boolean) => void) => {
const { pdfLayout, setView } = useLayoutContext()
useEffect(() => {
const listener = (event: Event) => {
const { id, suggestFix } = (
event as CustomEvent<{ id: string; suggestFix?: boolean }>
).detail
setShowLogs(true)
if (pdfLayout === 'flat') {
setView('pdf')
}
window.setTimeout(() => {
const element = document.querySelector(
`.log-entry[data-log-entry-id="${id}"]`
)
if (element) {
element.scrollIntoView({
block: 'start',
inline: 'nearest',
})
if (suggestFix) {
// if they are paywalled, click that instead
const paywall = document.querySelector<HTMLButtonElement>(
'button[data-action="assistant-paywall-show"]'
)
if (paywall) {
paywall.scrollIntoView({
block: 'start',
inline: 'nearest',
})
paywall.click()
} else {
element
.querySelector<HTMLButtonElement>(
'button[data-action="suggest-fix"]'
)
?.click()
}
}
}
})
}
window.addEventListener('editor:view-compile-log-entry', listener)
return () => {
window.removeEventListener('editor:view-compile-log-entry', listener)
}
}, [pdfLayout, setView, setShowLogs])
}

View File

@@ -0,0 +1,104 @@
import { useCallback, useEffect, useRef } from 'react'
import PDFJSWrapper from '../util/pdf-js-wrapper'
// We need this to work for both a traditional mouse wheel and a touchpad "pinch to zoom".
// From experimentation, trackpads tend to fire a lot of events with small deltaY's where
// as a mouse wheel will fire fewer events but sometimes with a very high deltaY if you
// move the wheel quickly.
// The divisor is set to a value that works for the trackpad with the maximum value ensuring
// that the scale doesn't suddenly change drastically from moving the mouse wheel quickly.
const MAX_SCALE_FACTOR = 1.2
const SCALE_FACTOR_DIVISOR = 20
export default function useMouseWheelZoom(
pdfJsWrapper: PDFJSWrapper | null | undefined,
setScale: (scale: string) => void
) {
const isZoomingRef = useRef(false)
// To avoid accidental pdf when pressing CMD/CTRL when the pdf scroll still has
// momentum, we only zoom if CMD/CTRL is pressed before the scroll starts. These refs
// keep track of if the pdf is currently scrolling.
// https://github.com/overleaf/internal/issues/20772
const isScrollingRef = useRef(false)
const isScrollingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null
)
const performZoom = useCallback(
(event: WheelEvent, pdfJsWrapper: PDFJSWrapper) => {
// First, we calculate and set the new scale
const scrollMagnitude = Math.abs(event.deltaY)
const scaleFactorMagnitude = Math.min(
1 + scrollMagnitude / SCALE_FACTOR_DIVISOR,
MAX_SCALE_FACTOR
)
const previousScale = pdfJsWrapper.viewer.currentScale
const scaleChangeDirection = Math.sign(event.deltaY)
const approximateScaleFactor =
scaleChangeDirection < 0
? scaleFactorMagnitude
: 1 / scaleFactorMagnitude
const newScale =
Math.round(previousScale * approximateScaleFactor * 100) / 100
const exactScaleFactor = newScale / previousScale
// Set the scale directly to ensure it is set before we do the scrolling below
pdfJsWrapper.viewer.currentScale = newScale
setScale(`${newScale}`)
// Then we need to ensure we are centering the zoom on the mouse position
const containerRect = pdfJsWrapper.container.getBoundingClientRect()
const top = containerRect.top
const left = containerRect.left
// Positions relative to pdf viewer
const currentMouseX = event.clientX - left
const currentMouseY = event.clientY - top
pdfJsWrapper.container.scrollBy({
left: currentMouseX * exactScaleFactor - currentMouseX,
top: currentMouseY * exactScaleFactor - currentMouseY,
behavior: 'instant',
})
},
[setScale]
)
useEffect(() => {
if (pdfJsWrapper) {
const wheelListener = (event: WheelEvent) => {
if ((event.metaKey || event.ctrlKey) && !isScrollingRef.current) {
event.preventDefault()
if (!isZoomingRef.current) {
isZoomingRef.current = true
performZoom(event, pdfJsWrapper)
setTimeout(() => {
isZoomingRef.current = false
}, 5)
}
} else {
isScrollingRef.current = true
if (isScrollingTimeoutRef.current) {
clearTimeout(isScrollingTimeoutRef.current)
}
isScrollingTimeoutRef.current = setTimeout(() => {
isScrollingRef.current = false
}, 100)
}
}
pdfJsWrapper.container.addEventListener('wheel', wheelListener)
return () => {
pdfJsWrapper.container.removeEventListener('wheel', wheelListener)
}
}
}, [pdfJsWrapper, setScale, performZoom])
}

View File

@@ -0,0 +1,176 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import PDFJSWrapper from '../util/pdf-js-wrapper'
import { sendMB } from '@/infrastructure/event-tracking'
import { debugConsole } from '@/utils/debugging'
type StoredPDFState = {
scrollMode?: number
spreadMode?: number
currentScaleValue?: string
}
export default function usePresentationMode(
pdfJsWrapper: PDFJSWrapper | null | undefined,
page: number | null,
handlePageChange: (page: number) => void,
scale: string,
setScale: (scale: string) => void
): () => void {
const storedState = useRef<StoredPDFState>({})
const [presentationMode, setPresentationMode] = useState(false)
const nextPage = useCallback(() => {
if (page !== null) {
handlePageChange(page + 1)
}
}, [handlePageChange, page])
const previousPage = useCallback(() => {
if (page !== null) {
handlePageChange(page - 1)
}
}, [handlePageChange, page])
const clickListener = useCallback(
event => {
if (event.target.tagName === 'A') {
return
}
if (event.shiftKey) {
previousPage()
} else {
nextPage()
}
},
[nextPage, previousPage]
)
const arrowKeyListener = useCallback(
event => {
switch (event.key) {
case 'ArrowLeft':
case 'ArrowUp':
case 'PageUp':
case 'Backspace':
previousPage()
break
case 'ArrowRight':
case 'ArrowDown':
case 'PageDown':
nextPage()
break
case ' ':
if (event.shiftKey) {
previousPage()
} else {
nextPage()
}
break
}
},
[nextPage, previousPage]
)
const isMouseWheelScrollingRef = useRef(false)
const mouseWheelListener = useCallback(
(event: WheelEvent) => {
if (
!isMouseWheelScrollingRef.current &&
!event.ctrlKey // Avoid trackpad pinching
) {
isMouseWheelScrollingRef.current = true
if (event.deltaY > 0) {
nextPage()
} else {
previousPage()
}
setTimeout(() => {
isMouseWheelScrollingRef.current = false
}, 200)
}
},
[nextPage, previousPage]
)
useEffect(() => {
if (presentationMode) {
window.addEventListener('keydown', arrowKeyListener)
window.addEventListener('click', clickListener)
window.addEventListener('wheel', mouseWheelListener)
return () => {
window.removeEventListener('keydown', arrowKeyListener)
window.removeEventListener('click', clickListener)
window.removeEventListener('wheel', mouseWheelListener)
}
}
}, [presentationMode, arrowKeyListener, clickListener, mouseWheelListener])
const requestPresentationMode = useCallback(() => {
sendMB('pdf-viewer-enter-presentation-mode')
if (pdfJsWrapper) {
pdfJsWrapper.container.parentElement
?.requestFullscreen()
.catch(debugConsole.error)
}
}, [pdfJsWrapper])
const handleEnterFullscreen = useCallback(() => {
if (pdfJsWrapper) {
storedState.current.scrollMode = pdfJsWrapper.viewer.scrollMode
storedState.current.spreadMode = pdfJsWrapper.viewer.spreadMode
storedState.current.currentScaleValue = scale
setScale('page-fit')
pdfJsWrapper.viewer.scrollMode = 3 // page
pdfJsWrapper.viewer.spreadMode = 0 // none
pdfJsWrapper.fetchAllData()
setPresentationMode(true)
}
}, [pdfJsWrapper, setScale, scale])
const handleExitFullscreen = useCallback(() => {
if (pdfJsWrapper) {
pdfJsWrapper.viewer.scrollMode = storedState.current.scrollMode!
pdfJsWrapper.viewer.spreadMode = storedState.current.spreadMode!
if (storedState.current.currentScaleValue !== undefined) {
setScale(storedState.current.currentScaleValue)
}
setPresentationMode(false)
}
}, [pdfJsWrapper, setScale])
const handleFullscreenChange = useCallback(() => {
if (pdfJsWrapper) {
const fullscreen =
document.fullscreenElement === pdfJsWrapper.container.parentNode
if (fullscreen) {
handleEnterFullscreen()
} else {
handleExitFullscreen()
}
}
}, [pdfJsWrapper, handleEnterFullscreen, handleExitFullscreen])
useEffect(() => {
window.addEventListener('fullscreenchange', handleFullscreenChange)
return () => {
window.removeEventListener('fullscreenchange', handleFullscreenChange)
}
}, [handleFullscreenChange])
return requestPresentationMode
}

View File

@@ -0,0 +1,233 @@
import { isMainFile } from './editor-files'
import getMeta from '../../../utils/meta'
import { deleteJSON, postJSON } from '../../../infrastructure/fetch-json'
import { debounce } from 'lodash'
import { EDITOR_SESSION_ID, trackPdfDownload } from './metrics'
import { enablePdfCaching } from './pdf-caching-flags'
import { debugConsole } from '@/utils/debugging'
import { signalWithTimeout } from '@/utils/abort-signal'
const AUTO_COMPILE_MAX_WAIT = 5000
// We add a 2 second debounce to sending user changes to server if they aren't
// collaborating with anyone. This needs to be higher than SINGLE_USER_FLUSH_DELAY, and allow for
// client to server latency, otherwise we compile before the op reaches the server
// and then again on ack.
const AUTO_COMPILE_DEBOUNCE = 2500
// If there is a pending op, wait for it to be saved before compiling
const PENDING_OP_MAX_WAIT = 10000
const searchParams = new URLSearchParams(window.location.search)
export default class DocumentCompiler {
constructor({
compilingRef,
projectId,
setChangedAt,
setCompiling,
setData,
setFirstRenderDone,
setDeliveryLatencies,
setError,
cleanupCompileResult,
signal,
openDocs,
}) {
this.compilingRef = compilingRef
this.projectId = projectId
this.setChangedAt = setChangedAt
this.setCompiling = setCompiling
this.setData = setData
this.setFirstRenderDone = setFirstRenderDone
this.setDeliveryLatencies = setDeliveryLatencies
this.setError = setError
this.cleanupCompileResult = cleanupCompileResult
this.signal = signal
this.openDocs = openDocs
this.projectRootDocId = null
this.clsiServerId = null
this.currentDoc = null
this.error = undefined
this.timer = 0
this.defaultOptions = {
draft: false,
stopOnFirstError: false,
}
this.debouncedAutoCompile = debounce(
() => {
this.compile({ isAutoCompileOnChange: true })
},
AUTO_COMPILE_DEBOUNCE,
{
maxWait: AUTO_COMPILE_MAX_WAIT,
}
)
}
// The main "compile" function.
// Call this directly to run a compile now, otherwise call debouncedAutoCompile.
async compile(options = {}) {
options = { ...this.defaultOptions, ...options }
if (options.isAutoCompileOnLoad && getMeta('ol-preventCompileOnLoad')) {
return
}
// set "compiling" to true (in the React component's state), and return if it was already true
const wasCompiling = this.compilingRef.current
this.setCompiling(true)
if (wasCompiling) {
if (options.isAutoCompileOnChange) {
this.debouncedAutoCompile()
}
return
}
try {
await this.openDocs.awaitBufferedOps(
signalWithTimeout(this.signal, PENDING_OP_MAX_WAIT)
)
// reset values
this.setChangedAt(0) // TODO: wait for doc:saved?
this.validationIssues = undefined
const params = this.buildCompileParams(options)
const t0 = performance.now()
const rootDocId = this.getRootDocOverrideId()
const body = {
rootDoc_id: rootDocId,
draft: options.draft,
check: 'silent', // NOTE: 'error' and 'validate' are possible, but unused
// use incremental compile for all users but revert to a full compile
// if there was previously a server error
incrementalCompilesEnabled: !this.error,
stopOnFirstError: options.stopOnFirstError,
editorId: EDITOR_SESSION_ID,
}
const data = await postJSON(
`/project/${this.projectId}/compile?${params}`,
{ body, signal: this.signal }
)
const compileTimeClientE2E = Math.ceil(performance.now() - t0)
const { deliveryLatencies, firstRenderDone } = trackPdfDownload(
data,
compileTimeClientE2E,
t0
)
this.setDeliveryLatencies(() => deliveryLatencies)
this.setFirstRenderDone(() => firstRenderDone)
// unset the error before it's set again later, so that components are recreated and events are tracked
this.setError(undefined)
data.options = options
data.rootDocId = rootDocId
if (data.clsiServerId) {
this.clsiServerId = data.clsiServerId
}
this.setData(data)
} catch (error) {
debugConsole.error(error)
this.cleanupCompileResult()
this.setError(error.info?.statusCode === 429 ? 'rate-limited' : 'error')
} finally {
this.setCompiling(false)
}
}
// parse the text of the current doc in the editor
// if it contains "\documentclass" then use this as the root doc
getRootDocOverrideId() {
// only override when not in the root doc itself
if (this.currentDoc && this.currentDoc.doc_id !== this.projectRootDocId) {
const snapshot = this.currentDoc.getSnapshot()
if (snapshot && isMainFile(snapshot)) {
return this.currentDoc.doc_id
}
}
return null
}
// build the query parameters added to post-compile requests
buildPostCompileParams() {
const params = new URLSearchParams()
// the id of the CLSI server that processed the previous compile request
if (this.clsiServerId) {
params.set('clsiserverid', this.clsiServerId)
}
return params
}
// build the query parameters for the compile request
buildCompileParams(options) {
const params = new URLSearchParams()
// note: no clsiserverid query param is set on "compile" requests,
// as this is added in the backend by the web api
// tell the server whether this is an automatic or manual compile request
if (options.isAutoCompileOnLoad || options.isAutoCompileOnChange) {
params.set('auto_compile', 'true')
}
// use the feature flag to enable PDF caching
if (enablePdfCaching) {
params.set('enable_pdf_caching', 'true')
}
// use the feature flag to enable "file line errors"
if (searchParams.get('file_line_errors') === 'true') {
params.file_line_errors = 'true'
}
return params
}
// send a request to stop the current compile
stopCompile() {
// NOTE: no stoppingCompile state, as this should happen fairly quickly
// and doesn't matter if it runs twice.
const params = this.buildPostCompileParams()
return postJSON(`/project/${this.projectId}/compile/stop?${params}`, {
signal: this.signal,
})
.catch(error => {
debugConsole.error(error)
this.setError('error')
})
.finally(() => {
this.setCompiling(false)
})
}
// send a request to clear the cache
clearCache() {
const params = this.buildPostCompileParams()
return deleteJSON(`/project/${this.projectId}/output?${params}`, {
signal: this.signal,
}).catch(error => {
debugConsole.error(error)
this.setError('clear-cache')
})
}
setOption(option, value) {
this.defaultOptions[option] = value
}
}

View File

@@ -0,0 +1,4 @@
const documentClassRe = /^[^%]*\\documentclass/
export const isMainFile = doc =>
doc.split('\n').some(line => documentClassRe.test(line))

View File

@@ -0,0 +1,88 @@
import {
CompileOutputFile,
CompileResponseData,
} from '../../../../../types/compile'
import { PdfFileDataList } from '@/features/pdf-preview/util/types'
const topFileTypes = ['bbl', 'gls', 'ind']
// NOTE: Updating this list requires a corresponding change in
// * services/clsi/app/js/OutputFileArchiveManager.js
const ignoreFiles = ['output.fls', 'output.fdb_latexmk']
export function buildFileList(
outputFiles: Map<string, CompileOutputFile>,
{
clsiServerId,
compileGroup,
outputFilesArchive,
fromCache = false,
}: CompileResponseData
): PdfFileDataList {
const files: PdfFileDataList = { top: [], other: [] }
if (outputFiles) {
const params = new URLSearchParams()
if (fromCache) {
params.set('clsiserverid', 'cache')
} else if (clsiServerId) {
params.set('clsiserverid', clsiServerId)
}
if (compileGroup) {
params.set('compileGroup', compileGroup)
}
const queryString = params.toString()
const allFiles = []
// filter out ignored files and set some properties
for (const file of outputFiles.values()) {
if (!ignoreFiles.includes(file.path)) {
file.main = file.path.startsWith('output.')
if (queryString.length) {
file.url += `?${queryString}`
}
allFiles.push(file)
}
}
// sort main files first, then alphabetical
allFiles.sort((a, b) => {
if (a.main && !b.main) {
return -1
}
if (b.main && !a.main) {
return 1
}
return a.path.localeCompare(b.path, undefined, { numeric: true })
})
// group files into "top" and "other"
for (const file of allFiles) {
if (topFileTypes.includes(file.type)) {
files.top.push(file)
} else if (!(file.type === 'pdf' && file.main === true)) {
files.other.push(file)
}
}
const archivableFiles = [...files.top, ...files.other]
if (outputFilesArchive && archivableFiles.length > 0) {
archivableFiles.forEach(file => params.append('files', file.path))
files.archive = {
...outputFilesArchive,
fileCount: archivableFiles.length,
url: `${outputFilesArchive.url}?${params.toString()}`,
}
}
}
return files
}

View File

@@ -0,0 +1,35 @@
import { PDFJS } from '@/features/pdf-preview/util/pdf-js'
export function buildHighlightElement(highlight, viewer) {
const pageView = viewer.getPageView(highlight.page - 1)
const viewport = pageView.viewport
const height = viewport.viewBox[3]
const rect = viewport.convertToViewportRectangle([
highlight.h, // xMin
height - (highlight.v + highlight.height) + 10, // yMin
highlight.h + highlight.width, // xMax
height - highlight.v + 10, // yMax
])
const [left, top, right, bottom] = PDFJS.Util.normalizeRect(rect)
const element = document.createElement('div')
element.style.left = Math.floor(pageView.div.offsetLeft + left) + 'px'
element.style.top = Math.floor(pageView.div.offsetTop + top) + 'px'
element.style.width = Math.ceil(right - left) + 'px'
element.style.height = Math.ceil(bottom - top) + 'px'
element.style.backgroundColor = 'rgba(255,255,0)'
element.style.position = 'absolute'
element.style.display = 'inline-block'
element.style.scrollMargin = '72px'
element.style.pointerEvents = 'none'
element.style.opacity = '0'
element.style.transition = 'opacity 1s'
viewer.viewer?.append(element)
return element
}

View File

@@ -0,0 +1,66 @@
import { v4 as uuid } from 'uuid'
import { sendMB } from '../../../infrastructure/event-tracking'
import { trackPdfDownloadEnabled } from './pdf-caching-flags'
import { debugConsole } from '@/utils/debugging'
// VERSION should get incremented when making changes to caching behavior or
// adjusting metrics collection.
const VERSION = 9
// editing session id
export const EDITOR_SESSION_ID = uuid()
const pdfCachingMetrics = {
viewerId: EDITOR_SESSION_ID,
}
export function getPdfCachingMetrics() {
return pdfCachingMetrics
}
export function trackPdfDownload(response, compileTimeClientE2E, t0) {
const { timings, pdfCachingMinChunkSize } = response
const deliveryLatencies = {
compileTimeClientE2E,
compileTimeServerE2E: timings?.compileE2E,
}
// There can be multiple "first" renderings with two pdf viewers.
// E.g. two pdf detach tabs or pdf detacher plus pdf detach.
// Let the pdfCachingMetrics round trip to account for pdf-detach.
let isFirstRender = true
function firstRenderDone({ latencyFetch, latencyRender, pdfCachingMetrics }) {
if (!isFirstRender) return
isFirstRender = false
deliveryLatencies.totalDeliveryTime = Math.ceil(performance.now() - t0)
deliveryLatencies.latencyFetch = latencyFetch
if (latencyRender) {
deliveryLatencies.latencyRender = latencyRender
}
if (trackPdfDownloadEnabled) {
// Submit latency along with compile context.
submitCompileMetrics({
pdfCachingMinChunkSize,
...deliveryLatencies,
...pdfCachingMetrics,
})
}
}
return {
deliveryLatencies,
firstRenderDone,
}
}
function submitCompileMetrics(metrics) {
const leanMetrics = {
version: VERSION,
...metrics,
id: EDITOR_SESSION_ID,
}
debugConsole.log('/event/compile-metrics', JSON.stringify(leanMetrics))
sendMB('compile-metrics-v6', leanMetrics)
}

View File

@@ -0,0 +1,276 @@
import HumanReadableLogs from '../../../ide/human-readable-logs/HumanReadableLogs'
import BibLogParser from '../../../ide/log-parser/bib-log-parser'
import { enablePdfCaching } from './pdf-caching-flags'
import { debugConsole } from '@/utils/debugging'
import { dirname, findEntityByPath } from '@/features/file-tree/util/path'
import '@/utils/readable-stream-async-iterator-polyfill'
import { EDITOR_SESSION_ID } from '@/features/pdf-preview/util/metrics'
// Warnings that may disappear after a second LaTeX pass
const TRANSIENT_WARNING_REGEX = /^(Reference|Citation).+undefined on input line/
const MAX_LOG_SIZE = 1024 * 1024 // 1MB
const MAX_BIB_LOG_SIZE_PER_FILE = MAX_LOG_SIZE
export function handleOutputFiles(outputFiles, projectId, data) {
const outputFile = outputFiles.get('output.pdf')
if (!outputFile) return null
outputFile.editorId = outputFile.editorId || EDITOR_SESSION_ID
// build the URL for viewing the PDF in the preview UI
const params = new URLSearchParams()
if (data.compileGroup) {
params.set('compileGroup', data.compileGroup)
}
if (data.clsiServerId) {
params.set('clsiserverid', data.clsiServerId)
}
if (enablePdfCaching) {
// Tag traffic that uses the pdf caching logic.
params.set('enable_pdf_caching', 'true')
}
outputFile.pdfUrl = `${buildURL(
outputFile,
data.pdfDownloadDomain
)}?${params}`
if (data.fromCache) {
outputFile.pdfDownloadUrl = outputFile.downloadURL
} else {
// build the URL for downloading the PDF
params.set('popupDownload', 'true') // save PDF download as file
outputFile.pdfDownloadUrl = `/download/project/${projectId}/build/${outputFile.build}/output/output.pdf?${params}`
}
return outputFile
}
let nextEntryId = 1
function generateEntryKey() {
return 'compile-log-entry-' + nextEntryId++
}
export const handleLogFiles = async (outputFiles, data, signal) => {
const result = {
log: null,
logEntries: {
errors: [],
warnings: [],
typesetting: [],
},
}
function accumulateResults(newEntries, type) {
for (const key in result.logEntries) {
if (newEntries[key]) {
for (const entry of newEntries[key]) {
if (type) {
entry.type = newEntries.type
}
if (entry.file) {
entry.file = normalizeFilePath(entry.file)
}
entry.key = generateEntryKey()
}
result.logEntries[key].push(...newEntries[key])
}
}
}
const logFile = outputFiles.get('output.log')
if (logFile) {
result.log = await fetchFileWithSizeLimit(
buildURL(logFile, data.pdfDownloadDomain),
signal,
MAX_LOG_SIZE
)
try {
let { errors, warnings, typesetting } = HumanReadableLogs.parse(
result.log,
{
ignoreDuplicates: true,
}
)
if (data.status === 'stopped-on-first-error') {
// Hide warnings that could disappear after a second pass
warnings = warnings.filter(warning => !isTransientWarning(warning))
}
accumulateResults({ errors, warnings, typesetting })
} catch (e) {
debugConsole.warn(e) // ignore failure to parse the log file, but log a warning
}
}
const blgFiles = []
for (const [filename, file] of outputFiles) {
if (filename.endsWith('.blg')) {
blgFiles.push(file)
}
}
for (const blgFile of blgFiles) {
const log = await fetchFileWithSizeLimit(
buildURL(blgFile, data.pdfDownloadDomain),
signal,
MAX_BIB_LOG_SIZE_PER_FILE
)
try {
const { errors, warnings } = new BibLogParser(log, {
maxErrors: 100,
}).parse()
accumulateResults({ errors, warnings }, 'BibTeX:')
} catch (e) {
// BibLog parsing errors are ignored
}
}
result.logEntries.all = [
...result.logEntries.errors,
...result.logEntries.warnings,
...result.logEntries.typesetting,
]
return result
}
export function buildLogEntryAnnotations(entries, fileTreeData, rootDocId) {
const rootDocDirname = dirname(fileTreeData, rootDocId)
const logEntryAnnotations = {}
const seenLine = {}
for (const entry of entries) {
if (entry.file) {
entry.file = normalizeFilePath(entry.file, rootDocDirname)
const entity = findEntityByPath(fileTreeData, entry.file)?.entity
if (entity) {
if (!(entity._id in logEntryAnnotations)) {
logEntryAnnotations[entity._id] = []
}
const annotation = {
id: entry.key,
entryIndex: logEntryAnnotations[entity._id].length, // used for maintaining the order of items on the same line
row: entry.line - 1,
type: entry.level === 'error' ? 'error' : 'warning',
text: entry.message,
source: 'compile', // NOTE: this is used in Ace for filtering the annotations
ruleId: entry.ruleId,
command: entry.command,
}
// set firstOnLine for the first non-typesetting annotation on a line
if (entry.level !== 'typesetting') {
if (!seenLine[entry.line]) {
annotation.firstOnLine = true
seenLine[entry.line] = true
}
}
logEntryAnnotations[entity._id].push(annotation)
}
}
}
return logEntryAnnotations
}
export const buildRuleCounts = (entries = []) => {
const counts = {}
for (const entry of entries) {
const key = `${entry.level}_${entry.ruleId}`
counts[key] = counts[key] ? counts[key] + 1 : 1
}
return counts
}
export const buildRuleDeltas = (ruleCounts, previousRuleCounts) => {
const counts = {}
// keys that are defined in the current log entries
for (const [key, value] of Object.entries(ruleCounts)) {
const previousValue = previousRuleCounts[key] ?? 0
counts[`delta_${key}`] = value - previousValue
}
// keys that are no longer defined in the current log entries
for (const [key, value] of Object.entries(previousRuleCounts)) {
if (!(key in ruleCounts)) {
counts[key] = 0
counts[`delta_${key}`] = -value
}
}
return counts
}
function buildURL(file, pdfDownloadDomain) {
if (file.build && pdfDownloadDomain) {
// Downloads from the compiles domain must include a build id.
// The build id is used implicitly for access control.
return `${pdfDownloadDomain}${file.url}`
}
// Go through web instead, which uses mongo for checking project access.
return `${window.origin}${file.url}`
}
function normalizeFilePath(path, rootDocDirname) {
path = path.replace(/\/\//g, '/')
path = path.replace(
/^.*\/compiles\/[0-9a-f]{24}(-[0-9a-f]{24})?\/(\.\/)?/,
''
)
path = path.replace(/^\/compile\//, '')
if (rootDocDirname) {
path = path.replace(/^\.\//, rootDocDirname + '/')
}
return path
}
function isTransientWarning(warning) {
return TRANSIENT_WARNING_REGEX.test(warning.message)
}
async function fetchFileWithSizeLimit(url, signal, maxSize) {
let result = ''
try {
const abortController = new AbortController()
// abort fetching the log file if the main signal is aborted
signal.addEventListener('abort', () => {
abortController.abort()
})
const response = await fetch(url, {
signal: abortController.signal,
})
if (!response.ok) {
throw new Error('Failed to fetch log file')
}
const reader = response.body.pipeThrough(new TextDecoderStream())
for await (const chunk of reader) {
result += chunk
if (result.length > maxSize) {
abortController.abort()
}
}
} catch (e) {
debugConsole.warn(e) // ignore failure to fetch the log file, but log a warning
}
return result
}

View File

@@ -0,0 +1,29 @@
import getMeta from '../../../utils/meta'
import { debugConsole } from '@/utils/debugging'
const hasTextEncoder = typeof TextEncoder !== 'undefined'
if (!hasTextEncoder) {
debugConsole.warn('TextEncoder is not available. Disabling pdf-caching.')
}
const isOpera =
Array.isArray(navigator.userAgentData?.brands) &&
navigator.userAgentData.brands.some(b => b.brand === 'Opera')
if (isOpera) {
debugConsole.warn('Browser cache is limited in Opera. Disabling pdf-caching.')
}
function isFlagEnabled(flag) {
if (!hasTextEncoder) return false
if (isOpera) return false
return getMeta('ol-splitTestVariants')?.[flag] === 'enabled'
}
export const cachedUrlLookupEnabled = isFlagEnabled(
'pdf-caching-cached-url-lookup'
)
export const prefetchingEnabled = isFlagEnabled('pdf-caching-prefetching')
export const prefetchLargeEnabled = isFlagEnabled('pdf-caching-prefetch-large')
export const enablePdfCaching = isFlagEnabled('pdf-caching-mode')
export const trackPdfDownloadEnabled = isFlagEnabled('track-pdf-download')
export const useClsiCache = isFlagEnabled('fall-back-to-clsi-cache')

View File

@@ -0,0 +1,267 @@
import OError from '@overleaf/o-error'
import { fallbackRequest, fetchRange } from './pdf-caching'
import { captureException } from '@/infrastructure/error-reporter'
import { EDITOR_SESSION_ID, getPdfCachingMetrics } from './metrics'
import {
cachedUrlLookupEnabled,
enablePdfCaching,
prefetchingEnabled,
prefetchLargeEnabled,
trackPdfDownloadEnabled,
useClsiCache,
} from './pdf-caching-flags'
import { isNetworkError } from '@/utils/is-network-error'
import { debugConsole } from '@/utils/debugging'
import { PDFJS } from './pdf-js'
// 30 seconds: The shutdown grace period of a clsi pre-emp instance.
const STALE_OUTPUT_REQUEST_THRESHOLD_MS = 30 * 1000
export function generatePdfCachingTransportFactory() {
// NOTE: The custom transport can be used for tracking download volume.
if (!enablePdfCaching && !trackPdfDownloadEnabled) {
return () => undefined
}
const usageScore = new Map()
const cachedUrls = new Map()
const metrics = Object.assign(getPdfCachingMetrics(), {
failedCount: 0,
failedOnce: false,
tooMuchBandwidthCount: 0,
tooManyRequestsCount: 0,
cachedCount: 0,
cachedBytes: 0,
fetchedCount: 0,
fetchedBytes: 0,
latencyComputeMax: 0,
latencyComputeTotal: 0,
requestedCount: 0,
requestedBytes: 0,
oldUrlHitCount: 0,
oldUrlMissCount: 0,
enablePdfCaching,
prefetchingEnabled,
prefetchLargeEnabled,
cachedUrlLookupEnabled,
})
const verifyChunks =
new URLSearchParams(window.location.search).get('verify_chunks') === 'true'
class PDFDataRangeTransport extends PDFJS.PDFDataRangeTransport {
constructor({ url, pdfFile, abortController, handleFetchError }) {
super(pdfFile.size, new Uint8Array())
this.url = url
pdfFile.ranges = pdfFile.ranges || []
pdfFile.editorId = pdfFile.editorId || EDITOR_SESSION_ID
this.pdfFile = pdfFile
// Clone the chunks as the objectId field is encoded to a Uint8Array.
this.leanPdfRanges = pdfFile.ranges.map(r => Object.assign({}, r))
this.handleFetchError = handleFetchError
this.abortController = abortController
this.startTime = performance.now()
const params = new URL(url).searchParams
// drop no needed params
params.delete('enable_pdf_caching')
params.delete('verify_chunks')
this.queryForChunks = params.toString()
}
abort() {
this.abortController.abort()
}
requestDataRange(start, end) {
const abortSignal = this.abortController.signal
const getDebugInfo = () => ({
// Sentry does not serialize objects in twice nested objects.
// Move the ranges to the root level to see them in Sentry.
pdfRanges: this.leanPdfRanges,
pdfFile: Object.assign({}, this.pdfFile, {
ranges: '[extracted]',
// Hide prefetched chunks as these include binary blobs.
prefetched: this.pdfFile.prefetched?.length,
}),
pdfUrl: this.url,
start,
end,
metrics,
})
const isStaleOutputRequest = () =>
performance.now() - this.startTime > STALE_OUTPUT_REQUEST_THRESHOLD_MS
const is404 = err => OError.getFullInfo(err).statusCode === 404
const isFromOutputPDFRequest = err =>
OError.getFullInfo(err).url?.includes?.('/output.pdf') === true
// Do not consider "expected 404s" and network errors as pdf caching
// failures.
// "expected 404s" here include:
// - any stale download request
// Example: The user returns to a browser tab after 1h and scrolls.
// - requests for the main output.pdf file
// A fallback request would not be able to retrieve the PDF either.
const isExpectedError = err =>
(is404(err) || isNetworkError(err)) &&
(isStaleOutputRequest() || isFromOutputPDFRequest(err))
const usesCache = url => {
if (!url) return false
const u = new URL(url)
return (
u.pathname.endsWith(
`build/${this.pdfFile.editorId}-${this.pdfFile.build}/output/output.pdf`
) && u.searchParams.get('clsiserverid') === 'cache'
)
}
const canTryFromCache = err => {
if (!useClsiCache) return false
if (!is404(err)) return false
return !usesCache(OError.getFullInfo(err).url)
}
const getOutputPDFURLFromCache = () => {
if (usesCache(this.url)) return this.url
const u = new URL(this.url)
u.searchParams.set('clsiserverid', 'cache')
u.pathname = u.pathname.replace(
/build\/[a-f0-9-]+\//,
`build/${this.pdfFile.editorId}-${this.pdfFile.build}/`
)
return u.href
}
const fetchFromCache = async () => {
// Try fetching the chunk from clsi-cache
const url = getOutputPDFURLFromCache()
return fallbackRequest({
file: this.pdfFile,
url,
start,
end,
abortSignal,
})
.then(blob => {
// Send the next output.pdf request directly to the cache.
this.url = url
// Only try downloading chunks that were cached previously
this.pdfFile.ranges = this.pdfFile.ranges.filter(r =>
cachedUrls.has(r.hash)
)
return blob
})
.catch(err => {
throw OError.tag(
new PDFJS.MissingPDFException(),
'cache-fallback',
{
statusCode: OError.getFullInfo(err).statusCode,
url: OError.getFullInfo(err).url,
err,
}
)
})
}
fetchRange({
url: this.url,
start,
end,
file: this.pdfFile,
queryForChunks: this.queryForChunks,
metrics,
usageScore,
cachedUrls,
verifyChunks,
prefetchingEnabled,
prefetchLargeEnabled,
cachedUrlLookupEnabled,
abortSignal,
canTryFromCache,
fallbackToCacheURL: getOutputPDFURLFromCache(),
})
.catch(err => {
if (abortSignal.aborted) return
if (canTryFromCache(err)) return fetchFromCache()
if (isExpectedError(err)) {
if (is404(err)) {
// A regular pdf-js request would have seen this 404 as well.
} else {
// Flaky network, switch back to regular pdf-js requests.
metrics.failedCount++
metrics.failedOnce = true
}
throw OError.tag(new PDFJS.MissingPDFException(), 'caching', {
statusCode: OError.getFullInfo(err).statusCode,
url: OError.getFullInfo(err).url,
err,
})
}
metrics.failedCount++
metrics.failedOnce = true
if (!enablePdfCaching) {
throw err // This was a fallback request already. Do not retry.
}
err = OError.tag(err, 'optimized pdf download error', getDebugInfo())
debugConsole.error(err)
captureException(err, {
tags: {
fromPdfCaching: true,
isFromOutputPDFRequest: isFromOutputPDFRequest(err),
},
})
return fallbackRequest({
file: this.pdfFile,
url: this.url,
start,
end,
abortSignal,
}).catch(err => {
if (canTryFromCache(err)) return fetchFromCache()
if (isExpectedError(err)) {
throw OError.tag(new PDFJS.MissingPDFException(), 'fallback', {
statusCode: OError.getFullInfo(err).statusCode,
url: OError.getFullInfo(err).url,
err,
})
}
throw err
})
})
.then(blob => {
if (abortSignal.aborted) return
this.onDataRange(start, blob)
})
.catch(err => {
if (abortSignal.aborted) return
err = OError.tag(err, 'fatal pdf download error', getDebugInfo())
debugConsole.error(err)
if (!(err instanceof PDFJS.MissingPDFException)) {
captureException(err, {
tags: {
fromPdfCaching: true,
isFromOutputPDFRequest: isFromOutputPDFRequest(err),
},
})
}
// Signal error for (subsequent) page load.
this.handleFetchError(err)
})
}
}
return function ({ url, pdfFile, abortController, handleFetchError }) {
if (metrics.failedOnce) {
// Disable pdf caching once any fetch request failed.
// Be trigger-happy here until we reached a stable state of the feature.
return undefined
}
// Latency is collected per preview cycle.
metrics.latencyComputeMax = 0
metrics.latencyComputeTotal = 0
return new PDFDataRangeTransport({
url,
pdfFile,
abortController,
handleFetchError,
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,206 @@
import { captureException } from '@/infrastructure/error-reporter'
import { generatePdfCachingTransportFactory } from './pdf-caching-transport'
import { PDFJS, loadPdfDocumentFromUrl, imageResourcesPath } from './pdf-js'
import {
PDFViewer,
EventBus,
PDFLinkService,
LinkTarget,
} from 'pdfjs-dist/web/pdf_viewer.mjs'
import 'pdfjs-dist/web/pdf_viewer.css'
import browser from '@/features/source-editor/extensions/browser'
const DEFAULT_RANGE_CHUNK_SIZE = 128 * 1024 // 128K chunks
export default class PDFJSWrapper {
public readonly viewer: PDFViewer
public readonly eventBus: EventBus
private readonly linkService: PDFLinkService
private readonly pdfCachingTransportFactory: any
private url?: string
// eslint-disable-next-line no-useless-constructor
constructor(public container: HTMLDivElement) {
// create the event bus
this.eventBus = new EventBus()
// create the link service
this.linkService = new PDFLinkService({
eventBus: this.eventBus,
externalLinkTarget: LinkTarget.BLANK,
externalLinkRel: 'noopener',
})
// create the viewer
this.viewer = new PDFViewer({
container: this.container,
eventBus: this.eventBus,
imageResourcesPath,
linkService: this.linkService,
maxCanvasPixels: browser.safari ? 4096 * 4096 : 8192 * 8192, // default is 4096 * 4096, increased for better resolution at high zoom levels (but not in Safari, which struggles with large canvases)
annotationMode: PDFJS.AnnotationMode.ENABLE, // enable annotations but not forms
annotationEditorMode: PDFJS.AnnotationEditorType.DISABLE, // disable annotation editing
})
this.linkService.setViewer(this.viewer)
this.pdfCachingTransportFactory = generatePdfCachingTransportFactory()
}
// load a document from a URL
async loadDocument({
url,
pdfFile,
abortController,
handleFetchError,
}: {
url: string
pdfFile: Record<string, any>
abortController: AbortController
handleFetchError: (error: Error) => void
}) {
this.url = url
const rangeTransport = this.pdfCachingTransportFactory({
url,
pdfFile,
abortController,
handleFetchError,
})
let rangeChunkSize = DEFAULT_RANGE_CHUNK_SIZE
if (rangeTransport && pdfFile.size < 2 * DEFAULT_RANGE_CHUNK_SIZE) {
// pdf.js disables the "bulk" download optimization when providing a
// custom range transport. Restore it by bumping the chunk size.
rangeChunkSize = pdfFile.size
}
try {
const doc = await loadPdfDocumentFromUrl(url, {
rangeChunkSize,
range: rangeTransport,
}).promise
// check that this is still the current URL
if (url !== this.url) {
return
}
this.viewer.setDocument(doc)
this.linkService.setDocument(doc)
return doc
} catch (error: any) {
if (!error || error.name !== 'MissingPDFException') {
captureException(error, {
tags: { handler: 'pdf-preview' },
})
}
throw error
}
}
async fetchAllData() {
await this.viewer.pdfDocument?.getData()
}
// update the current scale value if the container size changes
updateOnResize() {
if (!this.isVisible()) {
return
}
// Use requestAnimationFrame to prevent errors like "ResizeObserver loop
// completed with undelivered notifications" that can occur if updating the
// viewer causes another repaint. The cost of this is that the viewer update
// lags one frame behind, but it's unlikely to matter.
// Further reading: https://github.com/WICG/resize-observer/issues/38
window.requestAnimationFrame(() => {
const currentScaleValue = this.viewer.currentScaleValue
if (
currentScaleValue === 'auto' ||
currentScaleValue === 'page-fit' ||
currentScaleValue === 'page-height' ||
currentScaleValue === 'page-width'
) {
this.viewer.currentScaleValue = currentScaleValue
}
this.viewer.update()
})
}
// get the page and offset of a click event
clickPosition(event: MouseEvent, canvas: HTMLCanvasElement, page: number) {
if (!canvas) {
return
}
const { viewport } = this.viewer.getPageView(page)
const pageRect = canvas.getBoundingClientRect()
const dx = event.clientX - pageRect.left
const dy = event.clientY - pageRect.top
const [left, top] = viewport.convertToPdfPoint(dx, dy)
return {
page,
offset: {
left,
top: viewport.viewBox[3] - top,
},
}
}
// get the current page, offset and page size
get currentPosition() {
const pageIndex = this.viewer.currentPageNumber - 1
const pageView = this.viewer.getPageView(pageIndex)
const pageRect = pageView.div.getBoundingClientRect()
const containerRect = this.container.getBoundingClientRect()
const dy = containerRect.top - pageRect.top
const dx = containerRect.left - pageRect.left
const [left, top] = pageView.viewport.convertToPdfPoint(dx, dy)
const [, , width, height] = pageView.viewport.viewBox
return {
page: pageIndex,
offset: { top, left },
pageSize: { height, width },
}
}
scrollToPosition(position: Record<string, any>, scale = null) {
const destArray = [
null,
{
name: 'XYZ', // 'XYZ' = scroll to the given coordinates
},
position.offset.left,
position.offset.top,
scale,
]
this.viewer.scrollPageIntoView({
pageNumber: position.page + 1,
destArray,
})
// scroll the page left and down by an extra few pixels to account for the pdf.js viewer page border
const pageIndex = this.viewer.currentPageNumber - 1
const pageView = this.viewer.getPageView(pageIndex)
const offset = parseFloat(getComputedStyle(pageView.div).borderWidth)
this.viewer.container.scrollBy({
top: -offset,
left: -offset,
})
}
isVisible() {
return this.viewer.container.offsetParent !== null
}
}

View File

@@ -0,0 +1,33 @@
import * as PDFJS from 'pdfjs-dist'
import type { DocumentInitParameters } from 'pdfjs-dist/types/src/display/api'
export { PDFJS }
PDFJS.GlobalWorkerOptions.workerPort = new Worker(
/* webpackChunkName: "pdf-worker" */
new URL('pdfjs-dist/build/pdf.worker.mjs', import.meta.url) // NOTE: .mjs extension
)
export const imageResourcesPath = '/images/pdfjs-dist/'
const cMapUrl = '/js/pdfjs-dist/cmaps/'
const standardFontDataUrl = '/fonts/pdfjs-dist/'
const params = new URLSearchParams(window.location.search)
const disableFontFace = params.get('disable-font-face') === 'true'
const disableStream = process.env.NODE_ENV !== 'test'
export const loadPdfDocumentFromUrl = (
url: string,
options: Partial<DocumentInitParameters> = {}
) =>
PDFJS.getDocument({
url,
cMapUrl,
standardFontDataUrl,
disableFontFace,
disableAutoFetch: true, // only fetch the data needed for the displayed pages
disableStream,
isEvalSupported: false,
enableXfa: false, // default is false (2021-10-12), but set explicitly to be sure
...options,
})

View File

@@ -0,0 +1,42 @@
import React from 'react'
import { CompileOutputFile } from '../../../../../types/compile'
export type LogEntry = {
raw: string
level: ErrorLevel
key: string
file?: string
column?: number
line?: number
ruleId?: string
message?: string
content?: string
type?: string
messageComponent?: React.ReactNode
contentDetails?: string[]
}
export type ErrorLevel =
| 'error'
| 'warning'
| 'info'
| 'typesetting'
| 'raw'
| 'success'
export type SourceLocation = {
file?: string
// `line should be either a number or null (i.e. not required), but currently sometimes we get
// an empty string (from BibTeX errors).
line?: number | string | null
column?: number
}
export type PdfFileData = CompileOutputFile
type PdfFileArchiveData = CompileOutputFile & { fileCount: number }
export type PdfFileDataList = {
top: PdfFileData[]
other: PdfFileData[]
archive?: PdfFileArchiveData
}