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,53 @@
import {
FigureModalSource,
useFigureModalContext,
} from './figure-modal-context'
import { FigureModalHelp } from './figure-modal-help'
import { FigureModalFigureOptions } from './figure-modal-options'
import { FigureModalSourcePicker } from './figure-modal-source-picker'
import { FigureModalEditFigureSource } from './file-sources/figure-modal-edit-figure-source'
import { FigureModalOtherProjectSource } from './file-sources/figure-modal-other-project-source'
import { FigureModalCurrentProjectSource } from './file-sources/figure-modal-project-source'
import { FigureModalUploadFileSource } from './file-sources/figure-modal-upload-source'
import { FigureModalUrlSource } from './file-sources/figure-modal-url-source'
import { useCallback } from 'react'
import OLNotification from '@/features/ui/components/ol/ol-notification'
const sourceModes = new Map([
[FigureModalSource.FILE_TREE, FigureModalCurrentProjectSource],
[FigureModalSource.FROM_URL, FigureModalUrlSource],
[FigureModalSource.OTHER_PROJECT, FigureModalOtherProjectSource],
[FigureModalSource.FILE_UPLOAD, FigureModalUploadFileSource],
[FigureModalSource.EDIT_FIGURE, FigureModalEditFigureSource],
])
export default function FigureModalBody() {
const { source, helpShown, sourcePickerShown, error, dispatch } =
useFigureModalContext()
const Body = sourceModes.get(source)
const onDismiss = useCallback(() => {
dispatch({ error: undefined })
}, [dispatch])
if (helpShown) {
return <FigureModalHelp />
}
if (sourcePickerShown) {
return <FigureModalSourcePicker />
}
if (!Body) {
return null
}
return (
<>
{error && (
<OLNotification type="error" onDismiss={onDismiss} content={error} />
)}
<Body />
<FigureModalFigureOptions />
</>
)
}

View File

@@ -0,0 +1,125 @@
import { FC, createContext, useContext, useReducer } from 'react'
import { PastedImageData } from '../../extensions/figure-modal'
/* eslint-disable no-unused-vars */
export enum FigureModalSource {
NONE,
FILE_UPLOAD,
FILE_TREE,
FROM_URL,
OTHER_PROJECT,
EDIT_FIGURE,
}
/* eslint-enable no-unused-vars */
type FigureModalState = {
source: FigureModalSource
helpShown: boolean
sourcePickerShown: boolean
getPath?: () => Promise<string>
width: number | undefined
includeCaption: boolean
includeLabel: boolean
error?: string
pastedImageData?: PastedImageData
selectedItemId?: string
}
type FigureModalStateUpdate = Partial<FigureModalState>
const FigureModalContext = createContext<
| (FigureModalState & {
dispatch: (update: FigureModalStateUpdate) => void
})
| undefined
>(undefined)
export const useFigureModalContext = () => {
const context = useContext(FigureModalContext)
if (!context) {
throw new Error(
'useFigureModalContext is only available inside FigureModalProvider'
)
}
return context
}
const reducer = (prev: FigureModalState, action: Partial<FigureModalState>) => {
if ('source' in action && prev.source === FigureModalSource.NONE) {
// Reset when showing modal
return {
...prev,
width: 0.5,
includeLabel: true,
includeCaption: true,
helpShown: false,
sourcePickerShown: false,
getPath: undefined,
error: undefined,
pastedImageData: undefined,
...action,
}
}
return { ...prev, ...action }
}
type FigureModalExistingFigureState = {
name: string | undefined
hasComplexGraphicsArgument?: boolean
}
type FigureModalExistingFigureStateUpdate =
Partial<FigureModalExistingFigureState>
const FigureModalExistingFigureContext = createContext<
| (FigureModalExistingFigureState & {
dispatch: (update: FigureModalExistingFigureStateUpdate) => void
})
| undefined
>(undefined)
export const FigureModalProvider: FC = ({ children }) => {
const [state, dispatch] = useReducer(reducer, {
source: FigureModalSource.NONE,
helpShown: false,
sourcePickerShown: false,
getPath: undefined,
includeLabel: true,
includeCaption: true,
width: 0.5,
})
const [existingFigureState, dispatchFigureState] = useReducer(
(
prev: FigureModalExistingFigureState,
action: FigureModalExistingFigureStateUpdate
) => ({ ...prev, ...action }),
{
name: undefined,
}
)
return (
<FigureModalContext.Provider value={{ ...state, dispatch }}>
<FigureModalExistingFigureContext.Provider
value={{ ...existingFigureState, dispatch: dispatchFigureState }}
>
{children}
</FigureModalExistingFigureContext.Provider>
</FigureModalContext.Provider>
)
}
export const useFigureModalExistingFigureContext = () => {
const context = useContext(FigureModalExistingFigureContext)
if (!context) {
throw new Error(
'useFigureModalExistingFigureContext is only available inside FigureModalProvider'
)
}
return context
}

View File

@@ -0,0 +1,106 @@
import {
FigureModalSource,
useFigureModalContext,
} from './figure-modal-context'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { sendMB } from '../../../../infrastructure/event-tracking'
import OLButton from '@/features/ui/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
export const FigureModalFooter: FC<{
onInsert: () => void
onCancel: () => void
onDelete: () => void
}> = ({ onInsert, onCancel, onDelete }) => {
const { t } = useTranslation()
return (
<>
<HelpToggle />
<OLButton variant="secondary" onClick={onCancel}>
{t('cancel')}
</OLButton>
<FigureModalAction onInsert={onInsert} onDelete={onDelete} />
</>
)
}
const HelpToggle = () => {
const { t } = useTranslation()
const { helpShown, dispatch } = useFigureModalContext()
if (helpShown) {
return (
<OLButton
variant="link"
className="figure-modal-help-link me-auto"
onClick={() => dispatch({ helpShown: false })}
>
<span>
<MaterialIcon type="arrow_left_alt" className="align-text-bottom" />
</span>{' '}
{t('back')}
</OLButton>
)
}
return (
<OLButton
variant="link"
className="figure-modal-help-link me-auto"
onClick={() => dispatch({ helpShown: true })}
>
<span>
<MaterialIcon type="help" className="align-text-bottom" />
</span>{' '}
{t('help')}
</OLButton>
)
}
const FigureModalAction: FC<{
onInsert: () => void
onDelete: () => void
}> = ({ onInsert, onDelete }) => {
const { t } = useTranslation()
const { helpShown, getPath, source, sourcePickerShown } =
useFigureModalContext()
if (helpShown) {
return null
}
if (sourcePickerShown) {
return (
<OLButton variant="danger" onClick={onDelete}>
{t('delete_figure')}
</OLButton>
)
}
if (source === FigureModalSource.EDIT_FIGURE) {
return (
<OLButton
variant="primary"
onClick={() => {
onInsert()
sendMB('figure-modal-edit')
}}
>
{t('done')}
</OLButton>
)
}
return (
<OLButton
variant="primary"
disabled={getPath === undefined}
onClick={() => {
onInsert()
sendMB('figure-modal-insert')
}}
>
{t('insert_figure')}
</OLButton>
)
}

