first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
import 'bootstrap'
import './features/contact-form'
import './features/bookmarkable-tab/index'
import './features/tooltip/index-bs3'

View File

@@ -0,0 +1,3 @@
import './features/bookmarkable-tab/index-bs5'
import './features/tooltip/index-bs5'
import 'bootstrap-5'

View File

@@ -0,0 +1,5 @@
import importOverleafModules from '../macros/import-overleaf-module.macro'
if (process.env.NODE_ENV === 'development') {
importOverleafModules('devToolbar')
}

View File

@@ -0,0 +1,43 @@
import _ from 'lodash'
import AlgoliaSearch from 'algoliasearch'
import getMeta from '../../utils/meta'
let wikiIdx
export async function searchWiki(...args) {
if (!wikiIdx) {
const algoliaConfig = getMeta('ol-algolia')
const wikiIndex = _.get(algoliaConfig, 'indexes.wiki')
if (wikiIndex) {
const client = AlgoliaSearch(algoliaConfig.appId, algoliaConfig.apiKey)
wikiIdx = client.initIndex(wikiIndex)
}
}
if (!wikiIdx) {
return { hits: [], nbHits: 0, nbPages: 0 }
}
return wikiIdx.search(...args)
}
export function formatWikiHit(hit) {
const pageUnderscored = hit.pageName.replace(/\s/g, '_')
const pageSlug = encodeURIComponent(pageUnderscored)
const pagePath = hit.kb ? 'how-to' : 'latex'
let pageAnchor = ''
const rawPageName = hit._highlightResult.pageName.value
const sectionName = hit.sectionName
let pageName = rawPageName
if (sectionName) {
pageAnchor = `#${sectionName.replace(/\s/g, '_')}`
pageName += ' - ' + sectionName
}
const body = hit._highlightResult.content.value
const content = body
.split('\n')
.filter(line => line.includes('<em>') && !line.includes('[edit]'))
.join('\n...\n')
const url = `/learn/${pagePath}/${pageSlug}${pageAnchor}`
return { url, pageName, rawPageName, sectionName, content }
}

View File

@@ -0,0 +1,56 @@
function setup(videoEl) {
const reducedMotionReduce = window.matchMedia(
'(prefers-reduced-motion: reduce)'
)
if (reducedMotionReduce.matches) {
// TODO: on firefox, if user enters this mode, video can throw error
// in console, if user seeks the control seek bar relatively fast
// AbortError: The fetching process for the media resource was aborted by the user agent at the user's request.
// this is only a problem in firefox (tested in macOS), chrome and safari is fine
videoEl.setAttribute('controls', '')
return
}
const DELAY_BEFORE_REPLAY = 15 * 1000
// 0.7 will enable the autoplay on the desktop main homepage video for all users
const INTERSECTION_THRESHOLD = 0.7
let videoIsVisible
videoEl.addEventListener('ended', () => {
setTimeout(() => {
videoEl.currentTime = 0
if (videoIsVisible) {
videoEl.play()
}
}, DELAY_BEFORE_REPLAY)
})
const observer = new IntersectionObserver(
function onIntersecting(changes) {
for (const change of changes) {
if (change.isIntersecting) {
videoIsVisible = true
if (videoEl.readyState >= videoEl.HAVE_FUTURE_DATA) {
if (!videoEl.ended) {
videoEl.play()
}
} else {
videoEl.play()
}
} else {
videoIsVisible = false
}
}
},
{
threshold: INTERSECTION_THRESHOLD,
}
)
observer.observe(videoEl)
}
document.querySelectorAll('[data-ol-autoplay-video]').forEach(setup)

View File

@@ -0,0 +1,30 @@
import { Tab } from 'bootstrap-5'
function bookmarkableTab(tabEl: HTMLElement) {
tabEl.addEventListener('click', () => {
window.location.hash = tabEl.getAttribute('href') as string
})
}
function handleHashChange() {
const hash = window.location.hash
if (!hash) return
// Find the bookmarkable tab that links to the hash
const tabEl = document.querySelector(
`[data-ol-bookmarkable-tab][href="${hash}"]`
)
if (!tabEl) return
// Select the tab via Bootstrap 5
const tab = new Tab(tabEl)
tab.show()
}
document
.querySelectorAll('[data-ol-bookmarkable-tab]')
.forEach(tabEl => bookmarkableTab(tabEl as HTMLElement))
window.addEventListener('hashchange', handleHashChange)
handleHashChange()

View File

@@ -0,0 +1,21 @@
function bookmarkableTab(tabEl) {
tabEl.addEventListener('click', () => {
window.location.hash = tabEl.getAttribute('href')
})
}
function handleHashChange() {
const hash = window.location.hash
if (!hash) return
// Find the bookmarkable tab that links to the hash
const $tabEl = $(`[data-ol-bookmarkable-tab][href="${hash}"]`)
if (!$tabEl) return
// Select the tab via Bootstrap
$tabEl.tab('show')
}
document.querySelectorAll('[data-ol-bookmarkable-tab]').forEach(bookmarkableTab)
window.addEventListener('hashchange', handleHashChange)
handleHashChange()

View File

@@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import OLButton from '@/features/ui/components/ol/ol-button'
interface ChatFallbackErrorProps {
reconnect?: () => void
}
function ChatFallbackError({ reconnect }: ChatFallbackErrorProps) {
const { t } = useTranslation()
return (
<aside className="chat">
<div className="chat-error">
<OLNotification type="error" content={t('chat_error')} />
{reconnect && (
<p className="text-center">
<OLButton variant="secondary" onClick={reconnect}>
{t('reconnect')}
</OLButton>
</p>
)}
</div>
</aside>
)
}
export default ChatFallbackError

View File

