first commit

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

View File

@@ -0,0 +1,211 @@
import { useState, type ElementType } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { formatTime, relativeDate } from '../../utils/format-date'
import { fileUrl } from '../../utils/fileUrl'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { useProjectContext } from '@/shared/context/project-context'
import { Nullable } from '../../../../../types/utils'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { LinkedFileIcon } from './file-view-icons'
import { BinaryFile, hasProvider, LinkedFile } from '../types/binary-file'
import FileViewRefreshButton from './file-view-refresh-button'
import FileViewRefreshError from './file-view-refresh-error'
import MaterialIcon from '@/shared/components/material-icon'
import OLButton from '@/features/ui/components/ol/ol-button'
const tprFileViewInfo = importOverleafModules('tprFileViewInfo') as {
import: { TPRFileViewInfo: ElementType }
path: string
}[]
const tprFileViewNotOriginalImporter = importOverleafModules(
'tprFileViewNotOriginalImporter'
) as {
import: { TPRFileViewNotOriginalImporter: ElementType }
path: string
}[]
const MAX_URL_LENGTH = 60
const FRONT_OF_URL_LENGTH = 35
const FILLER = '...'
const TAIL_OF_URL_LENGTH = MAX_URL_LENGTH - FRONT_OF_URL_LENGTH - FILLER.length
function shortenedUrl(url: string) {
if (!url) {
return
}
if (url.length > MAX_URL_LENGTH) {
const front = url.slice(0, FRONT_OF_URL_LENGTH)
const tail = url.slice(url.length - TAIL_OF_URL_LENGTH)
return front + FILLER + tail
}
return url
}
type FileViewHeaderProps = {
file: BinaryFile
}
export default function FileViewHeader({ file }: FileViewHeaderProps) {
const { _id: projectId } = useProjectContext()
const { fileTreeReadOnly } = useFileTreeData()
const { t } = useTranslation()
const [refreshError, setRefreshError] = useState<Nullable<string>>(null)
let fileInfo
if (file.linkedFileData) {
if (hasProvider(file, 'url')) {
fileInfo = <UrlProvider file={file} />
} else if (hasProvider(file, 'project_file')) {
fileInfo = <ProjectFilePathProvider file={file} />
} else if (hasProvider(file, 'project_output_file')) {
fileInfo = <ProjectOutputFileProvider file={file} />
}
}
return (
<>
{file.linkedFileData && fileInfo}
{file.linkedFileData &&
tprFileViewInfo.map(({ import: { TPRFileViewInfo }, path }) => (
<TPRFileViewInfo key={path} file={file} />
))}
<div className="file-view-buttons">
{file.linkedFileData && !fileTreeReadOnly && (
<FileViewRefreshButton
file={file}
setRefreshError={setRefreshError}
/>
)}
<OLButton
variant="secondary"
download={file.name}
href={fileUrl(projectId, file.id, file.hash)}
>
<MaterialIcon type="download" className="align-middle" />{' '}
<span>{t('download')}</span>
</OLButton>
</div>
{file.linkedFileData &&
tprFileViewNotOriginalImporter.map(
({ import: { TPRFileViewNotOriginalImporter }, path }) => (
<TPRFileViewNotOriginalImporter key={path} file={file} />
)
)[0]}
{refreshError && (
<FileViewRefreshError file={file} refreshError={refreshError} />
)}
{/* Workaround for Safari issue: https://github.com/overleaf/internal/issues/21363
* The editor behind a file view receives key events and updates the file even if Codemirror view is not focused.
* Changing the focus to a hidden textarea prevents this
*/}
<textarea
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
aria-label="Invisible element to manage focus and prevent unintended behavior"
tabIndex={-1}
style={{ position: 'absolute', left: '-9999px' }}
/>
</>
)
}
type UrlProviderProps = {
file: LinkedFile<'url'>
}
function UrlProvider({ file }: UrlProviderProps) {
return (
<p>
<LinkedFileIcon />
&nbsp;
<Trans
i18nKey="imported_from_external_provider_at_date"
components={
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
[<a href={file.linkedFileData.url} />]
}
values={{
shortenedUrl: shortenedUrl(file.linkedFileData.url),
formattedDate: formatTime(file.created),
relativeDate: relativeDate(file.created),
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</p>
)
}
type ProjectFilePathProviderProps = {
file: LinkedFile<'project_file'>
}
function ProjectFilePathProvider({ file }: ProjectFilePathProviderProps) {
/* eslint-disable jsx-a11y/anchor-has-content, react/jsx-key */
return (
<p>
<LinkedFileIcon />{' '}
<Trans
i18nKey="imported_from_another_project_at_date"
components={
file.linkedFileData.v1_source_doc_id
? [<span />]
: [
<a
href={`/project/${file.linkedFileData.source_project_id}`}
target="_blank"
rel="noopener"
/>,
]
}
values={{
sourceEntityPath: file.linkedFileData.source_entity_path.slice(1),
formattedDate: formatTime(file.created),
relativeDate: relativeDate(file.created),
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</p>
/* esline-enable jsx-a11y/anchor-has-content, react/jsx-key */
)
}
type ProjectOutputFileProviderProps = {
file: LinkedFile<'project_output_file'>
}
function ProjectOutputFileProvider({ file }: ProjectOutputFileProviderProps) {
return (
<p>
<LinkedFileIcon />
&nbsp;
<Trans
i18nKey="imported_from_the_output_of_another_project_at_date"
components={
file.linkedFileData.v1_source_doc_id
? [<span />]
: [
<a
href={`/project/${file.linkedFileData.source_project_id}`}
target="_blank"
rel="noopener"
/>,
]
}
values={{
sourceOutputFilePath: file.linkedFileData.source_output_file_path,
formattedDate: formatTime(file.created),
relativeDate: relativeDate(file.created),
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</p>
)
}

View File

@@ -0,0 +1,12 @@
import MaterialIcon from '@/shared/components/material-icon'
export const LinkedFileIcon = props => {
return (
<MaterialIcon
type="open_in_new"
modifier="rotate-180"
className="align-middle linked-file-icon"
{...props}
/>
)
}

View File

@@ -0,0 +1,23 @@
import { useProjectContext } from '../../../shared/context/project-context'
import { BinaryFile } from '@/features/file-view/types/binary-file'
import { fileUrl } from '../../utils/fileUrl'
export default function FileViewImage({
file,
onLoad,
onError,
}: {
file: BinaryFile
onLoad: () => void
onError: () => void
}) {
const { _id: projectId } = useProjectContext()
return (
<img
src={fileUrl(projectId, file.id, file.hash)}
onLoad={onLoad}
onError={onError}
alt={file.name}
/>
)
}

View File

@@ -0,0 +1,78 @@
import { FC, useCallback } from 'react'
import useIsMounted from '@/shared/hooks/use-is-mounted'
import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
const FileViewPdf: FC<{
fileId: string
onLoad: () => void
onError: () => void
}> = ({ fileId, onLoad, onError }) => {
const mountedRef = useIsMounted()
const { previewByPath, pathInFolder } = useFileTreePathContext()
const handleContainer = useCallback(
async (element: HTMLDivElement | null) => {
if (element) {
const { loadPdfDocumentFromUrl } = await import(
'@/features/pdf-preview/util/pdf-js'
)
// bail out if loading PDF.js took too long
if (!mountedRef.current) {
return
}
const path = pathInFolder(fileId)
const preview = path ? previewByPath(path) : null
if (!preview) {
onError()
return
}
const pdf = await loadPdfDocumentFromUrl(preview.url).promise
// bail out if loading the PDF took too long
if (!mountedRef.current) {
return
}
element.textContent = '' // ensure the element is empty
const scale = window.devicePixelRatio || 1
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i)
// bail out if the component has been unmounted
if (!mountedRef.current) {
return
}
const viewport = page.getViewport({ scale })
const canvas = document.createElement('canvas')
canvas.classList.add('pdf-page')
canvas.width = viewport.width
canvas.height = viewport.height
canvas.style.width = `${viewport.width / scale}px`
canvas.style.height = `${viewport.height / scale}px`
element.append(canvas)
page.render({
canvasContext: canvas.getContext('2d')!,
viewport,
})
}
onLoad()
}
},
[mountedRef, pathInFolder, fileId, previewByPath, onLoad, onError]
)
return <div className="file-view-pdf" ref={handleContainer} />
}
export default FileViewPdf

View File

@@ -0,0 +1,110 @@
import {
type Dispatch,
type SetStateAction,
type ElementType,
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { postJSON } from '@/infrastructure/fetch-json'
import { useProjectContext } from '@/shared/context/project-context'
import type { BinaryFile } from '../types/binary-file'
import { Nullable } from '../../../../../types/utils'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import OLButton from '@/features/ui/components/ol/ol-button'
import { sendMB } from '@/infrastructure/event-tracking'
import useIsMounted from '@/shared/hooks/use-is-mounted'
type FileViewRefreshButtonProps = {
setRefreshError: Dispatch<SetStateAction<Nullable<string>>>
file: BinaryFile
}
const tprFileViewRefreshButton = importOverleafModules(
'tprFileViewRefreshButton'
) as {
import: { TPRFileViewRefreshButton: ElementType }
path: string
}[]
export default function FileViewRefreshButton({
setRefreshError,
file,
}: FileViewRefreshButtonProps) {
const { _id: projectId } = useProjectContext()
const [refreshing, setRefreshing] = useState(false)
const isMountedRef = useIsMounted()
const refreshFile = useCallback(
(isTPR: Nullable<boolean>) => {
setRefreshing(true)
// Replacement of the file handled by the file tree
window.expectingLinkedFileRefreshedSocketFor = file.name
const body = {
shouldReindexReferences: isTPR || /\.bib$/.test(file.name),
}
postJSON(`/project/${projectId}/linked_file/${file.id}/refresh`, {
body,
})
.then(() => {
if (isMountedRef.current) {
setRefreshing(false)
}
sendMB('refresh-linked-file', {
provider: file.linkedFileData?.provider,
})
})
.catch(err => {
if (isMountedRef.current) {
setRefreshing(false)
setRefreshError(err.data?.message || err.message)
}
})
},
[file, projectId, setRefreshError, isMountedRef]
)
if (tprFileViewRefreshButton.length > 0) {
return tprFileViewRefreshButton.map(
({ import: { TPRFileViewRefreshButton }, path }) => (
<TPRFileViewRefreshButton
key={path}
file={file}
refreshFile={refreshFile}
refreshing={refreshing}
/>
)
)[0]
} else {
return (
<FileViewRefreshButtonDefault
refreshFile={refreshFile}
refreshing={refreshing}
/>
)
}
}
type FileViewRefreshButtonDefaultProps = {
refreshFile: (isTPR: Nullable<boolean>) => void
refreshing: boolean
}
function FileViewRefreshButtonDefault({
refreshFile,
refreshing,
}: FileViewRefreshButtonDefaultProps) {
const { t } = useTranslation()
return (
<OLButton
variant="primary"
onClick={() => refreshFile(null)}
disabled={refreshing}
isLoading={refreshing}
loadingLabel={t('refreshing')}
>
{t('refresh')}
</OLButton>
)
}

View File

@@ -0,0 +1,49 @@
import type { ElementType } from 'react'
import { useTranslation } from 'react-i18next'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { BinaryFile } from '../types/binary-file'
import OLNotification from '@/features/ui/components/ol/ol-notification'
type FileViewRefreshErrorProps = {
file: BinaryFile
refreshError: string
}
const tprFileViewRefreshError = importOverleafModules(
'tprFileViewRefreshError'
) as {
import: { TPRFileViewRefreshError: ElementType }
path: string
}[]
export default function FileViewRefreshError({
file,
refreshError,
}: FileViewRefreshErrorProps) {
const { t } = useTranslation()
if (tprFileViewRefreshError.length > 0) {
return tprFileViewRefreshError.map(
({ import: { TPRFileViewRefreshError }, path }) => (
<TPRFileViewRefreshError
key={path}
file={file}
refreshError={refreshError}
/>
)
)[0]
} else {
return (
<div className="file-view-error">
<OLNotification
type="error"
content={
<span>
{t('access_denied')}: {refreshError}
</span>
}
/>
</div>
)
}
}

View File

@@ -0,0 +1,96 @@
import { useState, useEffect } from 'react'
import { useProjectContext } from '../../../shared/context/project-context'
import { debugConsole } from '@/utils/debugging'
import useAbortController from '../../../shared/hooks/use-abort-controller'
import { BinaryFile } from '@/features/file-view/types/binary-file'
import { fileUrl } from '../../utils/fileUrl'
const MAX_FILE_SIZE = 2 * 1024 * 1024
export default function FileViewText({
file,
onLoad,
onError,
}: {
file: BinaryFile
onLoad: () => void
onError: () => void
}) {
const { _id: projectId } = useProjectContext()
const [textPreview, setTextPreview] = useState('')
const [shouldShowDots, setShouldShowDots] = useState(false)
const [inFlight, setInFlight] = useState(false)
const fetchContentLengthController = useAbortController()
const fetchDataController = useAbortController()
useEffect(() => {
if (inFlight) {
return
}
const path = fileUrl(projectId, file.id, file.hash)
const fetchContentLengthTimeout = setTimeout(
() => fetchContentLengthController.abort(),
10000
)
let fetchDataTimeout: number | undefined
fetch(path, { method: 'HEAD', signal: fetchContentLengthController.signal })
.then(response => {
if (!response.ok) throw new Error('HTTP Error Code: ' + response.status)
return response.headers.get('Content-Length')
})
.then(fileSize => {
let truncated = false
const headers = new Headers()
if (fileSize && Number(fileSize) > MAX_FILE_SIZE) {
truncated = true
headers.set('Range', `bytes=0-${MAX_FILE_SIZE}`)
}
fetchDataTimeout = window.setTimeout(
() => fetchDataController.abort(),
60000
)
const signal = fetchDataController.signal
return fetch(path, { signal, headers }).then(response => {
return response.text().then(text => {
if (truncated) {
text = text.replace(/\n.*$/, '')
}
setTextPreview(text)
onLoad()
setShouldShowDots(truncated)
})
})
})
.catch(err => {
debugConsole.error('Error fetching file contents', err)
onError()
})
.finally(() => {
setInFlight(false)
clearTimeout(fetchContentLengthTimeout)
clearTimeout(fetchDataTimeout)
})
}, [
projectId,
file.id,
file.hash,
onError,
onLoad,
inFlight,
fetchContentLengthController,
fetchDataController,
])
return (
Boolean(textPreview) && (
<div className="text-preview">
<div className="scroll-container">
<p>{textPreview}</p>
{shouldShowDots && <p>...</p>}
</div>
</div>
)
)
}

View File

@@ -0,0 +1,90 @@
import { useState, useCallback } from 'react'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import FileViewHeader from './file-view-header'
import FileViewImage from './file-view-image'
import FileViewPdf from './file-view-pdf'
import FileViewText from './file-view-text'
import LoadingSpinner from '@/shared/components/loading-spinner'
import getMeta from '@/utils/meta'
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif']
export default function FileView({ file }) {
const [contentLoading, setContentLoading] = useState(true)
const [hasError, setHasError] = useState(false)
const { t } = useTranslation()
const { textExtensions, editableFilenames } = getMeta('ol-ExposedSettings')
const extension = file.name.split('.').pop().toLowerCase()
const isEditableTextFile =
textExtensions.includes(extension) ||
editableFilenames.includes(file.name.toLowerCase())
const isImageFile = imageExtensions.includes(extension)
const isPdfFile = extension === 'pdf'
const isUnpreviewableFile = !isEditableTextFile && !isImageFile && !isPdfFile
const handleLoad = useCallback(() => {
setContentLoading(false)
}, [])
const handleError = useCallback(() => {
if (!hasError) {
setContentLoading(false)
setHasError(true)
}
}, [hasError])
const content = (
<>
<FileViewHeader file={file} />
{isImageFile && (
<FileViewImage file={file} onLoad={handleLoad} onError={handleError} />
)}
{isEditableTextFile && (
<FileViewText file={file} onLoad={handleLoad} onError={handleError} />
)}
{isPdfFile && (
<FileViewPdf
fileId={file.id}
onLoad={handleLoad}
onError={handleError}
/>
)}
</>
)
return (
<div className="file-view full-size">
{!hasError && content}
{!isUnpreviewableFile && contentLoading && <FileViewLoadingIndicator />}
{(isUnpreviewableFile || hasError) && (
<p className="no-preview">{t('no_preview_available')}</p>
)}
</div>
)
}
function FileViewLoadingIndicator() {
return (
<div
className="loading-panel loading-panel-file-view"
data-testid="loading-panel-file-view"
>
<LoadingSpinner />
</div>
)
}
FileView.propTypes = {
file: PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
hash: PropTypes.string,
}).isRequired,
}

View File

@@ -0,0 +1,37 @@
export type LinkedFileData = {
url: {
provider: 'url'
url: string
}
project_file: {
provider: 'project_file'
v1_source_doc_id?: string
source_project_id: string
source_entity_path: string
}
project_output_file: {
provider: 'project_output_file'
v1_source_doc_id?: string
source_project_id: string
source_output_file_path: string
}
}
export type BinaryFile<T extends keyof LinkedFileData = keyof LinkedFileData> =
{
_id: string
name: string
created: Date
id: string
type: string
selected: boolean
linkedFileData?: LinkedFileData[T]
hash: string
}
export type LinkedFile<T extends keyof LinkedFileData> = Required<BinaryFile<T>>
export const hasProvider = <T extends keyof LinkedFileData>(
file: BinaryFile,
provider: T
): file is LinkedFile<T> => file.linkedFileData?.provider === provider