View File

@@ -0,0 +1,65 @@
import { FC } from 'react'
import { Trans, useTranslation } from 'react-i18next'
const LearnWikiLink: FC<{ article: string }> = ({ article, children }) => {
return <a href={`/learn/latex/${article}`}>{children}</a>
}
export const FigureModalHelp = () => {
const { t } = useTranslation()
return (
<>
<p>{t('this_tool_helps_you_insert_figures')}</p>
<b>{t('editing_captions')}</b>
<p>{t('when_you_tick_the_include_caption_box')}</p>
<b>{t('understanding_labels')}</b>
<p>
<Trans
i18nKey="labels_help_you_to_easily_reference_your_figures"
components={[
// eslint-disable-next-line react/jsx-key
<code />,
// eslint-disable-next-line react/jsx-key
<LearnWikiLink article="Inserting_Images#Labels_and_cross-references" />,
]}
/>
</p>
<b>{t('customizing_figures')}</b>
<p>
<Trans
i18nKey="there_are_lots_of_options_to_edit_and_customize_your_figures"
components={[
// eslint-disable-next-line react/jsx-key
<LearnWikiLink article="Inserting_Images" />,
]}
/>
</p>
<b>{t('changing_the_position_of_your_figure')}</b>
<p>
<Trans
i18nKey="latex_places_figures_according_to_a_special_algorithm"
components={[
// eslint-disable-next-line react/jsx-key
<LearnWikiLink article="Positioning_images_and_tables" />,
]}
/>
</p>
<b>{t('dealing_with_errors')}</b>
<p>
<Trans
i18nKey="are_you_getting_an_undefined_control_sequence_error"
components={[
// eslint-disable-next-line react/jsx-key
<code />,
// eslint-disable-next-line react/jsx-key
<LearnWikiLink article="Inserting_Images" />,
]}
/>
</p>
</>
)
}

View File

@@ -0,0 +1,123 @@
import { FC } from 'react'
import {
useFigureModalContext,
useFigureModalExistingFigureContext,
} from './figure-modal-context'
import { useTranslation } from 'react-i18next'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
import OLFormText from '@/features/ui/components/ol/ol-form-text'
import OLToggleButtonGroup from '@/features/ui/components/ol/ol-toggle-button-group'
import OLToggleButton from '@/features/ui/components/ol/ol-toggle-button'
import MaterialIcon from '@/shared/components/material-icon'
export const FigureModalFigureOptions: FC = () => {
const { t } = useTranslation()
const { includeCaption, includeLabel, dispatch, width } =
useFigureModalContext()
const { hasComplexGraphicsArgument } = useFigureModalExistingFigureContext()
return (
<>
<OLFormGroup>
<OLFormCheckbox
id="figure-modal-caption"
defaultChecked={includeCaption}
onChange={event => dispatch({ includeCaption: event.target.checked })}
label={t('include_caption')}
/>
</OLFormGroup>
<OLFormGroup>
<OLFormCheckbox
id="figure-modal-label"
data-cy="include-label-option"
defaultChecked={includeLabel}
onChange={event => dispatch({ includeLabel: event.target.checked })}
label={
<span className="figure-modal-label-content">
{t('include_label')}
<span aria-hidden="true">
<OLFormText>
{t(
'used_when_referring_to_the_figure_elsewhere_in_the_document'
)}
</OLFormText>
</span>
</span>
}
/>
</OLFormGroup>
<OLFormGroup className="mb-0">
<div className="figure-modal-switcher-input">
<div>
{t('image_width')}{' '}
{hasComplexGraphicsArgument ? (
<OLTooltip
id="figure-modal-image-width-warning-tooltip"
description={t('a_custom_size_has_been_used_in_the_latex_code')}
overlayProps={{ delay: 0, placement: 'top' }}
>
<span>
<MaterialIcon type="warning" className="align-text-bottom" />
</span>
</OLTooltip>
) : (
<OLTooltip
id="figure-modal-image-width-tooltip"
description={t(
'the_width_you_choose_here_is_based_on_the_width_of_the_text_in_your_document'
)}
overlayProps={{ delay: 0, placement: 'bottom' }}
>
<span>
<MaterialIcon type="help" className="align-text-bottom" />
</span>
</OLTooltip>
)}
</div>
<OLToggleButtonGroup
type="radio"
name="figure-width"
onChange={value => dispatch({ width: parseFloat(value) })}
defaultValue={width === 1 ? '1.0' : width?.toString()}
aria-label={t('image_width')}
>
<OLToggleButton
variant="secondary"
id="width-25p"
disabled={hasComplexGraphicsArgument}
value="0.25"
>
{t('1_4_width')}
</OLToggleButton>
<OLToggleButton
variant="secondary"
id="width-50p"
disabled={hasComplexGraphicsArgument}
value="0.5"
>
{t('1_2_width')}
</OLToggleButton>
<OLToggleButton
variant="secondary"
id="width-75p"
disabled={hasComplexGraphicsArgument}
value="0.75"
>
{t('3_4_width')}
</OLToggleButton>
<OLToggleButton
variant="secondary"
id="width-100p"
disabled={hasComplexGraphicsArgument}
value="1.0"
>
{t('full_width')}
</OLToggleButton>
</OLToggleButtonGroup>
</div>
</OLFormGroup>
</>
)
}