@@ -0,0 +1,117 @@
import React, { lazy, Suspense, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import MessageInput from './message-input'
import InfiniteScroll from './infinite-scroll'
import ChatFallbackError from './chat-fallback-error'
import { useLayoutContext } from '../../../shared/context/layout-context'
import { useUserContext } from '../../../shared/context/user-context'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import { FetchError } from '../../../infrastructure/fetch-json'
import { useChatContext } from '../context/chat-context'
import { FullSizeLoadingSpinner } from '../../../shared/components/loading-spinner'
import MaterialIcon from '@/shared/components/material-icon'
const MessageList = lazy(() => import('./message-list'))
const Loading = () => <FullSizeLoadingSpinner delay={500} className="pt-4" />
const ChatPane = React.memo(function ChatPane() {
const { t } = useTranslation()
const { chatIsOpen } = useLayoutContext()
const user = useUserContext()
const {
status,
messages,
initialMessagesLoaded,
atEnd,
loadInitialMessages,
loadMoreMessages,
reset,
sendMessage,
markMessagesAsRead,
error,
} = useChatContext()
useEffect(() => {
if (chatIsOpen && !initialMessagesLoaded) {
loadInitialMessages()
}
}, [chatIsOpen, loadInitialMessages, initialMessagesLoaded])
const shouldDisplayPlaceholder = status !== 'pending' && messages.length === 0
const messageContentCount = messages.reduce(
(acc, { contents }) => acc + contents.length,
0
)
// Keep the chat pane in the DOM to avoid resetting the form input and re-rendering MathJax content.
const [chatOpenedOnce, setChatOpenedOnce] = useState(chatIsOpen)
useEffect(() => {
if (chatIsOpen) {
setChatOpenedOnce(true)
}
}, [chatIsOpen])
if (error) {
// let user try recover from fetch errors
if (error instanceof FetchError) {
return <ChatFallbackError reconnect={reset} />
}
throw error
}
if (!user) {
return null
}
if (!chatOpenedOnce) {
return null
}
return (
<aside className="chat">
<InfiniteScroll
atEnd={atEnd}
className="messages"
fetchData={loadMoreMessages}
isLoading={status === 'pending'}
itemCount={messageContentCount}
>
<div>
<h2 className="visually-hidden">{t('chat')}</h2>
<Suspense fallback={<Loading />}>
{status === 'pending' && <Loading />}
{shouldDisplayPlaceholder && <Placeholder />}
<MessageList
messages={messages}
resetUnreadMessages={markMessagesAsRead}
/>
</Suspense>
</div>
</InfiniteScroll>
<MessageInput
resetUnreadMessages={markMessagesAsRead}
sendMessage={sendMessage}
/>
</aside>
)
})
function Placeholder() {
const { t } = useTranslation()
return (
<>
<div className="no-messages text-center small">{t('no_messages')}</div>
<div className="first-message text-center">
{t('send_first_message')}
<br />
<MaterialIcon type="arrow_downward" />
</div>
</>
)
}
export default withErrorBoundary(ChatPane, ChatFallbackError)

View File

@@ -0,0 +1,95 @@
import { useRef, useEffect, useLayoutEffect } from 'react'
import _ from 'lodash'
const SCROLL_END_OFFSET = 30
interface InfiniteScrollProps {
atEnd?: boolean
children: React.ReactElement
className?: string
fetchData(): void
itemCount: number
isLoading?: boolean
}
function InfiniteScroll({
atEnd,
children,
className = '',
fetchData,
itemCount,
isLoading,
}: InfiniteScrollProps) {
const root = useRef<HTMLDivElement>(null)
// we keep the value in a Ref instead of state so it can be safely used in effects
const scrollBottomRef = useRef(0)
function setScrollBottom(value: number) {
scrollBottomRef.current = value
}
function updateScrollPosition() {
if (root.current) {
root.current.scrollTop =
root.current.scrollHeight -
root.current.clientHeight -
scrollBottomRef.current
}
}
// Repositions the scroll after new items are loaded
useLayoutEffect(updateScrollPosition, [itemCount])
// Repositions the scroll after a window resize
useEffect(() => {
const handleResize = _.debounce(updateScrollPosition, 400)
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [])
function onScrollHandler(event: React.UIEvent<HTMLDivElement>) {
if (root.current) {
setScrollBottom(
root.current.scrollHeight -
root.current.scrollTop -
root.current.clientHeight
)
if (event.target !== event.currentTarget) {
// Ignore scroll events on nested divs
// (this check won't be necessary in React 17: https://github.com/facebook/react/issues/15723
return
}
if (shouldFetchData()) {
fetchData()
}
}
}
function shouldFetchData() {
if (!root.current) {
return false
}
const containerIsLargerThanContent =
root.current.children[0].clientHeight < root.current.clientHeight
if (atEnd || isLoading || containerIsLargerThanContent) {
return false
} else {
return root.current.scrollTop < SCROLL_END_OFFSET
}
}
return (
<div
ref={root}
onScroll={onScrollHandler}
className={`infinite-scroll ${className}`}
>
{children}
</div>
)
}
export default InfiniteScroll

View File

@@ -0,0 +1,43 @@
import { useRef, useEffect, type FC } from 'react'
import Linkify from 'react-linkify'
import useIsMounted from '../../../shared/hooks/use-is-mounted'
import { loadMathJax } from '../../mathjax/load-mathjax'
import { debugConsole } from '@/utils/debugging'
const MessageContent: FC<{ content: string }> = ({ content }) => {
const root = useRef<HTMLDivElement | null>(null)
const mounted = useIsMounted()
useEffect(() => {
if (root.current) {
// adds attributes to all the links generated by <Linkify/>, required due to https://github.com/tasti/react-linkify/issues/99
for (const a of root.current.getElementsByTagName('a')) {
a.setAttribute('target', '_blank')
a.setAttribute('rel', 'noreferrer noopener')
}
// MathJax v3 typesetting
loadMathJax()
.then(async MathJax => {
if (mounted.current) {
const element = root.current
try {
await MathJax.typesetPromise([element])
MathJax.typesetClear([element])
} catch (error) {
debugConsole.error(error)
}
}
})
.catch(debugConsole.error)
}
}, [content, mounted])
return (
<p ref={root}>
<Linkify>{content}</Linkify>
</p>
)
}
export default MessageContent

View File

@@ -0,0 +1,42 @@
import { useTranslation } from 'react-i18next'
type MessageInputProps = {
resetUnreadMessages: () => void
sendMessage: (message: string) => void
}
function MessageInput({ resetUnreadMessages, sendMessage }: MessageInputProps) {
const { t } = useTranslation()
function handleKeyDown(event: React.KeyboardEvent) {
const selectingCharacter = event.nativeEvent.isComposing
if (event.key === 'Enter' && !selectingCharacter) {
event.preventDefault()
const target = event.target as HTMLInputElement
sendMessage(target.value)
// wrap the form reset in setTimeout so input sources have time to finish
// https://github.com/overleaf/internal/pull/9206
window.setTimeout(() => {
target.blur()
target.closest('form')?.reset()
target.focus()
}, 0)
}
}
return (
<form className="new-message">
<label htmlFor="chat-input" className="visually-hidden">
{t('your_message_to_collaborators')}
</label>
<textarea
id="chat-input"
placeholder={`${t('your_message_to_collaborators')}`}
onKeyDown={handleKeyDown}
onClick={resetUnreadMessages}
/>
</form>
)
}
export default MessageInput

View File

@@ -0,0 +1,77 @@
import moment from 'moment'
import Message from './message'
import type { Message as MessageType } from '@/features/chat/context/chat-context'
import MessageRedesign from '@/features/ide-redesign/components/chat/message'
import { useUserContext } from '@/shared/context/user-context'
const FIVE_MINUTES = 5 * 60 * 1000
function formatTimestamp(date: moment.MomentInput) {
if (!date) {
return 'N/A'
} else {
return `${moment(date).format('h:mm a')} ${moment(date).calendar()}`
}
}
interface MessageListProps {
messages: MessageType[]
resetUnreadMessages(...args: unknown[]): unknown
newDesign?: boolean
}
function MessageList({
messages,
resetUnreadMessages,
newDesign,
}: MessageListProps) {
const user = useUserContext()
const MessageComponent = newDesign ? MessageRedesign : Message
function shouldRenderDate(messageIndex: number) {
if (messageIndex === 0) {
return true
} else {
const message = messages[messageIndex]
const previousMessage = messages[messageIndex - 1]
return (
message.timestamp &&
previousMessage.timestamp &&
message.timestamp - previousMessage.timestamp > FIVE_MINUTES
)
}
}
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<ul
className="list-unstyled"
onClick={resetUnreadMessages}
onKeyDown={resetUnreadMessages}
>
{messages.map((message, index) => (
// new messages are added to the beginning of the list, so we use a reversed index
<li key={message.id} className="message">
{shouldRenderDate(index) && (
<div className="date">
<time
dateTime={
message.timestamp
? moment(message.timestamp).format()
: undefined
}
>
{formatTimestamp(message.timestamp)}
</time>
</div>
)}
<MessageComponent
message={message}
fromSelf={message.user ? message.user.id === user.id : false}
/>
</li>
))}
</ul>
)
}
export default MessageList

View File

@@ -0,0 +1,50 @@
import { getHueForUserId } from '@/shared/utils/colors'
import MessageContent from './message-content'
import type { Message as MessageType } from '@/features/chat/context/chat-context'
import { User } from '../../../../../types/user'
export interface MessageProps {
message: MessageType
fromSelf: boolean
}
function hue(user?: User) {
return user ? getHueForUserId(user.id) : 0
}
function getMessageStyle(user?: User) {
return {
borderColor: `hsl(${hue(user)}, 85%, 40%)`,
backgroundColor: `hsl(${hue(user)}, 85%, 40%`,
}
}
function getArrowStyle(user?: User) {
return {
borderColor: `hsl(${hue(user)}, 85%, 40%)`,
}
}
function Message({ message, fromSelf }: MessageProps) {
return (
<div className="message-wrapper">
{!fromSelf && (
<div className="name">
<span>{message.user.first_name || message.user.email}</span>
</div>
)}
<div className="message" style={getMessageStyle(message.user)}>
{!fromSelf && (
<div className="arrow" style={getArrowStyle(message.user)} />
)}
<div className="message-content">
{message.contents.map((content, index) => (
<MessageContent key={index} content={content} />
))}
</div>
</div>
</div>
)
}
export default Message

View File

@@ -0,0 +1,399 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useReducer,
useMemo,
useRef,
FC,
} from 'react'
import { v4 as uuid } from 'uuid'
import { useUserContext } from '../../../shared/context/user-context'
import { useProjectContext } from '../../../shared/context/project-context'
import { getJSON, postJSON } from '../../../infrastructure/fetch-json'
import { appendMessage, prependMessages } from '../utils/message-list-appender'
import useBrowserWindow from '../../../shared/hooks/use-browser-window'
import { useLayoutContext } from '../../../shared/context/layout-context'
import { useIdeContext } from '@/shared/context/ide-context'
import getMeta from '@/utils/meta'
import { debugConsole } from '@/utils/debugging'
import { User } from '../../../../../types/user'
const PAGE_SIZE = 50
export type Message = {
id: string
timestamp: number
contents: string[]
user: User
}
type State = {
status: 'idle' | 'pending' | 'error'
messages: Message[]
initialMessagesLoaded: boolean
lastTimestamp: number | null
atEnd: boolean
unreadMessageCount: number
error?: Error | null
uniqueMessageIds: string[]
}
type Action =
| {
type: 'INITIAL_FETCH_MESSAGES'
}
| {
type: 'FETCH_MESSAGES'
}
| {
type: 'FETCH_MESSAGES_SUCCESS'
messages: Message[]
}
| {
type: 'SEND_MESSAGE'
user: any
content: any
}
| {
type: 'RECEIVE_MESSAGE'
message: any
}
| {
type: 'MARK_MESSAGES_AS_READ'
}
| {
type: 'CLEAR'
}
| {
type: 'ERROR'
error: any
}
// Wrap uuid in an object method so that it can be stubbed
export const chatClientIdGenerator = {
generate: () => uuid(),
}
let nextChatMessageId = 1
function generateChatMessageId() {
return '' + nextChatMessageId++
}
function chatReducer(state: State, action: Action): State {
switch (action.type) {
case 'INITIAL_FETCH_MESSAGES':
return {
...state,
status: 'pending',
initialMessagesLoaded: true,
}
case 'FETCH_MESSAGES':
return {
...state,
status: 'pending',
}
case 'FETCH_MESSAGES_SUCCESS':
return {
...state,
status: 'idle',
...prependMessages(
state.messages,
action.messages,
state.uniqueMessageIds
),
lastTimestamp: action.messages[0] ? action.messages[0].timestamp : null,
atEnd: action.messages.length < PAGE_SIZE,
}
case 'SEND_MESSAGE':
return {
...state,
...appendMessage(
state.messages,
{
// Messages are sent optimistically, so don't have an id (used for
// React keys). The id is valid for this session, and ensures all
// messages have an id. It will be overwritten by the actual ids on
// refresh
id: generateChatMessageId(),
user: action.user,
content: action.content,
timestamp: Date.now(),
},
state.uniqueMessageIds
),
}
case 'RECEIVE_MESSAGE':
return {
...state,
...appendMessage(
state.messages,
action.message,
state.uniqueMessageIds
),
unreadMessageCount: state.unreadMessageCount + 1,
}
case 'MARK_MESSAGES_AS_READ':
return {
...state,
unreadMessageCount: 0,
}
case 'CLEAR':
return { ...initialState }
case 'ERROR':
return {
...state,
status: 'error',
error: action.error,
}
default:
throw new Error('Unknown action')
}
}
const initialState: State = {
status: 'idle',
messages: [],
initialMessagesLoaded: false,
lastTimestamp: null,
atEnd: false,
unreadMessageCount: 0,
error: null,
uniqueMessageIds: [],
}
export const ChatContext = createContext<
| {
status: 'idle' | 'pending' | 'error'
messages: Message[]
initialMessagesLoaded: boolean
atEnd: boolean
unreadMessageCount: number
loadInitialMessages: () => void
loadMoreMessages: () => void
sendMessage: (message: any) => void
markMessagesAsRead: () => void
reset: () => void
error?: Error | null
}
| undefined
>(undefined)
export const ChatProvider: FC = ({ children }) => {
const chatEnabled = getMeta('ol-chatEnabled')
const clientId = useRef<string>()
if (clientId.current === undefined) {
clientId.current = chatClientIdGenerator.generate()
}
const user = useUserContext()
const { _id: projectId } = useProjectContext()
const { chatIsOpen } = useLayoutContext()
const {
hasFocus: windowHasFocus,
flashTitle,
stopFlashingTitle,
} = useBrowserWindow()
const [state, dispatch] = useReducer(chatReducer, initialState)
const { loadInitialMessages, loadMoreMessages, reset } = useMemo(() => {
function fetchMessages() {
if (state.atEnd) return
const query: Record<string, string> = {
limit: String(PAGE_SIZE),
}
if (state.lastTimestamp) {
query.before = String(state.lastTimestamp)
}
const queryString = new URLSearchParams(query)
const url = `/project/${projectId}/messages?${queryString.toString()}`
getJSON(url)
.then((messages = []) => {
dispatch({
type: 'FETCH_MESSAGES_SUCCESS',
messages: messages.reverse(),
})
})
.catch(error => {
dispatch({
type: 'ERROR',
error,
})
})
}
function loadInitialMessages() {
if (!chatEnabled) {
debugConsole.warn(`chat is disabled, won't load initial messages`)
return
}
if (state.initialMessagesLoaded) return
dispatch({ type: 'INITIAL_FETCH_MESSAGES' })
fetchMessages()
}
function loadMoreMessages() {
if (!chatEnabled) {
debugConsole.warn(`chat is disabled, won't load messages`)
return
}
dispatch({ type: 'FETCH_MESSAGES' })
fetchMessages()
}
function reset() {
if (!chatEnabled) {
debugConsole.warn(`chat is disabled, won't reset chat`)
return
}
dispatch({ type: 'CLEAR' })
fetchMessages()
}
return {
loadInitialMessages,
loadMoreMessages,
reset,
}
}, [
chatEnabled,
projectId,
state.atEnd,
state.initialMessagesLoaded,
state.lastTimestamp,
])
const sendMessage = useCallback(
content => {
if (!chatEnabled) {
debugConsole.warn(`chat is disabled, won't send message`)
return
}
if (!content) return
dispatch({
type: 'SEND_MESSAGE',
user,
content,
})
const url = `/project/${projectId}/messages`
postJSON(url, {
body: { content, client_id: clientId.current },
}).catch(error => {
dispatch({
type: 'ERROR',
error,
})
})
},
[chatEnabled, projectId, user]
)
const markMessagesAsRead = useCallback(() => {
if (!chatEnabled) {
debugConsole.warn(`chat is disabled, won't mark messages as read`)
return
}
dispatch({ type: 'MARK_MESSAGES_AS_READ' })
}, [chatEnabled])
// Handling receiving messages over the socket
const { socket } = useIdeContext()
useEffect(() => {
if (!chatEnabled || !socket) return
function receivedMessage(message: any) {
// If the message is from the current client id, then we are receiving the sent message back from the socket.
// Ignore it to prevent double message.
if (message.clientId === clientId.current) return
dispatch({ type: 'RECEIVE_MESSAGE', message })
}
socket.on('new-chat-message', receivedMessage)
return () => {
if (!socket) return
socket.removeListener('new-chat-message', receivedMessage)
}
}, [chatEnabled, socket])
// Handle unread messages
useEffect(() => {
if (windowHasFocus) {
stopFlashingTitle()
if (chatIsOpen) {
markMessagesAsRead()
}
}
if (!windowHasFocus && state.unreadMessageCount > 0) {
flashTitle('New Message')
}
}, [
windowHasFocus,
chatIsOpen,
state.unreadMessageCount,
flashTitle,
stopFlashingTitle,
markMessagesAsRead,
])
const value = useMemo(
() => ({
status: state.status,
messages: state.messages,
initialMessagesLoaded: state.initialMessagesLoaded,
atEnd: state.atEnd,
unreadMessageCount: state.unreadMessageCount,
loadInitialMessages,
loadMoreMessages,
reset,
sendMessage,
markMessagesAsRead,
error: state.error,
}),
[
loadInitialMessages,
loadMoreMessages,
markMessagesAsRead,
reset,
sendMessage,
state.atEnd,
state.error,
state.initialMessagesLoaded,
state.messages,
state.status,
state.unreadMessageCount,
]
)
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>
}
export function useChatContext() {
const context = useContext(ChatContext)
if (!context) {
throw new Error('useChatContext is only available inside ChatProvider')
}
return context
}

View File

@@ -0,0 +1,79 @@
const TIMESTAMP_GROUP_SIZE = 5 * 60 * 1000 // 5 minutes
export function appendMessage(messageList, message, uniqueMessageIds) {
if (uniqueMessageIds.includes(message.id)) {
return { messages: messageList, uniqueMessageIds }
}
uniqueMessageIds = uniqueMessageIds.slice(0)
uniqueMessageIds.push(message.id)
const lastMessage = messageList[messageList.length - 1]
const shouldGroup =
lastMessage &&
message &&
message.user &&
message.user.id &&
message.user.id === lastMessage.user.id &&
message.timestamp - lastMessage.timestamp < TIMESTAMP_GROUP_SIZE
if (shouldGroup) {
messageList = messageList.slice(0, messageList.length - 1).concat({
...lastMessage,
// the `id` is updated to the latest received content when a new
// message is appended or prepended
id: message.id,
timestamp: message.timestamp,
contents: lastMessage.contents.concat(message.content),
})
} else {
messageList = messageList.slice(0).concat({
id: message.id,
user: message.user,
timestamp: message.timestamp,
contents: [message.content],
})
}
return { messages: messageList, uniqueMessageIds }
}
export function prependMessages(messageList, messages, uniqueMessageIds) {
const listCopy = messageList.slice(0)
uniqueMessageIds = uniqueMessageIds.slice(0)
messages
.slice(0)
.reverse()
.forEach(message => {
if (uniqueMessageIds.includes(message.id)) {
return
}
uniqueMessageIds.push(message.id)
const firstMessage = listCopy[0]
const shouldGroup =
firstMessage &&
message &&
message.user &&
message.user.id === firstMessage.user.id &&
firstMessage.timestamp - message.timestamp < TIMESTAMP_GROUP_SIZE
if (shouldGroup) {
firstMessage.id = message.id
firstMessage.timestamp = message.timestamp
firstMessage.contents = [message.content].concat(firstMessage.contents)
} else {
listCopy.unshift({
id: message.id,
user: message.user,
timestamp: message.timestamp,
contents: [message.content],
})
}
})
return { messages: listCopy, uniqueMessageIds }
}

View File

