diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 3e97205589..8cd3eafdd4 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/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, diff --git a/services/web/app/templates/project_files/example-project-sp/main.tex b/services/web/app/templates/project_files/example-project-sp/main.tex index b5c506ba50..fd3c032630 100644 --- a/services/web/app/templates/project_files/example-project-sp/main.tex +++ b/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} diff --git a/services/web/app/templates/project_files/example-project/main.tex b/services/web/app/templates/project_files/example-project/main.tex index 872e26f660..b964f7728a 100644 --- a/services/web/app/templates/project_files/example-project/main.tex +++ b/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} diff --git a/services/web/frontend/js/features/file-tree/hooks/use-project-entities.js b/services/web/frontend/js/features/file-tree/hooks/use-project-entities.ts similarity index 71% rename from services/web/frontend/js/features/file-tree/hooks/use-project-entities.js rename to services/web/frontend/js/features/file-tree/hooks/use-project-entities.ts index 090eaacf2c..95c15424de 100644 --- a/services/web/frontend/js/features/file-tree/hooks/use-project-entities.js +++ b/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 +} -export function useProjectEntities(projectId) { +const alphabetical = (a: Entity, b: Entity) => + fileCollator.compare(a.path, b.path) + +export function useProjectEntities(projectId?: string) { const [loading, setLoading] = useState(false) - const [data, setData] = useState(null) - const [error, setError] = useState(false) + const [data, setData] = useState(null) + const [error, setError] = useState(false) const { signal } = useAbortController() diff --git a/services/web/frontend/js/features/file-tree/hooks/use-project-output-files.js b/services/web/frontend/js/features/file-tree/hooks/use-project-output-files.ts similarity index 62% rename from services/web/frontend/js/features/file-tree/hooks/use-project-output-files.js rename to services/web/frontend/js/features/file-tree/hooks/use-project-output-files.ts index 0e8e248b03..7c4573bdd2 100644 --- a/services/web/frontend/js/features/file-tree/hooks/use-project-output-files.js +++ b/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 +} -export function useProjectOutputFiles(projectId) { - const [loading, setLoading] = useState(false) - const [data, setData] = useState(null) - const [error, setError] = useState(false) +const alphabetical = (a: OutputEntity, b: OutputEntity) => + fileCollator.compare(a.path, b.path) + +export function useProjectOutputFiles(projectId?: string) { + const [loading, setLoading] = useState(false) + const [data, setData] = useState(null) + const [error, setError] = useState(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 }) diff --git a/services/web/frontend/js/features/file-tree/hooks/use-user-projects.js b/services/web/frontend/js/features/file-tree/hooks/use-user-projects.ts similarity index 69% rename from services/web/frontend/js/features/file-tree/hooks/use-user-projects.js rename to services/web/frontend/js/features/file-tree/hooks/use-user-projects.ts index 35e99acee3..39df478284 100644 --- a/services/web/frontend/js/features/file-tree/hooks/use-user-projects.js +++ b/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(null) + const [error, setError] = useState(false) const { signal } = useAbortController() diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx index 0287e5e538..7c9c397ce4 100644 --- a/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx +++ b/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() { + {sourceEditorComponents.map( diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-body.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-body.tsx new file mode 100644 index 0000000000..9cb20b7462 --- /dev/null +++ b/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 + } + + if (sourcePickerShown) { + return + } + + if (!Body) { + return null + } + + return ( + <> + {error && ( + + {error} + + )} + + + + ) +} diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-context.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-context.tsx new file mode 100644 index 0000000000..15b1e2d3a5 --- /dev/null +++ b/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 + width: number + includeCaption: boolean + includeLabel: boolean + error?: string +} + +type FigureModalStateUpdate = Partial + +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) => { + 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 + +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 ( + + + {children} + + + ) +} + +export const useFigureModalExistingFigureContext = () => { + const context = useContext(FigureModalExistingFigureContext) + + if (!context) { + throw new Error( + 'useFigureModalExistingFigureContext is only available inside FigureModalProvider' + ) + } + + return context +} diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-footer.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-footer.tsx new file mode 100644 index 0000000000..c5ffd92ae2 --- /dev/null +++ b/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 ( +
+
+ +
+
+ + +
+
+ ) +} + +const HelpToggle = () => { + const { helpShown, dispatch } = useFigureModalContext() + if (helpShown) { + return ( + + ) + } + return ( + + ) +} + +const FigureModalAction: FC<{ + onInsert: () => void + onDelete: () => void +}> = ({ onInsert, onDelete }) => { + const { helpShown, getPath, source, sourcePickerShown } = + useFigureModalContext() + + if (helpShown) { + return null + } + + if (sourcePickerShown) { + return ( + + ) + } + + if (source === FigureModalSource.EDIT_FIGURE) { + return ( + + ) + } + + return ( + + ) +} diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-help.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-help.tsx new file mode 100644 index 0000000000..b1c9157b35 --- /dev/null +++ b/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 {children} +} + +export const FigureModalHelp = () => { + return ( + <> +

+ 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. +

+ + Editing captions +

+ 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.{' '} +

+ + Understanding labels +

+ Labels help you to easily reference your figures throughout your + document. To reference a figure within the text, reference the label + using the \ref{...} command. This makes it easy + to reference figures without needing to manually remember the figure + numbering.{' '} + + Learn more + +

+ + Customizing figures +

+ 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.{' '} + Find out how +

+ + Changing the position of your figure +

+ LaTeX places figures according to a special algorithm. You can use + something called ‘placement parameters’ to influence the positioning of + the figure.{' '} + + Find out how + +

+ + Dealing with errors +

+ Are you getting an Undefined Control Sequence error? If you are, make + sure you’ve loaded the graphicx package— + \usepackage{graphicx}—in the preamble + (first section of code) in your document.{' '} + Learn more +

+ + ) +} diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-options.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-options.tsx new file mode 100644 index 0000000000..d2662f65bd --- /dev/null +++ b/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 ( + <> +
+ dispatch({ includeCaption: event.target.checked })} + /> + +
+
+ dispatch({ includeLabel: event.target.checked })} + /> + +
+
+
+ Image width{' '} + {hasComplexGraphicsArgument ? ( + + + + ) : ( + + + + )} +
+
+ dispatch({ width: parseFloat(value) })} + defaultValue={width === 1 ? '1.0' : width.toString()} + disabled={hasComplexGraphicsArgument} + > + + + + + +
+
+ + ) +} diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-source-picker.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-source-picker.tsx new file mode 100644 index 0000000000..cc4d11e1f9 --- /dev/null +++ b/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 ( +
+
+ + +
+
+ + +
+
+ ) +} + +const FigureModalSourceButton: FC<{ + type: FigureModalSource + title: string + icon: string +}> = ({ type, title, icon }) => { + const { dispatch } = useFigureModalContext() + return ( + + ) +} diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal.tsx new file mode 100644 index 0000000000..7b6bd6685c --- /dev/null +++ b/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 ( + + + + ) +}) + +const FigureModalContent = () => { + const { + source, + dispatch, + helpShown, + getPath, + width, + includeCaption, + includeLabel, + sourcePickerShown, + } = useFigureModalContext() + + const listener = useCallback( + (event: Event) => { + const { detail: source } = event as CustomEvent + 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(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(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(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 ( + + + + {helpShown + ? 'Help' + : sourcePickerShown + ? 'Replace figure' + : getTitle(source)}{' '} + + + + + + + + + + + + + ) +} diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/file-name-input.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/file-name-input.tsx new file mode 100644 index 0000000000..2590f72495 --- /dev/null +++ b/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, 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(false) + const [rootFolder] = useScopeValue('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) => { + if (!event.target) { + return true + } + const fileName = event.target.value + const fileExtensionIndex = fileName.lastIndexOf('.') + if (fileExtensionIndex >= 0) { + event.target.setSelectionRange(0, fileExtensionIndex) + } + }, []) + return ( + <> + + {overlap && ( + + A file with that name already exists. That file will be overwritten. + + )} + + ) +} diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/file-relocator.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/file-relocator.tsx new file mode 100644 index 0000000000..89185caf74 --- /dev/null +++ b/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) => { + 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 ( + <> + + + (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, + }) + }} + /> + { + const suggestion = nameDirty ? name : suggestName(item?.path ?? '') + setName(suggestion) + setFile(item ?? null) + updateDispatch({ + newFile: item ?? null, + newName: suggestion, + }) + }} + /> +
+ or{' '} + +
+ { + const newFolder = item ?? rootFile + updateDispatch({ newFolder }) + }} + onNameChanged={name => updateDispatch({ newName: name })} + setFolder={setFolder} + setName={setName} + setNameDirty={setNameDirty} + /> + + ) +} + +const SelectFile = ({ + disabled, + files, + onSelectedItemChange, +}: { + disabled: boolean + files?: T[] + onSelectedItemChange?: (item: T | null | undefined) => any +}) => { + const imageFiles = useMemo(() => files?.filter(isImageEntity), [files]) + return ( + (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, + }) + }} + /> + ) +} diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-upload-source.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-upload-source.tsx new file mode 100644 index 0000000000..a7f42f9505 --- /dev/null +++ b/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(null) + const [nameDirty, setNameDirty] = useState(false) + // Files are immutable, so this will point to a (possibly) old version of the file + const [file, setFile] = useState(null) + const [name, setName] = useState('') + const [uploading, setUploading] = useState(false) + const [uploadError, setUploadError] = useState(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 ( + <> +
+ {file ? ( + { + uppy.removeFile(file.id) + setFile(null) + const newName = nameDirty ? name : '' + setName(newName) + dispatchUploadAction(newName, null, folder) + }} + /> + ) : ( + + )} +
+ + 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 ( +
+
+ +
+ {name} + {size !== undefined && ( + + )} +
+ +
+
+ ) +} + +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 ( + + {sizeInUnits} {label} + + ) +} diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-url-source.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-url-source.tsx new file mode 100644 index 0000000000..e7fea93d5b --- /dev/null +++ b/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('') + const [nameDirty, setNameDirty] = useState(false) + const [name, setName] = useState('') + const { _id: projectId } = useProjectContext() + const [, rootFile] = useCurrentProjectFolders() + const [folder, setFolder] = useState(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 ( + <> + + { + 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) + }} + /> + ensureButtonActivation(url, name, folder)} + onNameChanged={name => ensureButtonActivation(url, name, folder)} + setFolder={setFolder} + setName={setName} + setNameDirty={setNameDirty} + /> + + ) +} diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/button-menu.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/button-menu.tsx new file mode 100644 index 0000000000..6625cb7845 --- /dev/null +++ b/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(null) + const { open, onToggle, ref } = useDropdown() + const button = ( + + ) + + const overlay = ( + onToggle(false)} + > + + { + onToggle(false) + }} + > + {children} + + + + ) + + if (!label) { + return ( + <> + {button} + {overlay} + + ) + } + + return ( + <> + {label}} + overlayProps={{ placement: 'bottom' }} + > + {button} + + {overlay} + + ) +}) diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/insert-figure-dropdown.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/insert-figure-dropdown.tsx new file mode 100644 index 0000000000..bb5e3c45da --- /dev/null +++ b/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 ( + + openFigureModal(FigureModalSource.FILE_UPLOAD)} + > + Upload from computer + + openFigureModal(FigureModalSource.FILE_TREE)} + > + From project files + + openFigureModal(FigureModalSource.OTHER_PROJECT)} + > + From another project + + openFigureModal(FigureModalSource.FROM_URL)} + > + From URL + + + ) +} diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx index 63aa9ddba5..1e8282bbf5 100644 --- a/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx +++ b/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 /> - + {showFigureModal ? ( + + ) : ( + + )} = { + readonly [P in keyof T]: NestedReadonly +} + +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( + 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) {} + + 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() + +export const editFigureData = StateField.define({ + 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] diff --git a/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts b/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts index 82b7d67e5c..067e29c68d 100644 --- a/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts +++ b/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 = '' diff --git a/services/web/frontend/js/features/source-editor/extensions/toolbar/toolbar-panel.ts b/services/web/frontend/js/features/source-editor/extensions/toolbar/toolbar-panel.ts index 0cbce418e4..a77eee2cba 100644 --- a/services/web/frontend/js/features/source-editor/extensions/toolbar/toolbar-panel.ts +++ b/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', diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts b/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts index dcb37253ae..6f6374a04b 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts +++ b/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) ) diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts index 8a6d472d06..0d3b08fa91 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts +++ b/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', + }, }) diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/editable-graphics.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/editable-graphics.ts new file mode 100644 index 0000000000..0559461187 --- /dev/null +++ b/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) + } + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/editable-inline-graphics.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/editable-inline-graphics.ts new file mode 100644 index 0000000000..18ff1462a5 --- /dev/null +++ b/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%' + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/graphics.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/graphics.ts index deb0313fa0..3e2a9ccd3e 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/graphics.ts +++ b/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, diff --git a/services/web/frontend/js/features/source-editor/hooks/useCurrentProjectFolders.ts b/services/web/frontend/js/features/source-editor/hooks/useCurrentProjectFolders.ts new file mode 100644 index 0000000000..01569e2dac --- /dev/null +++ b/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('rootFolder') + const rootFile = { ...rootFolder, path: '' } + const folders = useMemo(() => filterFolders(rootFolder), [rootFolder]) + return [folders, rootFile] +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/index.ts b/services/web/frontend/js/features/source-editor/languages/latex/index.ts index b2bc645906..c3a7503ca1 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/index.ts +++ b/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(), ]) } diff --git a/services/web/frontend/js/features/source-editor/utils/file.ts b/services/web/frontend/js/features/source-editor/utils/file.ts new file mode 100644 index 0000000000..26c0c2e88d --- /dev/null +++ b/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) +} diff --git a/services/web/frontend/js/features/source-editor/utils/tree-operations/environments.ts b/services/web/frontend/js/features/source-editor/utils/tree-operations/environments.ts index 62b369e5b4..2a95b6f13a 100644 --- a/services/web/frontend/js/features/source-editor/utils/tree-operations/environments.ts +++ b/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, + }) +} diff --git a/services/web/frontend/js/shared/components/select.tsx b/services/web/frontend/js/shared/components/select.tsx new file mode 100644 index 0000000000..0daf034352 --- /dev/null +++ b/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 = { + 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 = ({ + items, + itemToString = item => (item === null ? '' : String(item)), + label, + defaultText = 'Items', + itemToSubtitle, + itemToKey, + onSelectedItemChanged, + disabled = false, + optionalLabel = false, +}: SelectProps) => { + const { + isOpen, + selectedItem, + getToggleButtonProps, + getLabelProps, + getMenuProps, + getItemProps, + highlightedIndex, + } = useSelect({ + items: items ?? [], + itemToString, + onSelectedItemChange: changes => { + if (onSelectedItemChanged) { + onSelectedItemChanged(changes.selectedItem) + } + }, + }) + return ( +
+
+ {label ? ( + + ) : null} +
+
{selectedItem ? itemToString(selectedItem) : defaultText}
+
+ {isOpen ? ( + + ) : ( + + )} +
+
+
+
    + {isOpen && + items?.map((item, index) => ( +
  • + {itemToString(item)} + {itemToSubtitle ? ( + + {itemToSubtitle(item)} + + ) : null} +
  • + ))} +
