Преглед изворни кода

[cm6] Add figure modal (#12751)

GitOrigin-RevId: 3043d1369ed85b38b1fec7479385b123a304c05b
Mathias Jakobsen пре 3 година
родитељ
комит
31190b967b
49 измењених фајлова са 2821 додато и 62 уклоњено
  1. 6 0
      services/web/app/src/Features/Project/ProjectController.js
  2. 1 1
      services/web/app/templates/project_files/example-project-sp/main.tex
  3. 1 1
      services/web/app/templates/project_files/example-project/main.tex
  4. 9 4
      services/web/frontend/js/features/file-tree/hooks/use-project-entities.ts
  5. 17 8
      services/web/frontend/js/features/file-tree/hooks/use-project-output-files.ts
  6. 10 3
      services/web/frontend/js/features/file-tree/hooks/use-user-projects.ts
  7. 2 0
      services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx
  8. 55 0
      services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-body.tsx
  9. 121 0
      services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-context.tsx
  10. 108 0
      services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-footer.tsx
  11. 64 0
      services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-help.tsx
  12. 80 0
      services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-options.tsx
  13. 68 0
      services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-source-picker.tsx
  14. 259 0
      services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal.tsx
  15. 91 0
      services/web/frontend/js/features/source-editor/components/figure-modal/file-name-input.tsx
  16. 83 0
      services/web/frontend/js/features/source-editor/components/figure-modal/file-relocator.tsx
  17. 29 0
      services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-edit-figure-source.tsx
  18. 247 0
      services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-other-project-source.tsx
  19. 29 0
      services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-project-source.tsx
  20. 320 0
      services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-upload-source.tsx
  21. 95 0
      services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-url-source.tsx
  22. 79 0
      services/web/frontend/js/features/source-editor/components/toolbar/button-menu.tsx
  23. 45 0
      services/web/frontend/js/features/source-editor/components/toolbar/insert-figure-dropdown.tsx
  24. 14 6
      services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx
  25. 129 0
      services/web/frontend/js/features/source-editor/extensions/figure-modal.ts
  26. 1 1
      services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts
  27. 15 0
      services/web/frontend/js/features/source-editor/extensions/toolbar/toolbar-panel.ts
  28. 24 4
      services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts
  29. 17 0
      services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts
  30. 59 0
      services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/editable-graphics.ts
  31. 46 0
      services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/editable-inline-graphics.ts
  32. 30 4
      services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/graphics.ts
  33. 13 0
      services/web/frontend/js/features/source-editor/hooks/useCurrentProjectFolders.ts
  34. 2 0
      services/web/frontend/js/features/source-editor/languages/latex/index.ts
  35. 68 0
      services/web/frontend/js/features/source-editor/utils/file.ts
  36. 118 0
      services/web/frontend/js/features/source-editor/utils/tree-operations/environments.ts
  37. 99 0
      services/web/frontend/js/shared/components/select.tsx
  38. 61 0
      services/web/frontend/js/shared/components/switcher.tsx
  39. 26 3
      services/web/frontend/stories/source-editor/source-editor.stories.tsx
  40. 3 1
      services/web/frontend/stylesheets/_style_includes.less
  41. 1 0
      services/web/frontend/stylesheets/app/editor.less
  42. 145 0
      services/web/frontend/stylesheets/app/editor/figure-modal.less
  43. 3 1
      services/web/frontend/stylesheets/components/list-group.less
  44. 59 0
      services/web/frontend/stylesheets/components/select.less
  45. 37 0
      services/web/frontend/stylesheets/components/switcher.less
  46. 3 1
      services/web/frontend/stylesheets/core/variables.less
  47. 3 0
      services/web/frontend/stylesheets/main-style.less
  48. 26 0
      services/web/frontend/stylesheets/variables/all.less
  49. 0 24
      services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx

+ 6 - 0
services/web/app/src/Features/Project/ProjectController.js

@@ -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,

+ 1 - 1
services/web/app/templates/project_files/example-project-sp/main.tex

@@ -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}
 

+ 1 - 1
services/web/app/templates/project_files/example-project/main.tex

@@ -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
services/web/frontend/js/features/file-tree/hooks/use-project-entities.js → services/web/frontend/js/features/file-tree/hooks/use-project-entities.ts