@@ -0,0 +1,159 @@
/* eslint-disable jsx-a11y/no-autofocus */
import PropTypes from 'prop-types'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { postJSON } from '../../../infrastructure/fetch-json'
import { CloneProjectTag } from './clone-project-tag'
import {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import Notification from '@/shared/components/notification'
import OLForm from '@/features/ui/components/ol/ol-form'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLButton from '@/features/ui/components/ol/ol-button'
export default function CloneProjectModalContent({
handleHide,
inFlight,
setInFlight,
handleAfterCloned,
projectId,
projectName,
projectTags,
}) {
const { t } = useTranslation()
const [error, setError] = useState()
const [clonedProjectName, setClonedProjectName] = useState(
`${projectName} (Copy)`
)
const [clonedProjectTags, setClonedProjectTags] = useState(projectTags)
// valid if the cloned project has a name
const valid = useMemo(
() => clonedProjectName.trim().length > 0,
[clonedProjectName]
)
// form submission: clone the project if the name is valid
const handleSubmit = event => {
event.preventDefault()
if (!valid) {
return
}
setError(false)
setInFlight(true)
// clone the project
postJSON(`/project/${projectId}/clone`, {
body: {
projectName: clonedProjectName,
tags: clonedProjectTags.map(tag => ({ id: tag._id })),
},
})
.then(data => {
// open the cloned project
handleAfterCloned(data, clonedProjectTags)
})
.catch(({ response, data }) => {
if (response?.status === 400) {
setError(data.message)
} else {
setError(true)
}
})
.finally(() => {
setInFlight(false)
})
}
const removeTag = useCallback(tag => {
setClonedProjectTags(value => value.filter(item => item._id !== tag._id))
}, [])
return (
<>
<OLModalHeader closeButton>
<OLModalTitle>{t('copy_project')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<OLForm id="clone-project-form" onSubmit={handleSubmit}>
<OLFormGroup controlId="clone-project-form-name">
<OLFormLabel>{t('new_name')}</OLFormLabel>
<OLFormControl
type="text"
placeholder="New Project Name"
required
value={clonedProjectName}
onChange={event => setClonedProjectName(event.target.value)}
autoFocus
/>
</OLFormGroup>
{clonedProjectTags.length > 0 && (
<OLFormGroup
controlId="clone-project-tags-list"
className="clone-project-tag"
>
<OLFormLabel>{t('tags')}: </OLFormLabel>
<div role="listbox" id="clone-project-tags-list">
{clonedProjectTags.map(tag => (
<CloneProjectTag
key={tag._id}
tag={tag}
removeTag={removeTag}
/>
))}
</div>
</OLFormGroup>
)}
</OLForm>
{error && (
<Notification
content={error.length ? error : t('generic_something_went_wrong')}
type="error"
/>
)}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" disabled={inFlight} onClick={handleHide}>
{t('cancel')}
</OLButton>
<OLButton
variant="primary"
disabled={inFlight || !valid}
form="clone-project-form"
type="submit"
>
{inFlight ? <>{t('copying')}</> : t('copy')}
</OLButton>
</OLModalFooter>
</>
)
}
CloneProjectModalContent.propTypes = {
handleHide: PropTypes.func.isRequired,
inFlight: PropTypes.bool,
setInFlight: PropTypes.func.isRequired,
handleAfterCloned: PropTypes.func.isRequired,
projectId: PropTypes.string,
projectName: PropTypes.string,
projectTags: PropTypes.arrayOf(
PropTypes.shape({
_id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
color: PropTypes.string,
})
),
}

View File

@@ -0,0 +1,60 @@
import React, { memo, useCallback, useState } from 'react'
import PropTypes from 'prop-types'
import CloneProjectModalContent from './clone-project-modal-content'
import OLModal from '@/features/ui/components/ol/ol-modal'
function CloneProjectModal({
show,
handleHide,
handleAfterCloned,
projectId,
projectName,
projectTags,
}) {
const [inFlight, setInFlight] = useState(false)
const onHide = useCallback(() => {
if (!inFlight) {
handleHide()
}
}, [handleHide, inFlight])
return (
<OLModal
animation
show={show}
onHide={onHide}
id="clone-project-modal"
// backdrop="static" will disable closing the modal by clicking
// outside of the modal element
backdrop={inFlight ? 'static' : undefined}
>
<CloneProjectModalContent
handleHide={onHide}
inFlight={inFlight}
setInFlight={setInFlight}
handleAfterCloned={handleAfterCloned}
projectId={projectId}
projectName={projectName}
projectTags={projectTags}
/>
</OLModal>
)
}
CloneProjectModal.propTypes = {
handleHide: PropTypes.func.isRequired,
show: PropTypes.bool.isRequired,
handleAfterCloned: PropTypes.func.isRequired,
projectId: PropTypes.string,
projectName: PropTypes.string,
projectTags: PropTypes.arrayOf(
PropTypes.shape({
_id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
color: PropTypes.string,
})
),
}
export default memo(CloneProjectModal)

View File

@@ -0,0 +1,26 @@
import { FC } from 'react'
import { Tag as TagType } from '../../../../../app/src/Features/Tags/types'
import { getTagColor } from '@/features/project-list/util/tag'
import Tag from '@/features/ui/components/bootstrap-5/tag'
export const CloneProjectTag: FC<{
tag: TagType
removeTag: (tag: TagType) => void
}> = ({ tag, removeTag }) => {
return (
<Tag
prepend={
<i
className="badge-tag-circle"
style={{ backgroundColor: getTagColor(tag) }}
/>
}
closeBtnProps={{
onClick: () => removeTag(tag),
}}
className="ms-2 mb-2"
>
{tag.name}
</Tag>
)
}

View File

@@ -0,0 +1,39 @@
import React from 'react'
import PropTypes from 'prop-types'
import { useProjectContext } from '../../../shared/context/project-context'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import CloneProjectModal from './clone-project-modal'
const EditorCloneProjectModalWrapper = React.memo(
function EditorCloneProjectModalWrapper({ show, handleHide, openProject }) {
const {
_id: projectId,
name: projectName,
tags: projectTags,
} = useProjectContext()
if (!projectName) {
// wait for useProjectContext
return null
} else {
return (
<CloneProjectModal
handleHide={handleHide}
show={show}
handleAfterCloned={openProject}
projectId={projectId}
projectName={projectName}
projectTags={projectTags}
/>
)
}
}
)
EditorCloneProjectModalWrapper.propTypes = {
handleHide: PropTypes.func.isRequired,
show: PropTypes.bool.isRequired,
openProject: PropTypes.func.isRequired,
}
export default withErrorBoundary(EditorCloneProjectModalWrapper)

View File

@@ -0,0 +1,39 @@
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
import OLButton from '@/features/ui/components/ol/ol-button'
import { Trans, useTranslation } from 'react-i18next'
import { Interstitial } from '@/shared/components/interstitial'
export function CompromisedPasswordCard() {
const { t } = useTranslation()
const { isReady } = useWaitForI18n()
if (!isReady) {
return null
}
return (
<Interstitial
contentClassName="compromised-password-content"
showLogo={false}
title={t('compromised_password')}
>
<p>
<Trans
i18nKey="your_password_was_detected"
components={[
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
<a
href="https://haveibeenpwned.com/passwords"
target="_blank"
rel="noreferrer"
/>,
]}
/>
</p>
<OLButton className="btn-primary" href="/user/settings">
{t('change_password_in_account_settings')}
</OLButton>
</Interstitial>
)
}

View File

@@ -0,0 +1,35 @@
import { setupSearch } from './search'
document
.querySelectorAll('[data-ol-contact-form-with-search]')
.forEach(setupSearch)
document
.querySelectorAll('[data-ol-open-contact-form-modal="contact-us"]')
.forEach(el => {
el.addEventListener('click', function (e) {
e.preventDefault()
$('[data-ol-contact-form-modal="contact-us"]').modal()
})
})
document
.querySelectorAll('[data-ol-open-contact-form-modal="general"]')
.forEach(el => {
el.addEventListener('click', function (e) {
e.preventDefault()
$('[data-ol-contact-form-modal="general"]').modal()
})
})
document.querySelectorAll('[data-ol-contact-form]').forEach(el => {
el.addEventListener('submit', function (e) {
const emailValue = document.querySelector(
'[data-ol-contact-form-email-input]'
).value
const thankYouEmailEl = document.querySelector(
'[data-ol-contact-form-thank-you-email]'
)
thankYouEmailEl.textContent = emailValue
})
})

View File

@@ -0,0 +1,70 @@
import _ from 'lodash'
import { formatWikiHit, searchWiki } from '../algolia-search/search-wiki'
import { sendMB } from '../../infrastructure/event-tracking'
export function setupSearch(formEl) {
const inputEl = formEl.querySelector('[name="subject"]')
const resultsEl = formEl.querySelector('[data-ol-search-results]')
const wrapperEl = formEl.querySelector('[data-ol-search-results-wrapper]')
let lastValue = ''
function hideResults() {
wrapperEl.setAttribute('hidden', '')
}
function showResults() {
wrapperEl.removeAttribute('hidden')
}
async function handleChange() {
const value = inputEl.value
if (value === lastValue) return
lastValue = value
if (value.length < 3) {
hideResults()
return
}
try {
const { hits, nbHits } = await searchWiki(value, {
hitsPerPage: 3,
typoTolerance: 'strict',
})
resultsEl.innerText = ''
for (const hit of hits) {
const { url, pageName } = formatWikiHit(hit)
const liEl = document.createElement('li')
const linkEl = document.createElement('a')
linkEl.className = 'contact-suggestion-list-item'
linkEl.href = url
linkEl.target = '_blank'
liEl.append(linkEl)
const contentEl = document.createElement('span')
contentEl.innerHTML = pageName
linkEl.append(contentEl)
const iconEl = document.createElement('i')
iconEl.className = 'fa fa-angle-right'
iconEl.setAttribute('aria-hidden', 'true')
linkEl.append(iconEl)
resultsEl.append(liEl)
}
if (nbHits > 0) {
showResults()
sendMB('contact-form-suggestions-shown')
} else {
hideResults()
}
} catch (e) {
hideResults()
}
}
inputEl.addEventListener('input', _.debounce(handleChange, 350))
// display initial results
handleChange()
}

View File

@@ -0,0 +1,51 @@
import getMeta from '@/utils/meta'
function loadGA() {
if (window.olLoadGA) {
window.olLoadGA()
}
}
function setConsent(value) {
document.querySelector('.cookie-banner').classList.add('hidden')
const cookieDomain = getMeta('ol-ExposedSettings').cookieDomain
const oneYearInSeconds = 60 * 60 * 24 * 365
const cookieAttributes =
'; path=/' +
'; domain=' +
cookieDomain +
'; max-age=' +
oneYearInSeconds +
'; SameSite=Lax; Secure'
if (value === 'all') {
document.cookie = 'oa=1' + cookieAttributes
loadGA()
window.dispatchEvent(new CustomEvent('cookie-consent', { detail: true }))
} else {
document.cookie = 'oa=0' + cookieAttributes
window.dispatchEvent(new CustomEvent('cookie-consent', { detail: false }))
}
}
if (
getMeta('ol-ExposedSettings').gaToken ||
getMeta('ol-ExposedSettings').gaTokenV4
) {
document
.querySelectorAll('[data-ol-cookie-banner-set-consent]')
.forEach(el => {
el.addEventListener('click', function (e) {
e.preventDefault()
const consentType = el.getAttribute('data-ol-cookie-banner-set-consent')
setConsent(consentType)
})
})
const oaCookie = document.cookie.split('; ').find(c => c.startsWith('oa='))
if (!oaCookie) {
const cookieBannerEl = document.querySelector('.cookie-banner')
if (cookieBannerEl) {
cookieBannerEl.classList.remove('hidden')
}
}
}

View File

@@ -0,0 +1,102 @@
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useAsync from '../../../shared/hooks/use-async'
import { postJSON } from '../../../infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
import { learnedWords as initialLearnedWords } from '@/features/source-editor/extensions/spelling/learned-words'
type DictionaryModalContentProps = {
handleHide: () => void
}
const wordsSortFunction = (a: string, b: string) => a.localeCompare(b)
export default function DictionaryModalContent({
handleHide,
}: DictionaryModalContentProps) {
const { t } = useTranslation()
const [learnedWords, setLearnedWords] = useState<Set<string>>(
initialLearnedWords.global
)
const { isError, runAsync } = useAsync()
const handleRemove = useCallback(
word => {
runAsync(postJSON('/spelling/unlearn', { body: { word } }))
.then(() => {
setLearnedWords(prevLearnedWords => {
const learnedWords = new Set(prevLearnedWords)
learnedWords.delete(word)
return learnedWords
})
window.dispatchEvent(
new CustomEvent('editor:remove-learned-word', { detail: word })
)
})
.catch(debugConsole.error)
},
[runAsync]
)
return (
<>
<OLModalHeader closeButton>
<OLModalTitle>{t('edit_dictionary')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{isError ? (
<OLNotification
type="error"
content={t('generic_something_went_wrong')}
/>
) : null}
{learnedWords.size > 0 ? (
<ul className="list-unstyled dictionary-entries-list">
{[...learnedWords].sort(wordsSortFunction).map(learnedWord => (
<li key={learnedWord} className="dictionary-entry">
<span className="dictionary-entry-name">{learnedWord}</span>
<OLTooltip
id={`tooltip-remove-learned-word-${learnedWord}`}
description={t('edit_dictionary_remove')}
overlayProps={{ delay: 0 }}
>
<OLIconButton
variant="danger"
size="sm"
onClick={() => handleRemove(learnedWord)}
icon="delete"
accessibilityLabel={t('edit_dictionary_remove')}
/>
</OLTooltip>
</li>
))}
</ul>
) : (
<p className="dictionary-empty-body text-center">
<i>{t('edit_dictionary_empty')}</i>
</p>
)}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={handleHide}>
{t('close')}
</OLButton>
</OLModalFooter>
</>
)
}

View File

@@ -0,0 +1,26 @@
import React from 'react'
import DictionaryModalContent from './dictionary-modal-content'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import OLModal from '@/features/ui/components/ol/ol-modal'
type DictionaryModalProps = {
show?: boolean
handleHide: () => void
}
function DictionaryModal({ show, handleHide }: DictionaryModalProps) {
return (
<OLModal
animation
show={show}
onHide={handleHide}
id="dictionary-modal"
data-testid="dictionary-modal"
size="sm"
>
<DictionaryModalContent handleHide={handleHide} />
</OLModal>
)
}
export default withErrorBoundary(DictionaryModal)

View File

@@ -0,0 +1,22 @@
export const globalIgnoredWords = new Set([
'Overleaf',
'overleaf',
'ShareLaTeX',
'sharelatex',
'LaTeX',
'TeX',
'BibTeX',
'BibLaTeX',
'XeTeX',
'XeLaTeX',
'LuaTeX',
'LuaLaTeX',
'http',
'https',
'www',
'COVID',
'Lockdown',
'lockdown',
'Coronavirus',
'coronavirus',
])

View File

@@ -0,0 +1,41 @@
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import EditorCloneProjectModalWrapper from '../../clone-project-modal/components/editor-clone-project-modal-wrapper'
import LeftMenuButton from './left-menu-button'
import { useLocation } from '../../../shared/hooks/use-location'
import * as eventTracking from '../../../infrastructure/event-tracking'
type ProjectCopyResponse = {
project_id: string
}
export default function ActionsCopyProject() {
const [showModal, setShowModal] = useState(false)
const { t } = useTranslation()
const location = useLocation()
const openProject = useCallback(
({ project_id: projectId }: ProjectCopyResponse) => {
location.assign(`/project/${projectId}`)
},
[location]
)
const handleShowModal = useCallback(() => {
eventTracking.sendMB('left-menu-copy')
setShowModal(true)
}, [])
return (
<>
<LeftMenuButton onClick={handleShowModal} icon="file_copy">
{t('copy_project')}
</LeftMenuButton>
<EditorCloneProjectModalWrapper
show={showModal}
handleHide={() => setShowModal(false)}
openProject={openProject}
/>
</>
)
}

View File

@@ -0,0 +1,39 @@
import { ElementType } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '../../../utils/meta'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import ActionsCopyProject from './actions-copy-project'
import ActionsWordCount from './actions-word-count'
const components = importOverleafModules('editorLeftMenuManageTemplate') as {
import: { default: ElementType }
path: string
}[]
export default function ActionsMenu() {
const { t } = useTranslation()
const anonymous = getMeta('ol-anonymous')
if (anonymous) {
return null
}
return (
<>
<h4>{t('actions')}</h4>
<ul className="list-unstyled nav">
<li>
<ActionsCopyProject />
</li>
{components.map(({ import: { default: Component }, path }) => (
<li key={path}>
<Component />
</li>
))}
<li>
<ActionsWordCount />
</li>
</ul>
</>
)
}

View File

@@ -0,0 +1,50 @@
import { useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import WordCountModal from '../../word-count-modal/components/word-count-modal'
import LeftMenuButton from './left-menu-button'
import * as eventTracking from '../../../infrastructure/event-tracking'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
export default function ActionsWordCount() {
const [showModal, setShowModal] = useState(false)
const { pdfUrl } = useCompileContext()
const { t } = useTranslation()
const handleShowModal = useCallback(() => {
eventTracking.sendMB('left-menu-count')
setShowModal(true)
}, [])
return (
<>
{pdfUrl ? (
<LeftMenuButton onClick={handleShowModal} icon="match_case">
{t('word_count')}
</LeftMenuButton>
) : (
<OLTooltip
id="disabled-word-count"
description={t('please_compile_pdf_before_word_count')}
overlayProps={{
placement: 'top',
}}
>
{/* OverlayTrigger won't fire unless the child is a non-react html element (e.g div, span) */}
<div>
<LeftMenuButton
icon="match_case"
disabled
disabledAccesibilityText={t(
'please_compile_pdf_before_word_count'
)}
>
{t('word_count')}
</LeftMenuButton>
</div>
</OLTooltip>
)}
<WordCountModal show={showModal} handleHide={() => setShowModal(false)} />
</>
)
}

View File

@@ -0,0 +1,21 @@
import { useTranslation } from 'react-i18next'
import DownloadPDF from './download-pdf'
import DownloadSource from './download-source'
export default function DownloadMenu() {
const { t } = useTranslation()
return (
<>
<h4 className="mt-0">{t('download')}</h4>
<ul className="list-unstyled nav nav-downloads text-center">
<li>
<DownloadSource />
</li>
<li>
<DownloadPDF />
</li>
</ul>
</>
)
}

View File

@@ -0,0 +1,50 @@
import { useTranslation } from 'react-i18next'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import { useProjectContext } from '../../../shared/context/project-context'
import * as eventTracking from '../../../infrastructure/event-tracking'
import { isSmallDevice } from '../../../infrastructure/event-tracking'
import MaterialIcon from '@/shared/components/material-icon'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
export default function DownloadPDF() {
const { t } = useTranslation()
const { pdfDownloadUrl, pdfUrl } = useCompileContext()
const { _id: projectId } = useProjectContext()
function sendDownloadEvent() {
eventTracking.sendMB('download-pdf-button-click', {
projectId,
location: 'left-menu',
isSmallDevice,
})
}
if (pdfUrl) {
return (
<a
href={pdfDownloadUrl || pdfUrl}
target="_blank"
rel="noreferrer"
onClick={sendDownloadEvent}
>
<MaterialIcon type="picture_as_pdf" size="2x" />
<br />
PDF
</a>
)
} else {
return (
<OLTooltip
id="disabled-pdf-download"
description={t('please_compile_pdf_before_download')}
overlayProps={{ placement: 'bottom' }}
>
<div className="link-disabled">
<MaterialIcon type="picture_as_pdf" size="2x" />
<br />
PDF
</div>
</OLTooltip>
)
}
}

View File

@@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next'
import { useProjectContext } from '../../../shared/context/project-context'
import * as eventTracking from '../../../infrastructure/event-tracking'
import { isSmallDevice } from '../../../infrastructure/event-tracking'
import MaterialIcon from '@/shared/components/material-icon'
export default function DownloadSource() {
const { t } = useTranslation()
const { _id: projectId } = useProjectContext()
function sendDownloadEvent() {
eventTracking.sendMB('download-zip-button-click', {
projectId,
location: 'left-menu',
isSmallDevice,
})
}
return (
<a
href={`/project/${projectId}/download/zip`}
target="_blank"
rel="noreferrer"
onClick={sendDownloadEvent}
>
<MaterialIcon type="folder_zip" size="2x" />
<br />
{t('source')}
</a>
)
}

View File

@@ -0,0 +1,17 @@
import DownloadMenu from './download-menu'
import ActionsMenu from './actions-menu'
import HelpMenu from './help-menu'
import SyncMenu from './sync-menu'
import SettingsMenu from './settings-menu'
export default function EditorLeftMenuBody() {
return (
<>
<DownloadMenu />
<ActionsMenu />
<SyncMenu />
<SettingsMenu />
<HelpMenu />
</>
)
}

View File

@@ -0,0 +1,44 @@
import { createContext, FC, useCallback, useContext, useState } from 'react'
import useEventListener from '@/shared/hooks/use-event-listener'
type EditorLeftMenuState = {
settingToFocus?: string
}
export const EditorLeftMenuContext = createContext<
EditorLeftMenuState | undefined
>(undefined)
export const EditorLeftMenuProvider: FC = ({ children }) => {
const [value, setValue] = useState<EditorLeftMenuState>(() => ({
settingToFocus: undefined,
}))
useEventListener(
'ui.focus-setting',
useCallback(event => {
setValue(value => ({
...value,
settingToFocus: (event as CustomEvent<string>).detail,
}))
}, [])
)
return (
<EditorLeftMenuContext.Provider value={value}>
{children}
</EditorLeftMenuContext.Provider>
)
}
export const useEditorLeftMenuContext = () => {
const value = useContext(EditorLeftMenuContext)
if (!value) {
throw new Error(
`useEditorLeftMenuContext is only available inside EditorLeftMenuProvider`
)
}
return value
}

View File

@@ -0,0 +1,55 @@
import { useLayoutContext } from '../../../shared/context/layout-context'
import LeftMenuMask from './left-menu-mask'
import classNames from 'classnames'
import { lazy, memo, Suspense } from 'react'
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
import { Offcanvas } from 'react-bootstrap-5'
import { EditorLeftMenuProvider } from './editor-left-menu-context'
import withErrorBoundary from '@/infrastructure/error-boundary'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import { useTranslation } from 'react-i18next'
const EditorLeftMenuBody = lazy(() => import('./editor-left-menu-body'))
const LazyEditorLeftMenuWithErrorBoundary = withErrorBoundary(
() => (
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<EditorLeftMenuBody />
</Suspense>
),
() => {
const { t } = useTranslation()
return <OLNotification type="error" content={t('something_went_wrong')} />
}
)
function EditorLeftMenu() {
const { leftMenuShown, setLeftMenuShown } = useLayoutContext()
const closeLeftMenu = () => {
setLeftMenuShown(false)
}
return (
<EditorLeftMenuProvider>
<Offcanvas
show={leftMenuShown}
onHide={closeLeftMenu}
backdropClassName="left-menu-modal-backdrop"
id="left-menu-offcanvas"
>
<Offcanvas.Body
className={classNames('full-size', 'left-menu', {
shown: leftMenuShown,
})}
id="left-menu"
>
<LazyEditorLeftMenuWithErrorBoundary />
</Offcanvas.Body>
</Offcanvas>
{leftMenuShown && <LeftMenuMask />}
</EditorLeftMenuProvider>
)
}
export default memo(EditorLeftMenu)

View File

@@ -0,0 +1,24 @@
import { useTranslation } from 'react-i18next'
import { useCallback } from 'react'
import * as eventTracking from '../../../infrastructure/event-tracking'
import { useContactUsModal } from '../../../shared/hooks/use-contact-us-modal'
import LeftMenuButton from './left-menu-button'
export default function HelpContactUs() {
const { modal, showModal } = useContactUsModal()
const { t } = useTranslation()
const showModalWithAnalytics = useCallback(() => {
eventTracking.sendMB('left-menu-contact')
showModal()
}, [showModal])
return (
<>
<LeftMenuButton onClick={showModalWithAnalytics} icon="contact_support">
{t('contact_us')}
</LeftMenuButton>
{modal}
</>
)
}

View File

@@ -0,0 +1,14 @@
import { useTranslation } from 'react-i18next'
import LeftMenuButton from './left-menu-button'
export default function HelpDocumentation() {
const { t } = useTranslation()
return (
<>
<LeftMenuButton type="link" href="/learn" icon="book_4">
{t('documentation')}
</LeftMenuButton>
</>
)
}

View File

@@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next'
import getMeta from '../../../utils/meta'
import HelpContactUs from './help-contact-us'
import HelpDocumentation from './help-documentation'
import HelpShowHotkeys from './help-show-hotkeys'
export default function HelpMenu() {
const { t } = useTranslation()
const showSupport = getMeta('ol-showSupport')
return (
<>
<h4>{t('help')}</h4>
<ul className="list-unstyled nav">
<li>
<HelpShowHotkeys />
</li>
{showSupport ? (
<>
<li>
<HelpDocumentation />
</li>
<li>
<HelpContactUs />
</li>
</>
) : null}
</ul>
</>
)
}

View File

@@ -0,0 +1,32 @@
import { useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import * as eventTracking from '../../../infrastructure/event-tracking'
import { useProjectContext } from '../../../shared/context/project-context'
import HotkeysModal from '../../hotkeys-modal/components/hotkeys-modal'
import LeftMenuButton from './left-menu-button'
import { isMac } from '@/shared/utils/os'
export default function HelpShowHotkeys() {
const [showModal, setShowModal] = useState(false)
const { t } = useTranslation()
const { features } = useProjectContext()
const showModalWithAnalytics = useCallback(() => {
eventTracking.sendMB('left-menu-hotkeys')
setShowModal(true)
}, [])
return (
<>
<LeftMenuButton onClick={showModalWithAnalytics} icon="keyboard">
{t('show_hotkeys')}
</LeftMenuButton>
<HotkeysModal
show={showModal}
handleHide={() => setShowModal(false)}
isMac={isMac}
trackChangesVisible={features?.trackChangesVisible}
/>
</>
)
}

View File

@@ -0,0 +1,70 @@
import { PropsWithChildren } from 'react'
import MaterialIcon from '@/shared/components/material-icon'
type Props = {
onClick?: () => void
icon?: string
svgIcon?: React.ReactElement | null
disabled?: boolean
disabledAccesibilityText?: string
type?: 'button' | 'link'
href?: string
}
function LeftMenuButtonIcon({
svgIcon,
icon,
}: {
svgIcon?: React.ReactElement | null
icon?: string
}) {
if (svgIcon) {
return <div className="material-symbols">{svgIcon}</div>
} else if (icon) {
return <MaterialIcon type={icon} />
} else return null
}
export default function LeftMenuButton({
children,
svgIcon,
onClick,
icon,
disabled = false,
disabledAccesibilityText,
type = 'button',
href,
}: PropsWithChildren<Props>) {
if (disabled) {
return (
<div className="left-menu-button link-disabled">
<LeftMenuButtonIcon svgIcon={svgIcon} icon={icon} />
<span>{children}</span>
{disabledAccesibilityText ? (
<span className="sr-only">{disabledAccesibilityText}</span>
) : null}
</div>
)
}
if (type === 'button') {
return (
<button onClick={onClick} className="left-menu-button">
<LeftMenuButtonIcon svgIcon={svgIcon} icon={icon} />
<span>{children}</span>
</button>
)
} else {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="left-menu-button"
>
<LeftMenuButtonIcon svgIcon={svgIcon} icon={icon} />
<span>{children}</span>
</a>
)
}
}

View File

@@ -0,0 +1,31 @@
import { memo, useEffect, useRef, useState } from 'react'
import { useLayoutContext } from '../../../shared/context/layout-context'
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
export default memo(function LeftMenuMask() {
const { setLeftMenuShown } = useLayoutContext()
const { userSettings } = useUserSettingsContext()
const { editorTheme, overallTheme } = userSettings
const [original] = useState({ editorTheme, overallTheme })
const maskRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (maskRef.current) {
if (
editorTheme !== original.editorTheme ||
overallTheme !== original.overallTheme
) {
maskRef.current.style.opacity = '0'
}
}
}, [editorTheme, overallTheme, original])
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
id="left-menu-mask"
ref={maskRef}
onClick={() => setLeftMenuShown(false)}
/>
)
})

View File

@@ -0,0 +1,62 @@
import { useTranslation } from 'react-i18next'
import getMeta from '../../../utils/meta'
import SettingsAutoCloseBrackets from './settings/settings-auto-close-brackets'
import SettingsAutoComplete from './settings/settings-auto-complete'
import SettingsCompiler from './settings/settings-compiler'
import SettingsDictionary from './settings/settings-dictionary'
import SettingsDocument from './settings/settings-document'
import SettingsEditorTheme from './settings/settings-editor-theme'
import SettingsFontFamily from './settings/settings-font-family'
import SettingsFontSize from './settings/settings-font-size'
import SettingsImageName from './settings/settings-image-name'
import SettingsKeybindings from './settings/settings-keybindings'
import SettingsLineHeight from './settings/settings-line-height'
import SettingsOverallTheme from './settings/settings-overall-theme'
import SettingsPdfViewer from './settings/settings-pdf-viewer'
import SettingsSpellCheckLanguage from './settings/settings-spell-check-language'
import SettingsSyntaxValidation from './settings/settings-syntax-validation'
import SettingsMathPreview from './settings/settings-math-preview'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { ElementType } from 'react'
import OLForm from '@/features/ui/components/ol/ol-form'
const moduleSettings: Array<{
import: { default: ElementType }
path: string
}> = importOverleafModules('settingsEntries')
export default function SettingsMenu() {
const { t } = useTranslation()
const anonymous = getMeta('ol-anonymous')
if (anonymous) {
return null
}
return (
<>
<h4>{t('settings')}</h4>
<OLForm id="left-menu-setting" className="settings">
<SettingsCompiler />
<SettingsImageName />
<SettingsDocument />
<SettingsSpellCheckLanguage />
<SettingsDictionary />
{moduleSettings.map(({ import: { default: Component }, path }) => (
<Component key={path} />
))}
<SettingsAutoComplete />
<SettingsAutoCloseBrackets />
<SettingsSyntaxValidation />
<SettingsMathPreview />
<SettingsEditorTheme />
<SettingsOverallTheme />
<SettingsKeybindings />
<SettingsFontSize />
<SettingsFontFamily />
<SettingsLineHeight />
<SettingsPdfViewer />
</OLForm>
</>
)
}

View File

@@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import SettingsMenuSelect from './settings-menu-select'
export default function SettingsAutoCloseBrackets() {
const { t } = useTranslation()
const { autoPairDelimiters, setAutoPairDelimiters } =
useProjectSettingsContext()
return (
<SettingsMenuSelect
onChange={setAutoPairDelimiters}
value={autoPairDelimiters}
options={[
{
value: true,
label: t('on'),
},
{
value: false,
label: t('off'),
},
]}
label={t('auto_close_brackets')}
name="autoPairDelimiters"
/>
)
}

View File

@@ -0,0 +1,27 @@
import { useTranslation } from 'react-i18next'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import SettingsMenuSelect from './settings-menu-select'
export default function SettingsAutoComplete() {
const { t } = useTranslation()
const { autoComplete, setAutoComplete } = useProjectSettingsContext()
return (
<SettingsMenuSelect
onChange={setAutoComplete}
value={autoComplete}
options={[
{
value: true,
label: t('on'),
},
{
value: false,
label: t('off'),
},
]}
label={t('auto_complete')}
name="autoComplete"
/>
)
}

View File

@@ -0,0 +1,39 @@
import { useTranslation } from 'react-i18next'
import type { ProjectCompiler } from '../../../../../../types/project-settings'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import SettingsMenuSelect from './settings-menu-select'
export default function SettingsCompiler() {
const { t } = useTranslation()
const { write } = usePermissionsContext()
const { compiler, setCompiler } = useProjectSettingsContext()
return (
<SettingsMenuSelect<ProjectCompiler>
onChange={setCompiler}
value={compiler}
disabled={!write}
options={[
{
value: 'pdflatex',
label: 'pdfLaTeX',
},
{
value: 'latex',
label: 'LaTeX',
},
{
value: 'xelatex',
label: 'XeLaTeX',
},
{
value: 'lualatex',
label: 'LuaLaTeX',
},
]}
label={t('compiler')}
name="compiler"
/>
)
}

View File

@@ -0,0 +1,30 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import DictionaryModal from '../../../dictionary/components/dictionary-modal'
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'
export default function SettingsDictionary() {
const { t } = useTranslation()
const [showModal, setShowModal] = useState(false)
return (
<OLFormGroup className="left-menu-setting">
<OLFormLabel htmlFor="dictionary-settings">{t('dictionary')}</OLFormLabel>
<OLButton
id="dictionary-settings"
variant="secondary"
size="sm"
onClick={() => setShowModal(true)}
>
{t('edit')}
</OLButton>
<DictionaryModal
show={showModal}
handleHide={() => setShowModal(false)}
/>
</OLFormGroup>
)
}

View File

@@ -0,0 +1,48 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { isValidTeXFile } from '../../../../main/is-valid-tex-file'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import SettingsMenuSelect from './settings-menu-select'
import type { Option } from './settings-menu-select'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
export default function SettingsDocument() {
const { t } = useTranslation()
const { write } = usePermissionsContext()
const { docs } = useFileTreeData()
const { rootDocId, setRootDocId } = useProjectSettingsContext()
const validDocsOptions = useMemo(() => {
const filteredDocs =
docs?.filter(
doc => isValidTeXFile(doc.doc.name) || rootDocId === doc.doc.id
) ?? []
const mappedDocs: Array<Option> = filteredDocs.map(doc => ({
value: doc.doc.id,
label: doc.path,
}))
if (!rootDocId) {
mappedDocs.unshift({
value: '',
label: 'None',
disabled: true,
})
}
return mappedDocs
}, [docs, rootDocId])
return (
<SettingsMenuSelect
onChange={setRootDocId}
value={rootDocId ?? ''}
disabled={!write}
options={validDocsOptions}
label={t('main_document')}
name="rootDocId"
/>
)
}

View File

@@ -0,0 +1,45 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '../../../../utils/meta'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import SettingsMenuSelect from './settings-menu-select'
import type { Option } from './settings-menu-select'
export default function SettingsEditorTheme() {
const { t } = useTranslation()
const editorThemes = getMeta('ol-editorThemes')
const legacyEditorThemes = getMeta('ol-legacyEditorThemes')
const { editorTheme, setEditorTheme } = useProjectSettingsContext()
const options = useMemo(() => {
const editorThemeOptions: Array<Option> =
editorThemes?.map(theme => ({
value: theme,
label: theme.replace(/_/g, ' '),
})) ?? []
const dividerOption: Option = {
value: '-',
label: '—————————————————',
disabled: true,
}
const legacyEditorThemeOptions: Array<Option> =
legacyEditorThemes?.map(theme => ({
value: theme,
label: theme.replace(/_/g, ' ') + ' (Legacy)',
})) ?? []
return [...editorThemeOptions, dividerOption, ...legacyEditorThemeOptions]
}, [editorThemes, legacyEditorThemes])
return (
<SettingsMenuSelect
onChange={setEditorTheme}
value={editorTheme}
options={options}
label={t('editor_theme')}
name="editorTheme"
/>
)
}

View File

@@ -0,0 +1,47 @@
import { useTranslation } from 'react-i18next'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import SettingsMenuSelect from './settings-menu-select'
import BetaBadge from '@/shared/components/beta-badge'
import { FontFamily } from '@/shared/utils/styles'
export default function SettingsFontFamily() {
const { t } = useTranslation()
const { fontFamily, setFontFamily } = useProjectSettingsContext()
return (
<div className="left-menu-setting-position">
<SettingsMenuSelect<FontFamily>
onChange={setFontFamily}
value={fontFamily}
options={[
{
value: 'monaco',
label: 'Monaco / Menlo / Consolas',
},
{
value: 'lucida',
label: 'Lucida / Source Code Pro',
},
{
value: 'opendyslexicmono',
label: 'OpenDyslexic Mono',
},
]}
label={t('font_family')}
name="fontFamily"
/>
<BetaBadge
phase="release"
link={{
href: 'https://docs.google.com/forms/d/e/1FAIpQLScOt_IHTrcaM_uitP9dgCo_r4dl4cy9Ry6LhYYcwTN4qDTDUg/viewform',
className: 'left-menu-setting-icon',
}}
tooltip={{
id: 'font-family-tooltip',
text: `${t('new_font_open_dyslexic')} ${t('click_to_give_feedback')}`,
placement: 'right',
}}
/>
</div>
)
}

View File

@@ -0,0 +1,25 @@
import { useTranslation } from 'react-i18next'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import SettingsMenuSelect from './settings-menu-select'
import type { Option } from './settings-menu-select'
const sizes = [10, 11, 12, 13, 14, 16, 18, 20, 22, 24]
const options: Option<number>[] = sizes.map(size => ({
value: size,
label: `${size}px`,
}))
export default function SettingsFontSize() {
const { t } = useTranslation()
const { fontSize, setFontSize } = useProjectSettingsContext()
return (
<SettingsMenuSelect
onChange={setFontSize}
value={fontSize}
options={options}
label={t('font_size')}
name="fontSize"
/>
)
}

View File

@@ -0,0 +1,42 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '../../../../utils/meta'
import SettingsMenuSelect from './settings-menu-select'
import type { Option } from './settings-menu-select'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
export default function SettingsImageName() {
const { t } = useTranslation()
const { imageName, setImageName } = useProjectSettingsContext()
const { write } = usePermissionsContext()
const allowedImageNames = useMemo(
() => getMeta('ol-allowedImageNames') || [],
[]
)
const options: Array<Option> = useMemo(
() =>
allowedImageNames.map(({ imageName, imageDesc }) => ({
value: imageName,
label: imageDesc,
})),
[allowedImageNames]
)
if (allowedImageNames.length === 0) {
return null
}
return (
<SettingsMenuSelect
onChange={setImageName}
value={imageName}
disabled={!write}
options={options}
label={t('tex_live_version')}
name="imageName"
/>
)
}

View File

@@ -0,0 +1,32 @@
import { useTranslation } from 'react-i18next'
import type { Keybindings } from '../../../../../../types/user-settings'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import SettingsMenuSelect from './settings-menu-select'
export default function SettingsKeybindings() {
const { t } = useTranslation()
const { mode, setMode } = useProjectSettingsContext()
return (
<SettingsMenuSelect<Keybindings>
onChange={setMode}
value={mode}
options={[
{
value: 'default',
label: 'None',
},
{
value: 'vim',
label: 'Vim',
},
{
value: 'emacs',
label: 'Emacs',
},
]}
label={t('keybindings')}
name="mode"
/>
)
}

View File

@@ -0,0 +1,32 @@
import { useTranslation } from 'react-i18next'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import SettingsMenuSelect from './settings-menu-select'
import { LineHeight } from '@/shared/utils/styles'
export default function SettingsLineHeight() {
const { t } = useTranslation()
const { lineHeight, setLineHeight } = useProjectSettingsContext()
return (
<SettingsMenuSelect<LineHeight>
onChange={setLineHeight}
value={lineHeight}
options={[
{
value: 'compact',
label: t('compact'),
},
{
value: 'normal',
label: t('normal'),
},
{
value: 'wide',
label: t('wide'),
},
]}
label={t('line_height')}
name="lineHeight"
/>
)
}

View File

@@ -0,0 +1,27 @@
import { useTranslation } from 'react-i18next'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import SettingsMenuSelect from './settings-menu-select'
export default function SettingsMathPreview() {
const { t } = useTranslation()
const { mathPreview, setMathPreview } = useProjectSettingsContext()
return (
<SettingsMenuSelect
onChange={setMathPreview}
value={mathPreview}
options={[
{
value: true,
label: t('on'),
},
{
value: false,
label: t('off'),
},
]}
label={t('equation_preview')}
name="mathPreview"
/>
)
}

View File

@@ -0,0 +1,125 @@
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLFormSelect from '@/features/ui/components/ol/ol-form-select'
import { ChangeEventHandler, useCallback, useEffect, useRef } from 'react'
import { Spinner } from 'react-bootstrap-5'
import { useEditorLeftMenuContext } from '@/features/editor-left-menu/components/editor-left-menu-context'
type PossibleValue = string | number | boolean
export type Option<T extends PossibleValue = string> = {
value: T
label: string
ariaHidden?: 'true' | 'false'
disabled?: boolean
}
export type Optgroup<T extends PossibleValue = string> = {
label: string
options: Array<Option<T>>
}
type SettingsMenuSelectProps<T extends PossibleValue = string> = {
label: string
name: string
options: Array<Option<T>>
optgroup?: Optgroup<T>
loading?: boolean
onChange: (val: T) => void
value?: T
disabled?: boolean
}
export default function SettingsMenuSelect<T extends PossibleValue = string>({
label,
name,
options,
optgroup,
loading,
onChange,
value,
disabled = false,
}: SettingsMenuSelectProps<T>) {
const handleChange: ChangeEventHandler<HTMLSelectElement> = useCallback(
event => {
const selectedValue = event.target.value
let onChangeValue: PossibleValue = selectedValue
if (typeof value === 'boolean') {
onChangeValue = selectedValue === 'true'
} else if (typeof value === 'number') {
onChangeValue = parseInt(selectedValue, 10)
}
onChange(onChangeValue as T)
},
[onChange, value]
)
const { settingToFocus } = useEditorLeftMenuContext()
const selectRef = useRef<HTMLSelectElement | null>(null)
useEffect(() => {
if (settingToFocus === name && selectRef.current) {
selectRef.current.scrollIntoView({
block: 'center',
behavior: 'smooth',
})
selectRef.current.focus()
}
// clear the focus setting
window.dispatchEvent(
new CustomEvent('ui.focus-setting', { detail: undefined })
)
}, [name, settingToFocus])
return (
<OLFormGroup
controlId={`settings-menu-${name}`}
className="left-menu-setting"
>
<OLFormLabel>{label}</OLFormLabel>
{loading ? (
<p className="mb-0">
<Spinner
animation="border"
aria-hidden="true"
size="sm"
role="status"
/>
</p>
) : (
<OLFormSelect
size="sm"
onChange={handleChange}
value={value?.toString()}
disabled={disabled}
ref={selectRef}
>
{options.map(option => (
<option
key={`${name}-${option.value}`}
value={option.value.toString()}
aria-hidden={option.ariaHidden}
disabled={option.disabled}
>
{option.label}
</option>
))}
{optgroup ? (
<optgroup label={optgroup.label}>
{optgroup.options.map(option => (
<option
value={option.value.toString()}
key={option.value.toString()}
>
{option.label}
</option>
))}
</optgroup>
) : null}
</OLFormSelect>
)}
</OLFormGroup>
)
}

View File

@@ -0,0 +1,42 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useLayoutContext } from '../../../../shared/context/layout-context'
import getMeta from '../../../../utils/meta'
import SettingsMenuSelect, { Option } from './settings-menu-select'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import type { OverallThemeMeta } from '../../../../../../types/project-settings'
import { isIEEEBranded } from '@/utils/is-ieee-branded'
import { OverallTheme } from '@/shared/utils/styles'
export default function SettingsOverallTheme() {
const { t } = useTranslation()
const overallThemes = getMeta('ol-overallThemes') as
| OverallThemeMeta[]
| undefined
const { loadingStyleSheet } = useLayoutContext()
const { overallTheme, setOverallTheme } = useProjectSettingsContext()
const options: Array<Option<OverallTheme>> = useMemo(
() =>
overallThemes?.map(({ name, val }) => ({
value: val,
label: name,
})) ?? [],
[overallThemes]
)
if (!overallThemes || isIEEEBranded()) {
return null
}
return (
<SettingsMenuSelect<OverallTheme>
onChange={setOverallTheme}
value={overallTheme}
options={options}
loading={loadingStyleSheet}
label={t('overall_theme')}
name="overallTheme"
/>
)
}

