[cm6] Add figure modal (#12751)

GitOrigin-RevId: 3043d1369ed85b38b1fec7479385b123a304c05b
This commit is contained in:
Mathias Jakobsen
2023-05-15 10:17:13 +01:00
committed by Copybot
parent 09f5f879f1
commit 7fedecddad
49 changed files with 2821 additions and 62 deletions
@@ -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}
@@ -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()
@@ -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
})
@@ -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(
@@ -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 />
</>
)
}
@@ -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
}
@@ -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 />
&nbsp;Back
</Button>
)
}
return (
<Button
bsStyle={null}
className="btn-link figure-modal-help-link"
onClick={() => dispatch({ helpShown: true })}
>
<Icon type="question-circle" fw />
&nbsp;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>
)
}
@@ -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&#123;...&#125;</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. Youll 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 youve loaded the graphicx package&mdash;
<code>\usepackage&#123;graphicx&#125;</code>&mdash;in the preamble
(first section of code) in your document.{' '}
<LearnWikiLink article="Inserting_Images">Learn more</LearnWikiLink>
</p>
</>
)
}
@@ -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>
</>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)}
</>
)
}
@@ -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}
/>
</>
)
}
@@ -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 })
}}
/>
)
}
@@ -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}
/>
)
}
@@ -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,
})
}}
/>
)
}
@@ -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>
)
}
@@ -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}
</>
)
})
@@ -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,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,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',
},
})
@@ -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)
}
}
}
@@ -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%'
}
}
@@ -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)
}
@@ -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
@@ -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()