mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-06-06 07:39:02 +02:00
[cm6] Add figure modal (#12751)
GitOrigin-RevId: 3043d1369ed85b38b1fec7479385b123a304c05b
This commit is contained in:
committed by
Copybot
parent
09f5f879f1
commit
7fedecddad
@@ -1085,6 +1085,12 @@ const ProjectController = {
|
||||
}
|
||||
)
|
||||
},
|
||||
figureModalAssignment(cb) {
|
||||
SplitTestHandler.getAssignment(req, res, 'figure-modal', () => {
|
||||
// We'll pick up the assignment from the res.locals assignment.
|
||||
cb()
|
||||
})
|
||||
},
|
||||
onboardingVideoTourAssignment(cb) {
|
||||
SplitTestHandler.getAssignment(
|
||||
req,
|
||||
|
||||
@@ -43,7 +43,7 @@ Note that your figure will automatically be placed in the most appropriate place
|
||||
|
||||
\begin{figure}
|
||||
\centering
|
||||
\includegraphics[width=0.3\textwidth]{frog.jpg}
|
||||
\includegraphics[width=0.25\linewidth]{frog.jpg}
|
||||
\caption{\label{fig:frog}This frog was uploaded via the file-tree menu.}
|
||||
\end{figure}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ Note that your figure will automatically be placed in the most appropriate place
|
||||
|
||||
\begin{figure}
|
||||
\centering
|
||||
\includegraphics[width=0.3\textwidth]{frog.jpg}
|
||||
\includegraphics[width=0.25\linewidth]{frog.jpg}
|
||||
\caption{\label{fig:frog}This frog was uploaded via the file-tree menu.}
|
||||
\end{figure}
|
||||
|
||||
|
||||
+9
-4
@@ -3,12 +3,17 @@ import { getJSON } from '../../../infrastructure/fetch-json'
|
||||
import { fileCollator } from '../util/file-collator'
|
||||
import useAbortController from '../../../shared/hooks/use-abort-controller'
|
||||
|
||||
const alphabetical = (a, b) => fileCollator.compare(a.path, b.path)
|
||||
export type Entity = {
|
||||
path: string
|
||||
}
|
||||
|
||||
export function useProjectEntities(projectId) {
|
||||
const alphabetical = (a: Entity, b: Entity) =>
|
||||
fileCollator.compare(a.path, b.path)
|
||||
|
||||
export function useProjectEntities(projectId?: string) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [data, setData] = useState<Entity[] | null>(null)
|
||||
const [error, setError] = useState<any>(false)
|
||||
|
||||
const { signal } = useAbortController()
|
||||
|
||||
+17
-8
@@ -3,12 +3,20 @@ import { postJSON } from '../../../infrastructure/fetch-json'
|
||||
import { fileCollator } from '../util/file-collator'
|
||||
import useAbortController from '../../../shared/hooks/use-abort-controller'
|
||||
|
||||
const alphabetical = (a, b) => fileCollator.compare(a.path, b.path)
|
||||
export type OutputEntity = {
|
||||
path: string
|
||||
clsiServerId: string
|
||||
compileGroup: string
|
||||
build: string
|
||||
}
|
||||
|
||||
export function useProjectOutputFiles(projectId) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(false)
|
||||
const alphabetical = (a: OutputEntity, b: OutputEntity) =>
|
||||
fileCollator.compare(a.path, b.path)
|
||||
|
||||
export function useProjectOutputFiles(projectId?: string) {
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [data, setData] = useState<OutputEntity[] | null>(null)
|
||||
const [error, setError] = useState<any>(false)
|
||||
|
||||
const { signal } = useAbortController()
|
||||
|
||||
@@ -28,10 +36,11 @@ export function useProjectOutputFiles(projectId) {
|
||||
})
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
const filteredFiles = data.outputFiles.filter(file =>
|
||||
file.path.match(/.*\.(pdf|png|jpeg|jpg|gif)/)
|
||||
const filteredFiles = data.outputFiles.filter(
|
||||
(file: OutputEntity) =>
|
||||
file.path.match(/.*\.(pdf|png|jpeg|jpg|gif)/)
|
||||
)
|
||||
data.outputFiles.forEach(file => {
|
||||
data.outputFiles.forEach((file: OutputEntity) => {
|
||||
file.clsiServerId = data.clsiServerId
|
||||
file.compileGroup = data.compileGroup
|
||||
})
|
||||
+10
-3
@@ -3,12 +3,19 @@ import { getJSON } from '../../../infrastructure/fetch-json'
|
||||
import { fileCollator } from '../util/file-collator'
|
||||
import useAbortController from '../../../shared/hooks/use-abort-controller'
|
||||
|
||||
const alphabetical = (a, b) => fileCollator.compare(a.name, b.name)
|
||||
export type Project = {
|
||||
_id: string
|
||||
name: string
|
||||
accessLevel: string
|
||||
}
|
||||
|
||||
const alphabetical = (a: Project, b: Project) =>
|
||||
fileCollator.compare(a.name, b.name)
|
||||
|
||||
export function useUserProjects() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [data, setData] = useState<Project[] | null>(null)
|
||||
const [error, setError] = useState<any>(false)
|
||||
|
||||
const { signal } = useAbortController()
|
||||
|
||||
@@ -16,6 +16,7 @@ import { CodemirrorOutline } from './codemirror-outline'
|
||||
import { dispatchTimer } from '../../../infrastructure/cm6-performance'
|
||||
|
||||
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
|
||||
import { FigureModal } from './figure-modal/figure-modal'
|
||||
|
||||
const sourceEditorComponents = importOverleafModules(
|
||||
'sourceEditorComponents'
|
||||
@@ -53,6 +54,7 @@ function CodeMirrorEditor() {
|
||||
<CodeMirrorViewContext.Provider value={viewRef.current}>
|
||||
<CodemirrorOutline />
|
||||
<CodeMirrorView />
|
||||
<FigureModal />
|
||||
<CodeMirrorSearch />
|
||||
<CodeMirrorToolbar />
|
||||
{sourceEditorComponents.map(
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { Alert } from 'react-bootstrap'
|
||||
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'
|
||||
|
||||
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 const 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 && (
|
||||
<Alert bsStyle="danger" onDismiss={onDismiss}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<Body />
|
||||
<FigureModalFigureOptions />
|
||||
</>
|
||||
)
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import { FC, createContext, useContext, useReducer } from 'react'
|
||||
|
||||
/* 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
|
||||
includeCaption: boolean
|
||||
includeLabel: boolean
|
||||
error?: 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,
|
||||
...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
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import { Button } from 'react-bootstrap'
|
||||
import {
|
||||
FigureModalSource,
|
||||
useFigureModalContext,
|
||||
} from './figure-modal-context'
|
||||
import Icon from '../../../../shared/components/icon'
|
||||
import { FC } from 'react'
|
||||
|
||||
export const FigureModalFooter: FC<{
|
||||
onInsert: () => void
|
||||
onCancel: () => void
|
||||
onDelete: () => void
|
||||
}> = ({ onInsert, onCancel, onDelete }) => {
|
||||
return (
|
||||
<div className="figure-modal-footer">
|
||||
<div className="figure-modal-help-buttons">
|
||||
<HelpToggle />
|
||||
</div>
|
||||
<div className="figure-modal-actions">
|
||||
<Button
|
||||
bsStyle={null}
|
||||
className="btn-secondary"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<FigureModalAction onInsert={onInsert} onDelete={onDelete} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const HelpToggle = () => {
|
||||
const { helpShown, dispatch } = useFigureModalContext()
|
||||
if (helpShown) {
|
||||
return (
|
||||
<Button
|
||||
bsStyle={null}
|
||||
className="btn-link figure-modal-help-link"
|
||||
onClick={() => dispatch({ helpShown: false })}
|
||||
>
|
||||
<Icon type="arrow-left" fw />
|
||||
Back
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
bsStyle={null}
|
||||
className="btn-link figure-modal-help-link"
|
||||
onClick={() => dispatch({ helpShown: true })}
|
||||
>
|
||||
<Icon type="question-circle" fw />
|
||||
Help
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const FigureModalAction: FC<{
|
||||
onInsert: () => void
|
||||
onDelete: () => void
|
||||
}> = ({ onInsert, onDelete }) => {
|
||||
const { helpShown, getPath, source, sourcePickerShown } =
|
||||
useFigureModalContext()
|
||||
|
||||
if (helpShown) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (sourcePickerShown) {
|
||||
return (
|
||||
<Button
|
||||
bsStyle={null}
|
||||
className="btn-danger"
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
>
|
||||
Delete figure
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
if (source === FigureModalSource.EDIT_FIGURE) {
|
||||
return (
|
||||
<Button
|
||||
bsStyle={null}
|
||||
className="btn-success"
|
||||
type="button"
|
||||
onClick={onInsert}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
bsStyle={null}
|
||||
className="btn-success"
|
||||
type="button"
|
||||
disabled={getPath === undefined}
|
||||
onClick={onInsert}
|
||||
>
|
||||
Insert figure
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { FC } from 'react'
|
||||
|
||||
const LearnWikiLink: FC<{ article: string }> = ({ article, children }) => {
|
||||
return <a href={`/learn/latex/${article}`}>{children}</a>
|
||||
}
|
||||
|
||||
export const FigureModalHelp = () => {
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
This tool helps you insert figures into your project without needing to
|
||||
write the LaTeX code. The following information explains more about the
|
||||
options in the tool and how to further customize your figures.
|
||||
</p>
|
||||
|
||||
<b>Editing captions</b>
|
||||
<p>
|
||||
When you tick the box “Include caption” the image will be inserted into
|
||||
your document with a placeholder caption. To edit it, you simply select
|
||||
the placeholder text and type to replace it with your own.{' '}
|
||||
</p>
|
||||
|
||||
<b>Understanding labels</b>
|
||||
<p>
|
||||
Labels help you to easily reference your figures throughout your
|
||||
document. To reference a figure within the text, reference the label
|
||||
using the <code>\ref{...}</code> command. This makes it easy
|
||||
to reference figures without needing to manually remember the figure
|
||||
numbering.{' '}
|
||||
<LearnWikiLink article="Inserting_Images#Labels_and_cross-references">
|
||||
Learn more
|
||||
</LearnWikiLink>
|
||||
</p>
|
||||
|
||||
<b>Customizing figures</b>
|
||||
<p>
|
||||
There are lots of options to edit and customize your figures, such as
|
||||
wrapping text around the figure, rotating the image, or including
|
||||
multiple images in a single figure. You’ll need to edit the LaTeX code
|
||||
to do this.{' '}
|
||||
<LearnWikiLink article="Inserting_Images">Find out how</LearnWikiLink>
|
||||
</p>
|
||||
|
||||
<b>Changing the position of your figure</b>
|
||||
<p>
|
||||
LaTeX places figures according to a special algorithm. You can use
|
||||
something called ‘placement parameters’ to influence the positioning of
|
||||
the figure.{' '}
|
||||
<LearnWikiLink article="Positioning_images_and_tables">
|
||||
Find out how
|
||||
</LearnWikiLink>
|
||||
</p>
|
||||
|
||||
<b>Dealing with errors</b>
|
||||
<p>
|
||||
Are you getting an Undefined Control Sequence error? If you are, make
|
||||
sure you’ve loaded the graphicx package—
|
||||
<code>\usepackage{graphicx}</code>—in the preamble
|
||||
(first section of code) in your document.{' '}
|
||||
<LearnWikiLink article="Inserting_Images">Learn more</LearnWikiLink>
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { FC } from 'react'
|
||||
import Icon from '../../../../shared/components/icon'
|
||||
import Tooltip from '../../../../shared/components/tooltip'
|
||||
import {
|
||||
useFigureModalContext,
|
||||
useFigureModalExistingFigureContext,
|
||||
} from './figure-modal-context'
|
||||
import { Switcher, SwitcherItem } from '../../../../shared/components/switcher'
|
||||
|
||||
export const FigureModalFigureOptions: FC = () => {
|
||||
const { includeCaption, includeLabel, dispatch, width } =
|
||||
useFigureModalContext()
|
||||
|
||||
const { hasComplexGraphicsArgument } = useFigureModalExistingFigureContext()
|
||||
return (
|
||||
<>
|
||||
<div className="figure-modal-checkbox-input">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="figure-modal-caption"
|
||||
defaultChecked={includeCaption}
|
||||
onChange={event => dispatch({ includeCaption: event.target.checked })}
|
||||
/>
|
||||
<label className="figure-modal-label" htmlFor="figure-modal-caption">
|
||||
Include caption
|
||||
</label>
|
||||
</div>
|
||||
<div className="figure-modal-checkbox-input">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="figure-modal-label"
|
||||
defaultChecked={includeLabel}
|
||||
onChange={event => dispatch({ includeLabel: event.target.checked })}
|
||||
/>
|
||||
<label htmlFor="figure-modal-label" className="mb-0 figure-modal-label">
|
||||
Include label
|
||||
<br />
|
||||
<span className="text-muted text-small figure-modal-label-description">
|
||||
Used when referring to the figure elsewhere in the document
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="figure-modal-switcher-input">
|
||||
<div>
|
||||
Image width{' '}
|
||||
{hasComplexGraphicsArgument ? (
|
||||
<Tooltip
|
||||
id="figure-modal-image-width-warning-tooltip"
|
||||
description="A custom size has been used in the LaTeX code."
|
||||
overlayProps={{ delay: 0, placement: 'top' }}
|
||||
>
|
||||
<Icon type="exclamation-triangle" fw />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip
|
||||
id="figure-modal-image-width-tooltip"
|
||||
description="The width you choose here is based on the width of the text in your document. Alternatively, you can customize the image size directly in the LaTeX code."
|
||||
overlayProps={{ delay: 0, placement: 'bottom' }}
|
||||
>
|
||||
<Icon type="question-circle" fw />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Switcher
|
||||
name="figure-width"
|
||||
onChange={value => dispatch({ width: parseFloat(value) })}
|
||||
defaultValue={width === 1 ? '1.0' : width.toString()}
|
||||
disabled={hasComplexGraphicsArgument}
|
||||
>
|
||||
<SwitcherItem value="0.25" label="¼ width" />
|
||||
<SwitcherItem value="0.5" label="½ width" />
|
||||
<SwitcherItem value="0.75" label="¾ width" />
|
||||
<SwitcherItem value="1.0" label="Full width" />
|
||||
</Switcher>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { FC } from 'react'
|
||||
import {
|
||||
FigureModalSource,
|
||||
useFigureModalContext,
|
||||
} from './figure-modal-context'
|
||||
import Icon from '../../../../shared/components/icon'
|
||||
import { Button } from 'react-bootstrap'
|
||||
|
||||
export const FigureModalSourcePicker: FC = () => {
|
||||
return (
|
||||
<div className="figure-modal-source-selector">
|
||||
<div className="figure-modal-source-button-row">
|
||||
<FigureModalSourceButton
|
||||
type={FigureModalSource.FILE_UPLOAD}
|
||||
title="Replace from computer"
|
||||
icon="upload"
|
||||
/>
|
||||
<FigureModalSourceButton
|
||||
type={FigureModalSource.OTHER_PROJECT}
|
||||
title="Replace from another project"
|
||||
icon="folder-open"
|
||||
/>
|
||||
</div>
|
||||
<div className="figure-modal-source-button-row">
|
||||
<FigureModalSourceButton
|
||||
type={FigureModalSource.FILE_TREE}
|
||||
title="Replace from project files"
|
||||
icon="archive"
|
||||
/>
|
||||
<FigureModalSourceButton
|
||||
type={FigureModalSource.FROM_URL}
|
||||
title="Replace from URL"
|
||||
icon="globe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FigureModalSourceButton: FC<{
|
||||
type: FigureModalSource
|
||||
title: string
|
||||
icon: string
|
||||
}> = ({ type, title, icon }) => {
|
||||
const { dispatch } = useFigureModalContext()
|
||||
return (
|
||||
<Button
|
||||
bsStyle={null}
|
||||
bsClass=""
|
||||
className="figure-modal-source-button"
|
||||
onClick={() => {
|
||||
dispatch({ source: type, sourcePickerShown: false, getPath: undefined })
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
type={icon}
|
||||
className="figure-modal-source-button-icon source-icon"
|
||||
fw
|
||||
/>
|
||||
<span className="figure-modal-source-button-title">{title}</span>
|
||||
<Icon
|
||||
type="chevron-right"
|
||||
className="figure-modal-source-button-icon"
|
||||
fw
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
import { Modal } from 'react-bootstrap'
|
||||
import AccessibleModal from '../../../../shared/components/accessible-modal'
|
||||
import {
|
||||
FigureModalProvider,
|
||||
FigureModalSource,
|
||||
useFigureModalContext,
|
||||
useFigureModalExistingFigureContext,
|
||||
} from './figure-modal-context'
|
||||
import { FigureModalBody } from './figure-modal-body'
|
||||
import { FigureModalFooter } from './figure-modal-footer'
|
||||
import { memo, useCallback, useEffect } from 'react'
|
||||
import { useCodeMirrorViewContext } from '../codemirror-editor'
|
||||
import { ChangeSpec } from '@codemirror/state'
|
||||
import SplitTestBadge from '../../../../shared/components/split-test-badge'
|
||||
import {
|
||||
FigureData,
|
||||
editFigureData,
|
||||
editFigureDataEffect,
|
||||
} from '../../extensions/figure-modal'
|
||||
import { ensureEmptyLine } from '../../extensions/toolbar/commands'
|
||||
|
||||
const getTitle = (state: FigureModalSource) => {
|
||||
switch (state) {
|
||||
case FigureModalSource.FILE_UPLOAD:
|
||||
return 'Upload from computer'
|
||||
case FigureModalSource.FILE_TREE:
|
||||
return 'Insert from project files'
|
||||
case FigureModalSource.FROM_URL:
|
||||
return 'Insert from URL'
|
||||
case FigureModalSource.OTHER_PROJECT:
|
||||
return 'Insert from another project'
|
||||
case FigureModalSource.EDIT_FIGURE:
|
||||
return 'Edit figure'
|
||||
default:
|
||||
return 'Insert image'
|
||||
}
|
||||
}
|
||||
|
||||
export const FigureModal = memo(function FigureModal() {
|
||||
return (
|
||||
<FigureModalProvider>
|
||||
<FigureModalContent />
|
||||
</FigureModalProvider>
|
||||
)
|
||||
})
|
||||
|
||||
const FigureModalContent = () => {
|
||||
const {
|
||||
source,
|
||||
dispatch,
|
||||
helpShown,
|
||||
getPath,
|
||||
width,
|
||||
includeCaption,
|
||||
includeLabel,
|
||||
sourcePickerShown,
|
||||
} = useFigureModalContext()
|
||||
|
||||
const listener = useCallback(
|
||||
(event: Event) => {
|
||||
const { detail: source } = event as CustomEvent<FigureModalSource>
|
||||
dispatch({ source })
|
||||
},
|
||||
[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])
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => {
|
||||
const figure = view.state.field<FigureData>(editFigureData, false)
|
||||
if (!figure) {
|
||||
return
|
||||
}
|
||||
updateExistingFigure({
|
||||
name: figure.file.path,
|
||||
hasComplexGraphicsArgument:
|
||||
figure.unknownGraphicsArguments !== undefined,
|
||||
})
|
||||
dispatch({
|
||||
source: FigureModalSource.EDIT_FIGURE,
|
||||
width: figure.width ?? 0.5,
|
||||
includeCaption: figure.caption !== null,
|
||||
includeLabel: figure.label !== null,
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('figure-modal:open-modal', listener)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('figure-modal:open-modal', listener)
|
||||
}
|
||||
}, [view, dispatch, updateExistingFigure])
|
||||
|
||||
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.label?.from ?? figure.graphicsCommand.to,
|
||||
insert: (figure.label ? '' : '\n') + captionCommand,
|
||||
})
|
||||
}
|
||||
if (!hadLabelBefore && includeLabel) {
|
||||
// We should insert a label
|
||||
changes.push({
|
||||
from: figure.caption?.to ?? figure.graphicsCommand.to,
|
||||
insert: (includeCaption ? '' : '\n') + labelCommand,
|
||||
})
|
||||
}
|
||||
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) {
|
||||
// We understood the arguments, and should update the width
|
||||
if (figure.graphicsCommandArguments) {
|
||||
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 {
|
||||
view.dispatch(
|
||||
view.state.changeByRange(range => {
|
||||
const { pos, suffix } = ensureEmptyLine(view.state, range)
|
||||
const graphicxCommand = `\\includegraphics[width=${width}\\linewidth]{${path}}`
|
||||
const changes: ChangeSpec = view.state.changes({
|
||||
insert: `\\begin{figure}\n\\centering\n${graphicxCommand}\n${captionCommand}${labelCommand}${
|
||||
labelCommand || captionCommand ? '\n' : '' // Add an extra newline if we've added a caption or label
|
||||
}\\end{figure}${suffix}`,
|
||||
from: pos,
|
||||
})
|
||||
|
||||
return { range: range.map(changes), changes }
|
||||
})
|
||||
)
|
||||
}
|
||||
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 (
|
||||
<AccessibleModal onHide={hide} className="figure-modal" show>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
{helpShown
|
||||
? 'Help'
|
||||
: sourcePickerShown
|
||||
? 'Replace figure'
|
||||
: getTitle(source)}{' '}
|
||||
<SplitTestBadge
|
||||
splitTestName="figure-modal"
|
||||
displayOnVariants={['enabled']}
|
||||
/>
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
|
||||
<Modal.Body>
|
||||
<FigureModalBody />
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
<FigureModalFooter
|
||||
onInsert={insert}
|
||||
onCancel={onCancel}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</Modal.Footer>
|
||||
</AccessibleModal>
|
||||
)
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
DetailedHTMLProps,
|
||||
InputHTMLAttributes,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { File, FileOrDirectory } from '../../utils/file'
|
||||
import useScopeValue from '../../../../shared/hooks/use-scope-value'
|
||||
import { Alert } from 'react-bootstrap'
|
||||
|
||||
type FileNameInputProps = Omit<
|
||||
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
|
||||
'onFocus'
|
||||
> & { targetFolder: File | null }
|
||||
|
||||
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 = ({
|
||||
targetFolder,
|
||||
...props
|
||||
}: FileNameInputProps) => {
|
||||
const [overlap, setOverlap] = useState<boolean>(false)
|
||||
const [rootFolder] = useScopeValue<FileOrDirectory>('rootFolder')
|
||||
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 (
|
||||
<>
|
||||
<input {...props} type="text" onFocus={onFocus} />
|
||||
{overlap && (
|
||||
<Alert bsStyle="warning" className="mt-1 mb-0">
|
||||
A file with that name already exists. That file will be overwritten.
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
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/useCurrentProjectFolders'
|
||||
|
||||
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 [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 (
|
||||
<>
|
||||
<label htmlFor="figure-modal-relocated-file-name">
|
||||
File name in this project
|
||||
</label>
|
||||
<FileNameInput
|
||||
id="figure-modal-relocated-file-name"
|
||||
type="text"
|
||||
className="form-control figure-modal-input-field"
|
||||
value={name}
|
||||
disabled={nameDisabled}
|
||||
placeholder="example.jpg"
|
||||
onChange={nameChanged}
|
||||
targetFolder={folder}
|
||||
/>
|
||||
<Select
|
||||
items={folders || []}
|
||||
itemToString={item => {
|
||||
if (item?.path === '' && item?.name === 'rootFolder') {
|
||||
return 'No folder'
|
||||
}
|
||||
if (item) {
|
||||
return `${item.path}${item.name}`
|
||||
}
|
||||
return 'No folder'
|
||||
}}
|
||||
itemToSubtitle={item => item?.path ?? ''}
|
||||
itemToKey={item => item.id}
|
||||
defaultText="Select folder from project"
|
||||
label="Folder location"
|
||||
optionalLabel
|
||||
onSelectedItemChanged={selectedFolderChanged}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { FC, useEffect } from 'react'
|
||||
import { FileContainer, FileUploadStatus } from './figure-modal-upload-source'
|
||||
import {
|
||||
useFigureModalContext,
|
||||
useFigureModalExistingFigureContext,
|
||||
} from '../figure-modal-context'
|
||||
|
||||
export const FigureModalEditFigureSource: FC = () => {
|
||||
const { dispatch } = useFigureModalContext()
|
||||
const { name } = useFigureModalExistingFigureContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (name === undefined) {
|
||||
dispatch({ getPath: undefined })
|
||||
} else {
|
||||
dispatch({ getPath: async () => name })
|
||||
}
|
||||
}, [name, dispatch])
|
||||
|
||||
return (
|
||||
<FileContainer
|
||||
name={name ?? 'Unknown'}
|
||||
status={FileUploadStatus.SUCCESS}
|
||||
onDelete={() => {
|
||||
dispatch({ sourcePickerShown: true })
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
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 { Button } from 'react-bootstrap'
|
||||
import { useCurrentProjectFolders } from '../../../hooks/useCurrentProjectFolders'
|
||||
import { File, isImageEntity } from '../../../utils/file'
|
||||
import { postJSON } from '../../../../../infrastructure/fetch-json'
|
||||
import { useProjectContext } from '../../../../../shared/context/project-context'
|
||||
import { FileRelocator } from '../file-relocator'
|
||||
|
||||
function suggestName(path: string) {
|
||||
const parts = path.split('/')
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
export const FigureModalOtherProjectSource: FC = () => {
|
||||
const { dispatch } = useFigureModalContext()
|
||||
const { _id: projectId } = useProjectContext()
|
||||
const { loading: projectsLoading, data: projects, error } = useUserProjects()
|
||||
const [selectedProject, setSelectedProject] = useState<null | Project>(null)
|
||||
const [usingOutputFiles, setUsingOutputFiles] = useState<boolean>(false)
|
||||
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 () => {
|
||||
await postJSON(`/project/${projectId}/linked_file`, {
|
||||
body,
|
||||
})
|
||||
return targetFolder.path === '' && targetFolder.name === 'rootFolder'
|
||||
? `${newName}`
|
||||
: `${targetFolder.path ? targetFolder.path + '/' : ''}${
|
||||
targetFolder.name
|
||||
}/${name}`
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
items={projects ?? []}
|
||||
itemToString={project => (project ? project.name : '')}
|
||||
itemToKey={item => item._id}
|
||||
defaultText="Select a project"
|
||||
label="Project"
|
||||
disabled={projectsLoading}
|
||||
onSelectedItemChanged={item => {
|
||||
const suggestion = nameDirty ? name : ''
|
||||
setName(suggestion)
|
||||
setSelectedProject(item ?? null)
|
||||
setFile(null)
|
||||
updateDispatch({
|
||||
newSelectedProject: item ?? null,
|
||||
newFile: null,
|
||||
newName: suggestion,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<FileSelector
|
||||
projectId={selectedProject?._id}
|
||||
onSelectedItemChange={item => {
|
||||
const suggestion = nameDirty ? name : suggestName(item?.path ?? '')
|
||||
setName(suggestion)
|
||||
setFile(item ?? null)
|
||||
updateDispatch({
|
||||
newFile: item ?? null,
|
||||
newName: suggestion,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
or{' '}
|
||||
<Button
|
||||
className="px-0"
|
||||
bsStyle="link"
|
||||
type="button"
|
||||
onClick={() => setUsingOutputFiles(value => !value)}
|
||||
>
|
||||
<span>
|
||||
{usingOutputFiles
|
||||
? 'select from project files'
|
||||
: 'select from output files'}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<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,
|
||||
}: {
|
||||
disabled: boolean
|
||||
files?: T[]
|
||||
onSelectedItemChange?: (item: T | null | undefined) => any
|
||||
}) => {
|
||||
const imageFiles = useMemo(() => files?.filter(isImageEntity), [files])
|
||||
return (
|
||||
<Select
|
||||
items={imageFiles ?? []}
|
||||
itemToString={file => (file ? file.path.replace(/^\//, '') : '')}
|
||||
itemToKey={file => file.path}
|
||||
defaultText="Select a file"
|
||||
label="Image file"
|
||||
disabled={disabled}
|
||||
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
|
||||
files={entities ?? []}
|
||||
disabled={loading || !projectId}
|
||||
onSelectedItemChange={onSelectedItemChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectFromProjectOutputFiles: FC<{
|
||||
projectId?: string
|
||||
onSelectedItemChange?: (item: OutputEntity | null | undefined) => any
|
||||
}> = ({ projectId, onSelectedItemChange }) => {
|
||||
const { loading, data: entities, error } = useProjectOutputFiles(projectId)
|
||||
const { dispatch } = useFigureModalContext()
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
dispatch({ error })
|
||||
}
|
||||
}, [error, dispatch])
|
||||
return (
|
||||
<SelectFile
|
||||
files={entities ?? []}
|
||||
disabled={loading || !projectId}
|
||||
onSelectedItemChange={onSelectedItemChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { FC, useMemo } from 'react'
|
||||
import useScopeValue from '../../../../../shared/hooks/use-scope-value'
|
||||
import { Select } from '../../../../../shared/components/select'
|
||||
import { useFigureModalContext } from '../figure-modal-context'
|
||||
import { FileOrDirectory, filterFiles, isImageFile } from '../../../utils/file'
|
||||
|
||||
export const FigureModalCurrentProjectSource: FC = () => {
|
||||
const [rootFolder] = useScopeValue<FileOrDirectory>('rootFolder')
|
||||
const files = useMemo(
|
||||
() => filterFiles(rootFolder)?.filter(isImageFile),
|
||||
[rootFolder]
|
||||
)
|
||||
const { dispatch } = useFigureModalContext()
|
||||
return (
|
||||
<Select
|
||||
items={files || []}
|
||||
itemToString={file => (file ? file.name : '')}
|
||||
itemToSubtitle={item => item?.path ?? ''}
|
||||
itemToKey={item => item.id}
|
||||
defaultText="Select image from project files"
|
||||
label="Image file"
|
||||
onSelectedItemChanged={item => {
|
||||
dispatch({
|
||||
getPath: item ? async () => `${item.path}${item.name}` : undefined,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
import { FC, useCallback, useEffect, useState } from 'react'
|
||||
import { useFigureModalContext } from '../figure-modal-context'
|
||||
import { useCurrentProjectFolders } from '../../../hooks/useCurrentProjectFolders'
|
||||
import { File } from '../../../utils/file'
|
||||
import { Dashboard, useUppy } from '@uppy/react'
|
||||
import '@uppy/core/dist/style.css'
|
||||
import '@uppy/dashboard/dist/style.css'
|
||||
import { Uppy, 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 Icon from '../../../../../shared/components/icon'
|
||||
import classNames from 'classnames'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { FileRelocator } from '../file-relocator'
|
||||
|
||||
const maxFileSize = window.ExposedSettings.maxUploadSize
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
export enum FileUploadStatus {
|
||||
ERROR,
|
||||
SUCCESS,
|
||||
NOT_ATTEMPTED,
|
||||
UPLOADING,
|
||||
}
|
||||
/* eslint-enable no-unused-vars */
|
||||
|
||||
export const FigureModalUploadFileSource: FC = () => {
|
||||
const { dispatch } = useFigureModalContext()
|
||||
const { _id: projectId } = useProjectContext()
|
||||
const [, rootFolder] = 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 = useUppy(() =>
|
||||
new Uppy({
|
||||
allowMultipleUploads: false,
|
||||
restrictions: {
|
||||
maxNumberOfFiles: 1,
|
||||
maxFileSize: maxFileSize || null,
|
||||
allowedFileTypes: ['image/*', '.pdf'],
|
||||
},
|
||||
autoProceed: false,
|
||||
})
|
||||
// use the basic XHR uploader
|
||||
.use(XHRUpload, {
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': window.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 uploadResult = await uppy.upload()
|
||||
if (!uploadResult.successful) {
|
||||
throw new Error('Upload failed')
|
||||
}
|
||||
const uploadFolder = folder ?? rootFolder
|
||||
return uploadFolder.path === '' && uploadFolder.name === 'rootFolder'
|
||||
? `${name}`
|
||||
: `${uploadFolder.path ? uploadFolder.path + '/' : ''}${
|
||||
uploadFolder.name
|
||||
}/${name}`
|
||||
},
|
||||
})
|
||||
},
|
||||
[dispatch, rootFolder, uppy]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// broadcast doc metadata after each successful upload
|
||||
const onUploadSuccess = (_file: UppyFile, 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 ?? rootFolder).id
|
||||
}`,
|
||||
},
|
||||
})
|
||||
})
|
||||
setUploading(true)
|
||||
}
|
||||
|
||||
// handle upload errors
|
||||
const onError = (_file: UppyFile, 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,
|
||||
rootFolder,
|
||||
name,
|
||||
nameDirty,
|
||||
dispatchUploadAction,
|
||||
projectId,
|
||||
file,
|
||||
dispatch,
|
||||
])
|
||||
|
||||
return (
|
||||
<>
|
||||
<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: `Drag here, paste an image, or %{browseFiles}`,
|
||||
// Used as the label for the link that opens the system file selection dialog.
|
||||
browseFiles: 'select from your computer',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FileRelocator
|
||||
folder={folder}
|
||||
name={name}
|
||||
nameDisabled={!file && !nameDirty}
|
||||
onFolderChanged={item =>
|
||||
dispatchUploadAction(name, file, item ?? rootFolder)
|
||||
}
|
||||
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 }) => {
|
||||
let icon
|
||||
switch (status) {
|
||||
case FileUploadStatus.ERROR:
|
||||
icon = 'times-circle'
|
||||
break
|
||||
case FileUploadStatus.SUCCESS:
|
||||
icon = 'check-circle'
|
||||
break
|
||||
case FileUploadStatus.NOT_ATTEMPTED:
|
||||
icon = 'picture-o'
|
||||
break
|
||||
case FileUploadStatus.UPLOADING:
|
||||
icon = 'spinner'
|
||||
}
|
||||
return (
|
||||
<div className="file-container">
|
||||
<div className="file-container-file">
|
||||
<Icon
|
||||
spin={status === FileUploadStatus.UPLOADING}
|
||||
type={icon}
|
||||
className={classNames(
|
||||
{
|
||||
'text-success': status === FileUploadStatus.SUCCESS,
|
||||
'text-danger': status === FileUploadStatus.ERROR,
|
||||
},
|
||||
'file-icon'
|
||||
)}
|
||||
/>
|
||||
<div className="file-info">
|
||||
<span className="file-name">{name}</span>
|
||||
{size !== undefined && (
|
||||
<FileSize size={size} className="text-small" />
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
bsStyle={null}
|
||||
className="btn btn-link p-0"
|
||||
onClick={() => onDelete && onDelete()}
|
||||
>
|
||||
<Icon fw type="times-circle" className="file-action file-icon" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FileSize: FC<{ size: number; className?: string }> = ({
|
||||
size,
|
||||
className,
|
||||
}) => {
|
||||
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 (
|
||||
<span className={className}>
|
||||
{sizeInUnits} {label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
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/useCurrentProjectFolders'
|
||||
import { FileRelocator } from '../file-relocator'
|
||||
|
||||
function generateLinkedFileFetcher(
|
||||
projectId: string,
|
||||
url: string,
|
||||
name: string,
|
||||
folder: File
|
||||
) {
|
||||
return async () => {
|
||||
await postJSON(`/project/${projectId}/linked_file`, {
|
||||
body: {
|
||||
parent_folder_id: folder.id,
|
||||
provider: 'url',
|
||||
name,
|
||||
data: {
|
||||
url,
|
||||
},
|
||||
},
|
||||
})
|
||||
return folder.path === '' && folder.name === 'rootFolder'
|
||||
? `${name}`
|
||||
: `${folder.path ? folder.path + '/' : ''}${folder.name}/${name}`
|
||||
}
|
||||
}
|
||||
|
||||
export const FigureModalUrlSource: FC = () => {
|
||||
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
|
||||
),
|
||||
})
|
||||
} else if (getPath) {
|
||||
dispatch({ getPath: undefined })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<label htmlFor="figure-modal-url-url">Image URL</label>
|
||||
<input
|
||||
id="figure-modal-url-url"
|
||||
type="text"
|
||||
className="form-control figure-modal-input-field"
|
||||
placeholder="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)
|
||||
}}
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { FC, memo, useRef } from 'react'
|
||||
import { Button, ListGroup, Overlay, Popover } from 'react-bootstrap'
|
||||
import Icon from '../../../../shared/components/icon'
|
||||
import useDropdown from '../../../../shared/hooks/use-dropdown'
|
||||
import Tooltip from '../../../../shared/components/tooltip'
|
||||
|
||||
export const ToolbarButtonMenu: FC<{
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
}> = memo(function ButtonMenu({ icon, id, label, children }) {
|
||||
const target = useRef<any>(null)
|
||||
const { open, onToggle, ref } = useDropdown()
|
||||
const button = (
|
||||
<Button
|
||||
type="button"
|
||||
className="ol-cm-toolbar-button"
|
||||
bsStyle={null}
|
||||
onMouseDown={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={() => {
|
||||
onToggle(!open)
|
||||
}}
|
||||
ref={target}
|
||||
>
|
||||
<Icon type={icon} fw />
|
||||
</Button>
|
||||
)
|
||||
|
||||
const overlay = (
|
||||
<Overlay
|
||||
show={open}
|
||||
target={target.current}
|
||||
placement="bottom"
|
||||
container={document.querySelector('.cm-editor')}
|
||||
containerPadding={0}
|
||||
animation
|
||||
onHide={() => onToggle(false)}
|
||||
>
|
||||
<Popover
|
||||
id={`${id}-menu`}
|
||||
ref={ref}
|
||||
className="ol-cm-toolbar-button-menu-popover"
|
||||
>
|
||||
<ListGroup
|
||||
onClick={() => {
|
||||
onToggle(false)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ListGroup>
|
||||
</Popover>
|
||||
</Overlay>
|
||||
)
|
||||
|
||||
if (!label) {
|
||||
return (
|
||||
<>
|
||||
{button}
|
||||
{overlay}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
id={id}
|
||||
description={<div>{label}</div>}
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
>
|
||||
{button}
|
||||
</Tooltip>
|
||||
{overlay}
|
||||
</>
|
||||
)
|
||||
})
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { ListGroupItem } from 'react-bootstrap'
|
||||
import { ToolbarButtonMenu } from './button-menu'
|
||||
import Icon from '../../../../shared/components/icon'
|
||||
import { useCallback } from 'react'
|
||||
import { FigureModalSource } from '../figure-modal/figure-modal-context'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const InsertFigureDropdown = () => {
|
||||
const { t } = useTranslation()
|
||||
const openFigureModal = useCallback((source: FigureModalSource) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('figure-modal:open', {
|
||||
detail: source,
|
||||
})
|
||||
)
|
||||
}, [])
|
||||
return (
|
||||
<ToolbarButtonMenu
|
||||
id="toolbar-figure"
|
||||
label={t('toolbar_insert_figure')}
|
||||
icon="picture-o"
|
||||
>
|
||||
<ListGroupItem
|
||||
onClick={() => openFigureModal(FigureModalSource.FILE_UPLOAD)}
|
||||
>
|
||||
<Icon type="upload" fw /> Upload from computer
|
||||
</ListGroupItem>
|
||||
<ListGroupItem
|
||||
onClick={() => openFigureModal(FigureModalSource.FILE_TREE)}
|
||||
>
|
||||
<Icon type="archive" fw /> From project files
|
||||
</ListGroupItem>
|
||||
<ListGroupItem
|
||||
onClick={() => openFigureModal(FigureModalSource.OTHER_PROJECT)}
|
||||
>
|
||||
<Icon type="folder-open" fw /> From another project
|
||||
</ListGroupItem>
|
||||
<ListGroupItem
|
||||
onClick={() => openFigureModal(FigureModalSource.FROM_URL)}
|
||||
>
|
||||
<Icon type="globe" fw /> From URL
|
||||
</ListGroupItem>
|
||||
</ToolbarButtonMenu>
|
||||
)
|
||||
}
|
||||
+14
-6
@@ -14,6 +14,8 @@ import * as commands from '../../extensions/toolbar/commands'
|
||||
import { SectionHeadingDropdown } from './section-heading-dropdown'
|
||||
import { canAddComment } from '../../extensions/toolbar/comments'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import getMeta from '../../../../utils/meta'
|
||||
import { InsertFigureDropdown } from './insert-figure-dropdown'
|
||||
|
||||
const isMac = /Mac/.test(window.navigator?.platform)
|
||||
|
||||
@@ -27,6 +29,7 @@ export const ToolbarItems: FC<{
|
||||
const listDepth = minimumListDepthForSelection(state)
|
||||
const addCommentEmitter = useScopeEventEmitter('comment:start_adding')
|
||||
const { setReviewPanelOpen } = useLayoutContext()
|
||||
const splitTestVariants = getMeta('ol-splitTestVariants', {})
|
||||
const addComment = useCallback(
|
||||
(view: EditorView) => {
|
||||
const range = view.state.selection.main
|
||||
@@ -42,6 +45,7 @@ export const ToolbarItems: FC<{
|
||||
[addCommentEmitter, setReviewPanelOpen]
|
||||
)
|
||||
|
||||
const showFigureModal = splitTestVariants['figure-modal'] === 'enabled'
|
||||
const showGroup = (group: string) => !overflowed || overflowed.has(group)
|
||||
|
||||
return (
|
||||
@@ -149,12 +153,16 @@ export const ToolbarItems: FC<{
|
||||
icon="comment"
|
||||
hidden // enable this if an alternative to the floating "Add Comment" button is needed
|
||||
/>
|
||||
<ToolbarButton
|
||||
id="toolbar-figure"
|
||||
label={t('toolbar_insert_figure')}
|
||||
command={commands.insertFigure}
|
||||
icon="picture-o"
|
||||
/>
|
||||
{showFigureModal ? (
|
||||
<InsertFigureDropdown />
|
||||
) : (
|
||||
<ToolbarButton
|
||||
id="toolbar-figure"
|
||||
label={t('toolbar_insert_figure')}
|
||||
command={commands.insertFigure}
|
||||
icon="picture-o"
|
||||
/>
|
||||
)}
|
||||
<ToolbarButton
|
||||
id="toolbar-table"
|
||||
label={t('toolbar_insert_table')}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
ChangeSet,
|
||||
Extension,
|
||||
StateEffect,
|
||||
StateField,
|
||||
} from '@codemirror/state'
|
||||
|
||||
type NestedReadonly<T> = {
|
||||
readonly [P in keyof T]: NestedReadonly<T[P]>
|
||||
}
|
||||
|
||||
type FigureDataProps = {
|
||||
from: number
|
||||
to: number
|
||||
caption: {
|
||||
from: number
|
||||
to: number
|
||||
} | null
|
||||
label: { from: number; to: number } | null
|
||||
width?: number
|
||||
unknownGraphicsArguments?: string
|
||||
graphicsCommandArguments: {
|
||||
from: number
|
||||
to: number
|
||||
} | null
|
||||
graphicsCommand: { from: number; to: number }
|
||||
file: {
|
||||
from: number
|
||||
to: number
|
||||
path: string
|
||||
}
|
||||
}
|
||||
|
||||
function mapFromTo<T extends { from: number; to: number } | null>(
|
||||
position: T,
|
||||
changes: ChangeSet
|
||||
) {
|
||||
if (!position) {
|
||||
return position
|
||||
}
|
||||
return {
|
||||
...position,
|
||||
from: changes.mapPos(position.from),
|
||||
to: changes.mapPos(position.to),
|
||||
}
|
||||
}
|
||||
|
||||
export class FigureData {
|
||||
// eslint-disable-next-line no-useless-constructor
|
||||
constructor(private props: NestedReadonly<FigureDataProps>) {}
|
||||
|
||||
public get from() {
|
||||
return this.props.from
|
||||
}
|
||||
|
||||
public get to() {
|
||||
return this.props.to
|
||||
}
|
||||
|
||||
public get caption() {
|
||||
return this.props.caption
|
||||
}
|
||||
|
||||
public get label() {
|
||||
return this.props.label
|
||||
}
|
||||
|
||||
public get width() {
|
||||
return this.props.width
|
||||
}
|
||||
|
||||
public get unknownGraphicsArguments() {
|
||||
return this.props.unknownGraphicsArguments
|
||||
}
|
||||
|
||||
public get graphicsCommandArguments() {
|
||||
return this.props.graphicsCommandArguments
|
||||
}
|
||||
|
||||
public get graphicsCommand() {
|
||||
return this.props.graphicsCommand
|
||||
}
|
||||
|
||||
public get file() {
|
||||
return this.props.file
|
||||
}
|
||||
|
||||
map(changes: ChangeSet): FigureData {
|
||||
return new FigureData({
|
||||
from: changes.mapPos(this.from),
|
||||
to: changes.mapPos(this.to),
|
||||
caption: mapFromTo(this.caption, changes),
|
||||
label: mapFromTo(this.label, changes),
|
||||
graphicsCommand: mapFromTo(this.graphicsCommand, changes),
|
||||
width: this.width,
|
||||
file: mapFromTo(this.file, changes),
|
||||
graphicsCommandArguments: mapFromTo(
|
||||
this.graphicsCommandArguments,
|
||||
changes
|
||||
),
|
||||
unknownGraphicsArguments: this.unknownGraphicsArguments,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const editFigureDataEffect = StateEffect.define<FigureData | null>()
|
||||
|
||||
export const editFigureData = StateField.define<FigureData | null>({
|
||||
create: () => null,
|
||||
update: (current, transaction) => {
|
||||
let value: FigureData | null | undefined
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(editFigureDataEffect)) {
|
||||
value = effect.value
|
||||
}
|
||||
}
|
||||
// Allow setting to null
|
||||
if (value !== undefined) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
return current
|
||||
}
|
||||
return current.map(transaction.changes)
|
||||
},
|
||||
})
|
||||
|
||||
export const figureModal = (): Extension => [editFigureData]
|
||||
@@ -28,7 +28,7 @@ export const toggleNumberedList = toggleListForRanges('enumerate')
|
||||
export const wrapInInlineMath = wrapRanges('\\(', '\\)')
|
||||
export const wrapInDisplayMath = wrapRanges('\n\\[', '\\]\n')
|
||||
|
||||
const ensureEmptyLine = (state: EditorState, range: SelectionRange) => {
|
||||
export const ensureEmptyLine = (state: EditorState, range: SelectionRange) => {
|
||||
let pos = range.anchor
|
||||
let suffix = ''
|
||||
|
||||
|
||||
@@ -58,6 +58,21 @@ export const toolbarPanel = () => [
|
||||
},
|
||||
},
|
||||
},
|
||||
'.ol-cm-toolbar-button-menu-popover': {
|
||||
'& > .popover-content': {
|
||||
padding: 0,
|
||||
},
|
||||
'& .arrow': {
|
||||
display: 'none',
|
||||
},
|
||||
'& .list-group': {
|
||||
marginBottom: 0,
|
||||
},
|
||||
'& .list-group-item': {
|
||||
width: '100%',
|
||||
textAlign: 'start',
|
||||
},
|
||||
},
|
||||
'.ol-cm-toolbar-button-group': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
+24
-4
@@ -24,6 +24,7 @@ import { EndWidget } from './visual-widgets/end'
|
||||
import {
|
||||
getEnvironmentArguments,
|
||||
getEnvironmentName,
|
||||
parseFigureData,
|
||||
} from '../../utils/tree-operations/environments'
|
||||
import { MathWidget } from './visual-widgets/math'
|
||||
import { GraphicsWidget } from './visual-widgets/graphics'
|
||||
@@ -41,6 +42,9 @@ import { EndDocumentWidget } from './visual-widgets/end-document'
|
||||
import { EnvironmentLineWidget } from './visual-widgets/environment-line'
|
||||
import { ListEnvironmentName } from '../../utils/tree-operations/ancestors'
|
||||
import { InlineGraphicsWidget } from './visual-widgets/inline-graphics'
|
||||
import getMeta from '../../../../utils/meta'
|
||||
import { EditableGraphicsWidget } from './visual-widgets/editable-graphics'
|
||||
import { EditableInlineGraphicsWidget } from './visual-widgets/editable-inline-graphics'
|
||||
|
||||
type Options = {
|
||||
fileTreeManager: {
|
||||
@@ -109,6 +113,9 @@ const hasClosingBrace = (node: SyntaxNode) =>
|
||||
* Decorations that span multiple lines must be contained in a StateField, not a ViewPlugin.
|
||||
*/
|
||||
export const atomicDecorations = (options: Options) => {
|
||||
const splitTestVariants = getMeta('ol-splitTestVariants', {})
|
||||
const figureModalEnabled = splitTestVariants['figure-modal'] === 'enabled'
|
||||
|
||||
const getPreviewByPath = (path: string) =>
|
||||
options.fileTreeManager.getPreviewByPath(path)
|
||||
|
||||
@@ -698,19 +705,31 @@ export const atomicDecorations = (options: Options) => {
|
||||
environmentNode &&
|
||||
centeringNodeForEnvironment(environmentNode)
|
||||
)
|
||||
const figureData = environmentNode
|
||||
? parseFigureData(environmentNode, state)
|
||||
: null
|
||||
|
||||
const line = state.doc.lineAt(nodeRef.from)
|
||||
|
||||
const lineContainsOnlyNode =
|
||||
line.text.trim().length === nodeRef.to - nodeRef.from
|
||||
|
||||
const BlockGraphicsWidgetClass = figureModalEnabled
|
||||
? EditableGraphicsWidget
|
||||
: GraphicsWidget
|
||||
|
||||
const InlineGraphicsWidgetClass = figureModalEnabled
|
||||
? EditableInlineGraphicsWidget
|
||||
: InlineGraphicsWidget
|
||||
|
||||
if (lineContainsOnlyNode) {
|
||||
decorations.push(
|
||||
Decoration.replace({
|
||||
widget: new GraphicsWidget(
|
||||
widget: new BlockGraphicsWidgetClass(
|
||||
filePath,
|
||||
getPreviewByPath,
|
||||
centered
|
||||
centered,
|
||||
figureData
|
||||
),
|
||||
block: true,
|
||||
}).range(line.from, line.to)
|
||||
@@ -718,10 +737,11 @@ export const atomicDecorations = (options: Options) => {
|
||||
} else {
|
||||
decorations.push(
|
||||
Decoration.replace({
|
||||
widget: new InlineGraphicsWidget(
|
||||
widget: new InlineGraphicsWidgetClass(
|
||||
filePath,
|
||||
getPreviewByPath,
|
||||
centered
|
||||
centered,
|
||||
figureData
|
||||
),
|
||||
}).range(nodeRef.from, nodeRef.to)
|
||||
)
|
||||
|
||||
@@ -275,6 +275,15 @@ export const visualTheme = EditorView.theme({
|
||||
display: 'inline',
|
||||
},
|
||||
},
|
||||
'.ol-cm-graphics-inline-edit-wrapper': {
|
||||
display: 'inline-block',
|
||||
position: 'relative',
|
||||
verticalAlign: 'middle',
|
||||
'& .ol-cm-graphics': {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
},
|
||||
'.ol-cm-graphics-loading': {
|
||||
height: '300px', // guess that the height is the same as the max width
|
||||
},
|
||||
@@ -336,4 +345,12 @@ export const visualTheme = EditorView.theme({
|
||||
'.ol-cm-end-document-widget': {
|
||||
textAlign: 'center',
|
||||
},
|
||||
'.ol-cm-environment-figure': {
|
||||
position: 'relative',
|
||||
},
|
||||
'.ol-cm-graphics-edit-button': {
|
||||
position: 'absolute',
|
||||
top: '18px',
|
||||
right: '18px',
|
||||
},
|
||||
})
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { GraphicsWidget } from './graphics'
|
||||
import { editFigureDataEffect } from '../../figure-modal'
|
||||
|
||||
export class EditableGraphicsWidget extends GraphicsWidget {
|
||||
setEditDispatcher(button: HTMLButtonElement, view: EditorView) {
|
||||
button.classList.toggle('hidden', !this.figureData)
|
||||
if (this.figureData) {
|
||||
button.onmousedown = event => {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
view.dispatch({ effects: editFigureDataEffect.of(this.figureData) })
|
||||
window.dispatchEvent(new CustomEvent('figure-modal:open-modal'))
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
button.onmousedown = null
|
||||
}
|
||||
}
|
||||
|
||||
updateDOM(element: HTMLImageElement, view: EditorView): boolean {
|
||||
if (
|
||||
this.filePath === element.dataset.filepath &&
|
||||
element.dataset.width === this.figureData?.width?.toString()
|
||||
) {
|
||||
// Figure remained the same, so just update the event listener on the button
|
||||
this.setEditDispatcher(
|
||||
element.querySelector('.ol-cm-graphics-edit-button')!,
|
||||
view
|
||||
)
|
||||
return true
|
||||
}
|
||||
this.destroyed = false
|
||||
element.classList.toggle('ol-cm-environment-centered', this.centered)
|
||||
this.renderGraphic(element, view)
|
||||
return true
|
||||
}
|
||||
|
||||
createEditButton(view: EditorView) {
|
||||
const button = document.createElement('button')
|
||||
this.setEditDispatcher(button, view)
|
||||
button.classList.add('btn', 'btn-secondary', 'ol-cm-graphics-edit-button')
|
||||
const buttonLabel = document.createElement('span')
|
||||
buttonLabel.classList.add('fa', 'fa-pencil')
|
||||
button.append(buttonLabel)
|
||||
return button
|
||||
}
|
||||
|
||||
renderGraphic(element: HTMLElement, view: EditorView) {
|
||||
super.renderGraphic(element, view)
|
||||
if (this.figureData) {
|
||||
if (this.figureData.width) {
|
||||
element.dataset.width = this.figureData.width.toString()
|
||||
}
|
||||
const button = this.createEditButton(view)
|
||||
element.prepend(button)
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { EditableGraphicsWidget } from './editable-graphics'
|
||||
|
||||
export class EditableInlineGraphicsWidget extends EditableGraphicsWidget {
|
||||
updateElementData(element: HTMLElement) {
|
||||
element.dataset.filepath = this.filePath
|
||||
element.dataset.width = this.figureData?.width?.toString()
|
||||
if (this.figureData?.width) {
|
||||
element.style.width = `min(100%, ${this.figureData.width * 100}%)`
|
||||
} else {
|
||||
element.style.width = ''
|
||||
}
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
this.destroyed = false
|
||||
const element = document.createElement('span')
|
||||
element.classList.add('ol-cm-graphics-inline-edit-wrapper')
|
||||
this.updateElementData(element)
|
||||
const inlineElement = document.createElement('span')
|
||||
inlineElement.classList.add('ol-cm-graphics-inline')
|
||||
this.renderGraphic(inlineElement, view)
|
||||
element.append(inlineElement)
|
||||
return element
|
||||
}
|
||||
|
||||
updateDOM(element: HTMLImageElement, view: EditorView): boolean {
|
||||
const updated = super.updateDOM(element, view)
|
||||
if (!updated) {
|
||||
return false
|
||||
}
|
||||
// We need to make sure these are updated, as `renderGraphic` in the base
|
||||
// class will update them on the inner element.
|
||||
this.updateElementData(element)
|
||||
return true
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event) {
|
||||
return event.type !== 'mousedown' && event.type !== 'mouseup'
|
||||
}
|
||||
|
||||
// We set the actual figure width on the span rather than the img element
|
||||
getFigureWidth(): string {
|
||||
return '100%'
|
||||
}
|
||||
}
|
||||
+30
-4
@@ -1,9 +1,10 @@
|
||||
import { EditorView, WidgetType } from '@codemirror/view'
|
||||
import { placeSelectionInsideBlock } from '../selection'
|
||||
import { isEqual } from 'lodash'
|
||||
import { FigureData } from '../../figure-modal'
|
||||
|
||||
export class GraphicsWidget extends WidgetType {
|
||||
destroyed = false
|
||||
|
||||
height = 300 // for estimatedHeight, updated when the image is loaded
|
||||
|
||||
constructor(
|
||||
@@ -11,7 +12,8 @@ export class GraphicsWidget extends WidgetType {
|
||||
public getPreviewByPath: (
|
||||
filePath: string
|
||||
) => { url: string; extension: string } | null,
|
||||
public centered: boolean
|
||||
public centered: boolean,
|
||||
public figureData: FigureData | null
|
||||
) {
|
||||
super()
|
||||
}
|
||||
@@ -37,7 +39,9 @@ export class GraphicsWidget extends WidgetType {
|
||||
|
||||
eq(widget: GraphicsWidget) {
|
||||
return (
|
||||
widget.filePath === this.filePath && widget.centered === this.centered
|
||||
widget.filePath === this.filePath &&
|
||||
widget.centered === this.centered &&
|
||||
isEqual(this.figureData, widget.figureData)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,7 +53,14 @@ export class GraphicsWidget extends WidgetType {
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event) {
|
||||
return event.type !== 'mouseup'
|
||||
return (
|
||||
event.type !== 'mouseup' &&
|
||||
// Pass events through to the edit button
|
||||
!(
|
||||
event.target instanceof HTMLElement &&
|
||||
event.target.closest('.ol-cm-graphics-edit-button')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
destroy() {
|
||||
@@ -64,6 +75,8 @@ export class GraphicsWidget extends WidgetType {
|
||||
element.textContent = '' // ensure the element is empty
|
||||
|
||||
const preview = this.getPreviewByPath(this.filePath)
|
||||
element.dataset.filepath = this.filePath
|
||||
element.dataset.width = undefined
|
||||
|
||||
if (!preview) {
|
||||
const message = document.createElement('div')
|
||||
@@ -92,10 +105,20 @@ export class GraphicsWidget extends WidgetType {
|
||||
}
|
||||
}
|
||||
|
||||
getFigureWidth() {
|
||||
if (this.figureData?.width) {
|
||||
return `min(100%, ${this.figureData.width * 100}%)`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
createImage(view: EditorView, url: string) {
|
||||
const image = document.createElement('img')
|
||||
image.classList.add('ol-cm-graphics')
|
||||
image.classList.add('ol-cm-graphics-loading')
|
||||
const width = this.getFigureWidth()
|
||||
image.style.width = width
|
||||
image.style.maxWidth = width
|
||||
|
||||
image.src = url
|
||||
image.addEventListener('load', () => {
|
||||
@@ -126,6 +149,9 @@ export class GraphicsWidget extends WidgetType {
|
||||
const viewport = page.getViewport({ scale: 1 })
|
||||
canvas.width = viewport.width
|
||||
canvas.height = viewport.height
|
||||
const width = this.getFigureWidth()
|
||||
canvas.style.width = width
|
||||
canvas.style.maxWidth = width
|
||||
page.render({
|
||||
canvasContext: canvas.getContext('2d'),
|
||||
viewport,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useMemo } from 'react'
|
||||
import useScopeValue from '../../../shared/hooks/use-scope-value'
|
||||
import { File, FileOrDirectory, filterFolders } from '../utils/file'
|
||||
|
||||
export const useCurrentProjectFolders: () => [
|
||||
File[] | undefined,
|
||||
File
|
||||
] = () => {
|
||||
const [rootFolder] = useScopeValue<FileOrDirectory>('rootFolder')
|
||||
const rootFile = { ...rootFolder, path: '' }
|
||||
const folders = useMemo(() => filterFolders(rootFolder), [rootFolder])
|
||||
return [folders, rootFile]
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import importOverleafModules from '../../../../../macros/import-overleaf-module.
|
||||
import { documentOutline } from './document-outline'
|
||||
import { LaTeXLanguage } from './latex-language'
|
||||
import { documentEnvironmentNames } from './document-environment-names'
|
||||
import { figureModal } from '../../extensions/figure-modal'
|
||||
|
||||
const completionSources: CompletionSource[] = [
|
||||
...argumentCompletionSources,
|
||||
@@ -43,5 +44,6 @@ export const latex = () => {
|
||||
autocomplete: completionSource,
|
||||
})
|
||||
),
|
||||
figureModal(),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Entity } from '../../file-tree/hooks/use-project-entities'
|
||||
import { OutputEntity } from '../../file-tree/hooks/use-project-output-files'
|
||||
|
||||
export type FileOrDirectory = {
|
||||
name: string
|
||||
id: string
|
||||
type: 'file' | 'doc' | 'folder'
|
||||
children?: FileOrDirectory[]
|
||||
}
|
||||
|
||||
export type File = {
|
||||
path: string
|
||||
name: string
|
||||
id: string
|
||||
}
|
||||
|
||||
function filterByType(type: 'file' | 'doc' | 'folder') {
|
||||
return (
|
||||
tree: FileOrDirectory,
|
||||
path = '',
|
||||
list: undefined | File[] = undefined
|
||||
) => {
|
||||
if (!tree) {
|
||||
return list
|
||||
}
|
||||
if (list === undefined) {
|
||||
list = []
|
||||
}
|
||||
const isRootFolder = tree.name === 'rootFolder' && path === ''
|
||||
if (tree.children) {
|
||||
for (const child of tree.children) {
|
||||
filterByType(type)(
|
||||
child,
|
||||
`${isRootFolder ? '' : `${path ? path + '/' : path}${tree.name}/`}`,
|
||||
list
|
||||
)
|
||||
}
|
||||
}
|
||||
if (tree.type === type) {
|
||||
list.push({ path, id: tree.id, name: tree.name })
|
||||
}
|
||||
return list
|
||||
}
|
||||
}
|
||||
|
||||
export const filterFiles = filterByType('file')
|
||||
export const filterDocs = filterByType('doc')
|
||||
export const filterFolders = filterByType('folder')
|
||||
|
||||
const IMAGE_FILE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'pdf']
|
||||
|
||||
export function isImageFile(file: File) {
|
||||
const parts = file.name.split('.')
|
||||
if (parts.length < 2) {
|
||||
return false
|
||||
}
|
||||
const extension = parts[parts.length - 1].toLowerCase()
|
||||
return IMAGE_FILE_EXTENSIONS.includes(extension)
|
||||
}
|
||||
|
||||
export function isImageEntity(file: Entity | OutputEntity) {
|
||||
const parts = file.path.split('.')
|
||||
if (parts.length < 2) {
|
||||
return false
|
||||
}
|
||||
const extension = parts[parts.length - 1].toLowerCase()
|
||||
return IMAGE_FILE_EXTENSIONS.includes(extension)
|
||||
}
|
||||
+118
@@ -3,6 +3,7 @@ import { EditorState } from '@codemirror/state'
|
||||
import { SyntaxNode, SyntaxNodeRef, Tree } from '@lezer/common'
|
||||
import { previousSiblingIs } from './common'
|
||||
import { NodeIntersectsChangeFn, ProjectionItem } from './projection'
|
||||
import { FigureData } from '../../extensions/figure-modal'
|
||||
|
||||
const HUNDRED_MS = 100
|
||||
|
||||
@@ -238,3 +239,120 @@ export function getEnvironmentName(
|
||||
export function getEnvironmentArguments(environmentNode: SyntaxNode) {
|
||||
return environmentNode.getChild('BeginEnv')?.getChildren('TextArgument')
|
||||
}
|
||||
|
||||
export function parseFigureData(
|
||||
figureEnvironmentNode: SyntaxNode,
|
||||
state: EditorState
|
||||
): FigureData | null {
|
||||
let caption: FigureData['caption'] = null
|
||||
let label: FigureData['label'] = null
|
||||
let file: FigureData['file'] | undefined
|
||||
let width: FigureData['width']
|
||||
let unknownGraphicsArguments: FigureData['unknownGraphicsArguments']
|
||||
let graphicsCommand: FigureData['graphicsCommand'] | undefined
|
||||
let graphicsCommandArguments: FigureData['graphicsCommandArguments'] = null
|
||||
|
||||
const from = figureEnvironmentNode.from
|
||||
const to = figureEnvironmentNode.to
|
||||
|
||||
let error = false
|
||||
figureEnvironmentNode.cursor().iterate((node: SyntaxNodeRef) => {
|
||||
if (error) {
|
||||
return false
|
||||
}
|
||||
if (node.type.is('Caption')) {
|
||||
if (caption) {
|
||||
// Multiple captions
|
||||
error = true
|
||||
return false
|
||||
}
|
||||
caption = {
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
}
|
||||
}
|
||||
if (node.type.is('Label')) {
|
||||
if (label) {
|
||||
// Multiple labels
|
||||
error = true
|
||||
return false
|
||||
}
|
||||
label = {
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
}
|
||||
}
|
||||
if (node.type.is('IncludeGraphics')) {
|
||||
if (file) {
|
||||
// Multiple figure
|
||||
error = true
|
||||
return false
|
||||
}
|
||||
graphicsCommand = {
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
}
|
||||
const content = node.node
|
||||
.getChild('IncludeGraphicsArgument')
|
||||
?.getChild('FilePathArgument')
|
||||
?.getChild('LiteralArgContent')
|
||||
if (!content) {
|
||||
error = true
|
||||
return false
|
||||
}
|
||||
file = {
|
||||
from: content.from,
|
||||
to: content.to,
|
||||
path: state.sliceDoc(content.from, content.to),
|
||||
}
|
||||
const optionalArgs = node.node
|
||||
.getChild('OptionalArgument')
|
||||
?.getChild('ShortOptionalArg')
|
||||
if (!optionalArgs) {
|
||||
width = undefined
|
||||
return false
|
||||
}
|
||||
graphicsCommandArguments = {
|
||||
from: optionalArgs.from,
|
||||
to: optionalArgs.to,
|
||||
}
|
||||
const optionalArgContent = state.sliceDoc(
|
||||
optionalArgs.from,
|
||||
optionalArgs.to
|
||||
)
|
||||
const widthMatch = optionalArgContent.match(
|
||||
/^width=([0-9]|(?:[0-9]*\.[0-9]+)|(?:[0-9]+\.))\\(linewidth|pagewidth|textwidth|hsize|columnwidth)$/
|
||||
)
|
||||
if (widthMatch) {
|
||||
width = parseFloat(widthMatch[1])
|
||||
if (widthMatch[2] !== 'linewidth') {
|
||||
// We shouldn't edit any width other that linewidth
|
||||
unknownGraphicsArguments = optionalArgContent
|
||||
}
|
||||
} else {
|
||||
unknownGraphicsArguments = optionalArgContent
|
||||
}
|
||||
}
|
||||
})
|
||||
if (error) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
graphicsCommand === undefined ||
|
||||
file === undefined ||
|
||||
(width === undefined && unknownGraphicsArguments === undefined)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return new FigureData({
|
||||
caption,
|
||||
label,
|
||||
file,
|
||||
from,
|
||||
to,
|
||||
width,
|
||||
unknownGraphicsArguments,
|
||||
graphicsCommand,
|
||||
graphicsCommandArguments,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/* eslint-disable jsx-a11y/label-has-for */
|
||||
/* eslint-disable jsx-a11y/label-has-associated-control */
|
||||
import classNames from 'classnames'
|
||||
import { useSelect } from 'downshift'
|
||||
import Icon from './icon'
|
||||
|
||||
type SelectProps<T> = {
|
||||
items: T[]
|
||||
itemToString: (item: T | null) => string
|
||||
label?: string
|
||||
defaultText?: string
|
||||
itemToSubtitle?: (item: T | null) => string
|
||||
itemToKey: (item: T) => string
|
||||
onSelectedItemChanged?: (item: T | null | undefined) => void
|
||||
disabled?: boolean
|
||||
optionalLabel?: boolean
|
||||
}
|
||||
|
||||
export const Select = <T,>({
|
||||
items,
|
||||
itemToString = item => (item === null ? '' : String(item)),
|
||||
label,
|
||||
defaultText = 'Items',
|
||||
itemToSubtitle,
|
||||
itemToKey,
|
||||
onSelectedItemChanged,
|
||||
disabled = false,
|
||||
optionalLabel = false,
|
||||
}: SelectProps<T>) => {
|
||||
const {
|
||||
isOpen,
|
||||
selectedItem,
|
||||
getToggleButtonProps,
|
||||
getLabelProps,
|
||||
getMenuProps,
|
||||
getItemProps,
|
||||
highlightedIndex,
|
||||
} = useSelect({
|
||||
items: items ?? [],
|
||||
itemToString,
|
||||
onSelectedItemChange: changes => {
|
||||
if (onSelectedItemChanged) {
|
||||
onSelectedItemChanged(changes.selectedItem)
|
||||
}
|
||||
},
|
||||
})
|
||||
return (
|
||||
<div className="select-wrapper">
|
||||
<div>
|
||||
{label ? (
|
||||
<label {...getLabelProps()}>
|
||||
{label}{' '}
|
||||
{optionalLabel && (
|
||||
<span className="select-optional-label text-muted">
|
||||
(Optional)
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
) : null}
|
||||
<div
|
||||
className={classNames({ disabled }, 'select-trigger')}
|
||||
{...getToggleButtonProps({ disabled })}
|
||||
>
|
||||
<div>{selectedItem ? itemToString(selectedItem) : defaultText}</div>
|
||||
<div>
|
||||
{isOpen ? (
|
||||
<Icon type="chevron-up" fw />
|
||||
) : (
|
||||
<Icon type="chevron-down" fw />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
className={classNames({ hidden: !isOpen }, 'select-items')}
|
||||
{...getMenuProps({ disabled })}
|
||||
>
|
||||
{isOpen &&
|
||||
items?.map((item, index) => (
|
||||
<li
|
||||
className={classNames({
|
||||
'select-highlighted': highlightedIndex === index,
|
||||
'selected-active': selectedItem === item,
|
||||
})}
|
||||
key={itemToKey(item)}
|
||||
{...getItemProps({ item, index })}
|
||||
>
|
||||
<span className="select-item-title">{itemToString(item)}</span>
|
||||
{itemToSubtitle ? (
|
||||
<span className="text-muted select-item-subtitle">
|
||||
{itemToSubtitle(item)}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { FC, createContext, useContext } from 'react'
|
||||
|
||||
const SwitcherContext = createContext<
|
||||
| {
|
||||
name: string
|
||||
onChange?: (value: string) => any
|
||||
defaultValue?: string
|
||||
disabled: boolean
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
export const Switcher: FC<{
|
||||
name: string
|
||||
onChange?: (value: string) => any
|
||||
defaultValue?: string
|
||||
disabled?: boolean
|
||||
}> = ({ name, children, onChange, defaultValue, disabled = false }) => {
|
||||
return (
|
||||
<SwitcherContext.Provider
|
||||
value={{ name, onChange, defaultValue, disabled }}
|
||||
>
|
||||
<fieldset>{children}</fieldset>
|
||||
</SwitcherContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const SwitcherItem: FC<{
|
||||
value: string
|
||||
label: string
|
||||
checked?: boolean
|
||||
}> = ({ value, label, checked = false }) => {
|
||||
const ctx = useContext(SwitcherContext)
|
||||
if (!ctx) {
|
||||
throw new Error('SwitcherItem must be a child of Switcher')
|
||||
}
|
||||
const { name, onChange, defaultValue, disabled } = ctx
|
||||
|
||||
const id = `${name}-option-${value.replace(/\W/g, '')}`
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="radio"
|
||||
value={value}
|
||||
id={id}
|
||||
className="switcher-input"
|
||||
name={name}
|
||||
defaultChecked={!disabled && (checked || defaultValue === value)}
|
||||
disabled={disabled}
|
||||
onChange={evt => {
|
||||
if (onChange) {
|
||||
onChange(evt.target.value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label htmlFor={id} className="switcher-label" aria-disabled={disabled}>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -66,15 +66,38 @@ export const Visual = (args: any, { globals: { theme } }: any) => {
|
||||
open_doc_name: 'example.tex',
|
||||
showVisual: true,
|
||||
},
|
||||
rootFolder: {
|
||||
name: 'rootFolder',
|
||||
id: 'root-folder-id',
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
name: 'example.tex.tex',
|
||||
id: 'example-doc-id',
|
||||
type: 'doc',
|
||||
selected: false,
|
||||
$$hashKey: 'object:89',
|
||||
},
|
||||
{
|
||||
name: 'frog.jpg',
|
||||
id: 'frog-image-id',
|
||||
type: 'file',
|
||||
linkedFileData: null,
|
||||
created: '2023-05-04T16:11:04.352Z',
|
||||
$$hashKey: 'object:108',
|
||||
},
|
||||
],
|
||||
selected: false,
|
||||
},
|
||||
settings: {
|
||||
...settings,
|
||||
overallTheme: theme === 'default-' ? '' : theme,
|
||||
},
|
||||
})
|
||||
|
||||
useMeta({
|
||||
'ol-showSymbolPalette': true,
|
||||
'ol-mathJax3Path': 'https://unpkg.com/mathjax@3.2.2/es5/tex-svg-full.js',
|
||||
'ol-splitTestVariants': { 'figure-modal': 'enabled' },
|
||||
})
|
||||
|
||||
return <SourceEditor />
|
||||
@@ -183,8 +206,8 @@ Note that your figure will automatically be placed in the most appropriate place
|
||||
|
||||
\\begin{figure}
|
||||
\\centering
|
||||
\\includegraphics[width=0.3\\textwidth]{frog.jpg}
|
||||
\\caption{\\label{fig:frog}This frog was uploaded via the file-tree menu.}
|
||||
\\includegraphics[width=0.25\\linewidth]{frog.jpg}
|
||||
\\caption{This frog was uploaded via the file-tree menu.}\\label{fig:frog}
|
||||
\\end{figure}
|
||||
|
||||
\\subsection{How to add Tables}
|
||||
|
||||
@@ -45,8 +45,10 @@
|
||||
@import 'components/thumbnails.less';
|
||||
@import 'components/alerts.less';
|
||||
@import 'components/progress-bars.less';
|
||||
@import 'components/select.less';
|
||||
@import 'components/switcher.less';
|
||||
// @import "components/media.less";
|
||||
// @import "components/list-group.less";
|
||||
@import 'components/list-group.less';
|
||||
// @import "components/panels.less";
|
||||
// @import "components/wells.less";
|
||||
@import 'components/close.less';
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
@import './editor/logs.less';
|
||||
@import './editor/dictionary.less';
|
||||
@import './editor/compile-button.less';
|
||||
@import './editor/figure-modal.less';
|
||||
|
||||
@ui-layout-toggler-def-height: 50px;
|
||||
@ui-resizer-size: 7px;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
.figure-modal-label {
|
||||
font-weight: normal;
|
||||
line-height: 100%;
|
||||
}
|
||||
|
||||
.figure-modal-form tr,
|
||||
.figure-modal-form td {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.figure-modal-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.figure-modal-help-buttons,
|
||||
.figure-modal-actions {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.figure-modal-help-buttons {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.figure-modal-help-link,
|
||||
.figure-modal-help-link:hover,
|
||||
.figure-modal-help-link:focus,
|
||||
.figure-modal-help-link:active {
|
||||
color: @neutral-90;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.figure-modal-help-link {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.figure-modal-switcher-input {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.figure-modal-checkbox-input,
|
||||
.figure-modal-switcher-input {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.figure-modal-checkbox-input input[type='checkbox'] {
|
||||
vertical-align: top;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.figure-modal-switcher-input {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.figure-modal-input-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-container {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
padding: 22px 24px;
|
||||
border: 1px dashed #eaeaea;
|
||||
background-color: #fafafa;
|
||||
justify-content: space-between;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.file-container-file {
|
||||
display: flex;
|
||||
border: 1px solid @neutral-20;
|
||||
border-radius: 8px;
|
||||
background-color: white;
|
||||
height: 100%;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
margin-left: 18px;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 20px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.file-action {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.figure-modal-upload {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.figure-modal-source-button-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 0 auto;
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.figure-modal-source-button {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
align-items: center;
|
||||
box-shadow: 0px 2px 4px 0px #1e253029;
|
||||
line-height: 44px;
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
padding: 0 8px;
|
||||
|
||||
&:nth-child(even) {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
&:nth-child(odd) {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
&-title {
|
||||
flex: 1 1 auto;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.figure-modal-source-button-icon {
|
||||
flex: 0 0 auto;
|
||||
&.source-icon {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,8 @@
|
||||
// Use anchor elements instead of `li`s or `div`s to create linked list items.
|
||||
// Includes an extra `.active` modifier class for showing selected items.
|
||||
|
||||
a.list-group-item {
|
||||
a.list-group-item,
|
||||
button.list-group-item {
|
||||
color: @list-group-link-color;
|
||||
|
||||
.list-group-item-heading {
|
||||
@@ -58,6 +59,7 @@ a.list-group-item {
|
||||
// Hover state
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: @list-group-hover-color;
|
||||
text-decoration: none;
|
||||
background-color: @list-group-hover-bg;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
.select-trigger {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border: 1px solid black;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
user-select: none;
|
||||
color: @neutral-90;
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
background-color: @neutral-20;
|
||||
border-color: @neutral-20;
|
||||
}
|
||||
}
|
||||
|
||||
.select-highlighted {
|
||||
background-color: @neutral-10;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.select-active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.select-items {
|
||||
list-style: none;
|
||||
width: 100%;
|
||||
padding: 4px;
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
background-color: white;
|
||||
box-shadow: 0px 2px 4px 0px #1e253029;
|
||||
overflow-y: auto;
|
||||
z-index: 1;
|
||||
max-height: 200px;
|
||||
& li {
|
||||
padding: 12px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.select-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.select-item-title,
|
||||
.select-item-subtitle {
|
||||
display: block;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.select-item-subtitle {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.select-optional-label {
|
||||
font-weight: normal;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
.switcher-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.switcher-label {
|
||||
border: 1px solid black;
|
||||
padding: 4px 16px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.switcher-label:first-of-type {
|
||||
border-top-left-radius: 999px;
|
||||
border-bottom-left-radius: 999px;
|
||||
}
|
||||
|
||||
.switcher-label:last-of-type {
|
||||
border-top-right-radius: 999px;
|
||||
border-bottom-right-radius: 999px;
|
||||
}
|
||||
|
||||
.switcher-label:not(:first-of-type) {
|
||||
border-left-width: 0px;
|
||||
}
|
||||
|
||||
.switcher-input:checked + .switcher-label {
|
||||
background-color: @neutral-20;
|
||||
}
|
||||
|
||||
.switcher-input:disabled + .switcher-label {
|
||||
cursor: not-allowed;
|
||||
background-color: @neutral-20;
|
||||
border-color: @neutral-20;
|
||||
opacity: 1;
|
||||
color: @neutral-90;
|
||||
}
|
||||
@@ -710,7 +710,9 @@
|
||||
@list-group-border-radius: @border-radius-base;
|
||||
|
||||
//** Background color of single list elements on hover
|
||||
@list-group-hover-bg: #f5f5f5;
|
||||
@list-group-hover-bg: @component-active-bg;
|
||||
//** Text color of single list elements on hover
|
||||
@list-group-hover-color: @component-active-color;
|
||||
//** Text color of active list elements
|
||||
@list-group-active-color: @component-active-color;
|
||||
//** Background color of active list elements
|
||||
|
||||
@@ -73,6 +73,9 @@
|
||||
@import 'components/input-switch.less';
|
||||
@import 'components/container.less';
|
||||
@import 'components/split-menu.less';
|
||||
@import 'components/list-group.less';
|
||||
@import 'components/select.less';
|
||||
@import 'components/switcher.less';
|
||||
|
||||
// Components w/ JavaScript
|
||||
@import 'components/modals.less';
|
||||
|
||||
@@ -263,6 +263,32 @@
|
||||
// Note: Deprecated @dropdown-caret-color as of v3.1.0
|
||||
@dropdown-caret-color: #000;
|
||||
|
||||
//== List group
|
||||
//
|
||||
//##
|
||||
|
||||
//** Background color on `.list-group-item`
|
||||
@list-group-bg: #fff;
|
||||
//** `.list-group-item` border color
|
||||
@list-group-border: #ddd;
|
||||
//** List group border radius
|
||||
@list-group-border-radius: @border-radius-base;
|
||||
|
||||
//** Background color of single list elements on hover
|
||||
@list-group-hover-bg: @component-active-bg;
|
||||
//** Text color of single list elements on hover
|
||||
@list-group-hover-color: @component-active-color;
|
||||
//** Text color of active list elements
|
||||
@list-group-active-color: @component-active-color;
|
||||
//** Background color of active list elements
|
||||
@list-group-active-bg: @component-active-bg;
|
||||
//** Border color of active list elements
|
||||
@list-group-active-border: @list-group-active-bg;
|
||||
@list-group-active-text-color: lighten(@list-group-active-bg, 40%);
|
||||
|
||||
@list-group-link-color: #555;
|
||||
@list-group-link-heading-color: #333;
|
||||
|
||||
//-- Z-index master list
|
||||
//
|
||||
// Warning: Avoid customizing these values. They're used for a bird's eye view
|
||||
|
||||
-24
@@ -122,30 +122,6 @@ describe('<CodeMirrorEditor/> toolbar in Rich Text mode', function () {
|
||||
.should('have.text', '\\href{http://example.com}{test}')
|
||||
})
|
||||
|
||||
it('should insert a figure', function () {
|
||||
mountEditor('test')
|
||||
|
||||
clickToolbarButton('Insert Figure')
|
||||
|
||||
cy.get('.cm-content').should(
|
||||
'have.text',
|
||||
[
|
||||
'test',
|
||||
'\\begin{figure}',
|
||||
' \\centering',
|
||||
' \\includegraphics{}',
|
||||
' Caption',
|
||||
' 🏷fig:my_label',
|
||||
'\\end{figure}',
|
||||
].join('')
|
||||
)
|
||||
|
||||
cy.get('.cm-line')
|
||||
.eq(3)
|
||||
.type('test.png')
|
||||
.should('have.text', ' \\includegraphics{test.png}')
|
||||
})
|
||||
|
||||
it('should insert a bullet list', function () {
|
||||
mountEditor('test')
|
||||
selectAll()
|
||||
|
||||
Reference in New Issue
Block a user