View File

@@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next'
import type { PdfViewer } from '../../../../../../types/user-settings'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import SettingsMenuSelect from './settings-menu-select'
export default function SettingsPdfViewer() {
const { t } = useTranslation()
const { pdfViewer, setPdfViewer } = useProjectSettingsContext()
return (
<SettingsMenuSelect<PdfViewer>
onChange={setPdfViewer}
value={pdfViewer}
options={[
{
value: 'pdfjs',
label: t('overleaf'),
},
{
value: 'native',
label: t('browser'),
},
]}
label={t('pdf_viewer')}
name="pdfViewer"
/>
)
}

View File

@@ -0,0 +1,42 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '../../../../utils/meta'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import SettingsMenuSelect from './settings-menu-select'
import type { Optgroup } from './settings-menu-select'
import { useEditorContext } from '@/shared/context/editor-context'
import { supportsWebAssembly } from '@/utils/wasm'
export default function SettingsSpellCheckLanguage() {
const { t } = useTranslation()
const { spellCheckLanguage, setSpellCheckLanguage } =
useProjectSettingsContext()
const { permissionsLevel } = useEditorContext()
const optgroup: Optgroup = useMemo(() => {
const options = (getMeta('ol-languages') ?? [])
// only include spell-check languages that are available in the client
.filter(language => language.dic !== undefined)
return {
label: 'Language',
options: options.map(language => ({
value: language.code,
label: language.name,
})),
}
}, [])
return (
<SettingsMenuSelect
onChange={setSpellCheckLanguage}
value={supportsWebAssembly() ? spellCheckLanguage : ''}
options={[{ value: '', label: t('off') }]}
optgroup={optgroup}
label={t('spell_check')}
name="spellCheckLanguage"
disabled={permissionsLevel === 'readOnly' || !supportsWebAssembly()}
/>
)
}

