Merge pull request #31397 from overleaf/mj-old-editor-toolbar-teardown

[web] Tear down old editor toolbar

GitOrigin-RevId: 8ba74abcc56e7bd476a9d6cae72f38486168c2ed
This commit is contained in:
Mathias Jakobsen
2026-02-11 09:07:14 +00:00
committed by Copybot
parent 4f284e15d5
commit cc1dedca7d
32 changed files with 463 additions and 1796 deletions
@@ -530,14 +530,12 @@
"editing": "",
"editing_captions": "",
"editor": "",
"editor_and_pdf": "",
"editor_disconected_click_to_reconnect": "",
"editor_font_family": "",
"editor_font_size": "",
"editor_limit_exceeded_in_this_project": "",
"editor_line_height": "",
"editor_only": "",
"editor_only_hide_pdf": "",
"editor_theme": "",
"editor_theme_dark": "",
"editor_theme_light": "",
@@ -982,9 +980,7 @@
"latex_in_thirty_minutes": "",
"latex_places_figures_according_to_a_special_algorithm": "",
"latex_places_tables_according_to_a_special_algorithm": "",
"layout": "",
"layout_options": "",
"layout_processing": "",
"learn_more": "",
"learn_more_about": "",
"learn_more_about_account": "",
@@ -1300,9 +1296,7 @@
"pdf_compile_in_progress_error": "",
"pdf_compile_rate_limit_hit": "",
"pdf_compile_try_again": "",
"pdf_in_separate_tab": "",
"pdf_only": "",
"pdf_only_hide_editor": "",
"pdf_preview": "",
"pdf_preview_error": "",
"pdf_rendering_error": "",
@@ -1843,7 +1837,6 @@
"switch_easily_between_your_files_comments_track_changes_and_more": "",
"switch_to_editor": "",
"switch_to_new_editor_design": "",
"switch_to_new_look": "",
"switch_to_pdf": "",
"switch_to_standard_plan": "",
"symbol": "",
@@ -2309,7 +2302,6 @@
"your_account_is_managed_by_your_group_admin": "",
"your_add_on_has_been_cancelled_and_will_remain_active_until_your_billing_cycle_ends_on": "",
"your_affiliation_is_confirmed": "",
"your_browser_does_not_support_this_feature": "",
"your_changes_will_save": "",
"your_compile_timed_out": "",
"your_current_plan": "",
@@ -1,35 +0,0 @@
import { useTranslation } from 'react-i18next'
import * as eventTracking from '../../../infrastructure/event-tracking'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
function BackToProjectsButton() {
const { t } = useTranslation()
return (
<OLTooltip
id="back-to-projects"
description={t('back_to_your_projects')}
overlayProps={{ placement: 'right' }}
>
<div className="toolbar-item">
<a
className="btn btn-full-height"
draggable="false"
href="/project"
onClick={() => {
eventTracking.sendMB('navigation-clicked-home')
}}
>
<MaterialIcon
type="home"
className="align-text-bottom"
accessibilityLabel={t('back_to_your_projects')}
/>
</a>
</div>
</OLTooltip>
)
}
export default BackToProjectsButton
@@ -1,36 +0,0 @@
import classNames from 'classnames'
import { useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
import OLBadge from '@/shared/components/ol/ol-badge'
function ChatToggleButton({
chatIsOpen,
unreadMessageCount,
onClick,
}: {
chatIsOpen: boolean
unreadMessageCount: number
onClick: () => void
}) {
const { t } = useTranslation()
const classes = classNames('btn', 'btn-full-height', { active: chatIsOpen })
const hasUnreadMessages = unreadMessageCount > 0
return (
<div className="toolbar-item">
<button type="button" className={classes} onClick={onClick}>
<MaterialIcon
type="chat"
className={classNames('align-middle', {
bounce: hasUnreadMessages,
})}
/>
{hasUnreadMessages && <OLBadge bg="info">{unreadMessageCount}</OLBadge>}
<p className="toolbar-label">{t('chat')}</p>
</button>
</div>
)
}
export default ChatToggleButton
@@ -1,26 +0,0 @@
function CobrandingLogo({
brandVariationHomeUrl,
brandVariationName,
logoImgUrl,
}: {
brandVariationHomeUrl: string
brandVariationName: string
logoImgUrl: string
}) {
return (
<a
className="btn btn-full-height header-cobranding-logo-container"
href={brandVariationHomeUrl}
target="_blank"
rel="noreferrer noopener"
>
<img
src={logoImgUrl}
className="header-cobranding-logo"
alt={brandVariationName}
/>
</a>
)
}
export default CobrandingLogo
@@ -1,18 +0,0 @@
import { useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
function HistoryToggleButton({ onClick }: { onClick: () => void }) {
const { t } = useTranslation()
return (
<div className="toolbar-item">
<button type="button" className="btn btn-full-height" onClick={onClick}>
<MaterialIcon type="history" className="align-middle" />
<p className="toolbar-label">{t('history')}</p>
</button>
<div id="toolbar-cio-history" className="toolbar-cio-tooltip" />
</div>
)
}
export default HistoryToggleButton
@@ -1,241 +0,0 @@
import { memo, forwardRef } from 'react'
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownToggle,
DropdownToggleCustom,
} from '@/shared/components/dropdown/dropdown-menu'
import { Trans, useTranslation } from 'react-i18next'
import {
IdeLayout,
IdeView,
useLayoutContext,
} from '../../../shared/context/layout-context'
import * as eventTracking from '../../../infrastructure/event-tracking'
import { DetachRole } from '@/shared/context/detach-context'
import MaterialIcon from '@/shared/components/material-icon'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import OLSpinner from '@/shared/components/ol/ol-spinner'
const isActiveDropdownItem = ({
iconFor,
pdfLayout,
view,
detachRole,
}: {
iconFor: string
pdfLayout: IdeLayout
view: IdeView | null
detachRole?: DetachRole
}) => {
if (detachRole === 'detacher' || view === 'history') {
return false
}
if (
iconFor === 'editorOnly' &&
pdfLayout === 'flat' &&
(view === 'editor' || view === 'file')
) {
return true
} else if (iconFor === 'pdfOnly' && pdfLayout === 'flat' && view === 'pdf') {
return true
} else if (iconFor === 'sideBySide' && pdfLayout === 'sideBySide') {
return true
}
return false
}
function EnhancedDropdownItem({
active,
...props
}: React.ComponentProps<typeof DropdownItem>) {
return (
<DropdownItem
active={active}
aria-current={active}
trailingIcon={active ? 'check' : null}
{...props}
/>
)
}
const LayoutDropdownToggleButton = forwardRef<
HTMLButtonElement,
{
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void
}
>(({ onClick, ...props }, ref) => {
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
eventTracking.sendMB('navigation-clicked-layout')
onClick(e)
}
return <DropdownToggleCustom {...props} ref={ref} onClick={handleClick} />
})
LayoutDropdownToggleButton.displayName = 'LayoutDropdownToggleButton'
function BS5DetachDisabled() {
const { t } = useTranslation()
return (
<OLTooltip
id="detach-disabled"
description={t('your_browser_does_not_support_this_feature')}
overlayProps={{ placement: 'left' }}
>
<span>
<EnhancedDropdownItem disabled leadingIcon="select_window">
{t('pdf_in_separate_tab')}
</EnhancedDropdownItem>
</span>
</OLTooltip>
)
}
function LayoutDropdownButton() {
const {
detachIsLinked,
detachRole,
view,
pdfLayout,
handleChangeLayout,
handleDetach,
} = useLayoutContext()
return (
<LayoutDropdownButtonUi
processing={!detachIsLinked && detachRole === 'detacher'}
handleChangeLayout={handleChangeLayout}
handleDetach={handleDetach}
detachIsLinked={detachIsLinked}
detachRole={detachRole}
pdfLayout={pdfLayout}
view={view}
detachable={'BroadcastChannel' in window}
/>
)
}
type LayoutDropdownButtonUiProps = {
processing: boolean
handleChangeLayout: (newLayout: IdeLayout, newView?: IdeView) => void
handleDetach: () => void
detachIsLinked: boolean
detachRole: DetachRole
pdfLayout: IdeLayout
view: IdeView | null
detachable: boolean
}
export const LayoutDropdownButtonUi = ({
processing,
handleChangeLayout,
handleDetach,
detachIsLinked,
detachRole,
view,
pdfLayout,
detachable,
}: LayoutDropdownButtonUiProps) => {
const { t } = useTranslation()
return (
<>
<div aria-live="assertive" className="visually-hidden" id="layout-status">
{processing ? t('layout_processing') : ''}
</div>
<Dropdown className="toolbar-item layout-dropdown" align="end">
<DropdownToggle
aria-describedby={processing ? 'layout-status' : undefined}
id="layout-dropdown-btn"
className="btn-full-height"
as={LayoutDropdownToggleButton}
>
{processing ? (
<OLSpinner size="sm" />
) : (
<MaterialIcon type="dock_to_right" className="align-middle" />
)}
<span className="toolbar-label">{t('layout')}</span>
</DropdownToggle>
<DropdownMenu>
<EnhancedDropdownItem
onClick={() => handleChangeLayout('sideBySide')}
active={isActiveDropdownItem({
iconFor: 'sideBySide',
pdfLayout,
view,
detachRole,
})}
leadingIcon="dock_to_right"
>
{t('editor_and_pdf')}
</EnhancedDropdownItem>
<EnhancedDropdownItem
onClick={() => handleChangeLayout('flat', 'editor')}
active={isActiveDropdownItem({
iconFor: 'editorOnly',
pdfLayout,
view,
detachRole,
})}
leadingIcon="code"
>
<div className="d-flex flex-column">
<Trans
i18nKey="editor_only_hide_pdf"
components={[
<span key="editor_only_hide_pdf" className="subdued" />,
]}
/>
</div>
</EnhancedDropdownItem>
<EnhancedDropdownItem
onClick={() => handleChangeLayout('flat', 'pdf')}
active={isActiveDropdownItem({
iconFor: 'pdfOnly',
pdfLayout,
view,
detachRole,
})}
leadingIcon="picture_as_pdf"
>
<div className="d-flex flex-column">
<Trans
i18nKey="pdf_only_hide_editor"
components={[
<span key="pdf_only_hide_editor" className="subdued" />,
]}
/>
</div>
</EnhancedDropdownItem>
{detachable ? (
<EnhancedDropdownItem
onClick={() => handleDetach()}
active={detachRole === 'detacher' && detachIsLinked}
trailingIcon={
detachRole === 'detacher' ? (
detachIsLinked ? (
'check'
) : (
<OLSpinner size="sm" />
)
) : null
}
leadingIcon="select_window"
>
{t('pdf_in_separate_tab')}
</EnhancedDropdownItem>
) : (
<BS5DetachDisabled />
)}
</DropdownMenu>
</Dropdown>
</>
)
}
export default memo(LayoutDropdownButton)
@@ -1,17 +0,0 @@
import { useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
function MenuButton({ onClick }: { onClick: () => void }) {
const { t } = useTranslation()
return (
<div className="toolbar-item">
<button type="button" className="btn btn-full-height" onClick={onClick}>
<MaterialIcon type="menu" className="editor-menu-icon align-middle" />
<p className="toolbar-label">{t('menu')}</p>
</button>
</div>
)
}
export default MenuButton
@@ -1,5 +1,4 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { OnlineUser } from '@/features/ide-react/context/online-users-context'
import {
Dropdown,
DropdownHeader,
@@ -7,125 +6,141 @@ import {
DropdownMenu,
DropdownToggle,
} from '@/shared/components/dropdown/dropdown-menu'
import { getBackgroundColorForUserId } from '@/shared/utils/colors'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
import { OnlineUser } from '@/features/ide-react/context/online-users-context'
import {
getBackgroundColorForUserId,
hslStringToLuminance,
} from '@/shared/utils/colors'
import classNames from 'classnames'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Doc } from '@ol-types/doc'
function OnlineUsersWidget({
// Should be kept in sync with $max-user-circles-displayed CSS constant
const MAX_USER_CIRCLES_DISPLAYED = 5
// We don't want a +1 circle since we could just show the user instead
const MAX_USERS_WITH_OVERFLOW_VISIBLE = MAX_USER_CIRCLES_DISPLAYED - 1
export const OnlineUsersWidget = ({
onlineUsers,
goToUser,
}: {
onlineUsers: OnlineUser[]
goToUser: (user: OnlineUser) => Promise<Doc | undefined>
}) {
const { t } = useTranslation()
const shouldDisplayDropdown = onlineUsers.length >= 4
if (shouldDisplayDropdown) {
return (
<Dropdown className="online-users" align="end">
<DropdownToggle
id="online-users"
as={DropDownToggleButton}
// @ts-ignore: fix type of DropdownToggle with "as" prop so that it can accept
// custom props for that component
onlineUserCount={onlineUsers.length}
/>
<DropdownMenu>
<DropdownHeader aria-hidden="true">
{t('connected_users')}
</DropdownHeader>
{onlineUsers.map((user, index) => (
<li role="none" key={`${user.user_id}_${index}`}>
<DropdownItem
as="button"
tabIndex={-1}
onClick={() => goToUser(user)}
>
<UserIcon user={user} showName />
</DropdownItem>
</li>
))}
</DropdownMenu>
</Dropdown>
)
} else {
return (
<div className="online-users">
{onlineUsers.map((user, index) => (
<OLTooltip
key={`${user.user_id}_${index}`}
id="online-user"
description={user.name}
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
tooltipProps={{ translate: 'no' }}
>
<span>
{/* OverlayTrigger won't fire unless UserIcon is wrapped in a span */}
<UserIcon user={user} onClick={goToUser} />
</span>
</OLTooltip>
))}
</div>
)
}
}
function UserIcon({
user,
showName,
onClick,
}: {
user: OnlineUser
showName?: boolean
onClick?: (user: OnlineUser) => void
}) {
const backgroundColor = getBackgroundColorForUserId(user.user_id)
function handleOnClick() {
onClick?.(user)
}
const [character] = [...user.name]
}) => {
const hasOverflow = onlineUsers.length > MAX_USER_CIRCLES_DISPLAYED
const usersBeforeOverflow = useMemo(
() =>
hasOverflow
? onlineUsers.slice(0, MAX_USERS_WITH_OVERFLOW_VISIBLE)
: onlineUsers,
[onlineUsers, hasOverflow]
)
const usersInOverflow = useMemo(
() =>
hasOverflow ? onlineUsers.slice(MAX_USERS_WITH_OVERFLOW_VISIBLE) : [],
[onlineUsers, hasOverflow]
)
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<span onClick={handleOnClick}>
<span className="online-user" style={{ backgroundColor }}>
{character}
</span>
{showName && user.name}
<div className="online-users-row">
{usersBeforeOverflow.map((user, index) => (
<OnlineUserWidget
key={`${user.user_id}_${index}`}
user={user}
goToUser={goToUser}
id={`online-user-${user.user_id}_${index}`}
/>
))}
{hasOverflow && (
<OnlineUserOverflow goToUser={goToUser} users={usersInOverflow} />
)}
</div>
)
}
const OnlineUserWidget = ({
user,
goToUser,
id,
}: {
user: OnlineUser
goToUser: (user: OnlineUser) => void
id: string
}) => {
const onClick = useCallback(() => {
goToUser(user)
}, [goToUser, user])
return (
<OLTooltip
id={id}
description={user.name}
overlayProps={{
placement: 'bottom',
trigger: ['hover', 'focus'],
delay: 0,
}}
>
<button className="online-users-row-button" onClick={onClick}>
<OnlineUserCircle user={user} />
</button>
</OLTooltip>
)
}
const OnlineUserCircle = ({ user }: { user: OnlineUser }) => {
const backgroundColor = getBackgroundColorForUserId(user.user_id)
const luminance = hslStringToLuminance(backgroundColor)
const [character] = [...user.name]
return (
<span
className={classNames('online-user-circle', {
'online-user-circle-light-font': luminance < 0.5,
'online-user-circle-dark-font': luminance >= 0.5,
})}
style={{ backgroundColor }}
>
{character}
</span>
)
}
const DropDownToggleButton = React.forwardRef<
HTMLButtonElement,
{ onlineUserCount: number; onClick: React.MouseEventHandler }
>((props, ref) => {
const OnlineUserOverflow = ({
goToUser,
users,
}: {
goToUser: (user: OnlineUser) => void
users: OnlineUser[]
}) => {
const { t } = useTranslation()
return (
<OLTooltip
id="connected-users"
description={t('connected_users')}
overlayProps={{ placement: 'left' }}
>
<button
type="button"
className="online-user online-user-multi"
onClick={props.onClick} // required by Bootstrap Dropdown to trigger an opening
ref={ref}
>
<strong>{props.onlineUserCount}</strong>&nbsp;
<MaterialIcon type="groups" />
</button>
</OLTooltip>
<Dropdown align="end">
<DropdownToggle className="online-users-row-button online-user-overflow-toggle">
<OLTooltip
id="connected-users"
description={t('n_more_collaborators', { count: users.length })}
overlayProps={{ placement: 'bottom' }}
>
<span className="online-user-circle">+{users.length}</span>
</OLTooltip>
</DropdownToggle>
<DropdownMenu className="online-user-overflow-dropdown">
<DropdownHeader aria-hidden="true">
{t('connected_users')}
</DropdownHeader>
{users.map((user, index) => (
<li role="none" key={`${user.user_id}_${index}`}>
<DropdownItem
as="button"
tabIndex={-1}
onClick={() => goToUser(user)}
>
<OnlineUserCircle user={user} /> {user.name}
</DropdownItem>
</li>
))}
</DropdownMenu>
</Dropdown>
)
})
DropDownToggleButton.displayName = 'DropDownToggleButton'
export default OnlineUsersWidget
}
@@ -1,99 +0,0 @@
import { useEffect, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import classNames from 'classnames'
import OLFormControl from '@/shared/components/ol/ol-form-control'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
type ProjectNameEditableLabelProps = {
projectName: string
onChange: (value: string) => void
hasRenamePermissions?: boolean
className?: string
}
function ProjectNameEditableLabel({
projectName,
hasRenamePermissions,
onChange,
className,
}: ProjectNameEditableLabelProps) {
const { t } = useTranslation()
const [isRenaming, setIsRenaming] = useState(false)
const canRename = hasRenamePermissions && !isRenaming
const [inputContent, setInputContent] = useState(projectName)
const inputRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
if (isRenaming) {
inputRef.current?.select()
}
}, [isRenaming])
function startRenaming() {
if (canRename) {
setInputContent(projectName)
setIsRenaming(true)
}
}
function finishRenaming() {
setIsRenaming(false)
onChange(inputContent)
}
function handleKeyDown(event: React.KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault()
finishRenaming()
}
}
function handleOnChange(event: React.ChangeEvent<HTMLInputElement>) {
setInputContent(event.target.value)
}
function handleBlur() {
if (isRenaming) {
finishRenaming()
}
}
return (
<div className={classNames('project-name', className)}>
{!isRenaming && (
<span className="name" onDoubleClick={startRenaming}>
{projectName}
</span>
)}
{isRenaming && (
<OLFormControl
ref={inputRef}
type="text"
onKeyDown={handleKeyDown}
onChange={handleOnChange}
onBlur={handleBlur}
value={inputContent}
/>
)}
{canRename && (
<OLTooltip
id="online-user"
description={t('rename')}
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid, jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus */}
<a className="rename" role="button" onClick={startRenaming}>
<MaterialIcon type="edit" className="align-text-bottom" />
</a>
</OLTooltip>
)}
</div>
)
}
export default ProjectNameEditableLabel
@@ -1,18 +0,0 @@
import { useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
function ShareProjectButton({ onClick }: { onClick: () => void }) {
const { t } = useTranslation()
return (
<div className="toolbar-item">
<button type="button" className="btn btn-full-height" onClick={onClick}>
<MaterialIcon type="group_add" className="align-middle" />
<p className="toolbar-label">{t('share')}</p>
</button>
<div id="toolbar-cio-share" className="toolbar-cio-tooltip" />
</div>
)
}
export default ShareProjectButton
@@ -1,162 +0,0 @@
import React, { ElementType } from 'react'
import { useTranslation } from 'react-i18next'
import MenuButton from './menu-button'
import CobrandingLogo from './cobranding-logo'
import BackToProjectsButton from './back-to-projects-button'
import UpgradePrompt from './upgrade-prompt'
import ChatToggleButton from './chat-toggle-button'
import LayoutDropdownButton from './layout-dropdown-button'
import OnlineUsersWidget from './online-users-widget'
import ProjectNameEditableLabel from './project-name-editable-label'
import TrackChangesToggleButton from './track-changes-toggle-button'
import HistoryToggleButton from './history-toggle-button'
import ShareProjectButton from './share-project-button'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import BackToEditorButton from './back-to-editor-button'
import getMeta from '@/utils/meta'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import TryNewEditorButton from '../try-new-editor-button'
import { OnlineUser } from '@/features/ide-react/context/online-users-context'
import { Cobranding } from '../../../../../types/cobranding'
import { canUseNewEditor } from '@/features/ide-redesign/utils/new-editor-utils'
import { Doc } from '@ol-types/doc'
const [publishModalModules] = importOverleafModules('publishModal') as {
import: { default: ElementType }
path: string
}[]
const PublishButton = publishModalModules?.import.default
const offlineModeToolbarButtons = importOverleafModules(
'offlineModeToolbarButtons'
) as {
import: { default: ElementType }
path: string
}[]
// double opt-in
const enableROMirrorOnClient =
isSplitTestEnabled('ro-mirror-on-client') &&
new URLSearchParams(window.location.search).get('ro-mirror-on-client') ===
'enabled'
export type ToolbarHeaderProps = {
cobranding: Cobranding | undefined
onShowLeftMenuClick: () => void
chatIsOpen: boolean
toggleChatOpen: () => void
reviewPanelOpen: boolean
toggleReviewPanelOpen: (e: React.MouseEvent) => void
historyIsOpen: boolean
toggleHistoryOpen: () => void
unreadMessageCount: number
onlineUsers: OnlineUser[]
goToUser: (user: OnlineUser) => Promise<Doc | undefined>
isRestrictedTokenMember: boolean | undefined
hasPublishPermissions: boolean
chatVisible: boolean
projectName: string
renameProject: (name: string) => void
hasRenamePermissions: boolean
openShareModal: () => void
trackChangesVisible: boolean | undefined
}
const ToolbarHeader = React.memo(function ToolbarHeader({
cobranding,
onShowLeftMenuClick,
chatIsOpen,
toggleChatOpen,
reviewPanelOpen,
toggleReviewPanelOpen,
historyIsOpen,
toggleHistoryOpen,
unreadMessageCount,
onlineUsers,
goToUser,
isRestrictedTokenMember,
hasPublishPermissions,
chatVisible,
projectName,
renameProject,
hasRenamePermissions,
openShareModal,
trackChangesVisible,
}: ToolbarHeaderProps) {
const chatEnabled = getMeta('ol-capabilities')?.includes('chat')
const { t } = useTranslation()
const shouldDisplayPublishButton = hasPublishPermissions && PublishButton
return (
<nav className="toolbar toolbar-header" aria-label={t('project_actions')}>
<div className="toolbar-left">
<MenuButton onClick={onShowLeftMenuClick} />
{cobranding && cobranding.logoImgUrl && (
<CobrandingLogo {...cobranding} />
)}
<BackToProjectsButton />
{enableROMirrorOnClient &&
offlineModeToolbarButtons.map(
({ path, import: { default: OfflineModeToolbarButton } }) => {
return <OfflineModeToolbarButton key={path} />
}
)}
</div>
{getMeta('ol-showUpgradePrompt') && (
<div className="d-flex align-items-center">
<UpgradePrompt />
</div>
)}
<ProjectNameEditableLabel
className="toolbar-center"
projectName={projectName}
hasRenamePermissions={hasRenamePermissions}
onChange={renameProject}
/>
<div className="toolbar-right">
{canUseNewEditor() && <TryNewEditorButton />}
<OnlineUsersWidget onlineUsers={onlineUsers} goToUser={goToUser} />
{historyIsOpen ? (
<div className="d-flex align-items-center">
<BackToEditorButton onClick={toggleHistoryOpen} />
</div>
) : (
<>
{trackChangesVisible && (
<TrackChangesToggleButton
onMouseDown={toggleReviewPanelOpen}
disabled={historyIsOpen}
trackChangesIsOpen={reviewPanelOpen}
/>
)}
<ShareProjectButton onClick={openShareModal} />
{shouldDisplayPublishButton && (
<PublishButton cobranding={cobranding} />
)}
{!isRestrictedTokenMember && (
<HistoryToggleButton onClick={toggleHistoryOpen} />
)}
<LayoutDropdownButton />
{chatEnabled && chatVisible && (
<ChatToggleButton
chatIsOpen={chatIsOpen}
onClick={toggleChatOpen}
unreadMessageCount={unreadMessageCount}
/>
)}
</>
)}
</div>
</nav>
)
})
export default ToolbarHeader
@@ -1,36 +0,0 @@
import classNames from 'classnames'
import { useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
function TrackChangesToggleButton({
trackChangesIsOpen,
disabled,
onMouseDown,
}: {
trackChangesIsOpen: boolean
disabled?: boolean
onMouseDown: (e: React.MouseEvent) => void
}) {
const { t } = useTranslation()
const classes = classNames('btn', 'btn-full-height', {
active: trackChangesIsOpen && !disabled,
disabled,
})
return (
<div className="toolbar-item">
<button
type="button"
disabled={disabled}
className={classes}
onMouseDown={onMouseDown}
>
<MaterialIcon type="rate_review" className="align-middle" />
<p className="toolbar-label">{t('review')}</p>
</button>
<div id="toolbar-cio-review" className="toolbar-cio-tooltip" />
</div>
)
}
export default TrackChangesToggleButton
@@ -1,27 +0,0 @@
import { useTranslation } from 'react-i18next'
import * as eventTracking from '../../../infrastructure/event-tracking'
import OLButton from '@/shared/components/ol/ol-button'
function UpgradePrompt() {
const { t } = useTranslation()
function handleClick() {
eventTracking.send('subscription-funnel', 'code-editor', 'upgrade')
eventTracking.sendMB('upgrade-button-click', { source: 'code-editor' })
}
return (
<OLButton
variant="primary"
size="sm"
className="toolbar-header-upgrade-prompt"
href="/user/subscription/plans?itm_referrer=editor-header-upgrade-prompt"
target="_blank"
onClick={handleClick}
>
{t('upgrade')}
</OLButton>
)
}
export default UpgradePrompt
@@ -1,45 +0,0 @@
import { useCallback, useState } from 'react'
import OLButton from '../../shared/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
import { useSwitchEnableNewEditorState } from '../ide-redesign/hooks/use-switch-enable-new-editor-state'
import MaterialIcon from '@/shared/components/material-icon'
import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
import OldEditorWarningTooltip from '../ide-redesign/components/old-editor-warning-tooltip'
const TryNewEditorButton = () => {
const { t } = useTranslation()
const { loading, setEditorRedesignStatus } = useSwitchEnableNewEditorState()
const { sendEvent } = useEditorAnalytics()
const [buttonElt, setButtonElt] = useState<HTMLButtonElement | null>(null)
const buttonRef = useCallback((node: HTMLButtonElement) => {
if (node !== null) {
setButtonElt(node)
}
}, [])
const onClick = useCallback(() => {
sendEvent('switch-to-new-editor', {
location: 'toolbar',
})
setEditorRedesignStatus(true)
}, [setEditorRedesignStatus, sendEvent])
return (
<div className="d-flex align-items-center">
<OLButton
className="toolbar-experiment-button try-new-editor-button"
onClick={onClick}
size="sm"
variant="secondary"
isLoading={loading}
ref={buttonRef}
>
<MaterialIcon type="fiber_new" />
{t('switch_to_new_look')}
</OLButton>
<OldEditorWarningTooltip target={buttonElt} />
</div>
)
}
export default TryNewEditorButton
@@ -4,7 +4,7 @@ import {
useOnlineUsersContext,
} from '@/features/ide-react/context/online-users-context'
import { useCallback } from 'react'
import { OnlineUsersWidget } from '@/features/ide-redesign/components/online-users/online-users-widget'
import { OnlineUsersWidget } from '@/features/editor-navigation-toolbar/components/online-users-widget'
export const OnlineUsers = () => {
const { openDoc } = useEditorManagerContext()
@@ -59,15 +59,13 @@ export const ToolbarProjectTitle = () => {
<Dropdown align="start" className="ide-redesign-toolbar-project-dropdown">
<DropdownToggle
id="project-title-options"
aria-label={t('project_title_options')}
className="ide-redesign-toolbar-project-dropdown-toggle ide-redesign-toolbar-dropdown-toggle-subdued fw-bold ide-redesign-toolbar-button-subdued"
>
<span className="ide-redesign-toolbar-project-name" translate="no">
{name}
</span>
<MaterialIcon
type="keyboard_arrow_down"
accessibilityLabel={t('project_title_options')}
/>
<MaterialIcon type="keyboard_arrow_down" />
</DropdownToggle>
<DropdownMenu renderOnMount>
{shouldDisplaySubmitButton && !cobranding && (
@@ -1,146 +0,0 @@
import { OnlineUser } from '@/features/ide-react/context/online-users-context'
import {
Dropdown,
DropdownHeader,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/shared/components/dropdown/dropdown-menu'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import {
getBackgroundColorForUserId,
hslStringToLuminance,
} from '@/shared/utils/colors'
import classNames from 'classnames'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Doc } from '@ol-types/doc'
// Should be kept in sync with $max-user-circles-displayed CSS constant
const MAX_USER_CIRCLES_DISPLAYED = 5
// We don't want a +1 circle since we could just show the user instead
const MAX_USERS_WITH_OVERFLOW_VISIBLE = MAX_USER_CIRCLES_DISPLAYED - 1
export const OnlineUsersWidget = ({
onlineUsers,
goToUser,
}: {
onlineUsers: OnlineUser[]
goToUser: (user: OnlineUser) => Promise<Doc | undefined>
}) => {
const hasOverflow = onlineUsers.length > MAX_USER_CIRCLES_DISPLAYED
const usersBeforeOverflow = useMemo(
() =>
hasOverflow
? onlineUsers.slice(0, MAX_USERS_WITH_OVERFLOW_VISIBLE)
: onlineUsers,
[onlineUsers, hasOverflow]
)
const usersInOverflow = useMemo(
() =>
hasOverflow ? onlineUsers.slice(MAX_USERS_WITH_OVERFLOW_VISIBLE) : [],
[onlineUsers, hasOverflow]
)
return (
<div className="online-users-row">
{usersBeforeOverflow.map((user, index) => (
<OnlineUserWidget
key={`${user.user_id}_${index}`}
user={user}
goToUser={goToUser}
id={`online-user-${user.user_id}_${index}`}
/>
))}
{hasOverflow && (
<OnlineUserOverflow goToUser={goToUser} users={usersInOverflow} />
)}
</div>
)
}
const OnlineUserWidget = ({
user,
goToUser,
id,
}: {
user: OnlineUser
goToUser: (user: OnlineUser) => void
id: string
}) => {
const onClick = useCallback(() => {
goToUser(user)
}, [goToUser, user])
return (
<OLTooltip
id={id}
description={user.name}
overlayProps={{
placement: 'bottom',
trigger: ['hover', 'focus'],
delay: 0,
}}
>
<button className="online-users-row-button" onClick={onClick}>
<OnlineUserCircle user={user} />
</button>
</OLTooltip>
)
}
const OnlineUserCircle = ({ user }: { user: OnlineUser }) => {
const backgroundColor = getBackgroundColorForUserId(user.user_id)
const luminance = hslStringToLuminance(backgroundColor)
const [character] = [...user.name]
return (
<span
className={classNames('online-user-circle', {
'online-user-circle-light-font': luminance < 0.5,
'online-user-circle-dark-font': luminance >= 0.5,
})}
style={{ backgroundColor }}
>
{character}
</span>
)
}
const OnlineUserOverflow = ({
goToUser,
users,
}: {
goToUser: (user: OnlineUser) => void
users: OnlineUser[]
}) => {
const { t } = useTranslation()
return (
<Dropdown align="end">
<DropdownToggle className="online-users-row-button online-user-overflow-toggle">
<OLTooltip
id="connected-users"
description={t('n_more_collaborators', { count: users.length })}
overlayProps={{ placement: 'bottom' }}
>
<span className="online-user-circle">+{users.length}</span>
</OLTooltip>
</DropdownToggle>
<DropdownMenu className="online-user-overflow-dropdown">
<DropdownHeader aria-hidden="true">
{t('connected_users')}
</DropdownHeader>
{users.map((user, index) => (
<li role="none" key={`${user.user_id}_${index}`}>
<DropdownItem
as="button"
tabIndex={-1}
onClick={() => goToUser(user)}
>
<OnlineUserCircle user={user} /> {user.name}
</DropdownItem>
</li>
))}
</DropdownMenu>
</Dropdown>
)
}
@@ -5,7 +5,7 @@ export const MenuBar: FC<
React.PropsWithChildren<HTMLProps<HTMLDivElement> & { id: string }>
> = ({ children, id, ...props }) => {
return (
<div {...props}>
<div {...props} role="menubar">
<NestableDropdownContextProvider id={id}>
{children}
</NestableDropdownContextProvider>
@@ -1,42 +0,0 @@
import ToolbarHeader from '../js/features/editor-navigation-toolbar/components/toolbar-header'
import { ScopeDecorator } from './decorators/scope'
export const UpToThreeConnectedUsers = args => {
return <ToolbarHeader {...args} />
}
UpToThreeConnectedUsers.args = {
onlineUsers: ['a', 'c', 'd'].map(c => ({
user_id: c,
name: `${c}_user name`,
})),
}
export const ManyConnectedUsers = args => {
return <ToolbarHeader {...args} />
}
ManyConnectedUsers.args = {
onlineUsers: ['a', 'c', 'd', 'e', 'f'].map(c => ({
user_id: c,
name: `${c}_user name`,
})),
}
export default {
title: 'Editor / Toolbar',
component: ToolbarHeader,
argTypes: {
goToUser: { action: 'goToUser' },
renameProject: { action: 'renameProject' },
toggleHistoryOpen: { action: 'toggleHistoryOpen' },
toggleReviewPanelOpen: { action: 'toggleReviewPanelOpen' },
toggleChatOpen: { action: 'toggleChatOpen' },
openShareModal: { action: 'openShareModal' },
onShowLeftMenuClick: { action: 'onShowLeftMenuClick' },
},
args: {
projectName: 'Overleaf Project',
onlineUsers: [{ user_id: 'abc', name: 'overleaf' }],
unreadMessageCount: 0,
},
decorators: [ScopeDecorator],
}
@@ -1,35 +0,0 @@
import { LayoutDropdownButtonUi } from '@/features/editor-navigation-toolbar/components/layout-dropdown-button'
import { Meta } from '@storybook/react'
import { ComponentProps } from 'react'
export const LayoutDropdown = (
props: ComponentProps<typeof LayoutDropdownButtonUi>
) => (
<div className="toolbar toolbar-header justify-content-end m-4">
<div className="toolbar-right">
<LayoutDropdownButtonUi {...props} />
</div>
</div>
)
const meta: Meta<typeof LayoutDropdownButtonUi> = {
title: 'Editor / Toolbar / Layout Dropdown',
component: LayoutDropdownButtonUi,
argTypes: {
view: {
control: 'select',
options: [null, 'editor', 'file', 'pdf', 'history'],
},
detachRole: {
control: 'select',
options: ['detacher', 'detached'],
},
pdfLayout: {
control: 'select',
options: ['sideBySide', 'flat'],
},
},
parameters: { actions: { argTypesRegex: '^handle.*' } },
}
export default meta
@@ -1,7 +1,6 @@
import { Meta } from '@storybook/react'
import { OnlineUser } from '@/features/ide-react/context/online-users-context'
import OnlineUsersWidgetOld from '@/features/editor-navigation-toolbar/components/online-users-widget'
import { OnlineUsersWidget } from '@/features/ide-redesign/components/online-users/online-users-widget'
import { OnlineUsersWidget } from '@/features/editor-navigation-toolbar/components/online-users-widget'
const NAMES = [
'Alice',
@@ -31,7 +30,7 @@ const generateUser = (_: any, index: number): OnlineUser => {
}
}
export const OnlineUsersRedesign = ({ users }: { users: number }) => {
export const OnlineUsers = ({ users }: { users: number }) => {
const generatedUsers = Array.from({ length: users }, generateUser)
return (
<div
@@ -48,24 +47,7 @@ export const OnlineUsersRedesign = ({ users }: { users: number }) => {
)
}
export const OnlineUsersOld = ({ users }: { users: number }) => {
const generatedUsers = Array.from({ length: users }, generateUser)
return (
<div
style={{
backgroundColor: 'var(--online-users-border-color)',
padding: '20px',
}}
>
<OnlineUsersWidgetOld
onlineUsers={generatedUsers}
goToUser={(async () => {}) as any}
/>
</div>
)
}
const meta: Meta<typeof OnlineUsersRedesign> = {
const meta: Meta<typeof OnlineUsers> = {
title: 'Editor / Online Users Widget',
args: {
users: 6,
@@ -1,47 +1,15 @@
:root {
--toolbar-btn-color: var(--white);
--online-users-border-color: var(--bg-dark-primary);
--online-users-text-color: var(--content-primary);
--online-users-overflow-button-background-color: var(--bg-light-secondary);
}
@include theme('light') {
--toolbar-btn-color: var(--neutral-70);
--online-users-border-color: var(--bg-light-primary);
--online-users-text-color: var(--content-primary);
--online-users-overflow-button-background-color: var(--bg-light-secondary);
}
.online-users {
display: flex;
align-items: center;
.online-user {
display: inline-block;
width: 24px;
height: 24px;
line-height: 24px;
margin-right: var(--spacing-04);
text-align: center;
color: white;
text-transform: uppercase;
border-radius: var(--border-radius-base);
cursor: pointer;
}
.online-user-multi {
@include reset-button;
color: var(--toolbar-btn-color);
width: auto;
min-width: 24px;
padding-left: var(--spacing-04);
padding-right: var(--spacing-03);
display: flex;
align-items: center;
}
}
.online-users-row {
// Keep in sync with the MAX_USER_CIRCLES_DISPLAYED js constant
$max-user-circles-displayed: 5;
@@ -107,7 +107,7 @@
.toolbar-right,
.toolbar-left {
button:not(.back-to-editor-btn, .toolbar-experiment-button) {
button:not(.back-to-editor-btn) {
background: transparent;
box-shadow: none;
}
@@ -133,7 +133,7 @@
.toolbar-left > a:not(.btn),
.toolbar-left > button,
.toolbar-right > a:not(.btn),
.toolbar-right > button:not(.back-to-editor-btn, .toolbar-experiment-button) {
.toolbar-right > button:not(.back-to-editor-btn) {
display: inline-block;
color: var(--toolbar-btn-color);
background-color: transparent;
@@ -198,11 +198,6 @@
right: var(--spacing-02);
}
&.header-cobranding-logo-container {
height: calc(var(--toolbar-height) - 1px);
padding: var(--spacing-04) var(--spacing-05);
}
.spinner-border {
vertical-align: middle;
font-size: var(--font-size-02);
@@ -263,34 +258,12 @@
display: flex;
justify-content: center;
}
.toolbar-cio-tooltip {
position: absolute;
top: var(--toolbar-height);
width: 391px;
}
#toolbar-cio-history {
@include media-breakpoint-down(lg) {
right: 10px;
/* The iframe a message renders in isn't aware of the page width, so we
make it 1px narrower to enable max-width media queries */
width: 390px;
}
}
}
.layout-dropdown .dropdown-item {
align-items: center;
}
.header-cobranding-logo {
display: block;
width: auto;
max-height: 100%;
}
.toolbar-label {
display: none;
margin: 0 var(--spacing-02);
@@ -308,14 +281,6 @@
}
}
.toolbar-header-upgrade-prompt {
margin-left: var(--spacing-05);
@include media-breakpoint-down(lg) {
display: none !important;
}
}
.toolbar-filetree {
@include toolbar-sm-height;
@@ -324,57 +289,6 @@
flex-shrink: 0;
}
.editor-menu-icon {
&.material-symbols {
width: 1em;
text-indent: -9999px;
background: var(--editor-header-logo-background);
}
}
.project-name {
.name {
display: inline-block;
@include text-truncate;
padding: var(--spacing-03);
vertical-align: top;
color: var(--project-name-color);
font-weight: 700;
}
input {
height: 30px;
margin-top: var(--spacing-02);
padding: var(--spacing-03);
max-width: 500px;
text-align: center;
font-weight: 700;
}
a.rename {
visibility: hidden;
display: inline-block;
color: var(--project-rename-link-color);
padding: var(--spacing-03);
border-radius: var(--border-radius-base);
cursor: pointer;
&:hover {
text-shadow: 0 1px 0 rgb(0 0 0 / 25%);
color: var(--project-rename-link-color-hover);
text-decoration: none;
}
}
&:hover {
a.rename {
visibility: visible;
}
}
}
.toolbar-editor {
height: var(--toolbar-small-height);
background-color: var(--editor-toolbar-bg);
@@ -531,16 +445,6 @@
border-right: 1px solid var(--formatting-btn-border);
}
// Override a secondary button to ensure that the border is visible because
// overriding a borderless button will not add a border.
.toolbar-experiment-button.btn-secondary {
@include editor-switcher-button;
max-height: 39px;
font-size: var(--font-size-01);
margin-right: var(--spacing-04);
}
.try-new-editor-button {
.button-content {
gap: var(--spacing-02);