first commit
This commit is contained in:
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user