View File

@@ -0,0 +1,27 @@
import { useTranslation } from 'react-i18next'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import SettingsMenuSelect from './settings-menu-select'
export default function SettingsSyntaxValidation() {
const { t } = useTranslation()
const { syntaxValidation, setSyntaxValidation } = useProjectSettingsContext()
return (
<SettingsMenuSelect<boolean>
onChange={setSyntaxValidation}
value={syntaxValidation}
options={[
{
value: true,
label: t('on'),
},
{
value: false,
label: t('off'),
},
]}
label={t('syntax_validation')}
name="syntaxValidation"
/>
)
}

View File

@@ -0,0 +1,42 @@
import { ElementType } from 'react'
import { useTranslation } from 'react-i18next'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import getMeta from '../../../utils/meta'
const components = importOverleafModules('editorLeftMenuSync') as {
import: { default: ElementType }
path: string
}[]
export default function SyncMenu() {
const { t } = useTranslation()
const anonymous = getMeta('ol-anonymous')
const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
if (anonymous) {
return null
}
if (components.length === 0) {
return null
}
// This flag can only be false in CE and Server Pro. In this case we skip rendering the
// entire sync section, since Dropbox and GitHub are never available in SP
if (!gitBridgeEnabled) {
return null
}
return (
<>
<h4>{t('sync')}</h4>
<ul className="list-unstyled nav">
{components.map(({ import: { default: Component }, path }) => (
<li key={path}>
<Component />
</li>
))}
</ul>
</>
)
}

