first commit
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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)
|
@@ -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)
|
@@ -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)
|
@@ -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
|
||||
}
|
@@ -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)
|
@@ -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)
|
@@ -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')} <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)
|
@@ -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)
|
@@ -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)
|
@@ -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)
|
@@ -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
|
@@ -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)
|
@@ -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" />
|
||||
))
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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)
|
@@ -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)
|
@@ -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" />
|
||||
))
|
@@ -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)
|
@@ -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
|
@@ -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)
|
||||
}
|
@@ -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
|
@@ -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')}
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
@@ -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"
|
||||
/>
|
||||
|
||||
{t('tab_connecting')}…
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(PdfPreviewHybridToolbar)
|
@@ -0,0 +1,5 @@
|
||||
import { FC } from 'react'
|
||||
|
||||
export const PdfPreviewMessages: FC = ({ children }) => {
|
||||
return <div className="pdf-preview-messages">{children}</div>
|
||||
}
|
@@ -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)
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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" />
|
||||
))
|
@@ -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> {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> {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)
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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} — {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)
|
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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)
|
@@ -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)
|
@@ -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
|
@@ -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
|
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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)
|
@@ -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
|
@@ -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)
|
@@ -0,0 +1,67 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import useEventListener from '../../../shared/hooks/use-event-listener'
|
||||
import useDetachAction from '../../../shared/hooks/use-detach-action'
|
||||
|
||||
export const startCompileKeypress = event => {
|
||||
if (event.shiftKey || event.altKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (event.ctrlKey) {
|
||||
// Ctrl+s / Ctrl+Enter / Ctrl+.
|
||||
if (event.key === 's' || event.key === 'Enter' || event.key === '.') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Ctrl+s with Caps-Lock on
|
||||
if (event.key === 'S' && !event.shiftKey) {
|
||||
return true
|
||||
}
|
||||
} else if (event.metaKey) {
|
||||
// Cmd+s / Cmd+Enter
|
||||
if (event.key === 's' || event.key === 'Enter') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Cmd+s with Caps-Lock on
|
||||
if (event.key === 'S' && !event.shiftKey) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function useCompileTriggers(startCompile, setChangedAt) {
|
||||
const handleKeyDown = useCallback(
|
||||
event => {
|
||||
if (startCompileKeypress(event)) {
|
||||
event.preventDefault()
|
||||
startCompile()
|
||||
}
|
||||
},
|
||||
[startCompile]
|
||||
)
|
||||
|
||||
const handleStartCompile = useCallback(() => {
|
||||
startCompile()
|
||||
}, [startCompile])
|
||||
useEventListener('pdf:recompile', handleStartCompile)
|
||||
|
||||
useEffect(() => {
|
||||
document.body.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.body.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [handleKeyDown])
|
||||
|
||||
// record doc changes when notified by the editor
|
||||
const setOrTriggerChangedAt = useDetachAction(
|
||||
'set-changed-at',
|
||||
setChangedAt,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const setChangedAtHandler = useCallback(() => {
|
||||
setOrTriggerChangedAt(Date.now())
|
||||
}, [setOrTriggerChangedAt])
|
||||
useEventListener('doc:changed', setChangedAtHandler)
|
||||
}
|
@@ -0,0 +1,63 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||
|
||||
/**
|
||||
* This hook adds an event listener for events dispatched from the editor to the compile logs pane
|
||||
*/
|
||||
export const useLogEvents = (setShowLogs: (show: boolean) => void) => {
|
||||
const { pdfLayout, setView } = useLayoutContext()
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (event: Event) => {
|
||||
const { id, suggestFix } = (
|
||||
event as CustomEvent<{ id: string; suggestFix?: boolean }>
|
||||
).detail
|
||||
|
||||
setShowLogs(true)
|
||||
|
||||
if (pdfLayout === 'flat') {
|
||||
setView('pdf')
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
const element = document.querySelector(
|
||||
`.log-entry[data-log-entry-id="${id}"]`
|
||||
)
|
||||
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
block: 'start',
|
||||
inline: 'nearest',
|
||||
})
|
||||
|
||||
if (suggestFix) {
|
||||
// if they are paywalled, click that instead
|
||||
const paywall = document.querySelector<HTMLButtonElement>(
|
||||
'button[data-action="assistant-paywall-show"]'
|
||||
)
|
||||
|
||||
if (paywall) {
|
||||
paywall.scrollIntoView({
|
||||
block: 'start',
|
||||
inline: 'nearest',
|
||||
})
|
||||
paywall.click()
|
||||
} else {
|
||||
element
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'button[data-action="suggest-fix"]'
|
||||
)
|
||||
?.click()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('editor:view-compile-log-entry', listener)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('editor:view-compile-log-entry', listener)
|
||||
}
|
||||
}, [pdfLayout, setView, setShowLogs])
|
||||
}
|
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import PDFJSWrapper from '../util/pdf-js-wrapper'
|
||||
|
||||
// We need this to work for both a traditional mouse wheel and a touchpad "pinch to zoom".
|
||||
// From experimentation, trackpads tend to fire a lot of events with small deltaY's where
|
||||
// as a mouse wheel will fire fewer events but sometimes with a very high deltaY if you
|
||||
// move the wheel quickly.
|
||||
// The divisor is set to a value that works for the trackpad with the maximum value ensuring
|
||||
// that the scale doesn't suddenly change drastically from moving the mouse wheel quickly.
|
||||
const MAX_SCALE_FACTOR = 1.2
|
||||
const SCALE_FACTOR_DIVISOR = 20
|
||||
|
||||
export default function useMouseWheelZoom(
|
||||
pdfJsWrapper: PDFJSWrapper | null | undefined,
|
||||
setScale: (scale: string) => void
|
||||
) {
|
||||
const isZoomingRef = useRef(false)
|
||||
|
||||
// To avoid accidental pdf when pressing CMD/CTRL when the pdf scroll still has
|
||||
// momentum, we only zoom if CMD/CTRL is pressed before the scroll starts. These refs
|
||||
// keep track of if the pdf is currently scrolling.
|
||||
// https://github.com/overleaf/internal/issues/20772
|
||||
const isScrollingRef = useRef(false)
|
||||
const isScrollingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null
|
||||
)
|
||||
|
||||
const performZoom = useCallback(
|
||||
(event: WheelEvent, pdfJsWrapper: PDFJSWrapper) => {
|
||||
// First, we calculate and set the new scale
|
||||
const scrollMagnitude = Math.abs(event.deltaY)
|
||||
const scaleFactorMagnitude = Math.min(
|
||||
1 + scrollMagnitude / SCALE_FACTOR_DIVISOR,
|
||||
MAX_SCALE_FACTOR
|
||||
)
|
||||
const previousScale = pdfJsWrapper.viewer.currentScale
|
||||
const scaleChangeDirection = Math.sign(event.deltaY)
|
||||
|
||||
const approximateScaleFactor =
|
||||
scaleChangeDirection < 0
|
||||
? scaleFactorMagnitude
|
||||
: 1 / scaleFactorMagnitude
|
||||
|
||||
const newScale =
|
||||
Math.round(previousScale * approximateScaleFactor * 100) / 100
|
||||
const exactScaleFactor = newScale / previousScale
|
||||
|
||||
// Set the scale directly to ensure it is set before we do the scrolling below
|
||||
pdfJsWrapper.viewer.currentScale = newScale
|
||||
setScale(`${newScale}`)
|
||||
|
||||
// Then we need to ensure we are centering the zoom on the mouse position
|
||||
const containerRect = pdfJsWrapper.container.getBoundingClientRect()
|
||||
const top = containerRect.top
|
||||
const left = containerRect.left
|
||||
|
||||
// Positions relative to pdf viewer
|
||||
const currentMouseX = event.clientX - left
|
||||
const currentMouseY = event.clientY - top
|
||||
|
||||
pdfJsWrapper.container.scrollBy({
|
||||
left: currentMouseX * exactScaleFactor - currentMouseX,
|
||||
top: currentMouseY * exactScaleFactor - currentMouseY,
|
||||
behavior: 'instant',
|
||||
})
|
||||
},
|
||||
[setScale]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (pdfJsWrapper) {
|
||||
const wheelListener = (event: WheelEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && !isScrollingRef.current) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!isZoomingRef.current) {
|
||||
isZoomingRef.current = true
|
||||
|
||||
performZoom(event, pdfJsWrapper)
|
||||
|
||||
setTimeout(() => {
|
||||
isZoomingRef.current = false
|
||||
}, 5)
|
||||
}
|
||||
} else {
|
||||
isScrollingRef.current = true
|
||||
if (isScrollingTimeoutRef.current) {
|
||||
clearTimeout(isScrollingTimeoutRef.current)
|
||||
}
|
||||
|
||||
isScrollingTimeoutRef.current = setTimeout(() => {
|
||||
isScrollingRef.current = false
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
pdfJsWrapper.container.addEventListener('wheel', wheelListener)
|
||||
|
||||
return () => {
|
||||
pdfJsWrapper.container.removeEventListener('wheel', wheelListener)
|
||||
}
|
||||
}
|
||||
}, [pdfJsWrapper, setScale, performZoom])
|
||||
}
|
@@ -0,0 +1,176 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import PDFJSWrapper from '../util/pdf-js-wrapper'
|
||||
import { sendMB } from '@/infrastructure/event-tracking'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
|
||||
type StoredPDFState = {
|
||||
scrollMode?: number
|
||||
spreadMode?: number
|
||||
currentScaleValue?: string
|
||||
}
|
||||
|
||||
export default function usePresentationMode(
|
||||
pdfJsWrapper: PDFJSWrapper | null | undefined,
|
||||
page: number | null,
|
||||
handlePageChange: (page: number) => void,
|
||||
scale: string,
|
||||
setScale: (scale: string) => void
|
||||
): () => void {
|
||||
const storedState = useRef<StoredPDFState>({})
|
||||
|
||||
const [presentationMode, setPresentationMode] = useState(false)
|
||||
|
||||
const nextPage = useCallback(() => {
|
||||
if (page !== null) {
|
||||
handlePageChange(page + 1)
|
||||
}
|
||||
}, [handlePageChange, page])
|
||||
|
||||
const previousPage = useCallback(() => {
|
||||
if (page !== null) {
|
||||
handlePageChange(page - 1)
|
||||
}
|
||||
}, [handlePageChange, page])
|
||||
|
||||
const clickListener = useCallback(
|
||||
event => {
|
||||
if (event.target.tagName === 'A') {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.shiftKey) {
|
||||
previousPage()
|
||||
} else {
|
||||
nextPage()
|
||||
}
|
||||
},
|
||||
[nextPage, previousPage]
|
||||
)
|
||||
|
||||
const arrowKeyListener = useCallback(
|
||||
event => {
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowUp':
|
||||
case 'PageUp':
|
||||
case 'Backspace':
|
||||
previousPage()
|
||||
break
|
||||
|
||||
case 'ArrowRight':
|
||||
case 'ArrowDown':
|
||||
case 'PageDown':
|
||||
nextPage()
|
||||
break
|
||||
|
||||
case ' ':
|
||||
if (event.shiftKey) {
|
||||
previousPage()
|
||||
} else {
|
||||
nextPage()
|
||||
}
|
||||
break
|
||||
}
|
||||
},
|
||||
[nextPage, previousPage]
|
||||
)
|
||||
|
||||
const isMouseWheelScrollingRef = useRef(false)
|
||||
|
||||
const mouseWheelListener = useCallback(
|
||||
(event: WheelEvent) => {
|
||||
if (
|
||||
!isMouseWheelScrollingRef.current &&
|
||||
!event.ctrlKey // Avoid trackpad pinching
|
||||
) {
|
||||
isMouseWheelScrollingRef.current = true
|
||||
|
||||
if (event.deltaY > 0) {
|
||||
nextPage()
|
||||
} else {
|
||||
previousPage()
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
isMouseWheelScrollingRef.current = false
|
||||
}, 200)
|
||||
}
|
||||
},
|
||||
[nextPage, previousPage]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (presentationMode) {
|
||||
window.addEventListener('keydown', arrowKeyListener)
|
||||
window.addEventListener('click', clickListener)
|
||||
window.addEventListener('wheel', mouseWheelListener)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', arrowKeyListener)
|
||||
window.removeEventListener('click', clickListener)
|
||||
window.removeEventListener('wheel', mouseWheelListener)
|
||||
}
|
||||
}
|
||||
}, [presentationMode, arrowKeyListener, clickListener, mouseWheelListener])
|
||||
|
||||
const requestPresentationMode = useCallback(() => {
|
||||
sendMB('pdf-viewer-enter-presentation-mode')
|
||||
|
||||
if (pdfJsWrapper) {
|
||||
pdfJsWrapper.container.parentElement
|
||||
?.requestFullscreen()
|
||||
.catch(debugConsole.error)
|
||||
}
|
||||
}, [pdfJsWrapper])
|
||||
|
||||
const handleEnterFullscreen = useCallback(() => {
|
||||
if (pdfJsWrapper) {
|
||||
storedState.current.scrollMode = pdfJsWrapper.viewer.scrollMode
|
||||
storedState.current.spreadMode = pdfJsWrapper.viewer.spreadMode
|
||||
storedState.current.currentScaleValue = scale
|
||||
|
||||
setScale('page-fit')
|
||||
pdfJsWrapper.viewer.scrollMode = 3 // page
|
||||
pdfJsWrapper.viewer.spreadMode = 0 // none
|
||||
|
||||
pdfJsWrapper.fetchAllData()
|
||||
|
||||
setPresentationMode(true)
|
||||
}
|
||||
}, [pdfJsWrapper, setScale, scale])
|
||||
|
||||
const handleExitFullscreen = useCallback(() => {
|
||||
if (pdfJsWrapper) {
|
||||
pdfJsWrapper.viewer.scrollMode = storedState.current.scrollMode!
|
||||
pdfJsWrapper.viewer.spreadMode = storedState.current.spreadMode!
|
||||
|
||||
if (storedState.current.currentScaleValue !== undefined) {
|
||||
setScale(storedState.current.currentScaleValue)
|
||||
}
|
||||
|
||||
setPresentationMode(false)
|
||||
}
|
||||
}, [pdfJsWrapper, setScale])
|
||||
|
||||
const handleFullscreenChange = useCallback(() => {
|
||||
if (pdfJsWrapper) {
|
||||
const fullscreen =
|
||||
document.fullscreenElement === pdfJsWrapper.container.parentNode
|
||||
|
||||
if (fullscreen) {
|
||||
handleEnterFullscreen()
|
||||
} else {
|
||||
handleExitFullscreen()
|
||||
}
|
||||
}
|
||||
}, [pdfJsWrapper, handleEnterFullscreen, handleExitFullscreen])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||
return () => {
|
||||
window.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||
}
|
||||
}, [handleFullscreenChange])
|
||||
|
||||
return requestPresentationMode
|
||||
}
|
233
services/web/frontend/js/features/pdf-preview/util/compiler.js
Normal file
233
services/web/frontend/js/features/pdf-preview/util/compiler.js
Normal file
@@ -0,0 +1,233 @@
|
||||
import { isMainFile } from './editor-files'
|
||||
import getMeta from '../../../utils/meta'
|
||||
import { deleteJSON, postJSON } from '../../../infrastructure/fetch-json'
|
||||
import { debounce } from 'lodash'
|
||||
import { EDITOR_SESSION_ID, trackPdfDownload } from './metrics'
|
||||
import { enablePdfCaching } from './pdf-caching-flags'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { signalWithTimeout } from '@/utils/abort-signal'
|
||||
|
||||
const AUTO_COMPILE_MAX_WAIT = 5000
|
||||
// We add a 2 second debounce to sending user changes to server if they aren't
|
||||
// collaborating with anyone. This needs to be higher than SINGLE_USER_FLUSH_DELAY, and allow for
|
||||
// client to server latency, otherwise we compile before the op reaches the server
|
||||
// and then again on ack.
|
||||
const AUTO_COMPILE_DEBOUNCE = 2500
|
||||
|
||||
// If there is a pending op, wait for it to be saved before compiling
|
||||
const PENDING_OP_MAX_WAIT = 10000
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
|
||||
export default class DocumentCompiler {
|
||||
constructor({
|
||||
compilingRef,
|
||||
projectId,
|
||||
setChangedAt,
|
||||
setCompiling,
|
||||
setData,
|
||||
setFirstRenderDone,
|
||||
setDeliveryLatencies,
|
||||
setError,
|
||||
cleanupCompileResult,
|
||||
signal,
|
||||
openDocs,
|
||||
}) {
|
||||
this.compilingRef = compilingRef
|
||||
this.projectId = projectId
|
||||
this.setChangedAt = setChangedAt
|
||||
this.setCompiling = setCompiling
|
||||
this.setData = setData
|
||||
this.setFirstRenderDone = setFirstRenderDone
|
||||
this.setDeliveryLatencies = setDeliveryLatencies
|
||||
this.setError = setError
|
||||
this.cleanupCompileResult = cleanupCompileResult
|
||||
this.signal = signal
|
||||
this.openDocs = openDocs
|
||||
|
||||
this.projectRootDocId = null
|
||||
this.clsiServerId = null
|
||||
this.currentDoc = null
|
||||
this.error = undefined
|
||||
this.timer = 0
|
||||
this.defaultOptions = {
|
||||
draft: false,
|
||||
stopOnFirstError: false,
|
||||
}
|
||||
|
||||
this.debouncedAutoCompile = debounce(
|
||||
() => {
|
||||
this.compile({ isAutoCompileOnChange: true })
|
||||
},
|
||||
AUTO_COMPILE_DEBOUNCE,
|
||||
{
|
||||
maxWait: AUTO_COMPILE_MAX_WAIT,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// The main "compile" function.
|
||||
// Call this directly to run a compile now, otherwise call debouncedAutoCompile.
|
||||
async compile(options = {}) {
|
||||
options = { ...this.defaultOptions, ...options }
|
||||
|
||||
if (options.isAutoCompileOnLoad && getMeta('ol-preventCompileOnLoad')) {
|
||||
return
|
||||
}
|
||||
|
||||
// set "compiling" to true (in the React component's state), and return if it was already true
|
||||
const wasCompiling = this.compilingRef.current
|
||||
this.setCompiling(true)
|
||||
|
||||
if (wasCompiling) {
|
||||
if (options.isAutoCompileOnChange) {
|
||||
this.debouncedAutoCompile()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await this.openDocs.awaitBufferedOps(
|
||||
signalWithTimeout(this.signal, PENDING_OP_MAX_WAIT)
|
||||
)
|
||||
|
||||
// reset values
|
||||
this.setChangedAt(0) // TODO: wait for doc:saved?
|
||||
this.validationIssues = undefined
|
||||
|
||||
const params = this.buildCompileParams(options)
|
||||
|
||||
const t0 = performance.now()
|
||||
|
||||
const rootDocId = this.getRootDocOverrideId()
|
||||
|
||||
const body = {
|
||||
rootDoc_id: rootDocId,
|
||||
draft: options.draft,
|
||||
check: 'silent', // NOTE: 'error' and 'validate' are possible, but unused
|
||||
// use incremental compile for all users but revert to a full compile
|
||||
// if there was previously a server error
|
||||
incrementalCompilesEnabled: !this.error,
|
||||
stopOnFirstError: options.stopOnFirstError,
|
||||
editorId: EDITOR_SESSION_ID,
|
||||
}
|
||||
|
||||
const data = await postJSON(
|
||||
`/project/${this.projectId}/compile?${params}`,
|
||||
{ body, signal: this.signal }
|
||||
)
|
||||
|
||||
const compileTimeClientE2E = Math.ceil(performance.now() - t0)
|
||||
const { deliveryLatencies, firstRenderDone } = trackPdfDownload(
|
||||
data,
|
||||
compileTimeClientE2E,
|
||||
t0
|
||||
)
|
||||
this.setDeliveryLatencies(() => deliveryLatencies)
|
||||
this.setFirstRenderDone(() => firstRenderDone)
|
||||
|
||||
// unset the error before it's set again later, so that components are recreated and events are tracked
|
||||
this.setError(undefined)
|
||||
|
||||
data.options = options
|
||||
data.rootDocId = rootDocId
|
||||
if (data.clsiServerId) {
|
||||
this.clsiServerId = data.clsiServerId
|
||||
}
|
||||
this.setData(data)
|
||||
} catch (error) {
|
||||
debugConsole.error(error)
|
||||
this.cleanupCompileResult()
|
||||
this.setError(error.info?.statusCode === 429 ? 'rate-limited' : 'error')
|
||||
} finally {
|
||||
this.setCompiling(false)
|
||||
}
|
||||
}
|
||||
|
||||
// parse the text of the current doc in the editor
|
||||
// if it contains "\documentclass" then use this as the root doc
|
||||
getRootDocOverrideId() {
|
||||
// only override when not in the root doc itself
|
||||
if (this.currentDoc && this.currentDoc.doc_id !== this.projectRootDocId) {
|
||||
const snapshot = this.currentDoc.getSnapshot()
|
||||
|
||||
if (snapshot && isMainFile(snapshot)) {
|
||||
return this.currentDoc.doc_id
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// build the query parameters added to post-compile requests
|
||||
buildPostCompileParams() {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
// the id of the CLSI server that processed the previous compile request
|
||||
if (this.clsiServerId) {
|
||||
params.set('clsiserverid', this.clsiServerId)
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// build the query parameters for the compile request
|
||||
buildCompileParams(options) {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
// note: no clsiserverid query param is set on "compile" requests,
|
||||
// as this is added in the backend by the web api
|
||||
|
||||
// tell the server whether this is an automatic or manual compile request
|
||||
if (options.isAutoCompileOnLoad || options.isAutoCompileOnChange) {
|
||||
params.set('auto_compile', 'true')
|
||||
}
|
||||
|
||||
// use the feature flag to enable PDF caching
|
||||
if (enablePdfCaching) {
|
||||
params.set('enable_pdf_caching', 'true')
|
||||
}
|
||||
|
||||
// use the feature flag to enable "file line errors"
|
||||
if (searchParams.get('file_line_errors') === 'true') {
|
||||
params.file_line_errors = 'true'
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// send a request to stop the current compile
|
||||
stopCompile() {
|
||||
// NOTE: no stoppingCompile state, as this should happen fairly quickly
|
||||
// and doesn't matter if it runs twice.
|
||||
|
||||
const params = this.buildPostCompileParams()
|
||||
|
||||
return postJSON(`/project/${this.projectId}/compile/stop?${params}`, {
|
||||
signal: this.signal,
|
||||
})
|
||||
.catch(error => {
|
||||
debugConsole.error(error)
|
||||
this.setError('error')
|
||||
})
|
||||
.finally(() => {
|
||||
this.setCompiling(false)
|
||||
})
|
||||
}
|
||||
|
||||
// send a request to clear the cache
|
||||
clearCache() {
|
||||
const params = this.buildPostCompileParams()
|
||||
|
||||
return deleteJSON(`/project/${this.projectId}/output?${params}`, {
|
||||
signal: this.signal,
|
||||
}).catch(error => {
|
||||
debugConsole.error(error)
|
||||
this.setError('clear-cache')
|
||||
})
|
||||
}
|
||||
|
||||
setOption(option, value) {
|
||||
this.defaultOptions[option] = value
|
||||
}
|
||||
}
|
@@ -0,0 +1,4 @@
|
||||
const documentClassRe = /^[^%]*\\documentclass/
|
||||
|
||||
export const isMainFile = doc =>
|
||||
doc.split('\n').some(line => documentClassRe.test(line))
|
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
CompileOutputFile,
|
||||
CompileResponseData,
|
||||
} from '../../../../../types/compile'
|
||||
import { PdfFileDataList } from '@/features/pdf-preview/util/types'
|
||||
|
||||
const topFileTypes = ['bbl', 'gls', 'ind']
|
||||
// NOTE: Updating this list requires a corresponding change in
|
||||
// * services/clsi/app/js/OutputFileArchiveManager.js
|
||||
const ignoreFiles = ['output.fls', 'output.fdb_latexmk']
|
||||
|
||||
export function buildFileList(
|
||||
outputFiles: Map<string, CompileOutputFile>,
|
||||
{
|
||||
clsiServerId,
|
||||
compileGroup,
|
||||
outputFilesArchive,
|
||||
fromCache = false,
|
||||
}: CompileResponseData
|
||||
): PdfFileDataList {
|
||||
const files: PdfFileDataList = { top: [], other: [] }
|
||||
|
||||
if (outputFiles) {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (fromCache) {
|
||||
params.set('clsiserverid', 'cache')
|
||||
} else if (clsiServerId) {
|
||||
params.set('clsiserverid', clsiServerId)
|
||||
}
|
||||
if (compileGroup) {
|
||||
params.set('compileGroup', compileGroup)
|
||||
}
|
||||
|
||||
const queryString = params.toString()
|
||||
|
||||
const allFiles = []
|
||||
|
||||
// filter out ignored files and set some properties
|
||||
for (const file of outputFiles.values()) {
|
||||
if (!ignoreFiles.includes(file.path)) {
|
||||
file.main = file.path.startsWith('output.')
|
||||
|
||||
if (queryString.length) {
|
||||
file.url += `?${queryString}`
|
||||
}
|
||||
|
||||
allFiles.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
// sort main files first, then alphabetical
|
||||
allFiles.sort((a, b) => {
|
||||
if (a.main && !b.main) {
|
||||
return -1
|
||||
}
|
||||
|
||||
if (b.main && !a.main) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return a.path.localeCompare(b.path, undefined, { numeric: true })
|
||||
})
|
||||
|
||||
// group files into "top" and "other"
|
||||
for (const file of allFiles) {
|
||||
if (topFileTypes.includes(file.type)) {
|
||||
files.top.push(file)
|
||||
} else if (!(file.type === 'pdf' && file.main === true)) {
|
||||
files.other.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
const archivableFiles = [...files.top, ...files.other]
|
||||
|
||||
if (outputFilesArchive && archivableFiles.length > 0) {
|
||||
archivableFiles.forEach(file => params.append('files', file.path))
|
||||
|
||||
files.archive = {
|
||||
...outputFilesArchive,
|
||||
fileCount: archivableFiles.length,
|
||||
url: `${outputFilesArchive.url}?${params.toString()}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
@@ -0,0 +1,35 @@
|
||||
import { PDFJS } from '@/features/pdf-preview/util/pdf-js'
|
||||
|
||||
export function buildHighlightElement(highlight, viewer) {
|
||||
const pageView = viewer.getPageView(highlight.page - 1)
|
||||
|
||||
const viewport = pageView.viewport
|
||||
|
||||
const height = viewport.viewBox[3]
|
||||
|
||||
const rect = viewport.convertToViewportRectangle([
|
||||
highlight.h, // xMin
|
||||
height - (highlight.v + highlight.height) + 10, // yMin
|
||||
highlight.h + highlight.width, // xMax
|
||||
height - highlight.v + 10, // yMax
|
||||
])
|
||||
|
||||
const [left, top, right, bottom] = PDFJS.Util.normalizeRect(rect)
|
||||
|
||||
const element = document.createElement('div')
|
||||
element.style.left = Math.floor(pageView.div.offsetLeft + left) + 'px'
|
||||
element.style.top = Math.floor(pageView.div.offsetTop + top) + 'px'
|
||||
element.style.width = Math.ceil(right - left) + 'px'
|
||||
element.style.height = Math.ceil(bottom - top) + 'px'
|
||||
element.style.backgroundColor = 'rgba(255,255,0)'
|
||||
element.style.position = 'absolute'
|
||||
element.style.display = 'inline-block'
|
||||
element.style.scrollMargin = '72px'
|
||||
element.style.pointerEvents = 'none'
|
||||
element.style.opacity = '0'
|
||||
element.style.transition = 'opacity 1s'
|
||||
|
||||
viewer.viewer?.append(element)
|
||||
|
||||
return element
|
||||
}
|
@@ -0,0 +1,66 @@
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { sendMB } from '../../../infrastructure/event-tracking'
|
||||
import { trackPdfDownloadEnabled } from './pdf-caching-flags'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
|
||||
// VERSION should get incremented when making changes to caching behavior or
|
||||
// adjusting metrics collection.
|
||||
const VERSION = 9
|
||||
|
||||
// editing session id
|
||||
export const EDITOR_SESSION_ID = uuid()
|
||||
|
||||
const pdfCachingMetrics = {
|
||||
viewerId: EDITOR_SESSION_ID,
|
||||
}
|
||||
|
||||
export function getPdfCachingMetrics() {
|
||||
return pdfCachingMetrics
|
||||
}
|
||||
|
||||
export function trackPdfDownload(response, compileTimeClientE2E, t0) {
|
||||
const { timings, pdfCachingMinChunkSize } = response
|
||||
|
||||
const deliveryLatencies = {
|
||||
compileTimeClientE2E,
|
||||
compileTimeServerE2E: timings?.compileE2E,
|
||||
}
|
||||
|
||||
// There can be multiple "first" renderings with two pdf viewers.
|
||||
// E.g. two pdf detach tabs or pdf detacher plus pdf detach.
|
||||
// Let the pdfCachingMetrics round trip to account for pdf-detach.
|
||||
let isFirstRender = true
|
||||
function firstRenderDone({ latencyFetch, latencyRender, pdfCachingMetrics }) {
|
||||
if (!isFirstRender) return
|
||||
isFirstRender = false
|
||||
|
||||
deliveryLatencies.totalDeliveryTime = Math.ceil(performance.now() - t0)
|
||||
deliveryLatencies.latencyFetch = latencyFetch
|
||||
if (latencyRender) {
|
||||
deliveryLatencies.latencyRender = latencyRender
|
||||
}
|
||||
if (trackPdfDownloadEnabled) {
|
||||
// Submit latency along with compile context.
|
||||
submitCompileMetrics({
|
||||
pdfCachingMinChunkSize,
|
||||
...deliveryLatencies,
|
||||
...pdfCachingMetrics,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
deliveryLatencies,
|
||||
firstRenderDone,
|
||||
}
|
||||
}
|
||||
|
||||
function submitCompileMetrics(metrics) {
|
||||
const leanMetrics = {
|
||||
version: VERSION,
|
||||
...metrics,
|
||||
id: EDITOR_SESSION_ID,
|
||||
}
|
||||
debugConsole.log('/event/compile-metrics', JSON.stringify(leanMetrics))
|
||||
sendMB('compile-metrics-v6', leanMetrics)
|
||||
}
|
@@ -0,0 +1,276 @@
|
||||
import HumanReadableLogs from '../../../ide/human-readable-logs/HumanReadableLogs'
|
||||
import BibLogParser from '../../../ide/log-parser/bib-log-parser'
|
||||
import { enablePdfCaching } from './pdf-caching-flags'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { dirname, findEntityByPath } from '@/features/file-tree/util/path'
|
||||
import '@/utils/readable-stream-async-iterator-polyfill'
|
||||
import { EDITOR_SESSION_ID } from '@/features/pdf-preview/util/metrics'
|
||||
|
||||
// Warnings that may disappear after a second LaTeX pass
|
||||
const TRANSIENT_WARNING_REGEX = /^(Reference|Citation).+undefined on input line/
|
||||
|
||||
const MAX_LOG_SIZE = 1024 * 1024 // 1MB
|
||||
const MAX_BIB_LOG_SIZE_PER_FILE = MAX_LOG_SIZE
|
||||
|
||||
export function handleOutputFiles(outputFiles, projectId, data) {
|
||||
const outputFile = outputFiles.get('output.pdf')
|
||||
if (!outputFile) return null
|
||||
|
||||
outputFile.editorId = outputFile.editorId || EDITOR_SESSION_ID
|
||||
|
||||
// build the URL for viewing the PDF in the preview UI
|
||||
const params = new URLSearchParams()
|
||||
if (data.compileGroup) {
|
||||
params.set('compileGroup', data.compileGroup)
|
||||
}
|
||||
|
||||
if (data.clsiServerId) {
|
||||
params.set('clsiserverid', data.clsiServerId)
|
||||
}
|
||||
|
||||
if (enablePdfCaching) {
|
||||
// Tag traffic that uses the pdf caching logic.
|
||||
params.set('enable_pdf_caching', 'true')
|
||||
}
|
||||
|
||||
outputFile.pdfUrl = `${buildURL(
|
||||
outputFile,
|
||||
data.pdfDownloadDomain
|
||||
)}?${params}`
|
||||
|
||||
if (data.fromCache) {
|
||||
outputFile.pdfDownloadUrl = outputFile.downloadURL
|
||||
} else {
|
||||
// build the URL for downloading the PDF
|
||||
params.set('popupDownload', 'true') // save PDF download as file
|
||||
|
||||
outputFile.pdfDownloadUrl = `/download/project/${projectId}/build/${outputFile.build}/output/output.pdf?${params}`
|
||||
}
|
||||
|
||||
return outputFile
|
||||
}
|
||||
|
||||
let nextEntryId = 1
|
||||
|
||||
function generateEntryKey() {
|
||||
return 'compile-log-entry-' + nextEntryId++
|
||||
}
|
||||
|
||||
export const handleLogFiles = async (outputFiles, data, signal) => {
|
||||
const result = {
|
||||
log: null,
|
||||
logEntries: {
|
||||
errors: [],
|
||||
warnings: [],
|
||||
typesetting: [],
|
||||
},
|
||||
}
|
||||
|
||||
function accumulateResults(newEntries, type) {
|
||||
for (const key in result.logEntries) {
|
||||
if (newEntries[key]) {
|
||||
for (const entry of newEntries[key]) {
|
||||
if (type) {
|
||||
entry.type = newEntries.type
|
||||
}
|
||||
if (entry.file) {
|
||||
entry.file = normalizeFilePath(entry.file)
|
||||
}
|
||||
entry.key = generateEntryKey()
|
||||
}
|
||||
result.logEntries[key].push(...newEntries[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const logFile = outputFiles.get('output.log')
|
||||
|
||||
if (logFile) {
|
||||
result.log = await fetchFileWithSizeLimit(
|
||||
buildURL(logFile, data.pdfDownloadDomain),
|
||||
signal,
|
||||
MAX_LOG_SIZE
|
||||
)
|
||||
try {
|
||||
let { errors, warnings, typesetting } = HumanReadableLogs.parse(
|
||||
result.log,
|
||||
{
|
||||
ignoreDuplicates: true,
|
||||
}
|
||||
)
|
||||
|
||||
if (data.status === 'stopped-on-first-error') {
|
||||
// Hide warnings that could disappear after a second pass
|
||||
warnings = warnings.filter(warning => !isTransientWarning(warning))
|
||||
}
|
||||
|
||||
accumulateResults({ errors, warnings, typesetting })
|
||||
} catch (e) {
|
||||
debugConsole.warn(e) // ignore failure to parse the log file, but log a warning
|
||||
}
|
||||
}
|
||||
|
||||
const blgFiles = []
|
||||
|
||||
for (const [filename, file] of outputFiles) {
|
||||
if (filename.endsWith('.blg')) {
|
||||
blgFiles.push(file)
|
||||
}
|
||||
}
|
||||
for (const blgFile of blgFiles) {
|
||||
const log = await fetchFileWithSizeLimit(
|
||||
buildURL(blgFile, data.pdfDownloadDomain),
|
||||
signal,
|
||||
MAX_BIB_LOG_SIZE_PER_FILE
|
||||
)
|
||||
try {
|
||||
const { errors, warnings } = new BibLogParser(log, {
|
||||
maxErrors: 100,
|
||||
}).parse()
|
||||
accumulateResults({ errors, warnings }, 'BibTeX:')
|
||||
} catch (e) {
|
||||
// BibLog parsing errors are ignored
|
||||
}
|
||||
}
|
||||
|
||||
result.logEntries.all = [
|
||||
...result.logEntries.errors,
|
||||
...result.logEntries.warnings,
|
||||
...result.logEntries.typesetting,
|
||||
]
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function buildLogEntryAnnotations(entries, fileTreeData, rootDocId) {
|
||||
const rootDocDirname = dirname(fileTreeData, rootDocId)
|
||||
|
||||
const logEntryAnnotations = {}
|
||||
const seenLine = {}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.file) {
|
||||
entry.file = normalizeFilePath(entry.file, rootDocDirname)
|
||||
|
||||
const entity = findEntityByPath(fileTreeData, entry.file)?.entity
|
||||
|
||||
if (entity) {
|
||||
if (!(entity._id in logEntryAnnotations)) {
|
||||
logEntryAnnotations[entity._id] = []
|
||||
}
|
||||
|
||||
const annotation = {
|
||||
id: entry.key,
|
||||
entryIndex: logEntryAnnotations[entity._id].length, // used for maintaining the order of items on the same line
|
||||
row: entry.line - 1,
|
||||
type: entry.level === 'error' ? 'error' : 'warning',
|
||||
text: entry.message,
|
||||
source: 'compile', // NOTE: this is used in Ace for filtering the annotations
|
||||
ruleId: entry.ruleId,
|
||||
command: entry.command,
|
||||
}
|
||||
|
||||
// set firstOnLine for the first non-typesetting annotation on a line
|
||||
if (entry.level !== 'typesetting') {
|
||||
if (!seenLine[entry.line]) {
|
||||
annotation.firstOnLine = true
|
||||
seenLine[entry.line] = true
|
||||
}
|
||||
}
|
||||
|
||||
logEntryAnnotations[entity._id].push(annotation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return logEntryAnnotations
|
||||
}
|
||||
|
||||
export const buildRuleCounts = (entries = []) => {
|
||||
const counts = {}
|
||||
for (const entry of entries) {
|
||||
const key = `${entry.level}_${entry.ruleId}`
|
||||
counts[key] = counts[key] ? counts[key] + 1 : 1
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
export const buildRuleDeltas = (ruleCounts, previousRuleCounts) => {
|
||||
const counts = {}
|
||||
|
||||
// keys that are defined in the current log entries
|
||||
for (const [key, value] of Object.entries(ruleCounts)) {
|
||||
const previousValue = previousRuleCounts[key] ?? 0
|
||||
counts[`delta_${key}`] = value - previousValue
|
||||
}
|
||||
|
||||
// keys that are no longer defined in the current log entries
|
||||
for (const [key, value] of Object.entries(previousRuleCounts)) {
|
||||
if (!(key in ruleCounts)) {
|
||||
counts[key] = 0
|
||||
counts[`delta_${key}`] = -value
|
||||
}
|
||||
}
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
function buildURL(file, pdfDownloadDomain) {
|
||||
if (file.build && pdfDownloadDomain) {
|
||||
// Downloads from the compiles domain must include a build id.
|
||||
// The build id is used implicitly for access control.
|
||||
return `${pdfDownloadDomain}${file.url}`
|
||||
}
|
||||
// Go through web instead, which uses mongo for checking project access.
|
||||
return `${window.origin}${file.url}`
|
||||
}
|
||||
|
||||
function normalizeFilePath(path, rootDocDirname) {
|
||||
path = path.replace(/\/\//g, '/')
|
||||
path = path.replace(
|
||||
/^.*\/compiles\/[0-9a-f]{24}(-[0-9a-f]{24})?\/(\.\/)?/,
|
||||
''
|
||||
)
|
||||
|
||||
path = path.replace(/^\/compile\//, '')
|
||||
|
||||
if (rootDocDirname) {
|
||||
path = path.replace(/^\.\//, rootDocDirname + '/')
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
function isTransientWarning(warning) {
|
||||
return TRANSIENT_WARNING_REGEX.test(warning.message)
|
||||
}
|
||||
|
||||
async function fetchFileWithSizeLimit(url, signal, maxSize) {
|
||||
let result = ''
|
||||
try {
|
||||
const abortController = new AbortController()
|
||||
// abort fetching the log file if the main signal is aborted
|
||||
signal.addEventListener('abort', () => {
|
||||
abortController.abort()
|
||||
})
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch log file')
|
||||
}
|
||||
|
||||
const reader = response.body.pipeThrough(new TextDecoderStream())
|
||||
for await (const chunk of reader) {
|
||||
result += chunk
|
||||
if (result.length > maxSize) {
|
||||
abortController.abort()
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugConsole.warn(e) // ignore failure to fetch the log file, but log a warning
|
||||
}
|
||||
return result
|
||||
}
|
@@ -0,0 +1,29 @@
|
||||
import getMeta from '../../../utils/meta'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
|
||||
const hasTextEncoder = typeof TextEncoder !== 'undefined'
|
||||
if (!hasTextEncoder) {
|
||||
debugConsole.warn('TextEncoder is not available. Disabling pdf-caching.')
|
||||
}
|
||||
|
||||
const isOpera =
|
||||
Array.isArray(navigator.userAgentData?.brands) &&
|
||||
navigator.userAgentData.brands.some(b => b.brand === 'Opera')
|
||||
if (isOpera) {
|
||||
debugConsole.warn('Browser cache is limited in Opera. Disabling pdf-caching.')
|
||||
}
|
||||
|
||||
function isFlagEnabled(flag) {
|
||||
if (!hasTextEncoder) return false
|
||||
if (isOpera) return false
|
||||
return getMeta('ol-splitTestVariants')?.[flag] === 'enabled'
|
||||
}
|
||||
|
||||
export const cachedUrlLookupEnabled = isFlagEnabled(
|
||||
'pdf-caching-cached-url-lookup'
|
||||
)
|
||||
export const prefetchingEnabled = isFlagEnabled('pdf-caching-prefetching')
|
||||
export const prefetchLargeEnabled = isFlagEnabled('pdf-caching-prefetch-large')
|
||||
export const enablePdfCaching = isFlagEnabled('pdf-caching-mode')
|
||||
export const trackPdfDownloadEnabled = isFlagEnabled('track-pdf-download')
|
||||
export const useClsiCache = isFlagEnabled('fall-back-to-clsi-cache')
|
@@ -0,0 +1,267 @@
|
||||
import OError from '@overleaf/o-error'
|
||||
import { fallbackRequest, fetchRange } from './pdf-caching'
|
||||
import { captureException } from '@/infrastructure/error-reporter'
|
||||
import { EDITOR_SESSION_ID, getPdfCachingMetrics } from './metrics'
|
||||
import {
|
||||
cachedUrlLookupEnabled,
|
||||
enablePdfCaching,
|
||||
prefetchingEnabled,
|
||||
prefetchLargeEnabled,
|
||||
trackPdfDownloadEnabled,
|
||||
useClsiCache,
|
||||
} from './pdf-caching-flags'
|
||||
import { isNetworkError } from '@/utils/is-network-error'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { PDFJS } from './pdf-js'
|
||||
|
||||
// 30 seconds: The shutdown grace period of a clsi pre-emp instance.
|
||||
const STALE_OUTPUT_REQUEST_THRESHOLD_MS = 30 * 1000
|
||||
|
||||
export function generatePdfCachingTransportFactory() {
|
||||
// NOTE: The custom transport can be used for tracking download volume.
|
||||
if (!enablePdfCaching && !trackPdfDownloadEnabled) {
|
||||
return () => undefined
|
||||
}
|
||||
const usageScore = new Map()
|
||||
const cachedUrls = new Map()
|
||||
const metrics = Object.assign(getPdfCachingMetrics(), {
|
||||
failedCount: 0,
|
||||
failedOnce: false,
|
||||
tooMuchBandwidthCount: 0,
|
||||
tooManyRequestsCount: 0,
|
||||
cachedCount: 0,
|
||||
cachedBytes: 0,
|
||||
fetchedCount: 0,
|
||||
fetchedBytes: 0,
|
||||
latencyComputeMax: 0,
|
||||
latencyComputeTotal: 0,
|
||||
requestedCount: 0,
|
||||
requestedBytes: 0,
|
||||
oldUrlHitCount: 0,
|
||||
oldUrlMissCount: 0,
|
||||
enablePdfCaching,
|
||||
prefetchingEnabled,
|
||||
prefetchLargeEnabled,
|
||||
cachedUrlLookupEnabled,
|
||||
})
|
||||
const verifyChunks =
|
||||
new URLSearchParams(window.location.search).get('verify_chunks') === 'true'
|
||||
|
||||
class PDFDataRangeTransport extends PDFJS.PDFDataRangeTransport {
|
||||
constructor({ url, pdfFile, abortController, handleFetchError }) {
|
||||
super(pdfFile.size, new Uint8Array())
|
||||
this.url = url
|
||||
pdfFile.ranges = pdfFile.ranges || []
|
||||
pdfFile.editorId = pdfFile.editorId || EDITOR_SESSION_ID
|
||||
this.pdfFile = pdfFile
|
||||
// Clone the chunks as the objectId field is encoded to a Uint8Array.
|
||||
this.leanPdfRanges = pdfFile.ranges.map(r => Object.assign({}, r))
|
||||
this.handleFetchError = handleFetchError
|
||||
this.abortController = abortController
|
||||
this.startTime = performance.now()
|
||||
|
||||
const params = new URL(url).searchParams
|
||||
// drop no needed params
|
||||
params.delete('enable_pdf_caching')
|
||||
params.delete('verify_chunks')
|
||||
this.queryForChunks = params.toString()
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.abortController.abort()
|
||||
}
|
||||
|
||||
requestDataRange(start, end) {
|
||||
const abortSignal = this.abortController.signal
|
||||
const getDebugInfo = () => ({
|
||||
// Sentry does not serialize objects in twice nested objects.
|
||||
// Move the ranges to the root level to see them in Sentry.
|
||||
pdfRanges: this.leanPdfRanges,
|
||||
pdfFile: Object.assign({}, this.pdfFile, {
|
||||
ranges: '[extracted]',
|
||||
// Hide prefetched chunks as these include binary blobs.
|
||||
prefetched: this.pdfFile.prefetched?.length,
|
||||
}),
|
||||
pdfUrl: this.url,
|
||||
start,
|
||||
end,
|
||||
metrics,
|
||||
})
|
||||
|
||||
const isStaleOutputRequest = () =>
|
||||
performance.now() - this.startTime > STALE_OUTPUT_REQUEST_THRESHOLD_MS
|
||||
const is404 = err => OError.getFullInfo(err).statusCode === 404
|
||||
const isFromOutputPDFRequest = err =>
|
||||
OError.getFullInfo(err).url?.includes?.('/output.pdf') === true
|
||||
|
||||
// Do not consider "expected 404s" and network errors as pdf caching
|
||||
// failures.
|
||||
// "expected 404s" here include:
|
||||
// - any stale download request
|
||||
// Example: The user returns to a browser tab after 1h and scrolls.
|
||||
// - requests for the main output.pdf file
|
||||
// A fallback request would not be able to retrieve the PDF either.
|
||||
const isExpectedError = err =>
|
||||
(is404(err) || isNetworkError(err)) &&
|
||||
(isStaleOutputRequest() || isFromOutputPDFRequest(err))
|
||||
|
||||
const usesCache = url => {
|
||||
if (!url) return false
|
||||
const u = new URL(url)
|
||||
return (
|
||||
u.pathname.endsWith(
|
||||
`build/${this.pdfFile.editorId}-${this.pdfFile.build}/output/output.pdf`
|
||||
) && u.searchParams.get('clsiserverid') === 'cache'
|
||||
)
|
||||
}
|
||||
const canTryFromCache = err => {
|
||||
if (!useClsiCache) return false
|
||||
if (!is404(err)) return false
|
||||
return !usesCache(OError.getFullInfo(err).url)
|
||||
}
|
||||
const getOutputPDFURLFromCache = () => {
|
||||
if (usesCache(this.url)) return this.url
|
||||
const u = new URL(this.url)
|
||||
u.searchParams.set('clsiserverid', 'cache')
|
||||
u.pathname = u.pathname.replace(
|
||||
/build\/[a-f0-9-]+\//,
|
||||
`build/${this.pdfFile.editorId}-${this.pdfFile.build}/`
|
||||
)
|
||||
return u.href
|
||||
}
|
||||
const fetchFromCache = async () => {
|
||||
// Try fetching the chunk from clsi-cache
|
||||
const url = getOutputPDFURLFromCache()
|
||||
return fallbackRequest({
|
||||
file: this.pdfFile,
|
||||
url,
|
||||
start,
|
||||
end,
|
||||
abortSignal,
|
||||
})
|
||||
.then(blob => {
|
||||
// Send the next output.pdf request directly to the cache.
|
||||
this.url = url
|
||||
// Only try downloading chunks that were cached previously
|
||||
this.pdfFile.ranges = this.pdfFile.ranges.filter(r =>
|
||||
cachedUrls.has(r.hash)
|
||||
)
|
||||
return blob
|
||||
})
|
||||
.catch(err => {
|
||||
throw OError.tag(
|
||||
new PDFJS.MissingPDFException(),
|
||||
'cache-fallback',
|
||||
{
|
||||
statusCode: OError.getFullInfo(err).statusCode,
|
||||
url: OError.getFullInfo(err).url,
|
||||
err,
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fetchRange({
|
||||
url: this.url,
|
||||
start,
|
||||
end,
|
||||
file: this.pdfFile,
|
||||
queryForChunks: this.queryForChunks,
|
||||
metrics,
|
||||
usageScore,
|
||||
cachedUrls,
|
||||
verifyChunks,
|
||||
prefetchingEnabled,
|
||||
prefetchLargeEnabled,
|
||||
cachedUrlLookupEnabled,
|
||||
abortSignal,
|
||||
canTryFromCache,
|
||||
fallbackToCacheURL: getOutputPDFURLFromCache(),
|
||||
})
|
||||
.catch(err => {
|
||||
if (abortSignal.aborted) return
|
||||
if (canTryFromCache(err)) return fetchFromCache()
|
||||
if (isExpectedError(err)) {
|
||||
if (is404(err)) {
|
||||
// A regular pdf-js request would have seen this 404 as well.
|
||||
} else {
|
||||
// Flaky network, switch back to regular pdf-js requests.
|
||||
metrics.failedCount++
|
||||
metrics.failedOnce = true
|
||||
}
|
||||
throw OError.tag(new PDFJS.MissingPDFException(), 'caching', {
|
||||
statusCode: OError.getFullInfo(err).statusCode,
|
||||
url: OError.getFullInfo(err).url,
|
||||
err,
|
||||
})
|
||||
}
|
||||
metrics.failedCount++
|
||||
metrics.failedOnce = true
|
||||
if (!enablePdfCaching) {
|
||||
throw err // This was a fallback request already. Do not retry.
|
||||
}
|
||||
err = OError.tag(err, 'optimized pdf download error', getDebugInfo())
|
||||
debugConsole.error(err)
|
||||
captureException(err, {
|
||||
tags: {
|
||||
fromPdfCaching: true,
|
||||
isFromOutputPDFRequest: isFromOutputPDFRequest(err),
|
||||
},
|
||||
})
|
||||
return fallbackRequest({
|
||||
file: this.pdfFile,
|
||||
url: this.url,
|
||||
start,
|
||||
end,
|
||||
abortSignal,
|
||||
}).catch(err => {
|
||||
if (canTryFromCache(err)) return fetchFromCache()
|
||||
if (isExpectedError(err)) {
|
||||
throw OError.tag(new PDFJS.MissingPDFException(), 'fallback', {
|
||||
statusCode: OError.getFullInfo(err).statusCode,
|
||||
url: OError.getFullInfo(err).url,
|
||||
err,
|
||||
})
|
||||
}
|
||||
throw err
|
||||
})
|
||||
})
|
||||
.then(blob => {
|
||||
if (abortSignal.aborted) return
|
||||
this.onDataRange(start, blob)
|
||||
})
|
||||
.catch(err => {
|
||||
if (abortSignal.aborted) return
|
||||
err = OError.tag(err, 'fatal pdf download error', getDebugInfo())
|
||||
debugConsole.error(err)
|
||||
if (!(err instanceof PDFJS.MissingPDFException)) {
|
||||
captureException(err, {
|
||||
tags: {
|
||||
fromPdfCaching: true,
|
||||
isFromOutputPDFRequest: isFromOutputPDFRequest(err),
|
||||
},
|
||||
})
|
||||
}
|
||||
// Signal error for (subsequent) page load.
|
||||
this.handleFetchError(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return function ({ url, pdfFile, abortController, handleFetchError }) {
|
||||
if (metrics.failedOnce) {
|
||||
// Disable pdf caching once any fetch request failed.
|
||||
// Be trigger-happy here until we reached a stable state of the feature.
|
||||
return undefined
|
||||
}
|
||||
// Latency is collected per preview cycle.
|
||||
metrics.latencyComputeMax = 0
|
||||
metrics.latencyComputeTotal = 0
|
||||
return new PDFDataRangeTransport({
|
||||
url,
|
||||
pdfFile,
|
||||
abortController,
|
||||
handleFetchError,
|
||||
})
|
||||
}
|
||||
}
|
1107
services/web/frontend/js/features/pdf-preview/util/pdf-caching.js
Normal file
1107
services/web/frontend/js/features/pdf-preview/util/pdf-caching.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,206 @@
|
||||
import { captureException } from '@/infrastructure/error-reporter'
|
||||
import { generatePdfCachingTransportFactory } from './pdf-caching-transport'
|
||||
import { PDFJS, loadPdfDocumentFromUrl, imageResourcesPath } from './pdf-js'
|
||||
import {
|
||||
PDFViewer,
|
||||
EventBus,
|
||||
PDFLinkService,
|
||||
LinkTarget,
|
||||
} from 'pdfjs-dist/web/pdf_viewer.mjs'
|
||||
import 'pdfjs-dist/web/pdf_viewer.css'
|
||||
import browser from '@/features/source-editor/extensions/browser'
|
||||
|
||||
const DEFAULT_RANGE_CHUNK_SIZE = 128 * 1024 // 128K chunks
|
||||
|
||||
export default class PDFJSWrapper {
|
||||
public readonly viewer: PDFViewer
|
||||
public readonly eventBus: EventBus
|
||||
private readonly linkService: PDFLinkService
|
||||
private readonly pdfCachingTransportFactory: any
|
||||
private url?: string
|
||||
|
||||
// eslint-disable-next-line no-useless-constructor
|
||||
constructor(public container: HTMLDivElement) {
|
||||
// create the event bus
|
||||
this.eventBus = new EventBus()
|
||||
|
||||
// create the link service
|
||||
this.linkService = new PDFLinkService({
|
||||
eventBus: this.eventBus,
|
||||
externalLinkTarget: LinkTarget.BLANK,
|
||||
externalLinkRel: 'noopener',
|
||||
})
|
||||
|
||||
// create the viewer
|
||||
this.viewer = new PDFViewer({
|
||||
container: this.container,
|
||||
eventBus: this.eventBus,
|
||||
imageResourcesPath,
|
||||
linkService: this.linkService,
|
||||
maxCanvasPixels: browser.safari ? 4096 * 4096 : 8192 * 8192, // default is 4096 * 4096, increased for better resolution at high zoom levels (but not in Safari, which struggles with large canvases)
|
||||
annotationMode: PDFJS.AnnotationMode.ENABLE, // enable annotations but not forms
|
||||
annotationEditorMode: PDFJS.AnnotationEditorType.DISABLE, // disable annotation editing
|
||||
})
|
||||
|
||||
this.linkService.setViewer(this.viewer)
|
||||
|
||||
this.pdfCachingTransportFactory = generatePdfCachingTransportFactory()
|
||||
}
|
||||
|
||||
// load a document from a URL
|
||||
async loadDocument({
|
||||
url,
|
||||
pdfFile,
|
||||
abortController,
|
||||
handleFetchError,
|
||||
}: {
|
||||
url: string
|
||||
pdfFile: Record<string, any>
|
||||
abortController: AbortController
|
||||
handleFetchError: (error: Error) => void
|
||||
}) {
|
||||
this.url = url
|
||||
|
||||
const rangeTransport = this.pdfCachingTransportFactory({
|
||||
url,
|
||||
pdfFile,
|
||||
abortController,
|
||||
handleFetchError,
|
||||
})
|
||||
let rangeChunkSize = DEFAULT_RANGE_CHUNK_SIZE
|
||||
if (rangeTransport && pdfFile.size < 2 * DEFAULT_RANGE_CHUNK_SIZE) {
|
||||
// pdf.js disables the "bulk" download optimization when providing a
|
||||
// custom range transport. Restore it by bumping the chunk size.
|
||||
rangeChunkSize = pdfFile.size
|
||||
}
|
||||
|
||||
try {
|
||||
const doc = await loadPdfDocumentFromUrl(url, {
|
||||
rangeChunkSize,
|
||||
range: rangeTransport,
|
||||
}).promise
|
||||
|
||||
// check that this is still the current URL
|
||||
if (url !== this.url) {
|
||||
return
|
||||
}
|
||||
|
||||
this.viewer.setDocument(doc)
|
||||
this.linkService.setDocument(doc)
|
||||
|
||||
return doc
|
||||
} catch (error: any) {
|
||||
if (!error || error.name !== 'MissingPDFException') {
|
||||
captureException(error, {
|
||||
tags: { handler: 'pdf-preview' },
|
||||
})
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async fetchAllData() {
|
||||
await this.viewer.pdfDocument?.getData()
|
||||
}
|
||||
|
||||
// update the current scale value if the container size changes
|
||||
updateOnResize() {
|
||||
if (!this.isVisible()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use requestAnimationFrame to prevent errors like "ResizeObserver loop
|
||||
// completed with undelivered notifications" that can occur if updating the
|
||||
// viewer causes another repaint. The cost of this is that the viewer update
|
||||
// lags one frame behind, but it's unlikely to matter.
|
||||
// Further reading: https://github.com/WICG/resize-observer/issues/38
|
||||
window.requestAnimationFrame(() => {
|
||||
const currentScaleValue = this.viewer.currentScaleValue
|
||||
|
||||
if (
|
||||
currentScaleValue === 'auto' ||
|
||||
currentScaleValue === 'page-fit' ||
|
||||
currentScaleValue === 'page-height' ||
|
||||
currentScaleValue === 'page-width'
|
||||
) {
|
||||
this.viewer.currentScaleValue = currentScaleValue
|
||||
}
|
||||
|
||||
this.viewer.update()
|
||||
})
|
||||
}
|
||||
|
||||
// get the page and offset of a click event
|
||||
clickPosition(event: MouseEvent, canvas: HTMLCanvasElement, page: number) {
|
||||
if (!canvas) {
|
||||
return
|
||||
}
|
||||
|
||||
const { viewport } = this.viewer.getPageView(page)
|
||||
|
||||
const pageRect = canvas.getBoundingClientRect()
|
||||
|
||||
const dx = event.clientX - pageRect.left
|
||||
const dy = event.clientY - pageRect.top
|
||||
|
||||
const [left, top] = viewport.convertToPdfPoint(dx, dy)
|
||||
|
||||
return {
|
||||
page,
|
||||
offset: {
|
||||
left,
|
||||
top: viewport.viewBox[3] - top,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// get the current page, offset and page size
|
||||
get currentPosition() {
|
||||
const pageIndex = this.viewer.currentPageNumber - 1
|
||||
const pageView = this.viewer.getPageView(pageIndex)
|
||||
const pageRect = pageView.div.getBoundingClientRect()
|
||||
|
||||
const containerRect = this.container.getBoundingClientRect()
|
||||
const dy = containerRect.top - pageRect.top
|
||||
const dx = containerRect.left - pageRect.left
|
||||
const [left, top] = pageView.viewport.convertToPdfPoint(dx, dy)
|
||||
const [, , width, height] = pageView.viewport.viewBox
|
||||
|
||||
return {
|
||||
page: pageIndex,
|
||||
offset: { top, left },
|
||||
pageSize: { height, width },
|
||||
}
|
||||
}
|
||||
|
||||
scrollToPosition(position: Record<string, any>, scale = null) {
|
||||
const destArray = [
|
||||
null,
|
||||
{
|
||||
name: 'XYZ', // 'XYZ' = scroll to the given coordinates
|
||||
},
|
||||
position.offset.left,
|
||||
position.offset.top,
|
||||
scale,
|
||||
]
|
||||
|
||||
this.viewer.scrollPageIntoView({
|
||||
pageNumber: position.page + 1,
|
||||
destArray,
|
||||
})
|
||||
|
||||
// scroll the page left and down by an extra few pixels to account for the pdf.js viewer page border
|
||||
const pageIndex = this.viewer.currentPageNumber - 1
|
||||
const pageView = this.viewer.getPageView(pageIndex)
|
||||
const offset = parseFloat(getComputedStyle(pageView.div).borderWidth)
|
||||
this.viewer.container.scrollBy({
|
||||
top: -offset,
|
||||
left: -offset,
|
||||
})
|
||||
}
|
||||
|
||||
isVisible() {
|
||||
return this.viewer.container.offsetParent !== null
|
||||
}
|
||||
}
|
33
services/web/frontend/js/features/pdf-preview/util/pdf-js.ts
Normal file
33
services/web/frontend/js/features/pdf-preview/util/pdf-js.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as PDFJS from 'pdfjs-dist'
|
||||
import type { DocumentInitParameters } from 'pdfjs-dist/types/src/display/api'
|
||||
|
||||
export { PDFJS }
|
||||
|
||||
PDFJS.GlobalWorkerOptions.workerPort = new Worker(
|
||||
/* webpackChunkName: "pdf-worker" */
|
||||
new URL('pdfjs-dist/build/pdf.worker.mjs', import.meta.url) // NOTE: .mjs extension
|
||||
)
|
||||
|
||||
export const imageResourcesPath = '/images/pdfjs-dist/'
|
||||
const cMapUrl = '/js/pdfjs-dist/cmaps/'
|
||||
const standardFontDataUrl = '/fonts/pdfjs-dist/'
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const disableFontFace = params.get('disable-font-face') === 'true'
|
||||
const disableStream = process.env.NODE_ENV !== 'test'
|
||||
|
||||
export const loadPdfDocumentFromUrl = (
|
||||
url: string,
|
||||
options: Partial<DocumentInitParameters> = {}
|
||||
) =>
|
||||
PDFJS.getDocument({
|
||||
url,
|
||||
cMapUrl,
|
||||
standardFontDataUrl,
|
||||
disableFontFace,
|
||||
disableAutoFetch: true, // only fetch the data needed for the displayed pages
|
||||
disableStream,
|
||||
isEvalSupported: false,
|
||||
enableXfa: false, // default is false (2021-10-12), but set explicitly to be sure
|
||||
...options,
|
||||
})
|
42
services/web/frontend/js/features/pdf-preview/util/types.ts
Normal file
42
services/web/frontend/js/features/pdf-preview/util/types.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
import { CompileOutputFile } from '../../../../../types/compile'
|
||||
|
||||
export type LogEntry = {
|
||||
raw: string
|
||||
level: ErrorLevel
|
||||
key: string
|
||||
file?: string
|
||||
column?: number
|
||||
line?: number
|
||||
ruleId?: string
|
||||
message?: string
|
||||
content?: string
|
||||
type?: string
|
||||
messageComponent?: React.ReactNode
|
||||
contentDetails?: string[]
|
||||
}
|
||||
|
||||
export type ErrorLevel =
|
||||
| 'error'
|
||||
| 'warning'
|
||||
| 'info'
|
||||
| 'typesetting'
|
||||
| 'raw'
|
||||
| 'success'
|
||||
|
||||
export type SourceLocation = {
|
||||
file?: string
|
||||
// `line should be either a number or null (i.e. not required), but currently sometimes we get
|
||||
// an empty string (from BibTeX errors).
|
||||
line?: number | string | null
|
||||
column?: number
|
||||
}
|
||||
|
||||
export type PdfFileData = CompileOutputFile
|
||||
type PdfFileArchiveData = CompileOutputFile & { fileCount: number }
|
||||
|
||||
export type PdfFileDataList = {
|
||||
top: PdfFileData[]
|
||||
other: PdfFileData[]
|
||||
archive?: PdfFileArchiveData
|
||||
}
|
Reference in New Issue
Block a user