Merge pull request #20298 from overleaf/rd-ide-filetree

[web] Migrate the file tree on the editor page to Bootstrap 5

GitOrigin-RevId: e2efec26242c8cdab37a54bc182b83bfb0f1eb3c
This commit is contained in:
Rebeka Dekany 2024-09-25 15:46:02 +02:00 committed by Copybot
parent 6f8a9a0f81
commit abb59e4603
40 changed files with 1297 additions and 326 deletions

View file

@ -968,6 +968,7 @@
"only_group_admin_or_managers_can_delete_your_account_5": "",
"only_importer_can_refresh": "",
"open_a_file_on_the_left": "",
"open_action_menu": "",
"open_advanced_reference_search": "",
"open_file": "",
"open_link": "",

View file

@ -1,10 +1,15 @@
import React, { useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
import { Dropdown } from 'react-bootstrap'
import { Dropdown as BS3Dropdown } from 'react-bootstrap'
import {
Dropdown,
DropdownMenu,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { useFileTreeMainContext } from '../contexts/file-tree-main'
import FileTreeItemMenuItems from './file-tree-item/file-tree-item-menu-items'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
function FileTreeContextMenu() {
const { fileTreeReadOnly } = useFileTreeData()
@ -24,10 +29,10 @@ function FileTreeContextMenu() {
// A11y - Move the focus to the context menu when it opens
function focusContextMenu() {
const contextMenu = document.querySelector(
const BS3contextMenu = document.querySelector(
'[aria-labelledby="dropdown-file-tree-context-menu"]'
) as HTMLElement | null
contextMenu?.focus()
BS3contextMenu?.focus()
}
function close() {
@ -48,31 +53,58 @@ function FileTreeContextMenu() {
// A11y - Close the context menu when the user presses the Tab key
// Focus should move to the next element in the filetree
function handleKeyDown(event: React.KeyboardEvent<Dropdown>) {
function handleKeyDown(event: React.KeyboardEvent<BS3Dropdown | Element>) {
if (event.key === 'Tab') {
close()
}
}
return ReactDOM.createPortal(
<Dropdown
onClick={handleClick}
open
id="dropdown-file-tree-context-menu"
onToggle={handleToggle}
dropup={
document.body.offsetHeight / contextMenuCoords.top < 2 &&
document.body.offsetHeight - contextMenuCoords.top < 250
<BootstrapVersionSwitcher
bs3={
<BS3Dropdown
onClick={handleClick}
open
id="dropdown-file-tree-context-menu"
onToggle={handleToggle}
dropup={
document.body.offsetHeight / contextMenuCoords.top < 2 &&
document.body.offsetHeight - contextMenuCoords.top < 250
}
className="context-menu"
style={contextMenuCoords}
onKeyDown={handleKeyDown}
>
<FakeDropDownToggle bsRole="toggle" />
<BS3Dropdown.Menu tabIndex={-1}>
<FileTreeItemMenuItems />
</BS3Dropdown.Menu>
</BS3Dropdown>
}
className="context-menu"
style={contextMenuCoords}
onKeyDown={handleKeyDown}
>
<FakeDropDownToggle bsRole="toggle" />
<Dropdown.Menu tabIndex={-1}>
<FileTreeItemMenuItems />
</Dropdown.Menu>
</Dropdown>,
bs5={
<div style={contextMenuCoords} className="context-menu">
<Dropdown
show
drop={
document.body.offsetHeight / contextMenuCoords.top < 2 &&
document.body.offsetHeight - contextMenuCoords.top < 250
? 'up'
: 'down'
}
focusFirstItemOnShow // A11y - Focus the first item in the context menu when it opens since the menu is rendered at the root level
onKeyDown={handleKeyDown}
onToggle={handleToggle}
>
<DropdownMenu
className="dropdown-menu-sm-width"
id="dropdown-file-tree-context-menu"
>
<FileTreeItemMenuItems />
</DropdownMenu>
</Dropdown>
</div>
}
/>,
document.body
)
}

View file

@ -1,8 +1,8 @@
import OLNotification from '@/features/ui/components/ol/ol-notification'
import PropTypes from 'prop-types'
import { Alert } from 'react-bootstrap'
export default function DangerMessage({ children }) {
return <Alert bsStyle="danger">{children}</Alert>
return <OLNotification type="error" content={children} />
}
DangerMessage.propTypes = {
children: PropTypes.any.isRequired,

View file

@ -1,7 +1,4 @@
import ControlLabel from 'react-bootstrap/lib/ControlLabel'
import { Alert, FormControl } from 'react-bootstrap'
import FormGroup from 'react-bootstrap/lib/FormGroup'
import { useCallback } from 'react'
import { useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useFileTreeCreateName } from '../../contexts/file-tree-create-name'
import PropTypes from 'prop-types'
@ -10,6 +7,10 @@ import {
DuplicateFilenameError,
InvalidFilenameError,
} from '../../errors'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import OLNotification from '@/features/ui/components/ol/ol-notification'
/**
* A form component that renders a text input with label,
@ -29,42 +30,46 @@ export default function FileTreeCreateNameInput({
const { name, setName, touchedName, validName } = useFileTreeCreateName()
// focus the first part of the filename if needed
const inputRef = useCallback(
element => {
if (element && focusName) {
window.requestAnimationFrame(() => {
element.focus()
element.setSelectionRange(0, element.value.lastIndexOf('.'))
})
}
},
[focusName]
)
const inputRef = useRef(null)
useEffect(() => {
if (inputRef.current && focusName) {
window.requestAnimationFrame(() => {
if (inputRef.current) {
inputRef.current.focus()
inputRef.current.setSelectionRange(
0,
inputRef.current.value.lastIndexOf('.')
)
}
})
}
}, [focusName])
return (
<FormGroup controlId="new-doc-name" className={classes.formGroup}>
<ControlLabel>{label || t('file_name')}</ControlLabel>
<OLFormGroup controlId="new-doc-name" className={classes.formGroup}>
<OLFormLabel>{label || t('file_name')}</OLFormLabel>
<FormControl
<OLFormControl
type="text"
placeholder={placeholder || t('file_name')}
required
value={name}
onChange={event => setName(event.target.value)}
inputRef={inputRef}
ref={inputRef}
disabled={inFlight}
/>
<FormControl.Feedback />
{touchedName && !validName && (
<Alert bsStyle="danger" className="row-spaced-small">
{t('files_cannot_include_invalid_characters')}
</Alert>
<OLNotification
type="error"
className="row-spaced-small"
content={t('files_cannot_include_invalid_characters')}
/>
)}
{error && <ErrorMessage error={error} />}
</FormGroup>
</OLFormGroup>
)
}
@ -89,23 +94,29 @@ function ErrorMessage({ error }) {
switch (error.constructor) {
case DuplicateFilenameError:
return (
<Alert bsStyle="danger" className="row-spaced-small">
{t('file_already_exists')}
</Alert>
<OLNotification
type="error"
className="row-spaced-small"
content={t('file_already_exists')}
/>
)
case InvalidFilenameError:
return (
<Alert bsStyle="danger" className="row-spaced-small">
{t('files_cannot_include_invalid_characters')}
</Alert>
<OLNotification
type="error"
className="row-spaced-small"
content={t('files_cannot_include_invalid_characters')}
/>
)
case BlockedFilenameError:
return (
<Alert bsStyle="danger" className="row-spaced-small">
{t('blocked_filename')}
</Alert>
<OLNotification
type="error"
className="row-spaced-small"
content={t('blocked_filename')}
/>
)
default:

View file

@ -11,6 +11,7 @@ import importOverleafModules from '../../../../../macros/import-overleaf-module.
import { lazy, Suspense } from 'react'
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
import getMeta from '@/utils/meta'
import { bsVersion } from '@/features/utils/bootstrap-5'
const createFileModeModules = importOverleafModules('createFileModes')
@ -35,11 +36,11 @@ export default function FileTreeModalCreateFileBody() {
<table>
<tbody>
<tr>
<td className="modal-new-file--list">
<td className="modal-new-file-list">
<ul className="list-unstyled">
<FileTreeModalCreateFileMode
mode="doc"
icon="file"
icon={bsVersion({ bs5: 'description', bs3: 'file' })}
label={t('new_file')}
/>
@ -53,7 +54,7 @@ export default function FileTreeModalCreateFileBody() {
hasLinkedProjectOutputFileFeature) && (
<FileTreeModalCreateFileMode
mode="project"
icon="folder-open"
icon={bsVersion({ bs5: 'folder_open', bs3: 'folder-open' })}
label={t('from_another_project')}
/>
)}
@ -75,7 +76,7 @@ export default function FileTreeModalCreateFileBody() {
</td>
<td
className={`modal-new-file--body modal-new-file--body-${newFileCreateMode}`}
className={`modal-new-file-body modal-new-file-body-${newFileCreateMode}`}
>
{newFileCreateMode === 'doc' && (
<FileTreeCreateNameProvider initialName="name.tex">

View file

@ -1,9 +1,10 @@
import { useTranslation } from 'react-i18next'
import { Alert, Button } from 'react-bootstrap'
import { useFileTreeCreateForm } from '../../contexts/file-tree-create-form'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
import { useFileTreeData } from '../../../../shared/context/file-tree-data-context'
import PropTypes from 'prop-types'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLNotification from '@/features/ui/components/ol/ol-notification'
export default function FileTreeModalCreateFileFooter() {
const { valid } = useFileTreeCreateForm()
@ -40,31 +41,35 @@ export function FileTreeModalCreateFileFooterContent({
)}
{fileCount.status === 'error' && (
<Alert bsStyle="warning" className="at-file-limit">
<OLNotification
type="error"
className="at-file-limit"
content={t('project_has_too_many_files')}
>
{/* TODO: add parameter for fileCount.limit */}
{t('project_has_too_many_files')}
</Alert>
</OLNotification>
)}
<Button
bsStyle={null}
className="btn-secondary"
<OLButton
variant="secondary"
type="button"
disabled={inFlight}
onClick={cancel}
>
{t('cancel')}
</Button>
</OLButton>
{newFileCreateMode !== 'upload' && (
<Button
bsStyle="primary"
<OLButton
variant="primary"
type="submit"
form="create-file"
disabled={inFlight || !valid}
isLoading={inFlight}
bs3Props={{ loading: inFlight ? `${t('creating')}` : t('create') }}
>
<span>{inFlight ? `${t('creating')}` : t('create')}</span>
</Button>
{t('create')}
</OLButton>
)}
</>
)

View file

@ -1,9 +1,11 @@
import classnames from 'classnames'
import { Button } from 'react-bootstrap'
import PropTypes from 'prop-types'
import Icon from '../../../../shared/components/icon'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
import * as eventTracking from '../../../../infrastructure/event-tracking'
import OLButton from '@/features/ui/components/ol/ol-button'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import MaterialIcon from '@/shared/components/material-icon'
export default function FileTreeModalCreateFileMode({ mode, icon, label }) {
const { newFileCreateMode, startCreatingFile } = useFileTreeActionable()
@ -15,16 +17,18 @@ export default function FileTreeModalCreateFileMode({ mode, icon, label }) {
return (
<li className={classnames({ active: newFileCreateMode === mode })}>
<Button
bsStyle="link"
block
<OLButton
variant="link"
onClick={handleClick}
className="modal-new-file-mode"
>
<Icon type={icon} fw />
<BootstrapVersionSwitcher
bs3={<Icon type={icon} fw />}
bs5={<MaterialIcon type={icon} />}
/>
&nbsp;
{label}
</Button>
</OLButton>
</li>
)
}

View file

@ -3,8 +3,8 @@ import { useTranslation } from 'react-i18next'
import { useProjectContext } from '@/shared/context/project-context'
import { useCallback } from 'react'
import { syncDelete } from '@/features/file-tree/util/sync-mutation'
import { Button } from 'react-bootstrap'
import { TFunction } from 'i18next'
import OLButton from '@/features/ui/components/ol/ol-button'
export type Conflict = {
entity: FileTreeEntity
@ -48,7 +48,7 @@ export function FileUploadConflicts({
)
return (
<div className="small modal-new-file--body-conflict">
<div className="small modal-new-file-body-conflict">
{conflicts.length > 0 && (
<>
<p className="text-center mb-0">{getConflictText(conflicts, t)}</p>
@ -70,14 +70,14 @@ export function FileUploadConflicts({
)}
<p className="text-center">
<Button bsStyle={null} className="btn-secondary" onClick={cancel}>
<OLButton variant="secondary" onClick={cancel}>
{t('cancel')}
</Button>
</OLButton>
&nbsp;
{!hasFolderConflict && (
<Button bsStyle="danger" onClick={handleOverwrite}>
<OLButton variant="danger" onClick={handleOverwrite}>
{t('overwrite')}
</Button>
</OLButton>
)}
</p>
</div>
@ -118,7 +118,7 @@ export function FolderUploadConflicts({
}, [setError, conflicts, handleOverwrite, projectId])
return (
<div className="small modal-new-file--body-conflict">
<div className="small modal-new-file-body-conflict">
<p className="text-center mb-0">{getConflictText(conflicts, t)}</p>
<ul className="text-center list-unstyled row-spaced-small mt-1">
@ -140,14 +140,14 @@ export function FolderUploadConflicts({
)}
<p className="text-center">
<Button bsStyle={null} className="btn-secondary" onClick={cancel}>
<OLButton variant="secondary" onClick={cancel}>
{t('cancel')}
</Button>
</OLButton>
&nbsp;
{!hasFileConflict && (
<Button bsStyle="danger" onClick={deleteAndRetry}>
<OLButton variant="danger" onClick={deleteAndRetry}>
{t('overwrite')}
</Button>
</OLButton>
)}
</p>
</div>

View file

@ -5,7 +5,6 @@ import {
useMemo,
FormEventHandler,
} from 'react'
import { Button, ControlLabel, FormControl, FormGroup } from 'react-bootstrap'
import Icon from '../../../../../shared/components/icon'
import FileTreeCreateNameInput from '../file-tree-create-name-input'
import { useTranslation } from 'react-i18next'
@ -21,6 +20,13 @@ import * as eventTracking from '../../../../../infrastructure/event-tracking'
import { File } from '@/features/source-editor/utils/file'
import { Project } from '../../../../../../../types/project'
import getMeta from '@/utils/meta'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLForm from '@/features/ui/components/ol/ol-form'
import OLFormSelect from '@/features/ui/components/ol/ol-form-select'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import { Spinner } from 'react-bootstrap-5'
export default function FileTreeImportFromProject() {
const { t } = useTranslation()
@ -125,7 +131,7 @@ export default function FileTreeImportFromProject() {
}
return (
<form className="form-controls" id="create-file" onSubmit={handleSubmit}>
<OLForm id="create-file" onSubmit={handleSubmit}>
<SelectProject
selectedProject={selectedProject}
setSelectedProject={setSelectedProject}
@ -148,8 +154,8 @@ export default function FileTreeImportFromProject() {
{canSwitchOutputFilesMode && (
<div className="toggle-file-type-button">
or&nbsp;
<Button
bsStyle="link"
<OLButton
variant="link"
type="button"
onClick={() => setOutputFilesMode(value => !value)}
>
@ -158,7 +164,7 @@ export default function FileTreeImportFromProject() {
? t('select_from_source_files')
: t('select_from_output_files')}
</span>
</Button>
</OLButton>
</div>
)}
@ -173,7 +179,7 @@ export default function FileTreeImportFromProject() {
/>
{error && <ErrorMessage error={error} />}
</form>
</OLForm>
)
}
@ -204,18 +210,27 @@ function SelectProject({
}
return (
<FormGroup className="form-controls" controlId="project-select">
<ControlLabel>{t('select_a_project')}</ControlLabel>
<OLFormGroup controlId="project-select">
<OLFormLabel>{t('select_a_project')}</OLFormLabel>
{loading && (
<span>
&nbsp;
<Icon type="spinner" spin />
<BootstrapVersionSwitcher
bs3={<Icon type="spinner" spin />}
bs5={
<Spinner
animation="border"
aria-hidden="true"
size="sm"
role="status"
/>
}
/>
</span>
)}
<FormControl
componentClass="select"
<OLFormSelect
disabled={!data}
value={selectedProject ? selectedProject._id : ''}
onChange={event => {
@ -234,12 +249,12 @@ function SelectProject({
{project.name}
</option>
))}
</FormControl>
</OLFormSelect>
{filteredData && !filteredData.length && (
<small>{t('no_other_projects_found')}</small>
)}
</FormGroup>
</OLFormGroup>
)
}
@ -263,25 +278,34 @@ function SelectProjectOutputFile({
}
return (
<FormGroup
className="form-controls row-spaced-small"
<OLFormGroup
className="row-spaced-small"
controlId="project-output-file-select"
>
<ControlLabel>{t('select_an_output_file')}</ControlLabel>
<OLFormLabel>{t('select_an_output_file')}</OLFormLabel>
{loading && (
<span>
&nbsp;
<Icon type="spinner" spin />
<BootstrapVersionSwitcher
bs3={<Icon type="spinner" spin />}
bs5={
<Spinner
animation="border"
aria-hidden="true"
size="sm"
role="status"
/>
}
/>
</span>
)}
<FormControl
componentClass="select"
<OLFormSelect
disabled={!data}
value={selectedProjectOutputFile?.path || ''}
onChange={event => {
const path = (event.target as HTMLSelectElement).value
const path = (event.target as unknown as HTMLSelectElement).value
const file = data?.find(item => item.path === path)
setSelectedProjectOutputFile(file)
}}
@ -296,8 +320,8 @@ function SelectProjectOutputFile({
{file.path}
</option>
))}
</FormControl>
</FormGroup>
</OLFormSelect>
</OLFormGroup>
)
}
@ -321,21 +345,27 @@ function SelectProjectEntity({
}
return (
<FormGroup
className="form-controls row-spaced-small"
controlId="project-entity-select"
>
<ControlLabel>{t('select_a_file')}</ControlLabel>
<OLFormGroup className="row-spaced-small" controlId="project-entity-select">
<OLFormLabel>{t('select_a_file')}</OLFormLabel>
{loading && (
<span>
&nbsp;
<Icon type="spinner" spin />
<BootstrapVersionSwitcher
bs3={<Icon type="spinner" spin />}
bs5={
<Spinner
animation="border"
aria-hidden="true"
size="sm"
role="status"
/>
}
/>
</span>
)}
<FormControl
componentClass="select"
<OLFormSelect
disabled={!data}
value={selectedProjectEntity?.path || ''}
onChange={event => {
@ -354,7 +384,7 @@ function SelectProjectEntity({
{entity.path.slice(1)}
</option>
))}
</FormControl>
</FormGroup>
</OLFormSelect>
</OLFormGroup>
)
}

View file

@ -1,4 +1,3 @@
import { ControlLabel, FormControl, FormGroup } from 'react-bootstrap'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import FileTreeCreateNameInput from '../file-tree-create-name-input'
@ -7,6 +6,9 @@ import { useFileTreeCreateName } from '../../../contexts/file-tree-create-name'
import { useFileTreeCreateForm } from '../../../contexts/file-tree-create-form'
import ErrorMessage from '../error-message'
import * as eventTracking from '../../../../../infrastructure/event-tracking'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
export default function FileTreeImportFromUrl() {
const { t } = useTranslation()
@ -54,17 +56,16 @@ export default function FileTreeImportFromUrl() {
noValidate
onSubmit={handleSubmit}
>
<FormGroup controlId="import-from-url">
<ControlLabel>{t('url_to_fetch_the_file_from')}</ControlLabel>
<FormControl
<OLFormGroup controlId="import-from-url">
<OLFormLabel>{t('url_to_fetch_the_file_from')}</OLFormLabel>
<OLFormControl
type="url"
placeholder="https://example.com/my-file.png"
required
value={url}
onChange={handleChange}
/>
</FormGroup>
</OLFormGroup>
<FileTreeCreateNameInput
label={t('file_name_in_this_project')}

View file

@ -7,6 +7,8 @@ import { useTranslation } from 'react-i18next'
import Icon from '../../../shared/components/icon'
import iconTypeFromName from '../util/icon-type-from-name'
import classnames from 'classnames'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import MaterialIcon from '@/shared/components/material-icon'
function FileTreeDoc({ name, id, isFile, isLinkedFile }) {
const type = isFile ? 'file' : 'doc'
@ -46,25 +48,45 @@ FileTreeDoc.propTypes = {
export const FileTreeIcon = ({ isLinkedFile, name }) => {
const { t } = useTranslation()
const className = classnames('spaced', 'file-tree-icon', {
const className = classnames('file-tree-icon', {
'linked-file-icon': isLinkedFile,
})
return (
<>
&nbsp;
<Icon type={iconTypeFromName(name)} fw className={className} />
{isLinkedFile && (
<Icon
type="external-link-square"
modifier="rotate-180"
className="linked-file-highlight"
accessibilityLabel={t('linked_file')}
/>
)}
<BootstrapVersionSwitcher
bs3={
<>
<Icon type={iconTypeFromName(name)} fw className={className} />
{isLinkedFile && (
<Icon
type="external-link-square"
modifier="rotate-180"
className="linked-file-highlight"
accessibilityLabel={t('linked_file')}
/>
)}
</>
}
bs5={
<>
<MaterialIcon type={iconTypeFromName(name)} className={className} />
{isLinkedFile && (
<MaterialIcon
type="open_in_new"
modifier="rotate-180"
className="linked-file-highlight"
accessibilityLabel={t('linked_file')}
/>
)}
</>
}
/>
</>
)
}
FileTreeIcon.propTypes = {
name: PropTypes.string.isRequired,
isLinkedFile: PropTypes.bool,

View file

@ -1,6 +1,6 @@
import { Button } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { useLocation } from '../../../shared/hooks/use-location'
import OLButton from '@/features/ui/components/ol/ol-button'
function FileTreeError() {
const { t } = useTranslation()
@ -10,9 +10,9 @@ function FileTreeError() {
<div className="file-tree-error">
<p>{t('generic_something_went_wrong')}</p>
<p>{t('please_refresh')}</p>
<Button bsStyle="primary" onClick={handleClick}>
<OLButton variant="primary" onClick={handleClick}>
{t('refresh')}
</Button>
</OLButton>
</div>
)
}

View file

@ -13,6 +13,8 @@ import { useDroppable } from '../contexts/file-tree-draggable'
import FileTreeItemInner from './file-tree-item/file-tree-item-inner'
import FileTreeFolderList from './file-tree-folder-list'
import usePersistedState from '../../../shared/hooks/use-persisted-state'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import MaterialIcon from '@/shared/components/material-icon'
function FileTreeFolder({ name, id, folders, docs, files }) {
const { t } = useTranslation()
@ -43,23 +45,44 @@ function FileTreeFolder({ name, id, folders, docs, files }) {
const { isOver: isOverList, dropRef: dropRefList } = useDroppable(id)
const icons = (
<>
<button
onClick={handleExpandCollapseClick}
aria-label={expanded ? t('collapse') : t('expand')}
>
<Icon
type={expanded ? 'angle-down' : 'angle-right'}
fw
className="file-tree-expand-icon"
/>
</button>
<Icon
type={expanded ? 'folder-open' : 'folder'}
fw
className="file-tree-folder-icon"
/>
</>
<BootstrapVersionSwitcher
bs3={
<>
<button
onClick={handleExpandCollapseClick}
aria-label={expanded ? t('collapse') : t('expand')}
>
<Icon
type={expanded ? 'angle-down' : 'angle-right'}
fw
className="file-tree-expand-icon"
/>
</button>
<Icon
type={expanded ? 'folder-open' : 'folder'}
fw
className="file-tree-folder-icon"
/>
</>
}
bs5={
<>
<button
onClick={handleExpandCollapseClick}
aria-label={expanded ? t('collapse') : t('expand')}
>
<MaterialIcon
type={expanded ? 'expand_more' : 'chevron_right'}
className="file-tree-expand-icon"
/>
</button>
<MaterialIcon
type={expanded ? 'folder_open' : 'folder'}
className="file-tree-folder-icon"
/>
</>
}
/>
)
return (

View file

@ -90,7 +90,7 @@ function FileTreeItemInner({
isSelected={isSelected}
setIsDraggable={setIsDraggable}
/>
{hasMenu ? <FileTreeItemMenu id={id} /> : null}
{hasMenu ? <FileTreeItemMenu id={id} name={name} /> : null}
</div>
</div>
)

View file

@ -4,7 +4,12 @@ import * as eventTracking from '../../../../infrastructure/event-tracking'
import { useProjectContext } from '@/shared/context/project-context'
import { MenuItem } from 'react-bootstrap'
import {
DropdownDivider,
DropdownItem,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
function FileTreeItemMenuItems() {
const { t } = useTranslation()
@ -42,31 +47,82 @@ function FileTreeItemMenuItems() {
}, [startUploadingDocOrFile])
return (
<>
{canRename ? (
<MenuItem onClick={startRenaming}>{t('rename')}</MenuItem>
) : null}
{downloadPath ? (
<MenuItem
href={downloadPath}
onClick={downloadWithAnalytics}
download={selectedFileName}
>
{t('download')}
</MenuItem>
) : null}
{canDelete ? (
<MenuItem onClick={startDeleting}>{t('delete')}</MenuItem>
) : null}
{canCreate ? (
<BootstrapVersionSwitcher
bs3={
<>
<li role="none" className="divider" />
<MenuItem onClick={createWithAnalytics}>{t('new_file')}</MenuItem>
<MenuItem onClick={startCreatingFolder}>{t('new_folder')}</MenuItem>
<MenuItem onClick={uploadWithAnalytics}>{t('upload')}</MenuItem>
{canRename ? (
<MenuItem onClick={startRenaming}>{t('rename')}</MenuItem>
) : null}
{downloadPath ? (
<MenuItem
href={downloadPath}
onClick={downloadWithAnalytics}
download={selectedFileName}
>
{t('download')}
</MenuItem>
) : null}
{canDelete ? (
<MenuItem onClick={startDeleting}>{t('delete')}</MenuItem>
) : null}
{canCreate ? (
<>
<li role="none" className="divider" />
<MenuItem onClick={createWithAnalytics}>{t('new_file')}</MenuItem>
<MenuItem onClick={startCreatingFolder}>
{t('new_folder')}
</MenuItem>
<MenuItem onClick={uploadWithAnalytics}>{t('upload')}</MenuItem>
</>
) : null}
</>
) : null}
</>
}
bs5={
<>
{canRename ? (
<li role="none">
<DropdownItem onClick={startRenaming}>{t('rename')}</DropdownItem>
</li>
) : null}
{downloadPath ? (
<li role="none">
<DropdownItem
href={downloadPath}
onClick={downloadWithAnalytics}
download={selectedFileName}
>
{t('download')}
</DropdownItem>
</li>
) : null}
{canDelete ? (
<li role="none">
<DropdownItem onClick={startDeleting}>{t('delete')}</DropdownItem>
</li>
) : null}
{canCreate ? (
<>
<DropdownDivider role="none" />
<li role="none">
<DropdownItem onClick={createWithAnalytics}>
{t('new_file')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem onClick={startCreatingFolder}>
{t('new_folder')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem onClick={uploadWithAnalytics}>
{t('upload')}
</DropdownItem>
</li>
</>
) : null}
</>
}
/>
)
}

View file

@ -5,12 +5,16 @@ import { useTranslation } from 'react-i18next'
import Icon from '../../../../shared/components/icon'
import { useFileTreeMainContext } from '../../contexts/file-tree-main'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import MaterialIcon from '@/shared/components/material-icon'
function FileTreeItemMenu({ id }) {
function FileTreeItemMenu({ id, name }) {
const { t } = useTranslation()
const { contextMenuCoords, setContextMenuCoords } = useFileTreeMainContext()
const menuButtonRef = useRef()
const isMenuOpen = Boolean(contextMenuCoords)
function handleClick(event) {
event.stopPropagation()
if (!contextMenuCoords) {
@ -31,8 +35,14 @@ function FileTreeItemMenu({ id }) {
id={`menu-button-${id}`}
onClick={handleClick}
ref={menuButtonRef}
aria-haspopup="true"
aria-expanded={isMenuOpen}
aria-label={t('open_action_menu', { name })}
>
<Icon type="ellipsis-v" accessibilityLabel={t('menu')} />
<BootstrapVersionSwitcher
bs3={<Icon type="ellipsis-v" accessibilityLabel={t('menu')} />}
bs5={<MaterialIcon type="more_vert" accessibilityLabel={t('menu')} />}
/>
</button>
</div>
)
@ -40,6 +50,7 @@ function FileTreeItemMenu({ id }) {
FileTreeItemMenu.propTypes = {
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
}
export default FileTreeItemMenu

View file

@ -1,10 +1,14 @@
import { Modal } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
import FileTreeCreateFormProvider from '../../contexts/file-tree-create-form'
import FileTreeModalCreateFileBody from '../file-tree-create/file-tree-modal-create-file-body'
import FileTreeModalCreateFileFooter from '../file-tree-create/file-tree-modal-create-file-footer'
import AccessibleModal from '../../../../shared/components/accessible-modal'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
export default function FileTreeModalCreateFile() {
const { t } = useTranslation()
@ -17,19 +21,19 @@ export default function FileTreeModalCreateFile() {
return (
<FileTreeCreateFormProvider>
<AccessibleModal bsSize="large" onHide={cancel} show>
<Modal.Header closeButton>
<Modal.Title>{t('add_files')}</Modal.Title>
</Modal.Header>
<OLModal size="lg" onHide={cancel} show>
<OLModalHeader closeButton>
<OLModalTitle>{t('add_files')}</OLModalTitle>
</OLModalHeader>
<Modal.Body className="modal-new-file">
<OLModalBody className="modal-new-file">
<FileTreeModalCreateFileBody />
</Modal.Body>
</OLModalBody>
<Modal.Footer>
<OLModalFooter>
<FileTreeModalCreateFileFooter />
</Modal.Footer>
</AccessibleModal>
</OLModalFooter>
</OLModal>
</FileTreeCreateFormProvider>
)
}

View file

@ -1,17 +1,17 @@
import { useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import { Button, Modal } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
import AccessibleModal from '../../../../shared/components/accessible-modal'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
import { DuplicateFilenameError } from '../../errors'
import { isCleanFilename } from '../../util/safe-path'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
function FileTreeModalCreateFolder() {
const { t } = useTranslation()
@ -48,12 +48,12 @@ function FileTreeModalCreateFolder() {
}
return (
<AccessibleModal show onHide={handleHide}>
<Modal.Header>
<Modal.Title>{t('new_folder')}</Modal.Title>
</Modal.Header>
<OLModal show onHide={handleHide}>
<OLModalHeader>
<OLModalTitle>{t('new_folder')}</OLModalTitle>
</OLModalHeader>
<Modal.Body>
<OLModalBody>
<InputName
name={name}
setName={setName}
@ -79,33 +79,32 @@ function FileTreeModalCreateFolder() {
{errorMessage()}
</div>
) : null}
</Modal.Body>
</OLModalBody>
<Modal.Footer>
<OLModalFooter>
{inFlight ? (
<Button bsStyle="primary" disabled>
{t('creating')}
</Button>
<OLButton
variant="primary"
disabled
isLoading={inFlight}
bs3Props={{ loading: `${t('creating')}` }}
/>
) : (
<>
<Button
bsStyle={null}
className="btn-secondary"
onClick={handleHide}
>
<OLButton variant="secondary" onClick={handleHide}>
{t('cancel')}
</Button>
<Button
bsStyle="primary"
</OLButton>
<OLButton
variant="primary"
onClick={handleCreateFolder}
disabled={!validName}
>
{t('create')}
</Button>
</OLButton>
</>
)}
</Modal.Footer>
</AccessibleModal>
</OLModalFooter>
</OLModal>
)
}

View file

@ -1,9 +1,14 @@
import { Button, Modal } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import AccessibleModal from '../../../../shared/components/accessible-modal'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLNotification from '@/features/ui/components/ol/ol-notification'
function FileTreeModalDelete() {
const { t } = useTranslation()
@ -28,12 +33,12 @@ function FileTreeModalDelete() {
}
return (
<AccessibleModal show onHide={handleHide}>
<Modal.Header>
<Modal.Title>{t('delete')}</Modal.Title>
</Modal.Header>
<OLModal show onHide={handleHide}>
<OLModalHeader>
<OLModalTitle>{t('delete')}</OLModalTitle>
</OLModalHeader>
<Modal.Body>
<OLModalBody>
<p>{t('sure_you_want_to_delete')}</p>
<ul>
{actionedEntities.map(entity => (
@ -41,33 +46,33 @@ function FileTreeModalDelete() {
))}
</ul>
{error && (
<div className="alert alert-danger file-tree-modal-alert">
{t('generic_something_went_wrong')}
</div>
<OLNotification
type="error"
content={t('generic_something_went_wrong')}
/>
)}
</Modal.Body>
</OLModalBody>
<Modal.Footer>
<OLModalFooter>
{inFlight ? (
<Button bsStyle="danger" disabled>
{t('deleting')}
</Button>
<OLButton
variant="danger"
disabled
isLoading
bs3Props={{ loading: `${t('deleting')}` }}
/>
) : (
<>
<Button
bsStyle={null}
className="btn-secondary"
onClick={handleHide}
>
<OLButton className="secondary" onClick={handleHide}>
{t('cancel')}
</Button>
<Button bsStyle="danger" onClick={handleDelete}>
</OLButton>
<OLButton variant="danger" onClick={handleDelete}>
{t('delete')}
</Button>
</OLButton>
</>
)}
</Modal.Footer>
</AccessibleModal>
</OLModalFooter>
</OLModal>
)
}

View file

@ -1,4 +1,3 @@
import { Button, Modal } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
@ -9,6 +8,13 @@ import {
DuplicateFilenameError,
DuplicateFilenameMoveError,
} from '../../errors'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
function FileTreeModalError() {
const { t } = useTranslation()
@ -60,23 +66,23 @@ function FileTreeModalError() {
}
return (
<Modal show onHide={handleHide}>
<Modal.Header>
<Modal.Title>{errorTitle()}</Modal.Title>
</Modal.Header>
<OLModal show onHide={handleHide}>
<OLModalHeader>
<OLModalTitle>{errorTitle()}</OLModalTitle>
</OLModalHeader>
<Modal.Body>
<OLModalBody>
<div role="alert" aria-label={errorMessage()}>
{errorMessage()}
</div>
</Modal.Body>
</OLModalBody>
<Modal.Footer>
<Button onClick={handleHide} bsStyle="primary">
<OLModalFooter>
<OLButton onClick={handleHide} variant="primary">
{t('ok')}
</Button>
</Modal.Footer>
</Modal>
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View file

@ -1,3 +1,5 @@
import { isBootstrap5 } from '@/features/utils/bootstrap-5'
export default function iconTypeFromName(name) {
let ext = name.split('.').pop()
ext = ext ? ext.toLowerCase() : ext
@ -5,12 +7,12 @@ export default function iconTypeFromName(name) {
if (['png', 'pdf', 'jpg', 'jpeg', 'gif'].includes(ext)) {
return 'image'
} else if (['csv', 'xls', 'xlsx'].includes(ext)) {
return 'table'
return isBootstrap5() ? 'table_chart' : 'table'
} else if (['py', 'r'].includes(ext)) {
return 'file-text'
return isBootstrap5() ? 'code' : 'file-text'
} else if (['bib'].includes(ext)) {
return 'book'
return isBootstrap5() ? 'menu_book' : 'book'
} else {
return 'file'
return isBootstrap5() ? 'description' : 'file'
}
}

View file

@ -82,7 +82,7 @@ export default function TagsList() {
<DropdownToggle id={`${tag._id}-dropdown-toggle`}>
<span className="caret" />
</DropdownToggle>
<DropdownMenu className="sm">
<DropdownMenu className="dropdown-menu-sm-width">
<DropdownItem
as="li"
className="tag-action"

View file

@ -34,7 +34,10 @@ function LanguagePicker() {
{translatedLanguages?.[currentLangCode]}
</DropdownToggle>
<DropdownMenu className="sm" aria-labelledby="language-picker-toggle">
<DropdownMenu
className="dropdown-menu-sm-width"
aria-labelledby="language-picker-toggle"
>
{subdomainLang &&
Object.entries(subdomainLang).map(([subdomain, subdomainDetails]) => {
if (

View file

@ -18,6 +18,8 @@ export type DropdownProps = {
show?: boolean
autoClose?: boolean | 'inside' | 'outside'
drop?: 'up' | 'up-centered' | 'start' | 'end' | 'down' | 'down-centered'
focusFirstItemOnShow?: false | true | 'keyboard'
onKeyDown?: (event: React.KeyboardEvent) => void
}
export type DropdownItemProps = PropsWithChildren<{
@ -54,6 +56,7 @@ export type DropdownMenuProps = PropsWithChildren<{
show?: boolean
className?: string
flip?: boolean
id?: string
}>
export type DropdownDividerProps = PropsWithChildren<{

View file

@ -1,6 +1,8 @@
import { useTranslation } from 'react-i18next'
import Icon from './icon'
import { useEffect, useState } from 'react'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import { Spinner } from 'react-bootstrap-5'
function LoadingSpinner({
delay = 0,
@ -28,11 +30,27 @@ function LoadingSpinner({
}
return (
<div className="loading">
<Icon type="refresh" fw spin />
&nbsp;
{loadingText || t('loading')}
</div>
<BootstrapVersionSwitcher
bs3={
<div className="loading">
<Icon type="refresh" fw spin />
&nbsp;
{loadingText || t('loading')}
</div>
}
bs5={
<div className="text-center mt-4">
<Spinner
animation="border"
aria-hidden="true"
role="status"
className="align-bottom"
/>
&nbsp;
{loadingText || t('loading')}
</div>
}
/>
)
}

View file

@ -5,15 +5,17 @@ import { bsVersion } from '@/features/utils/bootstrap-5'
type IconProps = React.ComponentProps<'i'> & {
type: string
accessibilityLabel?: string
modifier?: string
}
function MaterialIcon({
type,
className,
accessibilityLabel,
modifier,
...rest
}: IconProps) {
const iconClassName = classNames('material-symbols', className)
const iconClassName = classNames('material-symbols', className, modifier)
return (
<>

View file

@ -422,7 +422,7 @@
}
}
}
.modal-new-file--list {
.modal-new-file-list {
background-color: @modal-footer-background-color;
width: 220px;
ul {
@ -473,24 +473,24 @@
}
.file-tree-modal-alert {
margin-top: 10px;
margin-bottom: 0px;
margin-top: 12.5px;
}
.btn.modal-new-file-mode {
text-align: left;
width: 100%;
}
.modal-new-file--body {
.modal-new-file-body {
padding: 20px;
padding-top: (@line-height-computed / 4);
}
.modal-new-file--body-upload {
.modal-new-file-body-upload {
padding-top: 20px;
}
.modal-new-file--body-conflict {
.modal-new-file-body-conflict {
background-color: @red-10;
border: 1px dashed @red-50;
min-height: 400px;
@ -512,11 +512,11 @@
}
}
.modal-new-file--body-upload .uppy-Root {
.modal-new-file-body-upload .uppy-Root {
font-family: inherit;
}
.modal-new-file--body-upload .uppy-Dashboard {
.modal-new-file-body-upload .uppy-Dashboard {
.uppy-Dashboard-inner {
border: none;
}

View file

@ -27,7 +27,6 @@
}
.ide-react-main {
//migrated to SCSS
height: 100%;
display: flex;
flex-direction: column;
@ -40,7 +39,6 @@
}
.ide-react-body {
//migrated to SCSS
flex-grow: 1;
background-color: @pdf-bg;
overflow-y: hidden;
@ -127,7 +125,6 @@
}
.ide-react-editor-sidebar {
//migrated to SCSS
height: 100%;
background-color: @file-tree-bg;
color: var(--neutral-20);
@ -140,7 +137,6 @@
}
.ide-react-file-tree-panel {
//migrated to SCSS
display: flex;
flex-direction: column;
@ -162,6 +158,7 @@
}
// Styles for placeholder elements that will eventually be replaced
// Unused, not migrated to SCSS
.ide-react-placeholder-chat {
background-color: var(--editor-toolbar-bg);
color: var(--neutral-20);

View file

@ -74,19 +74,6 @@
background-color: var(--bg-dark-secondary);
}
// Filetree
@mixin file-tree-item-color {
color: var(--content-primary-dark);
}
@mixin file-tree-item-hover-bg {
background-color: var(--bg-dark-secondary);
}
@mixin file-tree-bg {
background-color: var(--bg-dark-tertiary);
}
@mixin theme($name) {
@if index($themes, $name) {
[data-theme='#{$name}'] {

View file

@ -54,3 +54,7 @@ hr {
.row-spaced-extra-large {
margin-top: calc(var(--line-height-03) * 4);
}
.rotate-180 {
transform: rotate(180deg);
}

View file

@ -104,6 +104,7 @@
text-decoration: underline;
font-size: inherit;
vertical-align: inherit;
border-radius: 0;
&,
&:active,

View file

@ -14,7 +14,7 @@
min-width: 240px;
&.sm {
&.dropdown-menu-sm-width {
min-width: 160px;
}

View file

@ -1,3 +1,19 @@
:root {
--file-tree-item-hover-bg: var(--bg-dark-secondary);
--file-tree-item-selected-bg: var(--bg-accent-01);
--file-tree-item-color: var(--content-primary-dark);
--file-tree-bg: var(--bg-dark-tertiary);
--file-tree-item-selected-color: var(--content-primary-dark);
--file-tree-line-height: 2.05;
}
@include theme('light') {
--file-tree-item-hover-bg: var(--bg-light-tertiary);
--file-tree-item-color: var(--content-secondary);
--file-tree-bg: var(--bg-light-primary);
--file-tree-item-selected-color: var(--bg-light-primary);
}
.ide-react-file-tree-panel {
display: flex;
flex-direction: column;
@ -7,3 +23,590 @@
width: 100%;
}
}
.context-menu {
position: fixed;
z-index: 100;
}
.editor-sidebar {
background-color: var(--file-tree-bg);
display: flex;
flex-direction: column;
}
@mixin fake-full-width-bg($bg-color) {
&::before {
content: '\00a0';
position: absolute;
width: 9999px;
left: -9999px;
background-color: $bg-color;
}
}
.file-tree {
display: flex !important; // To work around jQuery layout's inline styles
flex-direction: column;
height: 100%;
.toolbar.toolbar-filetree {
@include toolbar-sm-height;
@include toolbar-alt-bg;
padding: 0 var(--spacing-03);
flex-shrink: 0;
}
> file-tree-root,
.file-tree-inner {
position: relative;
display: flex;
flex-direction: column;
flex-grow: 1;
overflow-y: auto;
width: inherit;
height: inherit;
&.no-toolbar {
top: 0;
}
}
// TODO; Consolidate with "Project files" in Overleaf
h3 {
font-size: 1rem;
border-bottom: 1px solid var(--border-primary);
padding-bottom: var(--spacing-02);
margin: var(--spacing-05);
}
&-history {
.entity-name {
padding-left: var(--spacing-03);
&.deleted {
text-decoration: line-through;
}
}
.loading {
padding-left: var(--spacing-03);
color: var(--content-primary-dark);
.material-symbols {
color: var(--content-primary-dark);
}
}
}
ul.file-tree-list {
margin: 0;
overflow: hidden auto;
height: 100%;
flex-grow: 1;
position: relative;
.entity > ul,
ul[role='tree'] {
margin-left: var(--spacing-08);
}
&::after {
content: '';
display: block;
min-height: var(--spacing-08);
}
li {
line-height: var(--file-tree-line-height);
position: relative;
&:focus {
outline: none;
}
.entity {
user-select: none;
&:focus {
outline: none;
}
}
.entity > .entity-name > button {
background-color: transparent;
border: 0;
padding: 0;
&:focus {
outline: none;
}
&.item-name-button {
color: inherit;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
padding-right: var(--spacing-09);
white-space: pre;
}
}
.entity-name {
color: var(--file-tree-item-color);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.entity-name-react {
text-overflow: clip;
}
&:focus {
outline: none;
}
background-color: transparent;
@include fake-full-width-bg(transparent);
&:hover {
background-color: var(--file-tree-item-hover-bg);
// When the entity is a subfolder, the DOM element is "indented" via margin-left. This makes the
// element not fill the entire file-tree width (as it's spaced from the left-hand side via margin)
// and, in consequence, the background gets clipped. The ::before pseudo-selector is used to fill
// the empty space.
@include fake-full-width-bg(var(--file-tree-item-hover-bg));
}
input {
line-height: 1.6;
}
.entity-menu-toggle > .material-symbols {
color: var(--content-primary-dark);
vertical-align: middle;
}
}
.material-symbols {
color: var(--content-disabled);
&.file-tree-icon {
margin-right: var(--spacing-02);
margin-left: var(--spacing-04);
vertical-align: sub;
&.linked-file-icon {
position: relative;
left: -2px;
+ .linked-file-highlight {
color: inherit;
position: relative;
top: 4px;
width: 0;
left: -5px;
font-size: 12px;
}
}
}
&.file-tree-folder-icon {
margin-right: var(--spacing-02);
vertical-align: sub;
}
&.file-tree-expand-icon {
margin-left: var(--spacing-04);
vertical-align: sub;
}
}
.material-symbols.folder-open,
.material-symbols.fa-folder {
color: var(--content-disabled);
}
.material-symbols.toggle {
width: 24px;
padding: var(--spacing-03);
font-size: var(--font-size-03);
color: var(--content-disabled);
}
.file-tree-dropdown-toggle {
display: flex;
align-items: center;
color: var(--content-primary-dark);
line-height: 1.6;
font-size: var(--font-size-05);
padding: 0 var(--font-size-02) 0 var(--font-size-04);
&:hover,
&:focus {
text-decoration: none;
}
&::before {
content: '\00B7\00B7\00B7';
transform: rotate(90deg);
letter-spacing: 0.5px;
}
}
&.multi-selected {
> .entity {
> .entity-name {
> div > .material-symbols,
> button > .material-symbols,
> .material-symbols,
.entity-menu-toggle .material-symbols {
color: var(--content-primary-dark);
}
> .material-symbols.linked-file-highlight {
color: var(--bg-info-01);
}
@include fake-full-width-bg(var(--bg-info-01));
color: var(--content-primary-dark);
font-weight: bold;
background-color: var(--bg-info-01);
&:hover {
background-color: var(--bg-info-02);
@include fake-full-width-bg(var(--bg-info-02));
}
}
}
}
.menu-button {
position: absolute;
right: 0;
top: 3px;
}
.rename-input {
display: block;
position: absolute;
top: 1px;
left: 32px;
right: 32px;
color: var(--content-primary);
input {
width: 100%;
}
}
> .entity > .entity-name {
.entity-menu-toggle {
display: none;
}
}
.entity-limit-hit {
line-height: var(--file-tree-line-height);
color: var(--file-tree-item-color);
margin-left: var(--spacing-05);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entity-limit-hit-message {
font-style: italic;
}
.material-symbols .entity-limit-hit-tooltip-trigger {
margin-left: var(spacing-03);
cursor: pointer;
}
}
}
&:not(.multi-selected) {
ul.file-tree-list li.selected {
> .entity {
> .entity-name {
background-color: var(--file-tree-item-selected-bg);
color: var(--file-tree-item-selected-color);
> div > .material-symbols,
> button > .material-symbols,
> .material-symbols,
.entity-menu-toggle .material-symbols {
color: var(--file-tree-item-selected-color);
}
> .material-symbols.linked-file-highlight {
color: var(--bg-info-01);
}
@include fake-full-width-bg(var(--file-tree-item-selected-bg));
font-weight: bold;
padding-right: var(--spacing-09);
.entity-menu-toggle {
display: inline-block;
background-color: transparent;
box-shadow: none;
border: 0;
padding-right: var(--spacing-02);
padding-left: var(--spacing-02);
}
}
}
}
}
// while dragging, the previously selected item gets no highlight
ul.file-tree-list.file-tree-dragging li.selected .entity .entity-name {
@include fake-full-width-bg(transparent);
font-weight: normal;
background-color: transparent;
color: var(--file-tree-item-color);
.material-symbols {
color: var(--content-disabled) !important;
}
}
// the items being dragged get the full "hover" colour
ul.file-tree-list.file-tree-dragging
li
.entity.file-tree-entity-dragging
.entity-name {
background-color: fade(var(--file-tree-item-hover-bg), 90%);
@include fake-full-width-bg(fade(var(--file-tree-item-hover-bg), 90%));
color: var(--file-tree-item-color);
.material-symbols {
color: var(--content-disabled) !important;
}
}
// the drop target gets the "selected" colour
ul.file-tree-list.file-tree-dragging
li.dnd-droppable-hover
.entity
.entity-name {
background-color: var(--file-tree-item-selected-bg);
@include fake-full-width-bg(var(--file-tree-item-selected-bg));
color: var(--file-tree-item-selected-color);
.material-symbols {
color: var(--file-tree-item-selected-color) !important;
}
}
.dnd-draggable-preview-layer {
position: absolute;
pointer-events: none;
z-index: 100;
width: 100%;
height: 100%;
overflow: hidden;
&.dnd-droppable-hover {
border: 3px solid var(--file-tree-item-selected-bg);
}
}
.dnd-draggable-preview-item {
color: var(--file-tree-item-selected-color);
background-color: fade(var(--file-tree-item-selected-bg), 60%);
width: 75%;
padding-left: var(--spacing-08);
line-height: 2.05;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.disconnected-overlay {
background-color: var(--file-tree-bg);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
opacity: 0.5;
cursor: wait;
}
}
.modal-new-file {
padding: 0;
table {
width: 100%;
table-layout: fixed;
td {
vertical-align: top;
}
}
.toggle-file-type-button {
font-size: 80%;
margin-top: calc(var(--spacing-05) * -1);
.btn {
display: inline-block;
padding: 0;
vertical-align: baseline;
font-size: inherit;
}
.btn:focus-within {
outline: none;
text-decoration: none;
}
}
}
.modal-new-file-list {
background-color: var(--bg-light-secondary);
width: 220px;
ul {
li {
/* old modal (a) */
a {
color: var(--content-secondary);
padding: var(--spacing-03);
display: block;
text-decoration: none;
}
/* new modal (button) */
.btn {
color: var(--content-secondary);
padding: var(--spacing-03);
}
.btn:hover {
text-decoration: none;
}
.btn:focus {
outline: none;
text-decoration: none;
background-color: white;
}
}
li.active {
background-color: white;
/* old modal (a) */
a {
color: var(--link-ui);
}
/* new modal (button) */
.btn {
color: var(--link-ui);
text-decoration: none;
}
}
li:hover {
background-color: white;
}
}
}
.file-tree-error {
text-align: center;
color: var(--content-secondary-dark);
padding: 20px;
}
.file-tree-modal-alert {
margin-top: var(--spacing-06);
}
.btn.modal-new-file-mode {
justify-content: left;
text-align: left;
text-decoration: none;
width: 100%;
}
.modal-new-file-body {
padding: 20px;
padding-top: var(--spacing-03);
}
.modal-new-file-body-upload {
padding-top: 20px;
}
.modal-new-file-body-conflict {
background-color: var(--bg-danger-03);
border: 1px dashed var(--border-danger);
min-height: 400px;
border-radius: 3px;
color: var(--content-primary);
display: flex;
flex-direction: column;
justify-content: center;
padding: var(--spacing-05);
}
.modal-footer {
.approaching-file-limit {
font-weight: bold;
}
.at-file-limit {
text-align: left;
}
}
/* stylelint-disable selector-class-pattern */
.modal-new-file-body-upload .uppy-Root {
font-family: inherit;
}
.modal-new-file-body-upload .uppy-Dashboard {
.uppy-Dashboard-inner {
border: none;
}
.uppy-Dashboard-dropFilesHereHint {
inset: 0;
}
.uppy-Dashboard-AddFiles {
margin: 0;
border: 1px dashed var(--border-primary);
height: 100%;
.uppy-Dashboard-AddFiles-title {
font-size: inherit;
}
}
.uppy-Dashboard-AddFiles-title {
width: 26em; // sized to create a wrap between the sentences
max-width: 100%;
}
}

View file

@ -27,8 +27,7 @@
}
.ide-react-editor-sidebar {
@include file-tree-bg;
background-color: var(--file-tree-bg);
height: 100%;
color: var(--content-secondary-dark);
}
@ -51,3 +50,141 @@
color: var(--neutral-20);
}
}
.ide-react-symbol-palette {
height: 100%;
background-color: var(--bg-dark-tertiary);
color: var(--neutral-20);
}
.ide-react-file-tree-panel {
display: flex;
flex-direction: column;
// Prevent the file tree expanding beyond the boundary of the panel
.file-tree {
width: 100%;
}
}
.ide-react-editor-panel {
display: flex;
flex-direction: column;
}
// Ensure an element with class "full-size", such as the binary file view, stays within the bounds of the panel
.ide-react-panel {
position: relative;
container-type: size;
}
.ide-panel-group-resizing {
background-color: white;
// Hide panel contents while resizing
.ide-react-editor-content,
.pdf {
display: none !important;
}
}
.horizontal-resize-handle {
width: 7px !important;
height: 100%;
// Enable ::before and ::after pseudo-elements to position themselves correctly
position: relative;
background-color: var(--bg-dark-secondary);
.custom-toggler {
padding: 0;
border-width: 0;
}
&.horizontal-resize-handle-enabled {
&::before,
&::after {
// This SVG has the colour hard-coded to the current value of @ol-blue-gray-2, so if we changed @ol-blue-gray-2,
// we'd have to change this SVG too
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='7' height='18' viewBox='0 0 7 18'%3E%3Cpath d='M2 0h3v3H2zM2 5h3v3H2zM2 10h3v3H2zM2 15h3v3H2z' style='fill:%239da7b7'/%3E%3C/svg%3E");
display: block;
position: absolute;
text-align: center;
left: 0;
width: 7px;
height: 18px;
}
&::before {
top: 25%;
}
&::after {
top: 75%;
}
}
&:not(.horizontal-resize-handle-enabled) {
cursor: default;
}
.synctex-controls {
left: -8px;
margin: 0;
// Ensure that SyncTex controls appear in front of PDF viewer controls and logs pane
z-index: 12;
}
}
.vertical-resize-handle {
height: 6px;
background-color: var(--bg-dark-secondary);
&.vertical-resize-handle-enabled {
&:hover {
background-color: var(--bg-dark-primary);
}
}
&:not(.vertical-resize-handle-enabled) {
opacity: 0.5;
cursor: default;
}
&::after {
// This SVG has the colour hard-coded to the current value of @ol-blue-gray-2, so if we changed @ol-blue-gray-2,
// we'd have to change this SVG too
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='6' viewBox='0 0 18 6'%3E%3Cpath d='M0 1.5h3v3H0zM5 1.5h3v3H5zM10 1.5h3v3h-3zM15 1.5h3v3h-3z' style='fill:%239da7b7'/%3E%3C/svg%3E");
display: block;
text-align: center;
line-height: 0;
}
}
.vertical-resizable-resizer {
background-color: var(--bg-dark-secondary);
&:hover {
background-color: var(--bg-dark-primary);
}
&::after {
@include heading-sm;
content: '\00b7\00b7\00b7\00b7';
display: block;
color: var(--content-disabled);
text-align: center;
pointer-events: none;
}
}
.vertical-resizable-resizer-disabled {
pointer-events: none;
opacity: 0.5;
&::after {
opacity: 0.5;
}
}

View file

@ -51,8 +51,7 @@
}
.outline-header-expand-collapse-btn {
@include file-tree-item-color;
color: var(--file-tree-item-color);
display: flex;
align-items: center;
background-color: transparent;
@ -81,8 +80,8 @@
.outline-header-name {
@include body-sm;
@include file-tree-item-color;
color: var(--file-tree-item-color);
display: inline-block;
font-weight: 700;
margin: 0;
@ -93,15 +92,13 @@
}
.outline-body {
@include file-tree-bg;
background-color: var(--file-tree-bg);
overflow-y: auto;
padding-right: var(--spacing-03);
}
.outline-body-no-elements {
@include file-tree-item-color;
color: var(--file-tree-item-color);
text-align: center;
padding: var(--spacing-08) var(--spacing-08) var(--spacing-11)
var(--spacing-08);
@ -109,15 +106,13 @@
}
.outline-body-link {
@include file-tree-item-color;
color: var(--file-tree-item-color);
display: block;
text-decoration: underline;
&:hover,
&:focus {
@include file-tree-item-color;
color: var(--file-tree-item-color);
text-decoration: underline;
}
}
@ -157,8 +152,7 @@
}
.outline-item-expand-collapse-btn {
@include file-tree-bg;
background-color: var(--file-tree-bg);
display: inline;
border: 0;
padding: 0;
@ -176,14 +170,14 @@
}
&:hover {
@include file-tree-item-hover-bg;
background-color: var(--file-tree-item-hover-bg);
}
}
.outline-item-link {
@include file-tree-item-color;
@include text-truncate;
color: var(--file-tree-item-color);
display: inline;
background-color: transparent;
border: 0;
@ -196,8 +190,7 @@
&:hover,
&:focus {
@include file-tree-item-hover-bg;
background-color: var(--file-tree-item-hover-bg);
outline: 0;
}
}

View file

@ -1375,6 +1375,7 @@
"only_group_admin_or_managers_can_delete_your_account_5": "For more information, see the \"Managed Accounts\" section in our terms of use, which you agree to by clicking Accept invitation",
"only_importer_can_refresh": "Only the person who originally imported this __provider__ file can refresh it.",
"open_a_file_on_the_left": "Open a file on the left",
"open_action_menu": "Open __name__ action menu",
"open_advanced_reference_search": "Open advanced reference search",
"open_as_template": "Open as Template",
"open_file": "Edit file",

View file

@ -116,7 +116,7 @@ describe('<FileTreeitemInner />', function () {
</EditorProviders>
)
cy.findByRole('button', { name: 'Menu' }).click()
cy.findByRole('button', { name: 'Open bar.tex action menu' }).click()
cy.findByRole('menuitem', { name: 'Rename' }).click()
cy.findByRole('button', { name: 'bar.tex' }).should('not.exist')
cy.findByRole('textbox')

View file

@ -90,7 +90,7 @@ describe('<FileTreeRoot/>', function () {
ctrlKey: true,
cmdKey: true,
})
cy.findByRole('button', { name: 'Menu' }).click()
cy.findByRole('button', { name: 'Open main.tex action menu' }).click()
cy.findByRole('menuitem', { name: 'Delete' }).click()
cy.findByRole('button', { name: 'Cancel' })
})
@ -256,7 +256,10 @@ describe('<FileTreeRoot/>', function () {
cy.findByRole('treeitem', { name: 'other.tex', selected: false })
// single item selected: menu button is visible
cy.findAllByRole('button', { name: 'Menu' }).should('have.length', 1)
cy.findAllByRole('button', { name: 'Open main.tex action menu' }).should(
'have.length',
1
)
// select the other item
cy.findByRole('treeitem', { name: 'other.tex' }).click()
@ -265,7 +268,10 @@ describe('<FileTreeRoot/>', function () {
cy.findByRole('treeitem', { name: 'other.tex', selected: true })
// single item selected: menu button is visible
cy.findAllByRole('button', { name: 'Menu' }).should('have.length', 1)
cy.findAllByRole('button', { name: 'Open other.tex action menu' }).should(
'have.length',
1
)
// multi-select the main item
cy.findByRole('treeitem', { name: 'main.tex' }).click({
@ -277,7 +283,10 @@ describe('<FileTreeRoot/>', function () {
cy.findByRole('treeitem', { name: 'other.tex', selected: true })
// multiple items selected: no menu button is visible
cy.findAllByRole('button', { name: 'Menu' }).should('have.length', 0)
cy.findAllByRole('button', { name: 'Open main.tex action menu' }).should(
'have.length',
0
)
})
describe('when deselecting files', function () {

View file

@ -47,7 +47,7 @@ describe('FileTree Delete Entity Flow', function () {
)
cy.findByRole('treeitem', { name: 'main.tex' }).click()
cy.findByRole('button', { name: 'Menu' }).click()
cy.findByRole('button', { name: 'Open main.tex action menu' }).click()
cy.findByRole('menuitem', { name: 'Delete' }).click()
})
@ -195,7 +195,7 @@ describe('FileTree Delete Entity Flow', function () {
// as a proxy to check that the child entity has been unselect we start
// a delete and ensure the modal is displayed (the cancel button can be
// selected) This is needed to make sure the test fail.
cy.findByRole('button', { name: 'Menu' }).click()
cy.findByRole('button', { name: 'Open main.tex action menu' }).click()
cy.findByRole('menuitem', { name: 'Delete' }).click()
cy.findByRole('button', { name: 'Cancel' })
})

View file

@ -157,7 +157,7 @@ describe('FileTree Rename Entity Flow', function () {
function renameItem(from: string, to: string) {
cy.findByRole('treeitem', { name: from }).click()
cy.findByRole('button', { name: 'Menu' }).click()
cy.findByRole('button', { name: `Open ${from} action menu` }).click()
cy.findByRole('menuitem', { name: 'Rename' }).click()
cy.findByRole('textbox').clear()
cy.findByRole('textbox').type(to + '{enter}')