View File

@@ -0,0 +1,73 @@
import { FC } from 'react'
import {
FigureModalSource,
useFigureModalContext,
} from './figure-modal-context'
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import MaterialIcon from '@/shared/components/material-icon'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
export const FigureModalSourcePicker: FC = () => {
const { t } = useTranslation()
const {
hasLinkedProjectFileFeature,
hasLinkedProjectOutputFileFeature,
hasLinkUrlFeature,
} = getMeta('ol-ExposedSettings')
const { write } = usePermissionsContext()
return (
<div className="figure-modal-source-button-grid">
{write && (
<FigureModalSourceButton
type={FigureModalSource.FILE_UPLOAD}
title={t('replace_from_computer')}
icon="upload"
/>
)}
<FigureModalSourceButton
type={FigureModalSource.FILE_TREE}
title={t('replace_from_project_files')}
icon="inbox"
/>
{write &&
(hasLinkedProjectFileFeature || hasLinkedProjectOutputFileFeature) && (
<FigureModalSourceButton
type={FigureModalSource.OTHER_PROJECT}
title={t('replace_from_another_project')}
icon="folder_open"
/>
)}
{write && hasLinkUrlFeature && (
<FigureModalSourceButton
type={FigureModalSource.FROM_URL}
title={t('replace_from_url')}
icon="public"
/>
)}
</div>
)
}
const FigureModalSourceButton: FC<{
type: FigureModalSource
title: string
icon: string
}> = ({ type, title, icon }) => {
const { dispatch } = useFigureModalContext()
return (
<button
type="button"
className="figure-modal-source-button"
onClick={() => {
dispatch({ source: type, sourcePickerShown: false, getPath: undefined })
}}
>
<MaterialIcon type={icon} className="figure-modal-source-button-icon" />
<span className="figure-modal-source-button-title">{title}</span>
<MaterialIcon type="chevron_right" />
</button>
)
}

View File

