first commit
This commit is contained in:
4
services/web/frontend/js/bootstrap-3.ts
Normal file
4
services/web/frontend/js/bootstrap-3.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import 'bootstrap'
|
||||
import './features/contact-form'
|
||||
import './features/bookmarkable-tab/index'
|
||||
import './features/tooltip/index-bs3'
|
3
services/web/frontend/js/bootstrap-5.ts
Normal file
3
services/web/frontend/js/bootstrap-5.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import './features/bookmarkable-tab/index-bs5'
|
||||
import './features/tooltip/index-bs5'
|
||||
import 'bootstrap-5'
|
5
services/web/frontend/js/dev-toolbar.ts
Normal file
5
services/web/frontend/js/dev-toolbar.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import importOverleafModules from '../macros/import-overleaf-module.macro'
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
importOverleafModules('devToolbar')
|
||||
}
|
@@ -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 }
|
||||
}
|
56
services/web/frontend/js/features/autoplay-video/index.js
Normal file
56
services/web/frontend/js/features/autoplay-video/index.js
Normal 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)
|
@@ -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()
|
21
services/web/frontend/js/features/bookmarkable-tab/index.js
Normal file
21
services/web/frontend/js/features/bookmarkable-tab/index.js
Normal 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()
|
@@ -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
|
117
services/web/frontend/js/features/chat/components/chat-pane.tsx
Normal file
117
services/web/frontend/js/features/chat/components/chat-pane.tsx
Normal 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)
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
399
services/web/frontend/js/features/chat/context/chat-context.tsx
Normal file
399
services/web/frontend/js/features/chat/context/chat-context.tsx
Normal 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
|
||||
}
|
@@ -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 }
|
||||
}
|
@@ -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,
|
||||
})
|
||||
),
|
||||
}
|
@@ -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)
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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)
|
@@ -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>
|
||||
)
|
||||
}
|
35
services/web/frontend/js/features/contact-form/index.js
Normal file
35
services/web/frontend/js/features/contact-form/index.js
Normal 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
|
||||
})
|
||||
})
|
70
services/web/frontend/js/features/contact-form/search.js
Normal file
70
services/web/frontend/js/features/contact-form/search.js
Normal 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()
|
||||
}
|
51
services/web/frontend/js/features/cookie-banner/index.js
Normal file
51
services/web/frontend/js/features/cookie-banner/index.js
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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)
|
@@ -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',
|
||||
])
|
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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)} />
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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
|
||||
}
|
@@ -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)
|
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
@@ -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)}
|
||||
/>
|
||||
)
|
||||
})
|
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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()}
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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
|
||||
}
|
@@ -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])
|
||||
}
|
@@ -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,
|
||||
}
|
||||
}
|
@@ -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,
|
||||
}
|
||||
}
|
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@@ -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]
|
||||
)
|
||||
}
|
@@ -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]
|
||||
)
|
||||
}
|
@@ -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,
|
||||
}
|
||||
}
|
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
@@ -0,0 +1,21 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
|
||||
function BackToEditorButton({ onClick }: { onClick: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
className="back-to-editor-btn"
|
||||
>
|
||||
<MaterialIcon type="arrow_back" className="toolbar-btn-secondary-icon" />
|
||||
<span className="toolbar-label">{t('back_to_editor')}</span>
|
||||
</OLButton>
|
||||
)
|
||||
}
|
||||
|
||||
export default BackToEditorButton
|
@@ -0,0 +1,35 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as eventTracking from '../../../infrastructure/event-tracking'
|
||||
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
function BackToProjectsButton() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<OLTooltip
|
||||
id="back-to-projects"
|
||||
description={t('back_to_your_projects')}
|
||||
overlayProps={{ placement: 'right' }}
|
||||
>
|
||||
<div className="toolbar-item">
|
||||
<a
|
||||
className="btn btn-full-height"
|
||||
draggable="false"
|
||||
href="/project"
|
||||
onClick={() => {
|
||||
eventTracking.sendMB('navigation-clicked-home')
|
||||
}}
|
||||
>
|
||||
<MaterialIcon
|
||||
type="home"
|
||||
className="align-text-bottom"
|
||||
accessibilityLabel={t('back_to_your_projects')}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</OLTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default BackToProjectsButton
|
@@ -0,0 +1,35 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import classNames from 'classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import OLBadge from '@/features/ui/components/ol/ol-badge'
|
||||
|
||||
function ChatToggleButton({ chatIsOpen, unreadMessageCount, onClick }) {
|
||||
const { t } = useTranslation()
|
||||
const classes = classNames('btn', 'btn-full-height', { active: chatIsOpen })
|
||||
|
||||
const hasUnreadMessages = unreadMessageCount > 0
|
||||
|
||||
return (
|
||||
<div className="toolbar-item">
|
||||
<button type="button" className={classes} onClick={onClick}>
|
||||
<MaterialIcon
|
||||
type="chat"
|
||||
className={classNames('align-middle', {
|
||||
bounce: hasUnreadMessages,
|
||||
})}
|
||||
/>
|
||||
{hasUnreadMessages && <OLBadge bg="info">{unreadMessageCount}</OLBadge>}
|
||||
<p className="toolbar-label">{t('chat')}</p>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
ChatToggleButton.propTypes = {
|
||||
chatIsOpen: PropTypes.bool,
|
||||
unreadMessageCount: PropTypes.number.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
export default ChatToggleButton
|
@@ -0,0 +1,30 @@
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
function CobrandingLogo({
|
||||
brandVariationHomeUrl,
|
||||
brandVariationName,
|
||||
logoImgUrl,
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
className="btn btn-full-height header-cobranding-logo-container"
|
||||
href={brandVariationHomeUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<img
|
||||
src={logoImgUrl}
|
||||
className="header-cobranding-logo"
|
||||
alt={brandVariationName}
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
CobrandingLogo.propTypes = {
|
||||
brandVariationHomeUrl: PropTypes.string.isRequired,
|
||||
brandVariationName: PropTypes.string.isRequired,
|
||||
logoImgUrl: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
export default CobrandingLogo
|
@@ -0,0 +1,133 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import ToolbarHeader from './toolbar-header'
|
||||
import { useEditorContext } from '../../../shared/context/editor-context'
|
||||
import { useChatContext } from '../../chat/context/chat-context'
|
||||
import { useLayoutContext } from '../../../shared/context/layout-context'
|
||||
import { useProjectContext } from '../../../shared/context/project-context'
|
||||
import * as eventTracking from '../../../infrastructure/event-tracking'
|
||||
import { Doc } from '../../../../../types/doc'
|
||||
|
||||
function isOpentoString(open: boolean) {
|
||||
return open ? 'open' : 'close'
|
||||
}
|
||||
|
||||
const EditorNavigationToolbarRoot = React.memo(
|
||||
function EditorNavigationToolbarRoot({
|
||||
onlineUsersArray,
|
||||
openDoc,
|
||||
openShareProjectModal,
|
||||
}: {
|
||||
onlineUsersArray: any[]
|
||||
openDoc: (doc: Doc, { gotoLine }: { gotoLine: number }) => void
|
||||
openShareProjectModal: () => void
|
||||
}) {
|
||||
const {
|
||||
name: projectName,
|
||||
features: { trackChangesVisible },
|
||||
} = useProjectContext()
|
||||
|
||||
const {
|
||||
cobranding,
|
||||
isRestrictedTokenMember,
|
||||
renameProject,
|
||||
permissionsLevel,
|
||||
} = useEditorContext()
|
||||
|
||||
const {
|
||||
chatIsOpen,
|
||||
setChatIsOpen,
|
||||
reviewPanelOpen,
|
||||
setReviewPanelOpen,
|
||||
view,
|
||||
setView,
|
||||
setLeftMenuShown,
|
||||
} = useLayoutContext()
|
||||
|
||||
const { markMessagesAsRead, unreadMessageCount } = useChatContext()
|
||||
|
||||
const toggleChatOpen = useCallback(() => {
|
||||
if (!chatIsOpen) {
|
||||
markMessagesAsRead()
|
||||
}
|
||||
eventTracking.sendMB('navigation-clicked-chat', {
|
||||
action: isOpentoString(!chatIsOpen),
|
||||
})
|
||||
setChatIsOpen(!chatIsOpen)
|
||||
}, [chatIsOpen, setChatIsOpen, markMessagesAsRead])
|
||||
|
||||
const toggleReviewPanelOpen = useCallback(
|
||||
event => {
|
||||
event.preventDefault()
|
||||
eventTracking.sendMB('navigation-clicked-review', {
|
||||
action: isOpentoString(!reviewPanelOpen),
|
||||
})
|
||||
setReviewPanelOpen(value => !value)
|
||||
},
|
||||
[reviewPanelOpen, setReviewPanelOpen]
|
||||
)
|
||||
|
||||
const [shouldReopenChat, setShouldReopenChat] = useState(chatIsOpen)
|
||||
const toggleHistoryOpen = useCallback(() => {
|
||||
const action = view === 'history' ? 'close' : 'open'
|
||||
eventTracking.sendMB('navigation-clicked-history', { action })
|
||||
|
||||
if (chatIsOpen && action === 'open') {
|
||||
setShouldReopenChat(true)
|
||||
toggleChatOpen()
|
||||
}
|
||||
if (shouldReopenChat && action === 'close') {
|
||||
setShouldReopenChat(false)
|
||||
toggleChatOpen()
|
||||
}
|
||||
setView(view === 'history' ? 'editor' : 'history')
|
||||
}, [view, chatIsOpen, shouldReopenChat, setView, toggleChatOpen])
|
||||
|
||||
const openShareModal = useCallback(() => {
|
||||
eventTracking.sendMB('navigation-clicked-share')
|
||||
openShareProjectModal()
|
||||
}, [openShareProjectModal])
|
||||
|
||||
const onShowLeftMenuClick = useCallback(() => {
|
||||
eventTracking.sendMB('navigation-clicked-menu')
|
||||
setLeftMenuShown(value => !value)
|
||||
}, [setLeftMenuShown])
|
||||
|
||||
const goToUser = useCallback(
|
||||
user => {
|
||||
if (user.doc && typeof user.row === 'number') {
|
||||
openDoc(user.doc, { gotoLine: user.row + 1 })
|
||||
}
|
||||
},
|
||||
[openDoc]
|
||||
)
|
||||
|
||||
return (
|
||||
<ToolbarHeader
|
||||
// @ts-ignore: TODO(convert ToolbarHeader to TSX)
|
||||
cobranding={cobranding}
|
||||
onShowLeftMenuClick={onShowLeftMenuClick}
|
||||
chatIsOpen={chatIsOpen}
|
||||
unreadMessageCount={unreadMessageCount}
|
||||
toggleChatOpen={toggleChatOpen}
|
||||
reviewPanelOpen={reviewPanelOpen}
|
||||
toggleReviewPanelOpen={toggleReviewPanelOpen}
|
||||
historyIsOpen={view === 'history'}
|
||||
toggleHistoryOpen={toggleHistoryOpen}
|
||||
onlineUsers={onlineUsersArray}
|
||||
goToUser={goToUser}
|
||||
isRestrictedTokenMember={isRestrictedTokenMember}
|
||||
hasPublishPermissions={
|
||||
permissionsLevel === 'owner' || permissionsLevel === 'readAndWrite'
|
||||
}
|
||||
chatVisible={!isRestrictedTokenMember}
|
||||
projectName={projectName}
|
||||
renameProject={renameProject}
|
||||
hasRenamePermissions={permissionsLevel === 'owner'}
|
||||
openShareModal={openShareModal}
|
||||
trackChangesVisible={trackChangesVisible}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export default EditorNavigationToolbarRoot
|
@@ -0,0 +1,23 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
function HistoryToggleButton({ onClick }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="toolbar-item">
|
||||
<button type="button" className="btn btn-full-height" onClick={onClick}>
|
||||
<MaterialIcon type="history" className="align-middle" />
|
||||
<p className="toolbar-label">{t('history')}</p>
|
||||
</button>
|
||||
<div id="toolbar-cio-history" className="toolbar-cio-tooltip" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
HistoryToggleButton.propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
export default HistoryToggleButton
|
@@ -0,0 +1,285 @@
|
||||
import { memo, useCallback, forwardRef } from 'react'
|
||||
import { Spinner } from 'react-bootstrap-5'
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownToggle,
|
||||
DropdownToggleCustom,
|
||||
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import {
|
||||
IdeLayout,
|
||||
IdeView,
|
||||
useLayoutContext,
|
||||
} from '../../../shared/context/layout-context'
|
||||
import * as eventTracking from '../../../infrastructure/event-tracking'
|
||||
import useEventListener from '../../../shared/hooks/use-event-listener'
|
||||
import { DetachRole } from '@/shared/context/detach-context'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
|
||||
|
||||
const isActiveDropdownItem = ({
|
||||
iconFor,
|
||||
pdfLayout,
|
||||
view,
|
||||
detachRole,
|
||||
}: {
|
||||
iconFor: string
|
||||
pdfLayout: IdeLayout
|
||||
view: IdeView | null
|
||||
detachRole?: DetachRole
|
||||
}) => {
|
||||
if (detachRole === 'detacher' || view === 'history') {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
iconFor === 'editorOnly' &&
|
||||
pdfLayout === 'flat' &&
|
||||
(view === 'editor' || view === 'file')
|
||||
) {
|
||||
return true
|
||||
} else if (iconFor === 'pdfOnly' && pdfLayout === 'flat' && view === 'pdf') {
|
||||
return true
|
||||
} else if (iconFor === 'sideBySide' && pdfLayout === 'sideBySide') {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function EnhancedDropdownItem({
|
||||
active,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownItem>) {
|
||||
return (
|
||||
<DropdownItem
|
||||
active={active}
|
||||
aria-current={active}
|
||||
trailingIcon={active ? 'check' : null}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const LayoutDropdownToggleButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
{
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
}
|
||||
>(({ onClick, ...props }, ref) => {
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
eventTracking.sendMB('navigation-clicked-layout')
|
||||
onClick(e)
|
||||
}
|
||||
|
||||
return <DropdownToggleCustom {...props} ref={ref} onClick={handleClick} />
|
||||
})
|
||||
LayoutDropdownToggleButton.displayName = 'LayoutDropdownToggleButton'
|
||||
|
||||
function BS5DetachDisabled() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<OLTooltip
|
||||
id="detach-disabled"
|
||||
description={t('your_browser_does_not_support_this_feature')}
|
||||
overlayProps={{ placement: 'left' }}
|
||||
>
|
||||
<span>
|
||||
<EnhancedDropdownItem disabled leadingIcon="select_window">
|
||||
{t('pdf_in_separate_tab')}
|
||||
</EnhancedDropdownItem>
|
||||
</span>
|
||||
</OLTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutDropdownButton() {
|
||||
const {
|
||||
reattach,
|
||||
detach,
|
||||
detachIsLinked,
|
||||
detachRole,
|
||||
changeLayout,
|
||||
view,
|
||||
pdfLayout,
|
||||
} = useLayoutContext()
|
||||
|
||||
const handleDetach = useCallback(() => {
|
||||
detach()
|
||||
eventTracking.sendMB('project-layout-detach')
|
||||
}, [detach])
|
||||
|
||||
const handleReattach = useCallback(() => {
|
||||
if (detachRole !== 'detacher') {
|
||||
return
|
||||
}
|
||||
reattach()
|
||||
eventTracking.sendMB('project-layout-reattach')
|
||||
}, [detachRole, reattach])
|
||||
|
||||
// reattach when the PDF pane opens
|
||||
useEventListener('ui:pdf-open', handleReattach)
|
||||
|
||||
const handleChangeLayout = useCallback(
|
||||
(newLayout: IdeLayout, newView?: IdeView) => {
|
||||
handleReattach()
|
||||
changeLayout(newLayout, newView)
|
||||
eventTracking.sendMB('project-layout-change', {
|
||||
layout: newLayout,
|
||||
view: newView,
|
||||
})
|
||||
},
|
||||
[changeLayout, handleReattach]
|
||||
)
|
||||
|
||||
return (
|
||||
<LayoutDropdownButtonUi
|
||||
processing={!detachIsLinked && detachRole === 'detacher'}
|
||||
handleChangeLayout={handleChangeLayout}
|
||||
handleDetach={handleDetach}
|
||||
detachIsLinked={detachIsLinked}
|
||||
detachRole={detachRole}
|
||||
pdfLayout={pdfLayout}
|
||||
view={view}
|
||||
detachable={'BroadcastChannel' in window}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type LayoutDropdownButtonUiProps = {
|
||||
processing: boolean
|
||||
handleChangeLayout: (newLayout: IdeLayout, newView?: IdeView) => void
|
||||
handleDetach: () => void
|
||||
detachIsLinked: boolean
|
||||
detachRole: DetachRole
|
||||
pdfLayout: IdeLayout
|
||||
view: IdeView | null
|
||||
detachable: boolean
|
||||
}
|
||||
|
||||
export const LayoutDropdownButtonUi = ({
|
||||
processing,
|
||||
handleChangeLayout,
|
||||
handleDetach,
|
||||
detachIsLinked,
|
||||
detachRole,
|
||||
view,
|
||||
pdfLayout,
|
||||
detachable,
|
||||
}: LayoutDropdownButtonUiProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<>
|
||||
{processing && (
|
||||
<div aria-live="assertive" className="sr-only">
|
||||
{t('layout_processing')}
|
||||
</div>
|
||||
)}
|
||||
<Dropdown className="toolbar-item layout-dropdown" align="end">
|
||||
<DropdownToggle
|
||||
id="layout-dropdown-btn"
|
||||
className="btn-full-height"
|
||||
as={LayoutDropdownToggleButton}
|
||||
>
|
||||
{processing ? (
|
||||
<Spinner
|
||||
animation="border"
|
||||
aria-hidden="true"
|
||||
size="sm"
|
||||
role="status"
|
||||
/>
|
||||
) : (
|
||||
<MaterialIcon type="dock_to_right" className="align-middle" />
|
||||
)}
|
||||
<span className="toolbar-label">{t('layout')}</span>
|
||||
</DropdownToggle>
|
||||
<DropdownMenu>
|
||||
<EnhancedDropdownItem
|
||||
onClick={() => handleChangeLayout('sideBySide')}
|
||||
active={isActiveDropdownItem({
|
||||
iconFor: 'sideBySide',
|
||||
pdfLayout,
|
||||
view,
|
||||
detachRole,
|
||||
})}
|
||||
leadingIcon="dock_to_right"
|
||||
>
|
||||
{t('editor_and_pdf')}
|
||||
</EnhancedDropdownItem>
|
||||
|
||||
<EnhancedDropdownItem
|
||||
onClick={() => handleChangeLayout('flat', 'editor')}
|
||||
active={isActiveDropdownItem({
|
||||
iconFor: 'editorOnly',
|
||||
pdfLayout,
|
||||
view,
|
||||
detachRole,
|
||||
})}
|
||||
leadingIcon="code"
|
||||
>
|
||||
<div className="d-flex flex-column">
|
||||
<Trans
|
||||
i18nKey="editor_only_hide_pdf"
|
||||
components={[
|
||||
<span key="editor_only_hide_pdf" className="subdued" />,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</EnhancedDropdownItem>
|
||||
|
||||
<EnhancedDropdownItem
|
||||
onClick={() => handleChangeLayout('flat', 'pdf')}
|
||||
active={isActiveDropdownItem({
|
||||
iconFor: 'pdfOnly',
|
||||
pdfLayout,
|
||||
view,
|
||||
detachRole,
|
||||
})}
|
||||
leadingIcon="picture_as_pdf"
|
||||
>
|
||||
<div className="d-flex flex-column">
|
||||
<Trans
|
||||
i18nKey="pdf_only_hide_editor"
|
||||
components={[
|
||||
<span key="pdf_only_hide_editor" className="subdued" />,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</EnhancedDropdownItem>
|
||||
|
||||
{detachable ? (
|
||||
<EnhancedDropdownItem
|
||||
onClick={() => handleDetach()}
|
||||
active={detachRole === 'detacher' && detachIsLinked}
|
||||
trailingIcon={
|
||||
detachRole === 'detacher' ? (
|
||||
detachIsLinked ? (
|
||||
'check'
|
||||
) : (
|
||||
<span className="spinner-container">
|
||||
<Spinner
|
||||
animation="border"
|
||||
aria-hidden="true"
|
||||
size="sm"
|
||||
role="status"
|
||||
/>
|
||||
<span className="visually-hidden">{t('loading')}</span>
|
||||
</span>
|
||||
)
|
||||
) : null
|
||||
}
|
||||
leadingIcon="select_window"
|
||||
>
|
||||
{t('pdf_in_separate_tab')}
|
||||
</EnhancedDropdownItem>
|
||||
) : (
|
||||
<BS5DetachDisabled />
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(LayoutDropdownButton)
|
@@ -0,0 +1,22 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
function MenuButton({ onClick }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="toolbar-item">
|
||||
<button type="button" className="btn btn-full-height" onClick={onClick}>
|
||||
<MaterialIcon type="menu" className="editor-menu-icon align-middle" />
|
||||
<p className="toolbar-label">{t('menu')}</p>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
MenuButton.propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
export default MenuButton
|
@@ -0,0 +1,133 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownHeader,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownToggle,
|
||||
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
|
||||
import { getBackgroundColorForUserId } from '@/shared/utils/colors'
|
||||
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
function OnlineUsersWidget({ onlineUsers, goToUser }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const shouldDisplayDropdown = onlineUsers.length >= 4
|
||||
|
||||
if (shouldDisplayDropdown) {
|
||||
return (
|
||||
<Dropdown id="online-users" className="online-users" align="end">
|
||||
<DropdownToggle
|
||||
as={DropDownToggleButton}
|
||||
onlineUserCount={onlineUsers.length}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownHeader aria-hidden="true">
|
||||
{t('connected_users')}
|
||||
</DropdownHeader>
|
||||
{onlineUsers.map((user, index) => (
|
||||
<li role="none" key={`${user.user_id}_${index}`}>
|
||||
<DropdownItem
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => goToUser(user)}
|
||||
>
|
||||
<UserIcon user={user} showName />
|
||||
</DropdownItem>
|
||||
</li>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div className="online-users">
|
||||
{onlineUsers.map((user, index) => (
|
||||
<OLTooltip
|
||||
key={`${user.user_id}_${index}`}
|
||||
id="online-user"
|
||||
description={user.name}
|
||||
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<span>
|
||||
{/* OverlayTrigger won't fire unless UserIcon is wrapped in a span */}
|
||||
<UserIcon user={user} onClick={goToUser} />
|
||||
</span>
|
||||
</OLTooltip>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OnlineUsersWidget.propTypes = {
|
||||
onlineUsers: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
user_id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
})
|
||||
).isRequired,
|
||||
goToUser: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
function UserIcon({ user, showName, onClick }) {
|
||||
const backgroundColor = getBackgroundColorForUserId(user.user_id)
|
||||
|
||||
function handleOnClick() {
|
||||
onClick?.(user)
|
||||
}
|
||||
|
||||
const [character] = [...user.name]
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<span onClick={handleOnClick}>
|
||||
<span className="online-user" style={{ backgroundColor }}>
|
||||
{character}
|
||||
</span>
|
||||
{showName && user.name}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
UserIcon.propTypes = {
|
||||
user: PropTypes.shape({
|
||||
user_id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
}),
|
||||
showName: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
}
|
||||
|
||||
const DropDownToggleButton = React.forwardRef((props, ref) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<OLTooltip
|
||||
id="connected-users"
|
||||
description={t('connected_users')}
|
||||
overlayProps={{ placement: 'left' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="online-user online-user-multi"
|
||||
onClick={props.onClick} // required by Bootstrap Dropdown to trigger an opening
|
||||
ref={ref}
|
||||
>
|
||||
<strong>{props.onlineUserCount}</strong>
|
||||
<MaterialIcon type="groups" />
|
||||
</button>
|
||||
</OLTooltip>
|
||||
)
|
||||
})
|
||||
|
||||
DropDownToggleButton.displayName = 'DropDownToggleButton'
|
||||
|
||||
DropDownToggleButton.propTypes = {
|
||||
onlineUserCount: PropTypes.number.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
}
|
||||
|
||||
export default OnlineUsersWidget
|
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import classNames from 'classnames'
|
||||
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
|
||||
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
type ProjectNameEditableLabelProps = {
|
||||
projectName: string
|
||||
onChange: (value: string) => void
|
||||
hasRenamePermissions?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
function ProjectNameEditableLabel({
|
||||
projectName,
|
||||
hasRenamePermissions,
|
||||
onChange,
|
||||
className,
|
||||
}: ProjectNameEditableLabelProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
|
||||
const canRename = hasRenamePermissions && !isRenaming
|
||||
|
||||
const [inputContent, setInputContent] = useState(projectName)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isRenaming) {
|
||||
inputRef.current?.select()
|
||||
}
|
||||
}, [isRenaming])
|
||||
|
||||
function startRenaming() {
|
||||
if (canRename) {
|
||||
setInputContent(projectName)
|
||||
setIsRenaming(true)
|
||||
}
|
||||
}
|
||||
|
||||
function finishRenaming() {
|
||||
setIsRenaming(false)
|
||||
onChange(inputContent)
|
||||
}
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
finishRenaming()
|
||||
}
|
||||
}
|
||||
|
||||
function handleOnChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
setInputContent(event.target.value)
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
if (isRenaming) {
|
||||
finishRenaming()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('project-name', className)}>
|
||||
{!isRenaming && (
|
||||
<span className="name" onDoubleClick={startRenaming}>
|
||||
{projectName}
|
||||
</span>
|
||||
)}
|
||||
{isRenaming && (
|
||||
<OLFormControl
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleOnChange}
|
||||
onBlur={handleBlur}
|
||||
value={inputContent}
|
||||
/>
|
||||
)}
|
||||
{canRename && (
|
||||
<OLTooltip
|
||||
id="online-user"
|
||||
description={t('rename')}
|
||||
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid, jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus */}
|
||||
<a className="rename" role="button" onClick={startRenaming}>
|
||||
<MaterialIcon type="edit" className="align-text-bottom" />
|
||||
</a>
|
||||
</OLTooltip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectNameEditableLabel
|
@@ -0,0 +1,23 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
function ShareProjectButton({ onClick }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="toolbar-item">
|
||||
<button type="button" className="btn btn-full-height" onClick={onClick}>
|
||||
<MaterialIcon type="group_add" className="align-middle" />
|
||||
<p className="toolbar-label">{t('share')}</p>
|
||||
</button>
|
||||
<div id="toolbar-cio-share" className="toolbar-cio-tooltip" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
ShareProjectButton.propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
export default ShareProjectButton
|
@@ -0,0 +1,157 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MenuButton from './menu-button'
|
||||
import CobrandingLogo from './cobranding-logo'
|
||||
import BackToProjectsButton from './back-to-projects-button'
|
||||
import UpgradePrompt from './upgrade-prompt'
|
||||
import ChatToggleButton from './chat-toggle-button'
|
||||
import LayoutDropdownButton from './layout-dropdown-button'
|
||||
import OnlineUsersWidget from './online-users-widget'
|
||||
import ProjectNameEditableLabel from './project-name-editable-label'
|
||||
import TrackChangesToggleButton from './track-changes-toggle-button'
|
||||
import HistoryToggleButton from './history-toggle-button'
|
||||
import ShareProjectButton from './share-project-button'
|
||||
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
|
||||
import BackToEditorButton from './back-to-editor-button'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
|
||||
import { canUseNewEditor } from '@/features/ide-redesign/utils/new-editor-utils'
|
||||
import TryNewEditorButton from '../try-new-editor-button'
|
||||
|
||||
const [publishModalModules] = importOverleafModules('publishModal')
|
||||
const PublishButton = publishModalModules?.import.default
|
||||
|
||||
const offlineModeToolbarButtons = importOverleafModules(
|
||||
'offlineModeToolbarButtons'
|
||||
)
|
||||
// double opt-in
|
||||
const enableROMirrorOnClient =
|
||||
isSplitTestEnabled('ro-mirror-on-client') &&
|
||||
new URLSearchParams(window.location.search).get('ro-mirror-on-client') ===
|
||||
'enabled'
|
||||
|
||||
const ToolbarHeader = React.memo(function ToolbarHeader({
|
||||
cobranding,
|
||||
onShowLeftMenuClick,
|
||||
chatIsOpen,
|
||||
toggleChatOpen,
|
||||
reviewPanelOpen,
|
||||
toggleReviewPanelOpen,
|
||||
historyIsOpen,
|
||||
toggleHistoryOpen,
|
||||
unreadMessageCount,
|
||||
onlineUsers,
|
||||
goToUser,
|
||||
isRestrictedTokenMember,
|
||||
hasPublishPermissions,
|
||||
chatVisible,
|
||||
projectName,
|
||||
renameProject,
|
||||
hasRenamePermissions,
|
||||
openShareModal,
|
||||
trackChangesVisible,
|
||||
}) {
|
||||
const chatEnabled = getMeta('ol-chatEnabled')
|
||||
|
||||
const { t } = useTranslation()
|
||||
const shouldDisplayPublishButton = hasPublishPermissions && PublishButton
|
||||
|
||||
return (
|
||||
<header
|
||||
className="toolbar toolbar-header"
|
||||
role="navigation"
|
||||
aria-label={t('project_layout_sharing_submission')}
|
||||
>
|
||||
<div className="toolbar-left">
|
||||
<MenuButton onClick={onShowLeftMenuClick} />
|
||||
{cobranding && cobranding.logoImgUrl && (
|
||||
<CobrandingLogo {...cobranding} />
|
||||
)}
|
||||
<BackToProjectsButton />
|
||||
{enableROMirrorOnClient &&
|
||||
offlineModeToolbarButtons.map(
|
||||
({ path, import: { default: OfflineModeToolbarButton } }) => {
|
||||
return <OfflineModeToolbarButton key={path} />
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
{getMeta('ol-showUpgradePrompt') && (
|
||||
<div className="d-flex align-items-center">
|
||||
<UpgradePrompt />
|
||||
</div>
|
||||
)}
|
||||
<ProjectNameEditableLabel
|
||||
className="toolbar-center"
|
||||
projectName={projectName}
|
||||
hasRenamePermissions={hasRenamePermissions}
|
||||
onChange={renameProject}
|
||||
/>
|
||||
|
||||
<div className="toolbar-right">
|
||||
{canUseNewEditor() && <TryNewEditorButton />}
|
||||
|
||||
<OnlineUsersWidget onlineUsers={onlineUsers} goToUser={goToUser} />
|
||||
|
||||
{historyIsOpen ? (
|
||||
<div className="d-flex align-items-center">
|
||||
<BackToEditorButton onClick={toggleHistoryOpen} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{trackChangesVisible && (
|
||||
<TrackChangesToggleButton
|
||||
onMouseDown={toggleReviewPanelOpen}
|
||||
disabled={historyIsOpen}
|
||||
trackChangesIsOpen={reviewPanelOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ShareProjectButton onClick={openShareModal} />
|
||||
{shouldDisplayPublishButton && (
|
||||
<PublishButton cobranding={cobranding} />
|
||||
)}
|
||||
|
||||
{!isRestrictedTokenMember && (
|
||||
<HistoryToggleButton onClick={toggleHistoryOpen} />
|
||||
)}
|
||||
|
||||
<LayoutDropdownButton />
|
||||
|
||||
{chatEnabled && chatVisible && (
|
||||
<ChatToggleButton
|
||||
chatIsOpen={chatIsOpen}
|
||||
onClick={toggleChatOpen}
|
||||
unreadMessageCount={unreadMessageCount}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
})
|
||||
|
||||
ToolbarHeader.propTypes = {
|
||||
onShowLeftMenuClick: PropTypes.func.isRequired,
|
||||
cobranding: PropTypes.object,
|
||||
chatIsOpen: PropTypes.bool,
|
||||
toggleChatOpen: PropTypes.func.isRequired,
|
||||
reviewPanelOpen: PropTypes.bool,
|
||||
toggleReviewPanelOpen: PropTypes.func.isRequired,
|
||||
historyIsOpen: PropTypes.bool,
|
||||
toggleHistoryOpen: PropTypes.func.isRequired,
|
||||
unreadMessageCount: PropTypes.number.isRequired,
|
||||
onlineUsers: PropTypes.array.isRequired,
|
||||
goToUser: PropTypes.func.isRequired,
|
||||
isRestrictedTokenMember: PropTypes.bool,
|
||||
hasPublishPermissions: PropTypes.bool,
|
||||
chatVisible: PropTypes.bool,
|
||||
projectName: PropTypes.string.isRequired,
|
||||
renameProject: PropTypes.func.isRequired,
|
||||
hasRenamePermissions: PropTypes.bool,
|
||||
openShareModal: PropTypes.func.isRequired,
|
||||
trackChangesVisible: PropTypes.bool,
|
||||
}
|
||||
|
||||
export default ToolbarHeader
|
@@ -0,0 +1,39 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import classNames from 'classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
function TrackChangesToggleButton({
|
||||
trackChangesIsOpen,
|
||||
disabled,
|
||||
onMouseDown,
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const classes = classNames('btn', 'btn-full-height', {
|
||||
active: trackChangesIsOpen && !disabled,
|
||||
disabled,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="toolbar-item">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={classes}
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
<MaterialIcon type="rate_review" className="align-middle" />
|
||||
<p className="toolbar-label">{t('review')}</p>
|
||||
</button>
|
||||
<div id="toolbar-cio-review" className="toolbar-cio-tooltip" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
TrackChangesToggleButton.propTypes = {
|
||||
trackChangesIsOpen: PropTypes.bool,
|
||||
disabled: PropTypes.bool,
|
||||
onMouseDown: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
export default TrackChangesToggleButton
|
@@ -0,0 +1,27 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as eventTracking from '../../../infrastructure/event-tracking'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
|
||||
function UpgradePrompt() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
function handleClick(e) {
|
||||
eventTracking.send('subscription-funnel', 'code-editor', 'upgrade')
|
||||
eventTracking.sendMB('upgrade-button-click', { source: 'code-editor' })
|
||||
}
|
||||
|
||||
return (
|
||||
<OLButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="toolbar-header-upgrade-prompt"
|
||||
href="/user/subscription/plans?itm_referrer=editor-header-upgrade-prompt"
|
||||
target="_blank"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{t('upgrade')}
|
||||
</OLButton>
|
||||
)
|
||||
}
|
||||
|
||||
export default UpgradePrompt
|
@@ -0,0 +1,28 @@
|
||||
import { useCallback } from 'react'
|
||||
import OLButton from '../ui/components/ol/ol-button'
|
||||
import { useIdeRedesignSwitcherContext } from '../ide-react/context/ide-redesign-switcher-context'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const TryNewEditorButton = () => {
|
||||
const { t } = useTranslation()
|
||||
const { setShowSwitcherModal } = useIdeRedesignSwitcherContext()
|
||||
const onClick = useCallback(() => {
|
||||
setShowSwitcherModal(true)
|
||||
}, [setShowSwitcherModal])
|
||||
return (
|
||||
<div className="d-flex align-items-center">
|
||||
<OLButton
|
||||
className="toolbar-experiment-button"
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
leadingIcon={<MaterialIcon type="experiment" unfilled />}
|
||||
variant="info"
|
||||
>
|
||||
{t('try_the_new_editor')}
|
||||
</OLButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TryNewEditorButton
|
@@ -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 })
|
||||
}
|
71
services/web/frontend/js/features/event-tracking/index.js
Normal file
71
services/web/frontend/js/features/event-tracking/index.js
Normal 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)
|
@@ -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)
|
||||
}
|
16
services/web/frontend/js/features/fallback-image/index.js
Normal file
16
services/web/frontend/js/features/fallback-image/index.js
Normal 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)
|
||||
}
|
||||
})
|
107
services/web/frontend/js/features/faq-search/index.js
Normal file
107
services/web/frontend/js/features/faq-search/index.js
Normal 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)
|
@@ -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
|
@@ -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
|
@@ -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,
|
||||
}
|
@@ -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,
|
||||
}
|
@@ -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,
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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,
|
||||
}
|
@@ -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} />
|
||||
|
||||
{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
Reference in New Issue
Block a user