diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 3c50ef96ef..85d472ed56 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -171,12 +171,15 @@
"bulk_reject_confirm": "",
"buy_overleaf_assist": "",
"by_subscribing_you_agree_to_our_terms_of_service": "",
+ "can_add_tracked_changes_and_comments": "",
"can_edit": "",
+ "can_edit_content": "",
"can_link_institution_email_acct_to_institution_acct": "",
"can_link_your_institution_acct_2": "",
"can_now_relink_dropbox": "",
"can_review": "",
"can_view": "",
+ "can_view_content": "",
"cancel": "",
"cancel_add_on": "",
"cancel_anytime": "",
@@ -1300,6 +1303,7 @@
"review": "",
"review_your_peers_work": "",
"reviewer": "",
+ "reviewing": "",
"revoke": "",
"revoke_invite": "",
"right": "",
@@ -1839,6 +1843,7 @@
"view_pdf": "",
"view_your_invoices": "",
"viewer": "",
+ "viewing": "",
"viewing_x": "",
"visual_editor": "",
"visual_editor_is_only_available_for_tex_files": "",
diff --git a/services/web/frontend/js/features/review-panel-new/components/review-mode-switcher.tsx b/services/web/frontend/js/features/review-panel-new/components/review-mode-switcher.tsx
new file mode 100644
index 0000000000..d1956d8d2f
--- /dev/null
+++ b/services/web/frontend/js/features/review-panel-new/components/review-mode-switcher.tsx
@@ -0,0 +1,171 @@
+import { forwardRef, memo, MouseEventHandler } from 'react'
+import {
+ Dropdown,
+ DropdownMenu,
+ DropdownToggle,
+} from '@/features/ui/components/bootstrap-5/dropdown-menu'
+import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
+import MaterialIcon from '@/shared/components/material-icon'
+import classNames from 'classnames'
+import {
+ useTrackChangesStateActionsContext,
+ useTrackChangesStateContext,
+} from '../context/track-changes-state-context'
+import { useUserContext } from '@/shared/context/user-context'
+import { useTranslation } from 'react-i18next'
+import { useEditorContext } from '@/shared/context/editor-context'
+import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
+
+type Mode = 'viewing' | 'reviewing' | 'editing'
+
+const useCurrentMode = (): Mode => {
+ const trackChanges = useTrackChangesStateContext()
+ const user = useUserContext()
+ const trackChangesForCurrentUser =
+ trackChanges?.onForEveryone ||
+ (user && user.id && trackChanges?.onForMembers[user.id])
+ const { write } = usePermissionsContext()
+
+ if (write && !trackChangesForCurrentUser) {
+ return 'editing'
+ } else if (write) {
+ return 'reviewing'
+ }
+
+ return 'viewing'
+}
+
+function ReviewModeSwitcher() {
+ const { t } = useTranslation()
+ const { saveTrackChangesForCurrentUser } =
+ useTrackChangesStateActionsContext()
+ const mode = useCurrentMode()
+
+ const { permissionsLevel } = useEditorContext()
+
+ const enableEditing =
+ permissionsLevel === 'owner' || permissionsLevel === 'readAndWrite'
+ const enableReviewing = enableEditing || permissionsLevel === 'review'
+ const showViewOption = !enableReviewing
+
+ return (
+
+
+
+
+ {
+ saveTrackChangesForCurrentUser(false)
+ }}
+ description={t('can_edit_content')}
+ leadingIcon="edit"
+ active={enableEditing && mode === 'editing'}
+ >
+ {t('editing')}
+
+ {
+ saveTrackChangesForCurrentUser(true)
+ }}
+ description={t('can_add_tracked_changes_and_comments')}
+ leadingIcon="rate_review"
+ active={enableReviewing && mode === 'reviewing'}
+ >
+ {t('reviewing')}
+
+ {showViewOption && (
+ {
+ saveTrackChangesForCurrentUser(true)
+ }}
+ description={t('can_view_content')}
+ leadingIcon="visibility"
+ active={mode === 'viewing'}
+ >
+ {t('viewing')}
+
+ )}
+
+
+
+ )
+}
+
+const ModeSwitcherToggleButton = forwardRef<
+ HTMLButtonElement,
+ { onClick: MouseEventHandler; 'aria-expanded': boolean }
+>(({ onClick, 'aria-expanded': ariaExpanded }, ref) => {
+ const { t } = useTranslation()
+ const mode = useCurrentMode()
+
+ if (mode === 'editing') {
+ return (
+
+ )
+ } else if (mode === 'reviewing') {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+})
+
+const ModeSwitcherToggleButtonContent = forwardRef<
+ HTMLButtonElement,
+ {
+ onClick: MouseEventHandler
+ className: string
+ iconType: string
+ label: string
+ ariaExpanded: boolean
+ }
+>(({ onClick, className, iconType, label, ariaExpanded }, ref) => {
+ return (
+
+ )
+})
+
+ModeSwitcherToggleButton.displayName = 'ModeSwitcherToggleButton'
+ModeSwitcherToggleButtonContent.displayName = 'ModeSwitcherToggleButtonContent'
+
+export default memo(ReviewModeSwitcher)
diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-container.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-container.tsx
index 93be6ab439..9ee8bc4062 100644
--- a/services/web/frontend/js/features/review-panel-new/components/review-panel-container.tsx
+++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-container.tsx
@@ -8,6 +8,8 @@ import { useThreadsContext } from '@/features/review-panel-new/context/threads-c
import { hasActiveRange } from '@/features/review-panel-new/utils/has-active-range'
import TrackChangesOnWidget from './track-changes-on-widget'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
+import ReviewModeSwitcher from './review-mode-switcher'
+import getMeta from '@/utils/meta'
function ReviewPanelContainer() {
const view = useCodeMirrorViewContext()
@@ -15,6 +17,7 @@ function ReviewPanelContainer() {
const threads = useThreadsContext()
const { reviewPanelOpen } = useLayoutContext()
const { wantTrackChanges } = useEditorManagerContext()
+ const enableReviewerRole = getMeta('ol-isReviewerRoleEnabled')
if (!view) {
return null
@@ -22,11 +25,13 @@ function ReviewPanelContainer() {
const hasCommentOrChange = hasActiveRange(ranges, threads)
const showPanel = reviewPanelOpen || hasCommentOrChange
- const showTrackChangesWidget = wantTrackChanges && !reviewPanelOpen
+ const showTrackChangesWidget =
+ !enableReviewerRole && wantTrackChanges && !reviewPanelOpen
return ReactDOM.createPortal(
<>
{showTrackChangesWidget && }
+ {enableReviewerRole && }
{showPanel && }
>,
view.scrollDOM
diff --git a/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx b/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx
index e6f1783be8..d47da8c10a 100644
--- a/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx
+++ b/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx
@@ -36,6 +36,7 @@ type SaveTrackChangesRequestBody = {
type TrackChangesStateActions = {
saveTrackChanges: (trackChangesBody: SaveTrackChangesRequestBody) => void
+ saveTrackChangesForCurrentUser: (trackChanges: boolean) => void
}
const TrackChangesStateActionsContext = createContext<
@@ -64,17 +65,6 @@ export const TrackChangesStateProvider: FC = ({ children }) => {
)
}, [setWantTrackChanges, trackChangesValue, user.id])
- const actions = useMemo(
- () => ({
- async saveTrackChanges(trackChangesBody: SaveTrackChangesRequestBody) {
- postJSON(`/project/${project._id}/track_changes`, {
- body: trackChangesBody,
- })
- },
- }),
- [project._id]
- )
-
const trackChangesIsObject =
trackChangesValue !== true && trackChangesValue !== false
const onForEveryone = trackChangesValue === true
@@ -94,6 +84,38 @@ export const TrackChangesStateProvider: FC = ({ children }) => {
return onForMembers
}, [trackChangesIsObject, trackChangesValue])
+ const saveTrackChanges = useCallback(
+ async (trackChangesBody: SaveTrackChangesRequestBody) => {
+ postJSON(`/project/${project._id}/track_changes`, {
+ body: trackChangesBody,
+ })
+ },
+ [project._id]
+ )
+
+ const saveTrackChangesForCurrentUser = useCallback(
+ async (trackChanges: boolean) => {
+ if (user.id) {
+ saveTrackChanges({
+ on_for: {
+ ...onForMembers,
+ [user.id]: trackChanges,
+ },
+ on_for_guests: onForGuests,
+ })
+ }
+ },
+ [onForMembers, onForGuests, user.id, saveTrackChanges]
+ )
+
+ const actions = useMemo(
+ () => ({
+ saveTrackChanges,
+ saveTrackChangesForCurrentUser,
+ }),
+ [saveTrackChanges, saveTrackChangesForCurrentUser]
+ )
+
useEventListener(
'toggle-track-changes',
useCallback(() => {
diff --git a/services/web/frontend/js/shared/context/editor-context.tsx b/services/web/frontend/js/shared/context/editor-context.tsx
index ac1c698ec3..beeb155358 100644
--- a/services/web/frontend/js/shared/context/editor-context.tsx
+++ b/services/web/frontend/js/shared/context/editor-context.tsx
@@ -43,7 +43,7 @@ export const EditorContext = createContext<
isProjectOwner: boolean
isRestrictedTokenMember?: boolean
isPendingEditor: boolean
- permissionsLevel: 'readOnly' | 'readAndWrite' | 'owner'
+ permissionsLevel: PermissionsLevel
deactivateTutorial: (tutorial: string) => void
inactiveTutorials: string[]
currentPopup: string | null
diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/dropdown-menu.scss b/services/web/frontend/stylesheets/bootstrap-5/components/dropdown-menu.scss
index b603c0093e..f312630bef 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/components/dropdown-menu.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/components/dropdown-menu.scss
@@ -109,6 +109,7 @@
color: var(--content-secondary);
margin-top: var(--spacing-01);
+ text-wrap: wrap;
}
.dropdown-item-description-container {
diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/review-panel-new.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/review-panel-new.scss
index 279407c2bb..9803329db7 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/review-panel-new.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/review-panel-new.scss
@@ -768,3 +768,75 @@
color: $rp-type-blue;
}
}
+
+.review-mode-switcher-container {
+ position: sticky;
+ top: 0;
+ right: 0;
+}
+
+.review-mode-switcher {
+ position: absolute;
+ top: var(--spacing-03);
+ right: var(--spacing-03);
+
+ &:hover,
+ &:focus {
+ .review-mode-switcher-toggle-button.editing {
+ background-color: var(--bg-light-tertiary);
+ }
+
+ .review-mode-switcher-toggle-button.reviewing {
+ background-color: var(--yellow-20);
+ }
+
+ .review-mode-switcher-toggle-button.viewing {
+ background-color: var(--blue-20);
+ }
+
+ .review-mode-switcher-toggle-label {
+ display: block;
+ }
+ }
+}
+
+.review-mode-switcher-toggle-button {
+ all: unset;
+ z-index: 2;
+ font-family: $font-family-base;
+ display: flex;
+ align-items: center;
+ border-radius: 14px;
+ font-size: var(--font-size-02);
+ padding: var(--spacing-02) var(--spacing-03);
+ gap: var(--spacing-02);
+ height: 20px;
+
+ .material-symbols {
+ font-size: 16px;
+ font-variation-settings:
+ 'FILL' 0,
+ 'wght' 400,
+ 'GRAD' 0,
+ 'opsz' 20;
+ }
+
+ &.editing {
+ background-color: var(--bg-light-secondary);
+ color: var(--content-primary);
+ }
+
+ &.reviewing {
+ background-color: var(--yellow-10);
+ color: var(--yellow-60);
+ }
+
+ &.viewing {
+ background-color: var(--blue-10);
+ color: var(--blue-70);
+ }
+
+ .review-mode-switcher-toggle-label {
+ display: none;
+ }
+}
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index d6259fbd96..bc2ed19165 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -233,7 +233,9 @@
"by_joining_labs": "By joining Labs, you agree to receive occasional emails and updates from Overleaf—for example, to request your feedback. You also agree to our <0>terms of service0> and <1>privacy notice1>.",
"by_registering_you_agree_to_our_terms_of_service": "By registering, you agree to our <0>terms of service0> and <1>privacy notice1>.",
"by_subscribing_you_agree_to_our_terms_of_service": "By subscribing, you agree to our <0>terms of service0>.",
+ "can_add_tracked_changes_and_comments": "Can add tracked changes and comments",
"can_edit": "Can edit",
+ "can_edit_content": "Can edit content",
"can_link_institution_email_acct_to_institution_acct": "You can now link your __email__ __appName__ account to your __institutionName__ institutional account.",
"can_link_institution_email_by_clicking": "You can link your __email__ __appName__ account to your __institutionName__ account by clicking __clickText__.",
"can_link_institution_email_to_login": "You can link your __email__ __appName__ account to your __institutionName__ account, which will allow you to log in to __appName__ through your institution and will reconfirm your institutional email address.",
@@ -241,6 +243,7 @@
"can_now_relink_dropbox": "You can now <0>relink your Dropbox account0>.",
"can_review": "Can review",
"can_view": "Can view",
+ "can_view_content": "Can view content",
"cancel": "Cancel",
"cancel_add_on": "Cancel add-on",
"cancel_anytime": "We’re confident that you’ll love __appName__, but if not you can cancel anytime. We’ll give you your money back, no questions asked, if you let us know within 30 days.",
@@ -1825,6 +1828,7 @@
"review": "Review",
"review_your_peers_work": "Review your peers’ work",
"reviewer": "Reviewer",
+ "reviewing": "Reviewing",
"revoke": "Revoke",
"revoke_invite": "Revoke Invite",
"right": "Right",
@@ -2482,6 +2486,7 @@
"view_source": "View Source",
"view_your_invoices": "View your invoices",
"viewer": "Viewer",
+ "viewing": "Viewing",
"viewing_x": "Viewing <0>__endTime__0>",
"visual_editor": "Visual Editor",
"visual_editor_is_only_available_for_tex_files": "Visual Editor is only available for TeX files",