+
+ ) +} diff --git a/services/web/frontend/js/shared/components/switcher.tsx b/services/web/frontend/js/shared/components/switcher.tsx new file mode 100644 index 0000000000..e75e6bc4b8 --- /dev/null +++ b/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 ( + +
{children}
+
+ ) +} + +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 ( + <> + { + if (onChange) { + onChange(evt.target.value) + } + }} + /> + + + ) +} diff --git a/services/web/frontend/stories/source-editor/source-editor.stories.tsx b/services/web/frontend/stories/source-editor/source-editor.stories.tsx index 648ace2662..0d1abb4fae 100644 --- a/services/web/frontend/stories/source-editor/source-editor.stories.tsx +++ b/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 @@ -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} diff --git a/services/web/frontend/stylesheets/_style_includes.less b/services/web/frontend/stylesheets/_style_includes.less index cd9bbbd3b5..1e3e102081 100644 --- a/services/web/frontend/stylesheets/_style_includes.less +++ b/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'; diff --git a/services/web/frontend/stylesheets/app/editor.less b/services/web/frontend/stylesheets/app/editor.less index e7b638d292..ff08e83f32 100644 --- a/services/web/frontend/stylesheets/app/editor.less +++ b/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; diff --git a/services/web/frontend/stylesheets/app/editor/figure-modal.less b/services/web/frontend/stylesheets/app/editor/figure-modal.less new file mode 100644 index 0000000000..648597aad7 --- /dev/null +++ b/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; + } +} diff --git a/services/web/frontend/stylesheets/components/list-group.less b/services/web/frontend/stylesheets/components/list-group.less index 372c0c7efc..66fcee7459 100755 --- a/services/web/frontend/stylesheets/components/list-group.less +++ b/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; } diff --git a/services/web/frontend/stylesheets/components/select.less b/services/web/frontend/stylesheets/components/select.less new file mode 100644 index 0000000000..61e6ace288 --- /dev/null +++ b/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; +} diff --git a/services/web/frontend/stylesheets/components/switcher.less b/services/web/frontend/stylesheets/components/switcher.less new file mode 100644 index 0000000000..e926fcfcb7 --- /dev/null +++ b/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; +} diff --git a/services/web/frontend/stylesheets/core/variables.less b/services/web/frontend/stylesheets/core/variables.less index c7da7073be..5d67ef583e 100644 --- a/services/web/frontend/stylesheets/core/variables.less +++ b/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 diff --git a/services/web/frontend/stylesheets/main-style.less b/services/web/frontend/stylesheets/main-style.less index e39681aabb..c5dd15fa5b 100644 --- a/services/web/frontend/stylesheets/main-style.less +++ b/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'; diff --git a/services/web/frontend/stylesheets/variables/all.less b/services/web/frontend/stylesheets/variables/all.less index 6b3ca2045a..2d7adb96b7 100644 --- a/services/web/frontend/stylesheets/variables/all.less +++ b/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 diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx index 5b666c19ff..727b74b4e6 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx @@ -122,30 +122,6 @@ describe(' 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()