@@ -0,0 +1,306 @@
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import {
FigureModalProvider,
FigureModalSource,
useFigureModalContext,
useFigureModalExistingFigureContext,
} from './figure-modal-context'
import { FigureModalFooter } from './figure-modal-footer'
import { lazy, memo, Suspense, useCallback, useEffect } from 'react'
import { useCodeMirrorViewContext } from '../codemirror-context'
import { ChangeSpec } from '@codemirror/state'
import { snippet } from '@codemirror/autocomplete'
import {
FigureData,
PastedImageData,
editFigureData,
editFigureDataEffect,
} from '../../extensions/figure-modal'
import { ensureEmptyLine } from '../../extensions/toolbar/commands'
import { useTranslation } from 'react-i18next'
import useEventListener from '../../../../shared/hooks/use-event-listener'
import { prepareLines } from '../../utils/prepare-lines'
import { FeedbackBadge } from '@/shared/components/feedback-badge'
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
const FigureModalBody = lazy(() => import('./figure-modal-body'))
export const FigureModal = memo(function FigureModal() {
return (
<FigureModalProvider>
<FigureModalContent />
</FigureModalProvider>
)
})
const FigureModalContent = () => {
const { t } = useTranslation()
const getTitle = useCallback(
(state: FigureModalSource) => {
switch (state) {
case FigureModalSource.FILE_UPLOAD:
return t('upload_from_computer')
case FigureModalSource.FILE_TREE:
return t('insert_from_project_files')
case FigureModalSource.FROM_URL:
return t('insert_from_url')
case FigureModalSource.OTHER_PROJECT:
return t('insert_from_another_project')
case FigureModalSource.EDIT_FIGURE:
return t('edit_figure')
default:
return t('insert_image')
}
},
[t]
)
const {
source,
dispatch,
helpShown,
getPath,
width,
includeCaption,
includeLabel,
sourcePickerShown,
} = useFigureModalContext()
const listener = useCallback(
(event: Event) => {
const { detail } = event as CustomEvent<{
source: FigureModalSource
fileId?: string
filePath?: string
}>
dispatch({
source: detail.source,
selectedItemId: detail.fileId,
getPath: detail.filePath ? async () => detail.filePath! : undefined,
})
},
[dispatch]
)
useEffect(() => {
window.addEventListener('figure-modal:open', listener)
return () => {
window.removeEventListener('figure-modal:open', listener)
}
}, [listener])
const { dispatch: updateExistingFigure } =
useFigureModalExistingFigureContext()
const view = useCodeMirrorViewContext()
const hide = useCallback(() => {
dispatch({ source: FigureModalSource.NONE })
view.requestMeasure()
view.focus()
}, [dispatch, view])
useEventListener(
'figure-modal:open-modal',
useCallback(() => {
const figure = view.state.field<FigureData>(editFigureData, false)
if (!figure) {
return
}
updateExistingFigure({
name: figure.file.path,
// The empty string should *not* be a complex argument
hasComplexGraphicsArgument: Boolean(figure.unknownGraphicsArguments),
})
dispatch({
source: FigureModalSource.EDIT_FIGURE,
width: figure.width,
includeCaption: figure.caption !== null,
includeLabel: figure.label !== null,
})
}, [view, dispatch, updateExistingFigure])
)
useEventListener(
'figure-modal:paste-image',
useCallback(
(image: CustomEvent<PastedImageData>) => {
dispatch({
source: FigureModalSource.FILE_UPLOAD,
pastedImageData: image.detail,
})
},
[dispatch]
)
)
const insert = useCallback(async () => {
const figure = view.state.field<FigureData>(editFigureData, false)
if (!getPath) {
throw new Error('Cannot insert figure without a file path')
}
let path: string
try {
path = await getPath()
} catch (error) {
dispatch({ error: String(error) })
return
}
const labelCommand = includeLabel ? '\\label{fig:enter-label}' : ''
const captionCommand = includeCaption ? '\\caption{Enter Caption}' : ''
if (figure) {
// Updating existing figure
const hadCaptionBefore = figure.caption !== null
const hadLabelBefore = figure.label !== null
const changes: ChangeSpec[] = []
if (!hadCaptionBefore && includeCaption) {
// We should insert a caption
changes.push({
from: figure.graphicsCommand.to,
insert: prepareLines(
['', captionCommand],
view.state,
figure.graphicsCommand.to
),
})
}
if (!hadLabelBefore && includeLabel) {
const from = figure.caption?.to ?? figure.graphicsCommand.to
// We should insert a label
changes.push({
from,
insert: prepareLines(['', labelCommand], view.state, from),
})
}
if (hadCaptionBefore && !includeCaption) {
// We should remove the caption
changes.push({
from: figure.caption!.from,
to: figure.caption!.to,
insert: '',
})
}
if (hadLabelBefore && !includeLabel) {
// We should remove th label
changes.push({
from: figure.label!.from,
to: figure.label!.to,
insert: '',
})
}
if (!figure.unknownGraphicsArguments && width) {
// We understood the arguments, and should update the width
if (figure.graphicsCommandArguments !== null) {
changes.push({
from: figure.graphicsCommandArguments.from,
to: figure.graphicsCommandArguments.to,
insert: `width=${width}\\linewidth`,
})
} else {
// Insert new args
changes.push({
from: figure.file.from - 1,
insert: `[width=${width}\\linewidth]`,
})
}
}
changes.push({ from: figure.file.from, to: figure.file.to, insert: path })
view.dispatch({
changes: view.state.changes(changes),
effects: editFigureDataEffect.of(null),
})
} else {
const { pos, suffix } = ensureEmptyLine(
view.state,
view.state.selection.main
)
const widthArgument =
width !== undefined ? `[width=${width}\\linewidth]` : ''
const caption = includeCaption ? `\n\t\\caption{\${Enter Caption}}` : ''
const label = includeLabel ? `\n\t\\label{\${fig:enter-label}}` : ''
snippet(
`\\begin{figure}
\t\\centering
\t\\includegraphics${widthArgument}{${path}}${caption}${label}
\\end{figure}${suffix}\${}`
)(
{ state: view.state, dispatch: view.dispatch },
{ label: 'figure' },
pos,
pos
)
}
hide()
}, [getPath, view, hide, includeCaption, includeLabel, width, dispatch])
const onDelete = useCallback(() => {
const figure = view.state.field<FigureData>(editFigureData, false)
if (!figure) {
dispatch({ error: "Couldn't remove figure" })
return
}
view.dispatch({
effects: editFigureDataEffect.of(null),
changes: view.state.changes({
from: figure.from,
to: figure.to,
insert: '',
}),
})
dispatch({ sourcePickerShown: false })
hide()
}, [view, hide, dispatch])
const onCancel = useCallback(() => {
dispatch({ sourcePickerShown: false })
view.dispatch({ effects: editFigureDataEffect.of(null) })
hide()
}, [hide, view, dispatch])
if (source === FigureModalSource.NONE) {
return null
}
return (
<OLModal onHide={hide} className="figure-modal" show>
<OLModalHeader closeButton>
<OLModalTitle>
{helpShown
? t('help')
: sourcePickerShown
? t('replace_figure')
: getTitle(source)}{' '}
<FeedbackBadge
id="figure-modal-feedback"
url="https://forms.gle/PfEtwceYBNQ32DF4A"
text="Please click to give feedback about editing figures."
/>
</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<Suspense fallback={<FullSizeLoadingSpinner minHeight="15rem" />}>
<FigureModalBody />
</Suspense>
</OLModalBody>
<OLModalFooter>
<FigureModalFooter
onInsert={insert}
onCancel={onCancel}
onDelete={onDelete}
/>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,99 @@
import { useCallback, useEffect, useState } from 'react'
import { File, FileOrDirectory } from '../../utils/file'
import { useTranslation } from 'react-i18next'
import { useCurrentProjectFolders } from '@/features/source-editor/hooks/use-current-project-folders'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLNotification from '@/features/ui/components/ol/ol-notification'
type FileNameInputProps = Omit<
React.ComponentProps<typeof OLFormControl>,
'onFocus'
> & { targetFolder: File | null; label: string }
function findFile(
folder: { id: string; name: string },
project: FileOrDirectory
): FileOrDirectory | null {
if (project.id === folder.id) {
return project
}
if (project.type !== 'folder') {
return null
}
for (const child of project.children ?? []) {
const search = findFile(folder, child)
if (search) {
return search
}
}
return null
}
function hasOverlap(
name: string,
folder: { id: string; name: string },
project: FileOrDirectory
): boolean {
const directory = findFile(folder, project)
if (!directory) {
return false
}
for (const child of directory.children ?? []) {
if (child.name === name) {
return true
}
}
return false
}
export const FileNameInput = ({
id,
label,
targetFolder,
...props
}: FileNameInputProps) => {
const { t } = useTranslation()
const [overlap, setOverlap] = useState<boolean>(false)
const { rootFolder } = useCurrentProjectFolders()
const { value } = props
useEffect(() => {
if (value) {
setOverlap(
hasOverlap(String(value), targetFolder ?? rootFolder, rootFolder)
)
} else {
setOverlap(false)
}
}, [value, targetFolder, rootFolder])
const onFocus = useCallback((event: React.FocusEvent<HTMLInputElement>) => {
if (!event.target) {
return true
}
const fileName = event.target.value
const fileExtensionIndex = fileName.lastIndexOf('.')
if (fileExtensionIndex >= 0) {
event.target.setSelectionRange(0, fileExtensionIndex)
}
}, [])
return (
<>
<OLFormGroup controlId={id}>
<OLFormLabel>{label}</OLFormLabel>
<OLFormControl onFocus={onFocus} {...props} />
{overlap && (
<OLNotification
type="warning"
content={t(
'a_file_with_that_name_already_exists_and_will_be_overriden'
)}
className="mt-1 mb-0"
/>
)}
</OLFormGroup>
</>
)
}

View File

@@ -0,0 +1,85 @@
import { useCallback } from 'react'
import { FileNameInput } from './file-name-input'
import { File } from '../../utils/file'
import { Select } from '../../../../shared/components/select'
import { useCurrentProjectFolders } from '../../hooks/use-current-project-folders'
import { useTranslation } from 'react-i18next'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
export const FileRelocator = ({
name,
setName,
onNameChanged,
onFolderChanged,
setNameDirty,
folder,
setFolder,
nameDisabled,
}: {
nameDisabled: boolean
name: string
setName: (name: string) => void
onNameChanged: (name: string) => void
folder: File | null
onFolderChanged: (folder: File | null | undefined) => void
setFolder: (folder: File) => void
setNameDirty: (nameDirty: boolean) => void
}) => {
const { t } = useTranslation()
const { folders, rootFile } = useCurrentProjectFolders()
const nameChanged = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setNameDirty(true)
setName(e.target.value)
onNameChanged(e.target.value)
},
[setName, setNameDirty, onNameChanged]
)
const selectedFolderChanged = useCallback(
(item: File | null | undefined) => {
if (item) {
setFolder(item)
} else {
setFolder(rootFile)
}
onFolderChanged(item)
},
[setFolder, onFolderChanged, rootFile]
)
return (
<>
<FileNameInput
id="figure-modal-relocated-file-name"
type="text"
label={t('file_name_in_this_project_figure_modal')}
value={name}
disabled={nameDisabled}
placeholder="example.jpg"
onChange={nameChanged}
targetFolder={folder}
/>
<OLFormGroup>
<Select
items={folders || []}
itemToString={item => {
if (item?.path === '' && item?.name === 'rootFolder') {
return t('no_folder')
}
if (item) {
return `${item.path}${item.name}`
}
return t('no_folder')
}}
itemToSubtitle={item => item?.path ?? ''}
itemToKey={item => item.id}
defaultText={t('select_folder_from_project')}
label={t('folder_location')}
optionalLabel
onSelectedItemChanged={selectedFolderChanged}
/>
</OLFormGroup>
</>
)
}