View File

@@ -0,0 +1,163 @@
import { createContext, FC, useContext, useMemo } from 'react'
import useProjectWideSettings from '../hooks/use-project-wide-settings'
import useUserWideSettings from '../hooks/use-user-wide-settings'
import useProjectWideSettingsSocketListener from '../hooks/use-project-wide-settings-socket-listener'
import type { ProjectSettings } from '../utils/api'
import { UserSettings } from '../../../../../types/user-settings'
type ProjectSettingsSetterContextValue = {
setCompiler: (compiler: ProjectSettings['compiler']) => void
setImageName: (imageName: ProjectSettings['imageName']) => void
setRootDocId: (rootDocId: ProjectSettings['rootDocId']) => void
setSpellCheckLanguage: (
spellCheckLanguage: ProjectSettings['spellCheckLanguage']
) => void
setAutoComplete: (autoComplete: UserSettings['autoComplete']) => void
setAutoPairDelimiters: (
autoPairDelimiters: UserSettings['autoPairDelimiters']
) => void
setSyntaxValidation: (
syntaxValidation: UserSettings['syntaxValidation']
) => void
setMode: (mode: UserSettings['mode']) => void
setEditorTheme: (editorTheme: UserSettings['editorTheme']) => void
setOverallTheme: (overallTheme: UserSettings['overallTheme']) => void
setFontSize: (fontSize: UserSettings['fontSize']) => void
setFontFamily: (fontFamily: UserSettings['fontFamily']) => void
setLineHeight: (lineHeight: UserSettings['lineHeight']) => void
setPdfViewer: (pdfViewer: UserSettings['pdfViewer']) => void
setMathPreview: (mathPreview: UserSettings['mathPreview']) => void
}
type ProjectSettingsContextValue = Partial<ProjectSettings> &
Partial<UserSettings> &
ProjectSettingsSetterContextValue
export const ProjectSettingsContext = createContext<
ProjectSettingsContextValue | undefined
>(undefined)
export const ProjectSettingsProvider: FC = ({ children }) => {
const {
compiler,
setCompiler,
imageName,
setImageName,
rootDocId,
setRootDocId,
spellCheckLanguage,
setSpellCheckLanguage,
} = useProjectWideSettings()
const {
autoComplete,
setAutoComplete,
autoPairDelimiters,
setAutoPairDelimiters,
syntaxValidation,
setSyntaxValidation,
editorTheme,
setEditorTheme,
overallTheme,
setOverallTheme,
mode,
setMode,
fontSize,
setFontSize,
fontFamily,
setFontFamily,
lineHeight,
setLineHeight,
pdfViewer,
setPdfViewer,
mathPreview,
setMathPreview,
} = useUserWideSettings()
useProjectWideSettingsSocketListener()
const value: ProjectSettingsContextValue = useMemo(
() => ({
compiler,
setCompiler,
imageName,
setImageName,
rootDocId,
setRootDocId,
spellCheckLanguage,
setSpellCheckLanguage,
autoComplete,
setAutoComplete,
autoPairDelimiters,
setAutoPairDelimiters,
syntaxValidation,
setSyntaxValidation,
editorTheme,
setEditorTheme,
overallTheme,
setOverallTheme,
mode,
setMode,
fontSize,
setFontSize,
fontFamily,
setFontFamily,
lineHeight,
setLineHeight,
pdfViewer,
setPdfViewer,
mathPreview,
setMathPreview,
}),
[
compiler,
setCompiler,
imageName,
setImageName,
rootDocId,
setRootDocId,
spellCheckLanguage,
setSpellCheckLanguage,
autoComplete,
setAutoComplete,
autoPairDelimiters,
setAutoPairDelimiters,
syntaxValidation,
setSyntaxValidation,
editorTheme,
setEditorTheme,
overallTheme,
setOverallTheme,
mode,
setMode,
fontSize,
setFontSize,
fontFamily,
setFontFamily,
lineHeight,
setLineHeight,
pdfViewer,
setPdfViewer,
mathPreview,
setMathPreview,
]
)
return (
<ProjectSettingsContext.Provider value={value}>
{children}
</ProjectSettingsContext.Provider>
)
}
export function useProjectSettingsContext() {
const context = useContext(ProjectSettingsContext)
if (!context) {
throw new Error(
'useProjectSettingsContext is only available inside ProjectSettingsProvider'
)
}
return context
}

View File

@@ -0,0 +1,58 @@
import { useCallback, useEffect } from 'react'
import { useIdeContext } from '../../../shared/context/ide-context'
import useScopeValue from '../../../shared/hooks/use-scope-value'
import type { ProjectSettings } from '../utils/api'
export default function useProjectWideSettingsSocketListener() {
const { socket } = useIdeContext()
const [project, setProject] = useScopeValue<ProjectSettings | undefined>(
'project'
)
const setCompiler = useCallback(
(compiler: ProjectSettings['compiler']) => {
if (project) {
setProject({ ...project, compiler })
}
},
[project, setProject]
)
const setImageName = useCallback(
(imageName: ProjectSettings['imageName']) => {
if (project) {
setProject({ ...project, imageName })
}
},
[project, setProject]
)
const setSpellCheckLanguage = useCallback(
(spellCheckLanguage: ProjectSettings['spellCheckLanguage']) => {
if (project) {
setProject({ ...project, spellCheckLanguage })
}
},
[project, setProject]
)
useEffect(() => {
// data is not available on initial mounting
const dataAvailable = !!project
if (dataAvailable && socket) {
socket.on('compilerUpdated', setCompiler)
socket.on('imageNameUpdated', setImageName)
socket.on('spellCheckLanguageUpdated', setSpellCheckLanguage)
return () => {
socket.removeListener('compilerUpdated', setCompiler)
socket.removeListener('imageNameUpdated', setImageName)
socket.removeListener(
'spellCheckLanguageUpdated',
setSpellCheckLanguage
)
}
}
}, [socket, project, setCompiler, setImageName, setSpellCheckLanguage])
}

View File

@@ -0,0 +1,41 @@
import { useCallback } from 'react'
import useScopeValue from '../../../shared/hooks/use-scope-value'
import type { ProjectSettings } from '../utils/api'
import useRootDocId from './use-root-doc-id'
import useSaveProjectSettings from './use-save-project-settings'
import useSetSpellCheckLanguage from './use-set-spell-check-language'
import { debugConsole } from '@/utils/debugging'
export default function useProjectWideSettings() {
// The value will be undefined on mount
const [project] = useScopeValue<ProjectSettings | undefined>('project')
const saveProjectSettings = useSaveProjectSettings()
const setCompiler = useCallback(
(newCompiler: ProjectSettings['compiler']) => {
saveProjectSettings('compiler', newCompiler).catch(debugConsole.error)
},
[saveProjectSettings]
)
const setImageName = useCallback(
(newImageName: ProjectSettings['imageName']) => {
saveProjectSettings('imageName', newImageName).catch(debugConsole.error)
},
[saveProjectSettings]
)
const { setRootDocId, rootDocId } = useRootDocId()
const setSpellCheckLanguage = useSetSpellCheckLanguage()
return {
compiler: project?.compiler,
setCompiler,
imageName: project?.imageName,
setImageName,
rootDocId,
setRootDocId,
spellCheckLanguage: project?.spellCheckLanguage,
setSpellCheckLanguage,
}
}

View File

@@ -0,0 +1,34 @@
import { useCallback } from 'react'
import { useEditorContext } from '../../../shared/context/editor-context'
import useScopeValue from '../../../shared/hooks/use-scope-value'
import type { ProjectSettings } from '../utils/api'
import useSaveProjectSettings from './use-save-project-settings'
export default function useRootDocId() {
const [rootDocId] =
useScopeValue<ProjectSettings['rootDocId']>('project.rootDoc_id')
const { permissionsLevel } = useEditorContext()
const saveProjectSettings = useSaveProjectSettings()
const setRootDocIdFunc = useCallback(
async (newRootDocId: ProjectSettings['rootDocId']) => {
// rootDocId will be undefined on angular scope on initialisation
const allowUpdate =
typeof rootDocId !== 'undefined' && permissionsLevel !== 'readOnly'
if (allowUpdate) {
try {
await saveProjectSettings('rootDocId', newRootDocId)
} catch (err) {
// TODO: retry mechanism (max 10x before failed completely and rollback the old value)
}
}
},
[permissionsLevel, rootDocId, saveProjectSettings]
)
return {
rootDocId,
setRootDocId: setRootDocIdFunc,
}
}

View File

@@ -0,0 +1,32 @@
import { type ProjectSettings, saveProjectSettings } from '../utils/api'
import { useProjectContext } from '../../../shared/context/project-context'
import useScopeValue from '../../../shared/hooks/use-scope-value'
export default function useSaveProjectSettings() {
// projectSettings value will be undefined on mount
const [projectSettings, setProjectSettings] = useScopeValue<
ProjectSettings | undefined
>('project')
const { _id: projectId } = useProjectContext()
return async (
key: keyof ProjectSettings,
newSetting: ProjectSettings[keyof ProjectSettings]
) => {
if (projectSettings) {
const currentSetting = projectSettings[key]
if (currentSetting !== newSetting) {
await saveProjectSettings(projectId, {
[key]: newSetting,
})
// rootDocId is used in our tsx and our endpoint, but rootDoc_id is used in our project $scope, etc
// as we use both namings in many files, and convert back and forth,
// its complicated to seperate and choose one name for all usages
// todo: make rootDocId or rootDoc_id consistent, and remove need for this/ other conversions
const settingsKey = key === 'rootDocId' ? 'rootDoc_id' : key
setProjectSettings({ ...projectSettings, [settingsKey]: newSetting })
}
}
}
}