@@ -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
+}
+
+const alphabetical = (a: Entity, b: Entity) =>
+  fileCollator.compare(a.path, b.path)
 
-export function useProjectEntities(projectId) {
+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
services/web/frontend/js/features/file-tree/hooks/use-project-output-files.js → services/web/frontend/js/features/file-tree/hooks/use-project-output-files.ts

@@ -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
+}
+
+const alphabetical = (a: OutputEntity, b: OutputEntity) =>
+  fileCollator.compare(a.path, b.path)
 
-export function useProjectOutputFiles(projectId) {
-  const [loading, setLoading] = useState(false)
-  const [data, setData] = useState(null)
-  const [error, setError] = useState(false)
+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
services/web/frontend/js/features/file-tree/hooks/use-user-projects.js → services/web/frontend/js/features/file-tree/hooks/use-user-projects.ts

@@ -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()
 

+ 2 - 0
services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx

@@ -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
services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-body.tsx

@@ -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
services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-context.tsx

@@ -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
services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-footer.tsx

@@ -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>
+  )
+}

+ 64 - 0
services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-help.tsx

@@ -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. 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&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>
+    </>
+  )
+}

+ 80 - 0
services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-options.tsx

@@ -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
services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-source-picker.tsx

@@ -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
services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal.tsx

@@ -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
services/web/frontend/js/features/source-editor/components/figure-modal/file-name-input.tsx

@@ -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
services/web/frontend/js/features/source-editor/components/figure-modal/file-relocator.tsx

@@ -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
services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-edit-figure-source.tsx

@@ -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
services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-other-project-source.tsx

@@ -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
services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-project-source.tsx

@@ -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
services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-upload-source.tsx

@@ -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
services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-url-source.tsx

@@ -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}
+      />
+    </>
+  )
+}

+ 79 - 0
services/web/frontend/js/features/source-editor/components/toolbar/button-menu.tsx

@@ -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
services/web/frontend/js/features/source-editor/components/toolbar/insert-figure-dropdown.tsx

@@ -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
services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx

@@ -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')}

+ 129 - 0
services/web/frontend/js/features/source-editor/extensions/figure-modal.ts

@@ -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]

+ 1 - 1
services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts

@@ -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 = ''
 

+ 15 - 0
services/web/frontend/js/features/source-editor/extensions/toolbar/toolbar-panel.ts

@@ -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
services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts

@@ -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)
                   )

+ 17 - 0
services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts

@@ -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
services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/editable-graphics.ts

@@ -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
services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/editable-inline-graphics.ts

@@ -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
services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/graphics.ts

@@ -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,

+ 13 - 0
services/web/frontend/js/features/source-editor/hooks/useCurrentProjectFolders.ts

@@ -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]
+}

+ 2 - 0
services/web/frontend/js/features/source-editor/languages/latex/index.ts

@@ -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(),
   ])
 }

+ 68 - 0
services/web/frontend/js/features/source-editor/utils/file.ts

@@ -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 - 0
services/web/frontend/js/features/source-editor/utils/tree-operations/environments.ts

@@ -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,
+  })
+}

+ 99 - 0
services/web/frontend/js/shared/components/select.tsx

@@ -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>
+  )
+}

+ 61 - 0
services/web/frontend/js/shared/components/switcher.tsx

@@ -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>
+    </>
+  )
+}

+ 26 - 3
services/web/frontend/stories/source-editor/source-editor.stories.tsx

@@ -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}

+ 3 - 1
services/web/frontend/stylesheets/_style_includes.less

@@ -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';

+ 1 - 0
services/web/frontend/stylesheets/app/editor.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;

+ 145 - 0
services/web/frontend/stylesheets/app/editor/figure-modal.less

@@ -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;
+  }
+}

+ 3 - 1
services/web/frontend/stylesheets/components/list-group.less

@@ -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;
   }

+ 59 - 0
services/web/frontend/stylesheets/components/select.less

@@ -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;
+}

+ 37 - 0
services/web/frontend/stylesheets/components/switcher.less

@@ -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;
+}

+ 3 - 1
services/web/frontend/stylesheets/core/variables.less

@@ -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

+ 3 - 0
services/web/frontend/stylesheets/main-style.less

@@ -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';

+ 26 - 0
services/web/frontend/stylesheets/variables/all.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

+ 0 - 24
services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx

@@ -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()