first commit
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
|
||||
function BackToEditorButton({ onClick }: { onClick: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
className="back-to-editor-btn"
|
||||
>
|
||||
<MaterialIcon type="arrow_back" className="toolbar-btn-secondary-icon" />
|
||||
<span className="toolbar-label">{t('back_to_editor')}</span>
|
||||
</OLButton>
|
||||
)
|
||||
}
|
||||
|
||||
export default BackToEditorButton
|
@@ -0,0 +1,35 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as eventTracking from '../../../infrastructure/event-tracking'
|
||||
import OLTooltip from '@/features/ui/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
|
@@ -0,0 +1,35 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import classNames from 'classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import OLBadge from '@/features/ui/components/ol/ol-badge'
|
||||
|
||||
function ChatToggleButton({ chatIsOpen, unreadMessageCount, onClick }) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
ChatToggleButton.propTypes = {
|
||||
chatIsOpen: PropTypes.bool,
|
||||
unreadMessageCount: PropTypes.number.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
export default ChatToggleButton
|
@@ -0,0 +1,30 @@
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
function CobrandingLogo({
|
||||
brandVariationHomeUrl,
|
||||
brandVariationName,
|
||||
logoImgUrl,
|
||||
}) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
CobrandingLogo.propTypes = {
|
||||
brandVariationHomeUrl: PropTypes.string.isRequired,
|
||||
brandVariationName: PropTypes.string.isRequired,
|
||||
logoImgUrl: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
export default CobrandingLogo
|
@@ -0,0 +1,133 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import ToolbarHeader from './toolbar-header'
|
||||
import { useEditorContext } from '../../../shared/context/editor-context'
|
||||
import { useChatContext } from '../../chat/context/chat-context'
|
||||
import { useLayoutContext } from '../../../shared/context/layout-context'
|
||||
import { useProjectContext } from '../../../shared/context/project-context'
|
||||
import * as eventTracking from '../../../infrastructure/event-tracking'
|
||||
import { Doc } from '../../../../../types/doc'
|
||||
|
||||
function isOpentoString(open: boolean) {
|
||||
return open ? 'open' : 'close'
|
||||
}
|
||||
|
||||
const EditorNavigationToolbarRoot = React.memo(
|
||||
function EditorNavigationToolbarRoot({
|
||||
onlineUsersArray,
|
||||
openDoc,
|
||||
openShareProjectModal,
|
||||
}: {
|
||||
onlineUsersArray: any[]
|
||||
openDoc: (doc: Doc, { gotoLine }: { gotoLine: number }) => void
|
||||
openShareProjectModal: () => void
|
||||
}) {
|
||||
const {
|
||||
name: projectName,
|
||||
features: { trackChangesVisible },
|
||||
} = useProjectContext()
|
||||
|
||||
const {
|
||||
cobranding,
|
||||
isRestrictedTokenMember,
|
||||
renameProject,
|
||||
permissionsLevel,
|
||||
} = useEditorContext()
|
||||
|
||||
const {
|
||||
chatIsOpen,
|
||||
setChatIsOpen,
|
||||
reviewPanelOpen,
|
||||
setReviewPanelOpen,
|
||||
view,
|
||||
setView,
|
||||
setLeftMenuShown,
|
||||
} = useLayoutContext()
|
||||
|
||||
const { markMessagesAsRead, unreadMessageCount } = useChatContext()
|
||||
|
||||
const toggleChatOpen = useCallback(() => {
|
||||
if (!chatIsOpen) {
|
||||
markMessagesAsRead()
|
||||
}
|
||||
eventTracking.sendMB('navigation-clicked-chat', {
|
||||
action: isOpentoString(!chatIsOpen),
|
||||
})
|
||||
setChatIsOpen(!chatIsOpen)
|
||||
}, [chatIsOpen, setChatIsOpen, markMessagesAsRead])
|
||||
|
||||
const toggleReviewPanelOpen = useCallback(
|
||||
event => {
|
||||
event.preventDefault()
|
||||
eventTracking.sendMB('navigation-clicked-review', {
|
||||
action: isOpentoString(!reviewPanelOpen),
|
||||
})
|
||||
setReviewPanelOpen(value => !value)
|
||||
},
|
||||
[reviewPanelOpen, setReviewPanelOpen]
|
||||
)
|
||||
|
||||
const [shouldReopenChat, setShouldReopenChat] = useState(chatIsOpen)
|
||||
const toggleHistoryOpen = useCallback(() => {
|
||||
const action = view === 'history' ? 'close' : 'open'
|
||||
eventTracking.sendMB('navigation-clicked-history', { action })
|
||||
|
||||
if (chatIsOpen && action === 'open') {
|
||||
setShouldReopenChat(true)
|
||||
toggleChatOpen()
|
||||
}
|
||||
if (shouldReopenChat && action === 'close') {
|
||||
setShouldReopenChat(false)
|
||||
toggleChatOpen()
|
||||
}
|
||||
setView(view === 'history' ? 'editor' : 'history')
|
||||
}, [view, chatIsOpen, shouldReopenChat, setView, toggleChatOpen])
|
||||
|
||||
const openShareModal = useCallback(() => {
|
||||
eventTracking.sendMB('navigation-clicked-share')
|
||||
openShareProjectModal()
|
||||
}, [openShareProjectModal])
|
||||
|
||||
const onShowLeftMenuClick = useCallback(() => {
|
||||
eventTracking.sendMB('navigation-clicked-menu')
|
||||
setLeftMenuShown(value => !value)
|
||||
}, [setLeftMenuShown])
|
||||
|
||||
const goToUser = useCallback(
|
||||
user => {
|
||||
if (user.doc && typeof user.row === 'number') {
|
||||
openDoc(user.doc, { gotoLine: user.row + 1 })
|
||||
}
|
||||
},
|
||||
[openDoc]
|
||||
)
|
||||
|
||||
return (
|
||||
<ToolbarHeader
|
||||
// @ts-ignore: TODO(convert ToolbarHeader to TSX)
|
||||
cobranding={cobranding}
|
||||
onShowLeftMenuClick={onShowLeftMenuClick}
|
||||
chatIsOpen={chatIsOpen}
|
||||
unreadMessageCount={unreadMessageCount}
|
||||
toggleChatOpen={toggleChatOpen}
|
||||
reviewPanelOpen={reviewPanelOpen}
|
||||
toggleReviewPanelOpen={toggleReviewPanelOpen}
|
||||
historyIsOpen={view === 'history'}
|
||||
toggleHistoryOpen={toggleHistoryOpen}
|
||||
onlineUsers={onlineUsersArray}
|
||||
goToUser={goToUser}
|
||||
isRestrictedTokenMember={isRestrictedTokenMember}
|
||||
hasPublishPermissions={
|
||||
permissionsLevel === 'owner' || permissionsLevel === 'readAndWrite'
|
||||
}
|
||||
chatVisible={!isRestrictedTokenMember}
|
||||
projectName={projectName}
|
||||
renameProject={renameProject}
|
||||
hasRenamePermissions={permissionsLevel === 'owner'}
|
||||
openShareModal={openShareModal}
|
||||
trackChangesVisible={trackChangesVisible}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export default EditorNavigationToolbarRoot
|
@@ -0,0 +1,23 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
function HistoryToggleButton({ onClick }) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
HistoryToggleButton.propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
export default HistoryToggleButton
|
@@ -0,0 +1,285 @@
|
||||
import { memo, useCallback, forwardRef } from 'react'
|
||||
import { Spinner } from 'react-bootstrap-5'
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownToggle,
|
||||
DropdownToggleCustom,
|
||||
} from '@/features/ui/components/bootstrap-5/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 useEventListener from '../../../shared/hooks/use-event-listener'
|
||||
import { DetachRole } from '@/shared/context/detach-context'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
|
||||
|
||||
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 {
|
||||
reattach,
|
||||
detach,
|
||||
detachIsLinked,
|
||||
detachRole,
|
||||
changeLayout,
|
||||
view,
|
||||
pdfLayout,
|
||||
} = useLayoutContext()
|
||||
|
||||
const handleDetach = useCallback(() => {
|
||||
detach()
|
||||
eventTracking.sendMB('project-layout-detach')
|
||||
}, [detach])
|
||||
|
||||
const handleReattach = useCallback(() => {
|
||||
if (detachRole !== 'detacher') {
|
||||
return
|
||||
}
|
||||
reattach()
|
||||
eventTracking.sendMB('project-layout-reattach')
|
||||
}, [detachRole, reattach])
|
||||
|
||||
// reattach when the PDF pane opens
|
||||
useEventListener('ui:pdf-open', handleReattach)
|
||||
|
||||
const handleChangeLayout = useCallback(
|
||||
(newLayout: IdeLayout, newView?: IdeView) => {
|
||||
handleReattach()
|
||||
changeLayout(newLayout, newView)
|
||||
eventTracking.sendMB('project-layout-change', {
|
||||
layout: newLayout,
|
||||
view: newView,
|
||||
})
|
||||
},
|
||||
[changeLayout, handleReattach]
|
||||
)
|
||||
|
||||
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 (
|
||||
<>
|
||||
{processing && (
|
||||
<div aria-live="assertive" className="sr-only">
|
||||
{t('layout_processing')}
|
||||
</div>
|
||||
)}
|
||||
<Dropdown className="toolbar-item layout-dropdown" align="end">
|
||||
<DropdownToggle
|
||||
id="layout-dropdown-btn"
|
||||
className="btn-full-height"
|
||||
as={LayoutDropdownToggleButton}
|
||||
>
|
||||
{processing ? (
|
||||
<Spinner
|
||||
animation="border"
|
||||
aria-hidden="true"
|
||||
size="sm"
|
||||
role="status"
|
||||
/>
|
||||
) : (
|
||||
<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'
|
||||
) : (
|
||||
<span className="spinner-container">
|
||||
<Spinner
|
||||
animation="border"
|
||||
aria-hidden="true"
|
||||
size="sm"
|
||||
role="status"
|
||||
/>
|
||||
<span className="visually-hidden">{t('loading')}</span>
|
||||
</span>
|
||||
)
|
||||
) : null
|
||||
}
|
||||
leadingIcon="select_window"
|
||||
>
|
||||
{t('pdf_in_separate_tab')}
|
||||
</EnhancedDropdownItem>
|
||||
) : (
|
||||
<BS5DetachDisabled />
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(LayoutDropdownButton)
|
@@ -0,0 +1,22 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
function MenuButton({ onClick }) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
MenuButton.propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
export default MenuButton
|
@@ -0,0 +1,133 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownHeader,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownToggle,
|
||||
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
|
||||
import { getBackgroundColorForUserId } from '@/shared/utils/colors'
|
||||
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
function OnlineUsersWidget({ onlineUsers, goToUser }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const shouldDisplayDropdown = onlineUsers.length >= 4
|
||||
|
||||
if (shouldDisplayDropdown) {
|
||||
return (
|
||||
<Dropdown id="online-users" className="online-users" align="end">
|
||||
<DropdownToggle
|
||||
as={DropDownToggleButton}
|
||||
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'] }}
|
||||
>
|
||||
<span>
|
||||
{/* OverlayTrigger won't fire unless UserIcon is wrapped in a span */}
|
||||
<UserIcon user={user} onClick={goToUser} />
|
||||
</span>
|
||||
</OLTooltip>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OnlineUsersWidget.propTypes = {
|
||||
onlineUsers: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
user_id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
})
|
||||
).isRequired,
|
||||
goToUser: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
function UserIcon({ user, showName, onClick }) {
|
||||
const backgroundColor = getBackgroundColorForUserId(user.user_id)
|
||||
|
||||
function handleOnClick() {
|
||||
onClick?.(user)
|
||||
}
|
||||
|
||||
const [character] = [...user.name]
|
||||
|
||||
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}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
UserIcon.propTypes = {
|
||||
user: PropTypes.shape({
|
||||
user_id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
}),
|
||||
showName: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
}
|
||||
|
||||
const DropDownToggleButton = React.forwardRef((props, ref) => {
|
||||
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>
|
||||
<MaterialIcon type="groups" />
|
||||
</button>
|
||||
</OLTooltip>
|
||||
)
|
||||
})
|
||||
|
||||
DropDownToggleButton.displayName = 'DropDownToggleButton'
|
||||
|
||||
DropDownToggleButton.propTypes = {
|
||||
onlineUserCount: PropTypes.number.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
}
|
||||
|
||||
export default OnlineUsersWidget
|
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import classNames from 'classnames'
|
||||
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
|
||||
import OLTooltip from '@/features/ui/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
|
@@ -0,0 +1,23 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
function ShareProjectButton({ onClick }) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
ShareProjectButton.propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
export default ShareProjectButton
|
@@ -0,0 +1,157 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
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 { canUseNewEditor } from '@/features/ide-redesign/utils/new-editor-utils'
|
||||
import TryNewEditorButton from '../try-new-editor-button'
|
||||
|
||||
const [publishModalModules] = importOverleafModules('publishModal')
|
||||
const PublishButton = publishModalModules?.import.default
|
||||
|
||||
const offlineModeToolbarButtons = importOverleafModules(
|
||||
'offlineModeToolbarButtons'
|
||||
)
|
||||
// double opt-in
|
||||
const enableROMirrorOnClient =
|
||||
isSplitTestEnabled('ro-mirror-on-client') &&
|
||||
new URLSearchParams(window.location.search).get('ro-mirror-on-client') ===
|
||||
'enabled'
|
||||
|
||||
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,
|
||||
}) {
|
||||
const chatEnabled = getMeta('ol-chatEnabled')
|
||||
|
||||
const { t } = useTranslation()
|
||||
const shouldDisplayPublishButton = hasPublishPermissions && PublishButton
|
||||
|
||||
return (
|
||||
<header
|
||||
className="toolbar toolbar-header"
|
||||
role="navigation"
|
||||
aria-label={t('project_layout_sharing_submission')}
|
||||
>
|
||||
<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>
|
||||
</header>
|
||||
)
|
||||
})
|
||||
|
||||
ToolbarHeader.propTypes = {
|
||||
onShowLeftMenuClick: PropTypes.func.isRequired,
|
||||
cobranding: PropTypes.object,
|
||||
chatIsOpen: PropTypes.bool,
|
||||
toggleChatOpen: PropTypes.func.isRequired,
|
||||
reviewPanelOpen: PropTypes.bool,
|
||||
toggleReviewPanelOpen: PropTypes.func.isRequired,
|
||||
historyIsOpen: PropTypes.bool,
|
||||
toggleHistoryOpen: PropTypes.func.isRequired,
|
||||
unreadMessageCount: PropTypes.number.isRequired,
|
||||
onlineUsers: PropTypes.array.isRequired,
|
||||
goToUser: PropTypes.func.isRequired,
|
||||
isRestrictedTokenMember: PropTypes.bool,
|
||||
hasPublishPermissions: PropTypes.bool,
|
||||
chatVisible: PropTypes.bool,
|
||||
projectName: PropTypes.string.isRequired,
|
||||
renameProject: PropTypes.func.isRequired,
|
||||
hasRenamePermissions: PropTypes.bool,
|
||||
openShareModal: PropTypes.func.isRequired,
|
||||
trackChangesVisible: PropTypes.bool,
|
||||
}
|
||||
|
||||
export default ToolbarHeader
|
@@ -0,0 +1,39 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import classNames from 'classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
function TrackChangesToggleButton({
|
||||
trackChangesIsOpen,
|
||||
disabled,
|
||||
onMouseDown,
|
||||
}) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
TrackChangesToggleButton.propTypes = {
|
||||
trackChangesIsOpen: PropTypes.bool,
|
||||
disabled: PropTypes.bool,
|
||||
onMouseDown: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
export default TrackChangesToggleButton
|
@@ -0,0 +1,27 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as eventTracking from '../../../infrastructure/event-tracking'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
|
||||
function UpgradePrompt() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
function handleClick(e) {
|
||||
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
|
@@ -0,0 +1,28 @@
|
||||
import { useCallback } from 'react'
|
||||
import OLButton from '../ui/components/ol/ol-button'
|
||||
import { useIdeRedesignSwitcherContext } from '../ide-react/context/ide-redesign-switcher-context'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const TryNewEditorButton = () => {
|
||||
const { t } = useTranslation()
|
||||
const { setShowSwitcherModal } = useIdeRedesignSwitcherContext()
|
||||
const onClick = useCallback(() => {
|
||||
setShowSwitcherModal(true)
|
||||
}, [setShowSwitcherModal])
|
||||
return (
|
||||
<div className="d-flex align-items-center">
|
||||
<OLButton
|
||||
className="toolbar-experiment-button"
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
leadingIcon={<MaterialIcon type="experiment" unfilled />}
|
||||
variant="info"
|
||||
>
|
||||
{t('try_the_new_editor')}
|
||||
</OLButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TryNewEditorButton
|
Reference in New Issue
Block a user