View File

@@ -0,0 +1,19 @@
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
import { saveUserSettings } from '../utils/api'
import { UserSettings } from '../../../../../types/user-settings'
export default function useSaveUserSettings() {
const { userSettings, setUserSettings } = useUserSettingsContext()
return (
key: keyof UserSettings,
newSetting: UserSettings[keyof UserSettings]
) => {
const currentSetting = userSettings[key]
if (currentSetting !== newSetting) {
setUserSettings({ ...userSettings, [key]: newSetting })
saveUserSettings(key, newSetting)
}
}
}

View File

@@ -0,0 +1,43 @@
import { useCallback, useEffect } from 'react'
import _ from 'lodash'
import { saveUserSettings } from '../utils/api'
import { UserSettings } from '../../../../../types/user-settings'
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
import getMeta from '@/utils/meta'
import { isIEEEBranded } from '@/utils/is-ieee-branded'
export default function useSetOverallTheme() {
const { userSettings, setUserSettings } = useUserSettingsContext()
const { overallTheme } = userSettings
const setOverallTheme = useCallback(
(overallTheme: UserSettings['overallTheme']) => {
setUserSettings(settings => ({ ...settings, overallTheme }))
},
[setUserSettings]
)
useEffect(() => {
// Sets the body's data-theme attribute for theming
const theme =
overallTheme === 'light-' && !isIEEEBranded() ? 'light' : 'default'
document.body.dataset.theme = theme
}, [overallTheme])
return useCallback(
(newOverallTheme: UserSettings['overallTheme']) => {
if (overallTheme !== newOverallTheme) {
const chosenTheme = _.find(
getMeta('ol-overallThemes'),
theme => theme.val === newOverallTheme
)
if (chosenTheme) {
setOverallTheme(newOverallTheme)
saveUserSettings('overallTheme', newOverallTheme)
}
}
},
[overallTheme, setOverallTheme]
)
}

View File

@@ -0,0 +1,32 @@
import { useCallback } from 'react'
import useScopeValue from '../../../shared/hooks/use-scope-value'
import { type ProjectSettings, saveUserSettings } from '../utils/api'
import useSaveProjectSettings from './use-save-project-settings'
export default function useSetSpellCheckLanguage() {
const [spellCheckLanguage, setSpellCheckLanguage] = useScopeValue<
ProjectSettings['spellCheckLanguage']
>('project.spellCheckLanguage')
const saveProjectSettings = useSaveProjectSettings()
return useCallback(
(newSpellCheckLanguage: ProjectSettings['spellCheckLanguage']) => {
const allowUpdate =
spellCheckLanguage != null &&
newSpellCheckLanguage !== spellCheckLanguage
if (allowUpdate) {
setSpellCheckLanguage(newSpellCheckLanguage)
// Save project settings is created from hooks because it will save the value on
// both server-side and client-side (angular scope)
saveProjectSettings('spellCheckLanguage', newSpellCheckLanguage)
// For user settings, we only need to save it on server-side,
// so we import the function directly without hooks
saveUserSettings('spellCheckLanguage', newSpellCheckLanguage)
}
},
[setSpellCheckLanguage, spellCheckLanguage, saveProjectSettings]
)
}

View File

@@ -0,0 +1,120 @@
import { useCallback } from 'react'
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
import useSetOverallTheme from './use-set-overall-theme'
import useSaveUserSettings from './use-save-user-settings'
import { UserSettings } from '../../../../../types/user-settings'
export default function useUserWideSettings() {
const saveUserSettings = useSaveUserSettings()
const { userSettings } = useUserSettingsContext()
const {
overallTheme,
autoComplete,
autoPairDelimiters,
syntaxValidation,
editorTheme,
mode,
fontSize,
fontFamily,
lineHeight,
pdfViewer,
mathPreview,
} = userSettings
const setOverallTheme = useSetOverallTheme()
const setAutoComplete = useCallback(
(autoComplete: UserSettings['autoComplete']) => {
saveUserSettings('autoComplete', autoComplete)
},
[saveUserSettings]
)
const setAutoPairDelimiters = useCallback(
(autoPairDelimiters: UserSettings['autoPairDelimiters']) => {
saveUserSettings('autoPairDelimiters', autoPairDelimiters)
},
[saveUserSettings]
)
const setSyntaxValidation = useCallback(
(syntaxValidation: UserSettings['syntaxValidation']) => {
saveUserSettings('syntaxValidation', syntaxValidation)
},
[saveUserSettings]
)
const setEditorTheme = useCallback(
(editorTheme: UserSettings['editorTheme']) => {
saveUserSettings('editorTheme', editorTheme)
},
[saveUserSettings]
)
const setMode = useCallback(
(mode: UserSettings['mode']) => {
saveUserSettings('mode', mode)
},
[saveUserSettings]
)
const setFontSize = useCallback(
(fontSize: UserSettings['fontSize']) => {
saveUserSettings('fontSize', fontSize)
},
[saveUserSettings]
)
const setFontFamily = useCallback(
(fontFamily: UserSettings['fontFamily']) => {
saveUserSettings('fontFamily', fontFamily)
},
[saveUserSettings]
)
const setLineHeight = useCallback(
(lineHeight: UserSettings['lineHeight']) => {
saveUserSettings('lineHeight', lineHeight)
},
[saveUserSettings]
)
const setPdfViewer = useCallback(
(pdfViewer: UserSettings['pdfViewer']) => {
saveUserSettings('pdfViewer', pdfViewer)
},
[saveUserSettings]
)
const setMathPreview = useCallback(
(mathPreview: UserSettings['mathPreview']) => {
saveUserSettings('mathPreview', mathPreview)
},
[saveUserSettings]
)
return {
autoComplete,
setAutoComplete,
autoPairDelimiters,
setAutoPairDelimiters,
syntaxValidation,
setSyntaxValidation,
editorTheme,
setEditorTheme,
overallTheme,
setOverallTheme,
mode,
setMode,
fontSize,
setFontSize,
fontFamily,
setFontFamily,
lineHeight,
setLineHeight,
pdfViewer,
setPdfViewer,
mathPreview,
setMathPreview,
}
}

View File

@@ -0,0 +1,46 @@
import type { ProjectCompiler } from '../../../../../types/project-settings'
import { sendMB } from '../../../infrastructure/event-tracking'
import { postJSON } from '../../../infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import { UserSettings } from '../../../../../types/user-settings'
export type ProjectSettings = {
compiler: ProjectCompiler
imageName: string
rootDocId: string
spellCheckLanguage: string
name: string
}
type SaveUserSettings = Partial<
UserSettings & {
spellCheckLanguage: ProjectSettings['spellCheckLanguage']
}
>
export function saveUserSettings(
key: keyof SaveUserSettings,
value: SaveUserSettings[keyof SaveUserSettings]
) {
sendMB('setting-changed', {
changedSetting: key,
changedSettingVal: value,
})
postJSON('/user/settings', {
body: {
[key]: value,
},
}).catch(debugConsole.error)
}
export const saveProjectSettings = async (
projectId: string,
data: Partial<ProjectSettings>
) => {
await postJSON<never>(`/project/${projectId}/settings`, {
body: {
...data,
},
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>&nbsp;
<MaterialIcon type="groups" />
</button>
</OLTooltip>
)
})
DropDownToggleButton.displayName = 'DropDownToggleButton'
DropDownToggleButton.propTypes = {
onlineUserCount: PropTypes.number.isRequired,
onClick: PropTypes.func,
}
export default OnlineUsersWidget

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
import {
isSmallDevice,
sendMBOncePerPageLoad,
} from '@/infrastructure/event-tracking'
import getMeta from '@/utils/meta'
export function recordDocumentFirstChangeEvent() {
const projectId = getMeta('ol-project_id')
sendMBOncePerPageLoad('document-first-change', { projectId, isSmallDevice })
}

View File

@@ -0,0 +1,71 @@
import * as eventTracking from '../../infrastructure/event-tracking'
import { debugConsole } from '@/utils/debugging'
function setupEventTracking(el) {
const key = el.getAttribute('event-tracking')
const action = el.getAttribute('event-tracking-action') || key
const label = el.getAttribute('event-tracking-label') || ''
const gaCategory = el.getAttribute('event-tracking-ga')
const sendMB = el.getAttribute('event-tracking-mb')
const trigger = el.getAttribute('event-tracking-trigger')
const sendOnce = el.getAttribute('event-tracking-send-once')
const element = el.getAttribute('event-tracking-element')
function submit() {
if (key === 'menu-expand') {
const expanded = el.getAttribute('aria-expanded')
if (expanded === 'true') {
// skip if the menu is already expanded
return
}
}
const segmentation = JSON.parse(
el.getAttribute('event-segmentation') || '{}'
)
segmentation.page = window.location.pathname
if (element === 'checkbox') {
segmentation.checkbox = el.checked ? 'checked' : 'unchecked'
} else if (element === 'select') {
segmentation.selectValue = el.value
}
if (sendMB) {
if (sendOnce) {
eventTracking.sendMBOnce(key, segmentation)
} else {
eventTracking.sendMB(key, segmentation)
}
}
if (gaCategory) {
if (sendOnce) {
eventTracking.sendOnce(gaCategory, action, label)
} else {
eventTracking.send(gaCategory, action, label)
}
}
}
let timer
let timeoutAmt = 500
switch (trigger) {
case 'click':
el.addEventListener('click', () => submit())
break
case 'hover':
if (el.getAttribute('event-hover-amt')) {
timeoutAmt = parseInt(el.getAttribute('event-hover-amt'), 10)
}
el.addEventListener('mouseenter', () => {
timer = setTimeout(() => submit(), timeoutAmt)
})
el.addEventListener('mouseleave', () => clearTimeout(timer))
break
default:
debugConsole.error(`unsupported event tracking action: ${trigger}`)
}
}
document.querySelectorAll('[event-tracking]').forEach(setupEventTracking)

View File

@@ -0,0 +1,36 @@
import { sendMB } from '@/infrastructure/event-tracking'
type SearchEventSegmentation = {
'search-open':
| ({
searchType: 'full-project'
} & (
| { method: 'keyboard' }
| { method: 'button'; location: 'toolbar' | 'search-form' }
))
| ({
searchType: 'document'
mode: 'visual' | 'source'
} & ({ method: 'keyboard' } | { method: 'button'; location: 'toolbar' }))
'search-execute': {
searchType: 'full-project'
totalDocs: number
totalResults: number
}
'search-result-click': {
searchType: 'full-project'
}
'search-replace-click': {
searchType: 'document'
method: 'keyboard' | 'button'
action: 'replace' | 'replace-all'
}
}
export const sendSearchEvent = <T extends keyof SearchEventSegmentation>(
eventName: T,
segmentation: SearchEventSegmentation[T]
) => {
sendMB(eventName, segmentation)
}

View File

@@ -0,0 +1,16 @@
function loadingFailed(imgEl) {
return imgEl.complete && imgEl.naturalWidth === 0
}
document.querySelectorAll('[data-ol-fallback-image]').forEach(imgEl => {
function showFallback() {
imgEl.src = imgEl.getAttribute('data-ol-fallback-image')
}
if (loadingFailed(imgEl)) {
// The image loading failed before the sprinkle ran.
showFallback()
} else {
// The image loading might fail in the future.
imgEl.addEventListener('error', showFallback)
}
})

View File

@@ -0,0 +1,107 @@
import _ from 'lodash'
import { formatWikiHit, searchWiki } from '../algolia-search/search-wiki'
function setupSearch(formEl) {
const inputEl = formEl.querySelector('input[type="text"]')
const resultsEl = formEl.querySelector('[data-ol-search-results]')
const wrapperEl = formEl.querySelector('[data-ol-search-results-wrapper]')
const noResultsEl = formEl.querySelector('[data-ol-search-no-results]')
const srHelpMsgEl = formEl.querySelector('[data-ol-search-sr-help-message]')
function hideResultsPane() {
wrapperEl.hidden = true
}
function showResultsPane() {
wrapperEl.hidden = false
hideNoResultsMsg()
}
function hideNoResultsMsg() {
noResultsEl.hidden = true
}
function showNoResultsMsg() {
noResultsEl.hidden = false
hideResultsPane()
}
let lastValue = ''
async function handleChange() {
const value = inputEl.value
if (value === lastValue) return
lastValue = value
if (value.length === 0) {
hideResultsPane()
hideNoResultsMsg()
return
}
try {
const { hits, nbHits } = await searchWiki(value, {
hitsPerPage: 20,
})
if (nbHits === 0) {
showNoResultsMsg()
return
}
if (nbHits > 20) {
srHelpMsgEl.innerText = `Showing first 20 results of ${nbHits} for ${value}`
} else {
srHelpMsgEl.innerText = `${nbHits} results for ${value}`
}
resultsEl.innerText = ''
for (const hit of hits) {
const { url, pageName, content } = formatWikiHit(hit)
const linkEl = document.createElement('a')
linkEl.className = 'search-result card card-thin'
linkEl.href = url
const headerEl = document.createElement('span')
headerEl.innerHTML = pageName
linkEl.append(headerEl)
if (content) {
const contentEl = document.createElement('div')
contentEl.className = 'search-result-content'
contentEl.innerHTML = content
linkEl.append(contentEl)
}
resultsEl.append(linkEl)
}
showResultsPane()
} catch (e) {
showNoResultsMsg()
}
}
function updateClearBtnVisibility() {
const value = inputEl.value
formEl.querySelectorAll('[data-ol-clear-search]').forEach(el => {
el.hidden = value === ''
})
}
function handleClear() {
inputEl.value = ''
hideResultsPane()
hideNoResultsMsg()
updateClearBtnVisibility()
}
formEl.querySelectorAll('[data-ol-clear-search]').forEach(el => {
el.addEventListener('click', handleClear)
})
formEl.addEventListener('submit', evt => {
evt.preventDefault()
return false
})
inputEl.addEventListener('input', _.debounce(handleChange, 100))
inputEl.addEventListener('input', updateClearBtnVisibility)
// display initial results
handleChange()
}
document.querySelectorAll('[data-ol-faq-search]').forEach(setupSearch)

