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)