View File

@@ -0,0 +1,31 @@
import { FC, useEffect } from 'react'
import { FileContainer, FileUploadStatus } from './figure-modal-upload-source'
import {
useFigureModalContext,
useFigureModalExistingFigureContext,
} from '../figure-modal-context'
import { useTranslation } from 'react-i18next'
export const FigureModalEditFigureSource: FC = () => {
const { t } = useTranslation()
const { dispatch } = useFigureModalContext()
const { name } = useFigureModalExistingFigureContext()
useEffect(() => {
if (name === undefined) {
dispatch({ getPath: undefined })
} else {
dispatch({ getPath: async () => name })
}
}, [name, dispatch])
return (
<FileContainer
name={name ?? t('unknown')}
status={FileUploadStatus.SUCCESS}
onDelete={() => {
dispatch({ sourcePickerShown: true })
}}
/>
)
}

View File

@@ -0,0 +1,282 @@
import { FC, useEffect, useMemo, useState } from 'react'
import { Select } from '../../../../../shared/components/select'
import { useFigureModalContext } from '../figure-modal-context'
import {
Project,
useUserProjects,
} from '../../../../file-tree/hooks/use-user-projects'
import {
Entity,
useProjectEntities,
} from '../../../../file-tree/hooks/use-project-entities'
import {
OutputEntity,
useProjectOutputFiles,
} from '../../../../file-tree/hooks/use-project-output-files'
import { useCurrentProjectFolders } from '../../../hooks/use-current-project-folders'
import { File, isImageEntity } from '../../../utils/file'
import { postJSON } from '../../../../../infrastructure/fetch-json'
import { useProjectContext } from '../../../../../shared/context/project-context'
import { FileRelocator } from '../file-relocator'
import { useTranslation } from 'react-i18next'
import { waitForFileTreeUpdate } from '../../../extensions/figure-modal'
import { useCodeMirrorViewContext } from '../../codemirror-context'
import getMeta from '@/utils/meta'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
function suggestName(path: string) {
const parts = path.split('/')
return parts[parts.length - 1]
}
export const FigureModalOtherProjectSource: FC = () => {
const { t } = useTranslation()
const view = useCodeMirrorViewContext()
const { dispatch } = useFigureModalContext()
const { _id: projectId } = useProjectContext()
const { loading: projectsLoading, data: projects, error } = useUserProjects()
const [selectedProject, setSelectedProject] = useState<null | Project>(null)
const { hasLinkedProjectFileFeature, hasLinkedProjectOutputFileFeature } =
getMeta('ol-ExposedSettings')
const [usingOutputFiles, setUsingOutputFiles] = useState<boolean>(
!hasLinkedProjectFileFeature
)
const [nameDirty, setNameDirty] = useState<boolean>(false)
const [name, setName] = useState<string>('')
const [folder, setFolder] = useState<File | null>(null)
const { rootFile } = useCurrentProjectFolders()
const [file, setFile] = useState<OutputEntity | Entity | null>(null)
const FileSelector = usingOutputFiles
? SelectFromProjectOutputFiles
: SelectFromProject
useEffect(() => {
if (error) {
dispatch({ error })
}
}, [error, dispatch])
const updateDispatch: (args: {
newFolder?: File | null
newName?: string
newSelectedProject?: Project | null
newFile?: OutputEntity | Entity | null
}) => void = ({
newFolder = folder,
newName = name,
newSelectedProject = selectedProject,
newFile = file,
}) => {
const targetFolder = newFolder ?? rootFile
if (!newName || !newSelectedProject || !newFile) {
dispatch({ getPath: undefined })
return
}
let body:
| {
parent_folder_id: string
provider: 'project_file'
name: string
data: { source_project_id: string; source_entity_path: string }
}
| {
parent_folder_id: string
provider: 'project_output_file'
name: string
data: {
source_project_id: string
source_output_file_path: string
build_id?: string
clsiServerId?: string
}
} = {
provider: 'project_file',
parent_folder_id: targetFolder.id,
name: newName,
data: {
source_project_id: newSelectedProject._id,
source_entity_path: newFile.path,
},
}
if (usingOutputFiles) {
body = {
...body,
provider: 'project_output_file',
data: {
source_project_id: newSelectedProject._id,
source_output_file_path: newFile.path,
clsiServerId: (newFile as OutputEntity).clsiServerId,
build_id: (newFile as OutputEntity).build,
},
}
}
dispatch({
getPath: async () => {
const fileTreeUpdate = waitForFileTreeUpdate(view)
await postJSON(`/project/${projectId}/linked_file`, {
body,
})
await fileTreeUpdate.withTimeout(500)
return targetFolder.path === '' && targetFolder.name === 'rootFolder'
? `${newName}`
: `${targetFolder.path ? targetFolder.path + '/' : ''}${
targetFolder.name
}/${name}`
},
})
}
return (
<>
<OLFormGroup>
<Select
items={projects ?? []}
itemToString={project => (project ? project.name : '')}
itemToKey={item => item._id}
defaultText={t('select_a_project_figure_modal')}
label={t('project_figure_modal')}
disabled={projectsLoading}
onSelectedItemChanged={item => {
const suggestion = nameDirty ? name : ''
setName(suggestion)
setSelectedProject(item ?? null)
setFile(null)
updateDispatch({
newSelectedProject: item ?? null,
newFile: null,
newName: suggestion,
})
}}
/>
</OLFormGroup>
<OLFormGroup>
<FileSelector
projectId={selectedProject?._id}
onSelectedItemChange={item => {
const suggestion = nameDirty ? name : suggestName(item?.path ?? '')
setName(suggestion)
setFile(item ?? null)
updateDispatch({
newFile: item ?? null,
newName: suggestion,
})
}}
/>
{hasLinkedProjectFileFeature && hasLinkedProjectOutputFileFeature && (
<div>
or{' '}
<OLButton
variant="link"
onClick={() => setUsingOutputFiles(value => !value)}
className="p-0 select-from-files-btn"
>
{usingOutputFiles
? t('select_from_project_files')
: t('select_from_output_files')}
</OLButton>
</div>
)}
</OLFormGroup>
<FileRelocator
folder={folder}
name={name}
nameDisabled={!file && !nameDirty}
onFolderChanged={item => {
const newFolder = item ?? rootFile
updateDispatch({ newFolder })
}}
onNameChanged={name => updateDispatch({ newName: name })}
setFolder={setFolder}
setName={setName}
setNameDirty={setNameDirty}
/>
</>
)
}
const SelectFile = <T extends { path: string }>({
disabled,
files,
onSelectedItemChange,
defaultText,
label,
loading = false,
}: {
disabled: boolean
files?: T[] | null
defaultText?: string
label?: string
loading?: boolean
onSelectedItemChange?: (item: T | null | undefined) => any
}) => {
const { t } = useTranslation()
defaultText = defaultText ?? t('select_a_file_figure_modal')
label = label ?? t('image_file')
const imageFiles = useMemo(() => files?.filter(isImageEntity), [files])
const empty = loading || !imageFiles || imageFiles.length === 0
return (
<Select
loading={loading}
items={imageFiles ?? []}
itemToString={file => (file ? file.path.replace(/^\//, '') : '')}
itemToKey={file => file.path}
defaultText={
imageFiles?.length === 0 ? t('no_image_files_found') : defaultText
}
label={label}
disabled={disabled || empty}
onSelectedItemChanged={onSelectedItemChange}
/>
)
}
const SelectFromProject: FC<{
projectId?: string
onSelectedItemChange?: (item: Entity | null | undefined) => any
}> = ({ projectId, onSelectedItemChange }) => {
const { loading, data: entities, error } = useProjectEntities(projectId)
const { dispatch } = useFigureModalContext()
useEffect(() => {
if (error) {
dispatch({ error })
}
}, [error, dispatch])
return (
<SelectFile
key={projectId}
files={entities}
loading={loading}
disabled={!projectId}
onSelectedItemChange={onSelectedItemChange}
/>
)
}
const SelectFromProjectOutputFiles: FC<{
projectId?: string
onSelectedItemChange?: (item: OutputEntity | null | undefined) => any
}> = ({ projectId, onSelectedItemChange }) => {
const { t } = useTranslation()
const { loading, data: entities, error } = useProjectOutputFiles(projectId)
const { dispatch } = useFigureModalContext()
useEffect(() => {
if (error) {
dispatch({ error })
}
}, [error, dispatch])
return (
<SelectFile
label={t('output_file')}
defaultText={t('select_an_output_file_figure_modal')}
loading={loading}
files={entities}
disabled={!projectId}
onSelectedItemChange={onSelectedItemChange}
/>
)
}

View File

@@ -0,0 +1,44 @@
import { FC, useMemo } from 'react'
import { Select } from '../../../../../shared/components/select'
import { useFigureModalContext } from '../figure-modal-context'
import { filterFiles, isImageFile } from '../../../utils/file'
import { useTranslation } from 'react-i18next'
import { useCurrentProjectFolders } from '@/features/source-editor/hooks/use-current-project-folders'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
export const FigureModalCurrentProjectSource: FC = () => {
const { t } = useTranslation()
const { rootFolder } = useCurrentProjectFolders()
const files = useMemo(
() => filterFiles(rootFolder)?.filter(isImageFile),
[rootFolder]
)
const { dispatch, selectedItemId } = useFigureModalContext()
const noFiles = files?.length === 0
return (
<OLFormGroup>
<Select
items={files || []}
itemToString={file => (file ? file.name : '')}
itemToSubtitle={item => item?.path ?? ''}
itemToKey={item => item.id}
defaultItem={
files && selectedItemId
? files.find(item => item.id === selectedItemId)
: undefined
}
defaultText={
noFiles
? t('no_image_files_found')
: t('select_image_from_project_files')
}
label="Image file"
onSelectedItemChanged={item => {
dispatch({
getPath: item ? async () => `${item.path}${item.name}` : undefined,
})
}}
/>
</OLFormGroup>
)
}

View File

@@ -0,0 +1,346 @@
import { FC, useCallback, useEffect, useState } from 'react'
import { useFigureModalContext } from '../figure-modal-context'
import { useCurrentProjectFolders } from '../../../hooks/use-current-project-folders'
import { File } from '../../../utils/file'
import { Dashboard } from '@uppy/react'
import '@uppy/core/dist/style.css'
import '@uppy/dashboard/dist/style.css'
import { Uppy, type UppyFile } from '@uppy/core'
import XHRUpload from '@uppy/xhr-upload'
import { refreshProjectMetadata } from '../../../../file-tree/util/api'
import { useProjectContext } from '../../../../../shared/context/project-context'
import classNames from 'classnames'
import { FileRelocator } from '../file-relocator'
import { useTranslation } from 'react-i18next'
import { useCodeMirrorViewContext } from '../../codemirror-context'
import { waitForFileTreeUpdate } from '../../../extensions/figure-modal'
import getMeta from '@/utils/meta'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLButton from '@/features/ui/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
import OLSpinner from '@/features/ui/components/ol/ol-spinner'
/* eslint-disable no-unused-vars */
export enum FileUploadStatus {
ERROR,
SUCCESS,
NOT_ATTEMPTED,
UPLOADING,
}
/* eslint-enable no-unused-vars */
export const FigureModalUploadFileSource: FC = () => {
const { t } = useTranslation()
const view = useCodeMirrorViewContext()
const { dispatch, pastedImageData } = useFigureModalContext()
const { _id: projectId } = useProjectContext()
const { rootFile } = useCurrentProjectFolders()
const [folder, setFolder] = useState<File | null>(null)
const [nameDirty, setNameDirty] = useState<boolean>(false)
// Files are immutable, so this will point to a (possibly) old version of the file
const [file, setFile] = useState<UppyFile | null>(null)
const [name, setName] = useState<string>('')
const [uploading, setUploading] = useState<boolean>(false)
const [uploadError, setUploadError] = useState<any>(null)
const [uppy] = useState(() =>
new Uppy({
allowMultipleUploadBatches: false,
restrictions: {
maxNumberOfFiles: 1,
maxFileSize: getMeta('ol-ExposedSettings').maxUploadSize,
allowedFileTypes: ['image/*', '.pdf'],
},
autoProceed: false,
})
// use the basic XHR uploader
.use(XHRUpload, {
endpoint: `/project/${projectId}/upload?folder_id=${rootFile.id}`,
headers: {
'X-CSRF-TOKEN': getMeta('ol-csrfToken'),
},
// limit: maxConnections || 1,
limit: 1,
fieldName: 'qqfile', // "qqfile" field inherited from FineUploader
})
)
const dispatchUploadAction = useCallback(
(name?: string, file?: UppyFile | null, folder?: File | null) => {
if (!name || !file) {
dispatch({ getPath: undefined })
return
}
dispatch({
getPath: async () => {
const fileTreeUpdate = waitForFileTreeUpdate(view)
const uploadResult = await uppy.upload()
await fileTreeUpdate.withTimeout(500)
if (!uploadResult.successful) {
throw new Error('Upload failed')
}
const uploadFolder = folder ?? rootFile
return uploadFolder.path === '' && uploadFolder.name === 'rootFolder'
? `${name}`
: `${uploadFolder.path ? uploadFolder.path + '/' : ''}${
uploadFolder.name
}/${name}`
},
})
},
[dispatch, rootFile, uppy, view]
)
useEffect(() => {
// broadcast doc metadata after each successful upload
const onUploadSuccess = (_file: UppyFile | undefined, response: any) => {
setUploading(false)
if (response.body.entity_type === 'doc') {
window.setTimeout(() => {
refreshProjectMetadata(projectId, response.body.entity_id)
}, 250)
}
}
const onFileAdded = (file: UppyFile) => {
const newName = nameDirty ? name : file.name
setName(newName)
setFile(file)
dispatchUploadAction(newName, file, folder)
}
const onFileRemoved = () => {
if (!nameDirty) {
setName('')
}
setFile(null)
dispatchUploadAction(undefined, null, folder)
}
const onUpload = () => {
// Set endpoint dynamically https://github.com/transloadit/uppy/issues/1790#issuecomment-581402293
setUploadError(null)
uppy.getFiles().forEach(file => {
uppy.setFileState(file.id, {
// HACK: There seems to be no other way of renaming the underlying file object
data: new globalThis.File([file.data], name),
meta: {
...file.meta,
name,
},
name,
xhrUpload: {
...(file as any).xhrUpload,
endpoint: `/project/${projectId}/upload?folder_id=${
(folder ?? rootFile).id
}`,
},
})
})
setUploading(true)
}
// handle upload errors
const onError = (
_file: UppyFile | undefined,
error: any,
response: any
) => {
setUploading(false)
setUploadError(error)
switch (response?.status) {
case 429:
dispatch({
error: 'Unable to process your file. Please try again later.',
})
break
case 403:
dispatch({ error: 'Your session has expired' })
break
default:
dispatch({
error: response?.body?.error ?? 'An unknown error occured',
})
break
}
}
uppy
.on('file-added', onFileAdded)
.on('file-removed', onFileRemoved)
.on('upload-success', onUploadSuccess)
.on('upload', onUpload)
.on('upload-error', onError)
return () => {
uppy
.off('file-added', onFileAdded)
.off('file-removed', onFileRemoved)
.off('upload-success', onUploadSuccess)
.off('upload', onUpload)
.off('upload-error', onError)
}
}, [
uppy,
folder,
rootFile,
name,
nameDirty,
dispatchUploadAction,
projectId,
file,
dispatch,
])
useEffect(() => {
if (pastedImageData) {
uppy.addFile(pastedImageData)
}
}, [uppy, pastedImageData])
return (
<>
<OLFormGroup>
<div className="figure-modal-upload">
{file ? (
<FileContainer
name={file.name}
size={file.size}
status={
uploading
? FileUploadStatus.UPLOADING
: uploadError
? FileUploadStatus.ERROR
: FileUploadStatus.NOT_ATTEMPTED
}
onDelete={() => {
uppy.removeFile(file.id)
setFile(null)
const newName = nameDirty ? name : ''
setName(newName)
dispatchUploadAction(newName, null, folder)
}}
/>
) : (
<Dashboard
uppy={uppy}
showProgressDetails
height={120}
width="100%"
showLinkToFileUploadResult={false}
proudlyDisplayPoweredByUppy={false}
showSelectedFiles={false}
hideUploadButton
locale={{
strings: {
// Text to show on the droppable area.
// `%{browseFiles}` is replaced with a link that opens the system file selection dialog.
dropPasteFiles: `${t(
'drag_here_paste_an_image_or'
)} %{browseFiles}`,
// Used as the label for the link that opens the system file selection dialog.
browseFiles: t('select_from_your_computer'),
},
}}
/>
)}
</div>
</OLFormGroup>
<FileRelocator
folder={folder}
name={name}
nameDisabled={!file && !nameDirty}
onFolderChanged={item =>
dispatchUploadAction(name, file, item ?? rootFile)
}
onNameChanged={name => dispatchUploadAction(name, file, folder)}
setFolder={setFolder}
setName={setName}
setNameDirty={setNameDirty}
/>
</>
)
}
export const FileContainer: FC<{
name: string
size?: number
status: FileUploadStatus
onDelete?: () => any
}> = ({ name, size, status, onDelete }) => {
const { t } = useTranslation()
let icon = ''
switch (status) {
case FileUploadStatus.ERROR:
icon = 'cancel'
break
case FileUploadStatus.SUCCESS:
icon = 'check_circle'
break
case FileUploadStatus.NOT_ATTEMPTED:
icon = 'imagesmode'
break
}
return (
<div className="file-container">
<div className="file-container-file">
<span
className={classNames({
'text-success': status === FileUploadStatus.SUCCESS,
'text-danger': status === FileUploadStatus.ERROR,
})}
>
{status === FileUploadStatus.UPLOADING ? (
<OLSpinner size="sm" />
) : (
<MaterialIcon type={icon} className="align-text-bottom" />
)}
</span>
<div className="file-info">
<span className="file-name" aria-label={t('file_name_figure_modal')}>
{name}
</span>
{size !== undefined && <FileSize size={size} />}
</div>
<OLButton
variant="link"
className="p-0 text-decoration-none"
aria-label={t('remove_or_replace_figure')}
onClick={() => onDelete && onDelete()}
>
<MaterialIcon type="cancel" />
</OLButton>
</div>
</div>
)
}
const FileSize: FC<{ size: number; className?: string }> = ({
size,
className,
}) => {
const { t } = useTranslation()
const BYTE_UNITS: [string, number][] = [
['B', 1],
['KB', 1e3],
['MB', 1e6],
['GB', 1e9],
['TB', 1e12],
['PB', 1e15],
]
const labelIndex = Math.min(
Math.floor(Math.log10(size) / 3),
BYTE_UNITS.length - 1
)
const [label, bytesPerUnit] = BYTE_UNITS[labelIndex]
const sizeInUnits = Math.round(size / bytesPerUnit)
return (
<small aria-label={t('file_size')} className={className}>
{sizeInUnits} {label}
</small>
)
}

View File

@@ -0,0 +1,109 @@
import { FC, useState } from 'react'
import { useFigureModalContext } from '../figure-modal-context'
import { postJSON } from '../../../../../infrastructure/fetch-json'
import { useProjectContext } from '../../../../../shared/context/project-context'
import { File } from '../../../utils/file'
import { useCurrentProjectFolders } from '../../../hooks/use-current-project-folders'
import { FileRelocator } from '../file-relocator'
import { useTranslation } from 'react-i18next'
import { useCodeMirrorViewContext } from '../../codemirror-context'
import { EditorView } from '@codemirror/view'
import { waitForFileTreeUpdate } from '../../../extensions/figure-modal'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
function generateLinkedFileFetcher(
projectId: string,
url: string,
name: string,
folder: File,
view: EditorView
) {
return async () => {
const fileTreeUpdate = waitForFileTreeUpdate(view)
await postJSON(`/project/${projectId}/linked_file`, {
body: {
parent_folder_id: folder.id,
provider: 'url',
name,
data: {
url,
},
},
})
await fileTreeUpdate.withTimeout(500)
return folder.path === '' && folder.name === 'rootFolder'
? `${name}`
: `${folder.path ? folder.path + '/' : ''}${folder.name}/${name}`
}
}
export const FigureModalUrlSource: FC = () => {
const view = useCodeMirrorViewContext()
const { t } = useTranslation()
const [url, setUrl] = useState<string>('')
const [nameDirty, setNameDirty] = useState<boolean>(false)
const [name, setName] = useState<string>('')
const { _id: projectId } = useProjectContext()
const { rootFile } = useCurrentProjectFolders()
const [folder, setFolder] = useState<File>(rootFile)
const { dispatch, getPath } = useFigureModalContext()
// TODO: Find another way to do this
const ensureButtonActivation = (
newUrl: string,
newName: string,
folder: File | null | undefined
) => {
if (newUrl && newName) {
dispatch({
getPath: generateLinkedFileFetcher(
projectId,
newUrl,
newName,
folder ?? rootFile,
view
),
})
} else if (getPath) {
dispatch({ getPath: undefined })
}
}
return (
<>
<OLFormGroup controlId="figure-modal-url-url">
<OLFormLabel>{t('image_url')}</OLFormLabel>
<OLFormControl
type="text"
placeholder={t('enter_image_url')}
value={url}
onChange={e => {
setUrl(e.target.value)
let newName = name
if (!nameDirty) {
// TODO: Improve this
const parts = e.target.value.split('/')
newName = parts[parts.length - 1] ?? ''
setName(newName)
}
ensureButtonActivation(e.target.value, newName, folder)
}}
/>
</OLFormGroup>
<FileRelocator
folder={folder}
name={name}
nameDisabled={url.length === 0}
onFolderChanged={folder => ensureButtonActivation(url, name, folder)}
onNameChanged={name => ensureButtonActivation(url, name, folder)}
setFolder={setFolder}
setName={setName}
setNameDirty={setNameDirty}
/>
</>
)
}