View File

@@ -0,0 +1,73 @@
import React, { useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
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'
function FileTreeContextMenu() {
const { fileTreeReadOnly } = useFileTreeData()
const { contextMenuCoords, setContextMenuCoords } = useFileTreeMainContext()
const toggleButtonRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => {
if (contextMenuCoords) {
toggleButtonRef.current = document.querySelector(
'.entity-menu-toggle'
) as HTMLButtonElement | null
}
}, [contextMenuCoords])
if (!contextMenuCoords || fileTreeReadOnly) return null
function close() {
setContextMenuCoords(null)
if (toggleButtonRef.current) {
// A11y - Move the focus back to the toggle button when the context menu closes by pressing the Esc key
toggleButtonRef.current.focus()
}
}
function handleToggle(wantOpen: boolean) {
if (!wantOpen) close()
}
// 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<Element>) {
if (event.key === 'Tab') {
close()
}
}
return ReactDOM.createPortal(
<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
)
}
export default FileTreeContextMenu

View File

@@ -0,0 +1,43 @@
import { FileTreeMainProvider } from '../contexts/file-tree-main'
import { FileTreeActionableProvider } from '../contexts/file-tree-actionable'
import { FileTreeSelectableProvider } from '../contexts/file-tree-selectable'
import { FileTreeDraggableProvider } from '../contexts/file-tree-draggable'
import { FC } from 'react'
// renders all the contexts needed for the file tree:
// FileTreeMain: generic store
// FileTreeActionable: global UI state for actions (rename, delete, etc.)
// FileTreeMutable: provides entities mutation operations
// FileTreeSelectable: handles selection and multi-selection
const FileTreeContext: FC<{
refProviders: Record<string, boolean>
setRefProviderEnabled: (provider: string, value: boolean) => void
setStartedFreeTrial: (value: boolean) => void
onSelect: () => void
fileTreeContainer?: HTMLDivElement
}> = ({
refProviders,
setRefProviderEnabled,
setStartedFreeTrial,
onSelect,
fileTreeContainer,
children,
}) => {
return (
<FileTreeMainProvider
refProviders={refProviders}
setRefProviderEnabled={setRefProviderEnabled}
setStartedFreeTrial={setStartedFreeTrial}
>
<FileTreeSelectableProvider onSelect={onSelect}>
<FileTreeActionableProvider>
<FileTreeDraggableProvider fileTreeContainer={fileTreeContainer}>
{children}
</FileTreeDraggableProvider>
</FileTreeActionableProvider>
</FileTreeSelectableProvider>
</FileTreeMainProvider>
)
}
export default FileTreeContext

View File

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

View File

@@ -0,0 +1,124 @@
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import { FetchError } from '../../../../infrastructure/fetch-json'
import RedirectToLogin from './redirect-to-login'
import {
BlockedFilenameError,
DuplicateFilenameError,
InvalidFilenameError,
} from '../../errors'
import DangerMessage from './danger-message'
export default function ErrorMessage({ error }) {
const { t } = useTranslation()
const fileNameLimit = 150
// the error is a string
// TODO: translate? always? is this a key or a message?
if (typeof error === 'string') {
switch (error) {
case 'name-exists':
return <DangerMessage>{t('file_already_exists')}</DangerMessage>
case 'too-many-files':
return <DangerMessage>{t('project_has_too_many_files')}</DangerMessage>
case 'remote-service-error':
return <DangerMessage>{t('remote_service_error')}</DangerMessage>
case 'folder_not_found':
return (
<DangerMessage>
{t('the_target_folder_could_not_be_found')}
</DangerMessage>
)
case 'invalid_filename':
return (
<DangerMessage>
{t('invalid_filename', {
nameLimit: fileNameLimit,
})}
</DangerMessage>
)
case 'duplicate_file_name':
return (
<DangerMessage>
{t('file_or_folder_name_already_exists')}
</DangerMessage>
)
case 'rate-limit-hit':
return (
<DangerMessage>
{t('too_many_files_uploaded_throttled_short_period')}
</DangerMessage>
)
case 'not-logged-in':
return (
<DangerMessage>
<RedirectToLogin />
</DangerMessage>
)
case 'linked-project-compile-error':
return (
<DangerMessage>
{t('generic_linked_file_compile_error')}
</DangerMessage>
)
default:
// TODO: convert error.response.data to an error key and try again?
// return error
return (
<DangerMessage>{t('generic_something_went_wrong')}</DangerMessage>
)
}
}
// the error is an object
// TODO: error.name?
switch (error.constructor) {
case FetchError: {
const message = error.data?.message
if (message) {
return <DangerMessage>{message.text || message}</DangerMessage>
}
// TODO: translations
switch (error.response?.status) {
case 400:
return <DangerMessage>{t('invalid_request')}</DangerMessage>
case 403:
return <DangerMessage>{t('session_error')}</DangerMessage>
case 429:
return <DangerMessage>{t('too_many_attempts')}</DangerMessage>
default:
return (
<DangerMessage>{t('something_went_wrong_server')}</DangerMessage>
)
}
}
// these are handled by the filename input component
case DuplicateFilenameError:
case InvalidFilenameError:
case BlockedFilenameError:
return null
// a generic error message
default:
// return error.message
return <DangerMessage>{t('generic_something_went_wrong')}</DangerMessage>
}
}
ErrorMessage.propTypes = {
error: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired,
}

View File

@@ -0,0 +1,129 @@
import { useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useFileTreeCreateName } from '../../contexts/file-tree-create-name'
import PropTypes from 'prop-types'
import {
BlockedFilenameError,
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,
* plus a validation warning and/or an error message when needed
*/
export default function FileTreeCreateNameInput({
label,
focusName = false,
classes = {},
placeholder,
error,
inFlight,
}) {
const { t } = useTranslation()
// the value is stored in a context provider, so it's available elsewhere in the form
const { name, setName, touchedName, validName } = useFileTreeCreateName()
// focus the first part of the filename if needed
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 (
<OLFormGroup controlId="new-doc-name" className={classes.formGroup}>
<OLFormLabel>{label || t('file_name')}</OLFormLabel>
<OLFormControl
type="text"
placeholder={placeholder || t('file_name')}
required
value={name}
onChange={event => setName(event.target.value)}
ref={inputRef}
disabled={inFlight}
/>
{touchedName && !validName && (
<OLNotification
type="error"
className="row-spaced-small"
content={t('files_cannot_include_invalid_characters')}
/>
)}
{error && <ErrorMessage error={error} />}
</OLFormGroup>
)
}
FileTreeCreateNameInput.propTypes = {
focusName: PropTypes.bool,
label: PropTypes.string,
classes: PropTypes.shape({
formGroup: PropTypes.string,
}),
placeholder: PropTypes.string,
error: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
inFlight: PropTypes.bool.isRequired,
}
function ErrorMessage({ error }) {
const { t } = useTranslation()
// if (typeof error === 'string') {
// return error
// }
switch (error.constructor) {
case DuplicateFilenameError:
return (
<OLNotification
type="error"
className="row-spaced-small"
content={t('file_already_exists')}
/>
)
case InvalidFilenameError:
return (
<OLNotification
type="error"
className="row-spaced-small"
content={t('files_cannot_include_invalid_characters')}
/>
)
case BlockedFilenameError:
return (
<OLNotification
type="error"
className="row-spaced-small"
content={t('blocked_filename')}
/>
)
default:
// return <Trans i18nKey="generic_something_went_wrong" />
return null // other errors are displayed elsewhere
}
}
ErrorMessage.propTypes = {
error: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired,
}

View File

@@ -0,0 +1,114 @@
import { useTranslation } from 'react-i18next'
import FileTreeCreateNewDoc from './modes/file-tree-create-new-doc'
import FileTreeImportFromUrl from './modes/file-tree-import-from-url'
import FileTreeImportFromProject from './modes/file-tree-import-from-project'
import FileTreeModalCreateFileMode from './file-tree-modal-create-file-mode'
import FileTreeCreateNameProvider from '../../contexts/file-tree-create-name'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
import { useFileTreeData } from '../../../../shared/context/file-tree-data-context'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
import { lazy, Suspense } from 'react'
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
import getMeta from '@/utils/meta'
const createFileModeModules = importOverleafModules('createFileModes')
const FileTreeUploadDoc = lazy(() => import('./modes/file-tree-upload-doc'))
export default function FileTreeModalCreateFileBody() {
const { t } = useTranslation()
const { newFileCreateMode } = useFileTreeActionable()
const { fileCount } = useFileTreeData()
const {
hasLinkedProjectFileFeature,
hasLinkedProjectOutputFileFeature,
hasLinkUrlFeature,
} = getMeta('ol-ExposedSettings')
if (!fileCount || fileCount.status === 'error') {
return null
}
return (
<table>
<tbody>
<tr>
<td className="modal-new-file-list">
<ul className="list-unstyled">
<FileTreeModalCreateFileMode
mode="doc"
icon="description"
label={t('new_file')}
/>
<FileTreeModalCreateFileMode
mode="upload"
icon="upload"
label={t('upload')}
/>
{(hasLinkedProjectFileFeature ||
hasLinkedProjectOutputFileFeature) && (
<FileTreeModalCreateFileMode
mode="project"
icon="folder_open"
label={t('from_another_project')}
/>
)}
{hasLinkUrlFeature && (
<FileTreeModalCreateFileMode
mode="url"
icon="globe"
label={t('from_external_url')}
/>
)}
{createFileModeModules.map(
({ import: { CreateFileMode }, path }) => (
<CreateFileMode key={path} />
)
)}
</ul>
</td>
<td
className={`modal-new-file-body modal-new-file-body-${newFileCreateMode}`}
>
{newFileCreateMode === 'doc' && (
<FileTreeCreateNameProvider initialName="name.tex">
<FileTreeCreateNewDoc />
</FileTreeCreateNameProvider>
)}
{newFileCreateMode === 'url' && (
<FileTreeCreateNameProvider>
<FileTreeImportFromUrl />
</FileTreeCreateNameProvider>
)}
{newFileCreateMode === 'project' && (
<FileTreeCreateNameProvider>
<FileTreeImportFromProject />
</FileTreeCreateNameProvider>
)}
{newFileCreateMode === 'upload' && (
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<FileTreeUploadDoc />
</Suspense>
)}
{createFileModeModules.map(
({ import: { CreateFilePane }, path }) => (
<CreateFilePane key={path} />
)
)}
</td>
</tr>
</tbody>
</table>
)
}

View File

@@ -0,0 +1,86 @@
import { useTranslation } from 'react-i18next'
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()
const { newFileCreateMode, inFlight, cancel } = useFileTreeActionable()
const { fileCount } = useFileTreeData()
return (
<FileTreeModalCreateFileFooterContent
valid={valid}
cancel={cancel}
newFileCreateMode={newFileCreateMode}
inFlight={inFlight}
fileCount={fileCount}
/>
)
}
export function FileTreeModalCreateFileFooterContent({
valid,
fileCount,
inFlight,
newFileCreateMode,
cancel,
}) {
const { t } = useTranslation()
return (
<>
{fileCount.status === 'warning' && (
<div className="modal-footer-left approaching-file-limit">
{t('project_approaching_file_limit')} ({fileCount.value}/
{fileCount.limit})
</div>
)}
{fileCount.status === 'error' && (
<OLNotification
type="error"
className="at-file-limit"
content={t('project_has_too_many_files')}
>
{/* TODO: add parameter for fileCount.limit */}
</OLNotification>
)}
<OLButton
variant="secondary"
type="button"
disabled={inFlight}
onClick={cancel}
>
{t('cancel')}
</OLButton>
{newFileCreateMode !== 'upload' && (
<OLButton
variant="primary"
type="submit"
form="create-file"
disabled={inFlight || !valid}
isLoading={inFlight}
>
{t('create')}
</OLButton>
)}
</>
)
}
FileTreeModalCreateFileFooterContent.propTypes = {
cancel: PropTypes.func.isRequired,
fileCount: PropTypes.shape({
limit: PropTypes.number.isRequired,
status: PropTypes.string.isRequired,
value: PropTypes.number.isRequired,
}).isRequired,
inFlight: PropTypes.bool.isRequired,
newFileCreateMode: PropTypes.string,
valid: PropTypes.bool.isRequired,
}

View File

@@ -0,0 +1,35 @@
import classnames from 'classnames'
import PropTypes from 'prop-types'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
import * as eventTracking from '../../../../infrastructure/event-tracking'
import OLButton from '@/features/ui/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
export default function FileTreeModalCreateFileMode({ mode, icon, label }) {
const { newFileCreateMode, startCreatingFile } = useFileTreeActionable()
const handleClick = () => {
startCreatingFile(mode)
eventTracking.sendMB('file-modal-click', { method: mode })
}
return (
<li className={classnames({ active: newFileCreateMode === mode })}>
<OLButton
variant="link"
onClick={handleClick}
className="modal-new-file-mode"
>
<MaterialIcon type={icon} />
&nbsp;
{label}
</OLButton>
</li>
)
}
FileTreeModalCreateFileMode.propTypes = {
mode: PropTypes.string.isRequired,
icon: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
}

Some files were not shown because too many files have changed in this diff Show More