mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
Merge pull request #34357 from overleaf/mg-combine-floating-menus
Add combined floating menu for comment, tracked changes, writefull GitOrigin-RevId: 4de5b9d3b1717fc76394f7c0edfe4d7230394c8d
This commit is contained in:
@@ -1098,6 +1098,7 @@ module.exports = {
|
||||
referenceSearchSetting: [],
|
||||
settingsModalEditorTabSections: [],
|
||||
settingsModalSpellcheckSections: [],
|
||||
editorFloatingMenuActions: [],
|
||||
errorLogsComponents: [],
|
||||
referenceIndices: [],
|
||||
railEntries: [],
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { FC, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import { sendMB } from '@/infrastructure/event-tracking'
|
||||
|
||||
const AddCommentAction: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const permissions = usePermissionsContext()
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
sendMB('add-comment', { location: 'tooltip' })
|
||||
window.dispatchEvent(new Event('add-new-review-comment'))
|
||||
}, [])
|
||||
|
||||
if (!permissions.comment) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<OLTooltip
|
||||
id="editor-floating-menu-add-comment"
|
||||
description={t('add_comment')}
|
||||
overlayProps={{ placement: 'right' }}
|
||||
>
|
||||
<button
|
||||
className="editor-floating-menu-button"
|
||||
onClick={handleClick}
|
||||
aria-label={t('add_comment')}
|
||||
>
|
||||
<MaterialIcon type="chat" />
|
||||
</button>
|
||||
</OLTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddCommentAction
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { useCallback } from 'react'
|
||||
import { EditorSelection } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context'
|
||||
import { buildAddNewCommentRangeEffect } from '@/features/source-editor/extensions/review-tooltip'
|
||||
import { selectHighlightedOrNearestToken } from '@/features/source-editor/utils/select-highlighted-or-nearest-token'
|
||||
import { isCursorNearViewportEdge } from '@/features/source-editor/utils/is-cursor-near-edge'
|
||||
import useEventListener from '@/shared/hooks/use-event-listener'
|
||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import { useReviewPanelViewActionsContext } from '@/features/review-panel/context/review-panel-view-context'
|
||||
import useReviewPanelLayout from '@/features/review-panel/hooks/use-review-panel-layout'
|
||||
|
||||
// Headless owner of the "add comment" editor command. It is mounted whenever
|
||||
// commenting is possible, independently of the floating menu's visibility, so
|
||||
// the keyboard shortcut, toolbar command and floating menu button
|
||||
// (which all dispatch `add-new-review-comment`) work even when the menu is hidden
|
||||
const AddCommentCommand = () => {
|
||||
const view = useCodeMirrorViewContext()
|
||||
const permissions = usePermissionsContext()
|
||||
const { setView } = useReviewPanelViewActionsContext()
|
||||
const { openReviewPanel } = useReviewPanelLayout()
|
||||
|
||||
const addComment = useCallback(() => {
|
||||
if (!permissions.comment) {
|
||||
return
|
||||
}
|
||||
|
||||
let { main } = view.state.selection
|
||||
|
||||
if (main.empty) {
|
||||
const tokenRange = selectHighlightedOrNearestToken(view.state)
|
||||
if (!tokenRange) {
|
||||
return
|
||||
}
|
||||
main = EditorSelection.range(tokenRange.from, tokenRange.to)
|
||||
}
|
||||
|
||||
openReviewPanel()
|
||||
setView('cur_file')
|
||||
|
||||
const effects = isCursorNearViewportEdge(view, main.anchor)
|
||||
? [
|
||||
buildAddNewCommentRangeEffect(main),
|
||||
EditorView.scrollIntoView(main.anchor, { y: 'center' }),
|
||||
]
|
||||
: [buildAddNewCommentRangeEffect(main)]
|
||||
|
||||
// Dispatching a new selection clears the review tooltip state, which
|
||||
// dismisses the floating menu — no need to toggle menu state from here.
|
||||
view.dispatch({
|
||||
selection: { anchor: main.anchor, head: main.head },
|
||||
effects,
|
||||
})
|
||||
}, [view, permissions.comment, openReviewPanel, setView])
|
||||
|
||||
useEventListener('add-new-review-comment', addComment)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default AddCommentCommand
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { FC, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import {
|
||||
useCodeMirrorStateContext,
|
||||
useCodeMirrorViewContext,
|
||||
} from '@/features/source-editor/components/codemirror-context'
|
||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import { useModalsContext } from '@/features/ide-react/context/modals-context'
|
||||
import {
|
||||
useRangesActionsContext,
|
||||
useRangesContext,
|
||||
} from '@/features/review-panel/context/ranges-context'
|
||||
import { numberOfChangesInSelection } from '@/features/review-panel/utils/changes-in-selection'
|
||||
import { isInsertOperation } from '@/utils/operations'
|
||||
import { captureException } from '@/infrastructure/error-reporter'
|
||||
|
||||
const TrackedChangesActions: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const view = useCodeMirrorViewContext()
|
||||
const state = useCodeMirrorStateContext()
|
||||
const permissions = usePermissionsContext()
|
||||
const ranges = useRangesContext()
|
||||
const { acceptChanges, rejectChanges } = useRangesActionsContext()
|
||||
const { showGenericConfirmModal } = useModalsContext()
|
||||
|
||||
const changesInSelection = useMemo(() => {
|
||||
return (ranges?.changes ?? []).filter(({ op }) => {
|
||||
const opFrom = op.p
|
||||
const opLength = isInsertOperation(op) ? op.i.length : 0
|
||||
const opTo = opFrom + opLength
|
||||
const selection = state.selection.main
|
||||
return opFrom >= selection.from && opTo <= selection.to
|
||||
})
|
||||
}, [ranges, state.selection.main])
|
||||
|
||||
const acceptChangesHandler = useCallback(() => {
|
||||
const nChanges = numberOfChangesInSelection(
|
||||
ranges,
|
||||
view.state.selection.main
|
||||
)
|
||||
showGenericConfirmModal({
|
||||
message: t('confirm_accept_selected_changes', { count: nChanges }),
|
||||
title: t('accept_selected_changes'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await acceptChanges(...changesInSelection)
|
||||
} catch (err: any) {
|
||||
captureException(err)
|
||||
}
|
||||
},
|
||||
primaryVariant: 'danger',
|
||||
})
|
||||
}, [
|
||||
acceptChanges,
|
||||
changesInSelection,
|
||||
ranges,
|
||||
showGenericConfirmModal,
|
||||
view,
|
||||
t,
|
||||
])
|
||||
|
||||
const rejectChangesHandler = useCallback(() => {
|
||||
const nChanges = numberOfChangesInSelection(
|
||||
ranges,
|
||||
view.state.selection.main
|
||||
)
|
||||
showGenericConfirmModal({
|
||||
message: t('confirm_reject_selected_changes', { count: nChanges }),
|
||||
title: t('reject_selected_changes'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await rejectChanges(...changesInSelection)
|
||||
} catch (err: any) {
|
||||
captureException(err)
|
||||
}
|
||||
},
|
||||
primaryVariant: 'danger',
|
||||
})
|
||||
}, [
|
||||
showGenericConfirmModal,
|
||||
t,
|
||||
ranges,
|
||||
view,
|
||||
rejectChanges,
|
||||
changesInSelection,
|
||||
])
|
||||
|
||||
if (!permissions.comment || changesInSelection.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="editor-floating-menu-divider" />
|
||||
<OLTooltip
|
||||
id="editor-floating-menu-accept-changes"
|
||||
description={t('accept_selected_changes')}
|
||||
overlayProps={{ placement: 'right' }}
|
||||
>
|
||||
<button
|
||||
className="editor-floating-menu-button"
|
||||
onClick={acceptChangesHandler}
|
||||
aria-label={t('accept_selected_changes')}
|
||||
>
|
||||
<MaterialIcon type="check" />
|
||||
</button>
|
||||
</OLTooltip>
|
||||
<OLTooltip
|
||||
id="editor-floating-menu-reject-changes"
|
||||
description={t('reject_selected_changes')}
|
||||
overlayProps={{ placement: 'right' }}
|
||||
>
|
||||
<button
|
||||
className="editor-floating-menu-button"
|
||||
onClick={rejectChangesHandler}
|
||||
aria-label={t('reject_selected_changes')}
|
||||
>
|
||||
<MaterialIcon type="clear" />
|
||||
</button>
|
||||
</OLTooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrackedChangesActions
|
||||
@@ -0,0 +1,188 @@
|
||||
import React, {
|
||||
ComponentType,
|
||||
FC,
|
||||
memo,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import classNames from 'classnames'
|
||||
import { getTooltip } from '@codemirror/view'
|
||||
import importOverleafModules from '../../../macros/import-overleaf-module.macro'
|
||||
import {
|
||||
useCodeMirrorStateContext,
|
||||
useCodeMirrorViewContext,
|
||||
} from '@/features/source-editor/components/codemirror-context'
|
||||
import { reviewTooltipStateField } from '@/features/source-editor/extensions/review-tooltip'
|
||||
import usePreviousValue from '@/shared/hooks/use-previous-value'
|
||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
|
||||
import AddCommentAction from './components/add-comment-action'
|
||||
import TrackedChangesActions from './components/tracked-changes-actions'
|
||||
|
||||
const TOOLTIP_SHOW_DELAY = 120
|
||||
|
||||
// Each default-exports a self-gating component.
|
||||
const editorFloatingMenuActions = importOverleafModules(
|
||||
'editorFloatingMenuActions'
|
||||
) as { import: { default: ComponentType }; path: string }[]
|
||||
|
||||
const EditorFloatingMenu: FC = () => {
|
||||
const state = useCodeMirrorStateContext()
|
||||
const view = useCodeMirrorViewContext()
|
||||
const [show, setShow] = useState(true)
|
||||
const tooltipState = state.field(reviewTooltipStateField, false)?.tooltip
|
||||
const previousTooltipState = usePreviousValue(tooltipState)
|
||||
|
||||
useEffect(() => {
|
||||
if (tooltipState !== null && previousTooltipState === null) {
|
||||
setShow(true)
|
||||
}
|
||||
}, [tooltipState, previousTooltipState])
|
||||
|
||||
useEffect(() => {
|
||||
if (!show || !tooltipState) {
|
||||
return
|
||||
}
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
const target = event.target as Element | null
|
||||
if (
|
||||
!view.contentDOM.contains(target) &&
|
||||
!target?.closest?.('.review-tooltip-menu-container') &&
|
||||
!target?.closest?.('.modal') &&
|
||||
!target?.closest?.('.modal-backdrop')
|
||||
) {
|
||||
setShow(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleMouseDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMouseDown)
|
||||
}
|
||||
}, [show, tooltipState, view])
|
||||
|
||||
if (!show || !tooltipState) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tooltipView = getTooltip(view, tooltipState)
|
||||
|
||||
if (!tooltipView) {
|
||||
return null
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(<EditorFloatingMenuContent />, tooltipView.dom)
|
||||
}
|
||||
|
||||
const EditorFloatingMenuContent = memo(function EditorFloatingMenuContent() {
|
||||
const view = useCodeMirrorViewContext()
|
||||
const { reviewPanelOpen } = useLayoutContext()
|
||||
const { wantTrackChanges } = useEditorPropertiesContext()
|
||||
const [visible, setVisible] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const measure = () => {
|
||||
view.requestMeasure({
|
||||
key: 'editor-floating-menu-position',
|
||||
read(view) {
|
||||
const cursorCoords = view.coordsAtPos(view.state.selection.main.head)
|
||||
if (!cursorCoords) {
|
||||
return
|
||||
}
|
||||
|
||||
const menuHeight =
|
||||
menuRef.current?.getBoundingClientRect().height ?? 0
|
||||
const scrollDomRect = view.scrollDOM.getBoundingClientRect()
|
||||
const contentDomRect = view.contentDOM.getBoundingClientRect()
|
||||
const cursorCenterY = (cursorCoords.top + cursorCoords.bottom) / 2
|
||||
|
||||
if (
|
||||
// Cursor scrolls out of view at the top
|
||||
cursorCoords.top < scrollDomRect.top ||
|
||||
// Cursor scrolls out of view at the bottom
|
||||
cursorCoords.top > scrollDomRect.bottom
|
||||
) {
|
||||
return { visibility: 'hidden' as const }
|
||||
}
|
||||
|
||||
return {
|
||||
position: 'fixed' as const,
|
||||
// Align centrally
|
||||
top: cursorCenterY - menuHeight / 2,
|
||||
right: window.innerWidth - contentDomRect.left,
|
||||
}
|
||||
},
|
||||
// Mutate the DOM directly rather than via state to avoid re-rendering
|
||||
// on every scroll frame.
|
||||
write(res) {
|
||||
const el = menuRef.current
|
||||
if (!el || !res) return
|
||||
// Only toggle visibility when off-screen
|
||||
if (res.visibility === 'hidden') {
|
||||
el.style.visibility = 'hidden'
|
||||
} else {
|
||||
el.style.visibility = ''
|
||||
el.style.position = res.position
|
||||
el.style.top = `${res.top}px`
|
||||
el.style.right = `${res.right}px`
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
measure()
|
||||
|
||||
// Re-center when the menu's own height changes (e.g. tracked-change
|
||||
// actions appear/disappear).
|
||||
const observer = new ResizeObserver(measure)
|
||||
if (menuRef.current) {
|
||||
observer.observe(menuRef.current)
|
||||
}
|
||||
// The scroller's box tracks the editor pane, so this catches pane/window
|
||||
// resizes. It doesn't fire on scroll, hence the separate scroll listener.
|
||||
observer.observe(view.scrollDOM)
|
||||
|
||||
// Track the cursor as the editor scrolls.
|
||||
view.scrollDOM.addEventListener('scroll', measure)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
view.scrollDOM.removeEventListener('scroll', measure)
|
||||
}
|
||||
}, [view, reviewPanelOpen, wantTrackChanges])
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(false)
|
||||
const timeout = setTimeout(() => {
|
||||
setVisible(true)
|
||||
}, TOOLTIP_SHOW_DELAY)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={classNames('editor-floating-menu', {
|
||||
'editor-floating-menu-visible': visible,
|
||||
})}
|
||||
>
|
||||
<AddCommentAction />
|
||||
<TrackedChangesActions />
|
||||
{editorFloatingMenuActions.map(
|
||||
({ import: { default: Component }, path }) => (
|
||||
<React.Fragment key={path}>
|
||||
<div className="editor-floating-menu-divider" />
|
||||
<Component />
|
||||
</React.Fragment>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default EditorFloatingMenu
|
||||
@@ -44,6 +44,7 @@ const EDIT_MODE_SWITCH_WIDGET_HEIGHT = 40
|
||||
const CM_LINE_RIGHT_PADDING = 8
|
||||
const TOOLTIP_SHOW_DELAY = 120
|
||||
|
||||
// TODO remove when `writefull-toolbar-migration` fully rolled out
|
||||
const ReviewTooltipMenu: FC = () => {
|
||||
const state = useCodeMirrorStateContext()
|
||||
const view = useCodeMirrorViewContext()
|
||||
|
||||
@@ -13,6 +13,8 @@ import { ReviewPanelProviders } from '@/features/review-panel/context/review-pan
|
||||
import { ReviewPanelRoot } from '@/features/review-panel/components/review-panel-root'
|
||||
import ReviewPanelTabsHeaderPortal from '@/features/review-panel/components/review-panel-tabs-header-portal'
|
||||
import ReviewTooltipMenu from '@/features/review-panel/components/review-tooltip-menu'
|
||||
import EditorFloatingMenu from '@/features/editor-floating-menu/editor-floating-menu'
|
||||
import AddCommentCommand from '@/features/editor-floating-menu/components/add-comment-command'
|
||||
import {
|
||||
CodeMirrorStateContext,
|
||||
CodeMirrorViewContext,
|
||||
@@ -93,6 +95,9 @@ function CodeMirrorEditorComponents({
|
||||
}: CodeMirrorEditorComponentsProps) {
|
||||
useToolbarMenuBarEditorCommands()
|
||||
const { features } = useProjectContext()
|
||||
const writefullToolbarMigrationEnabled = useFeatureFlag(
|
||||
'writefull-toolbar-migration'
|
||||
)
|
||||
return (
|
||||
<ReviewPanelProviders>
|
||||
<CodemirrorOutline />
|
||||
@@ -104,7 +109,15 @@ function CodeMirrorEditorComponents({
|
||||
|
||||
<MathPreviewTooltip />
|
||||
<EditorContextMenu />
|
||||
{features.trackChangesVisible && <ReviewTooltipMenu />}
|
||||
{features.trackChangesVisible &&
|
||||
(writefullToolbarMigrationEnabled ? (
|
||||
<>
|
||||
<AddCommentCommand />
|
||||
<EditorFloatingMenu />
|
||||
</>
|
||||
) : (
|
||||
<ReviewTooltipMenu />
|
||||
))}
|
||||
{features.trackChangesVisible && <ReviewPanelTabsHeaderPortal />}
|
||||
{features.trackChangesVisible && <ReviewPanelRoot />}
|
||||
{features.trackChangesVisible && <UpgradeTrackChangesModal />}
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
.project-ds-nav-page,
|
||||
// Codemirror tooltips are rendered outside of the main app container
|
||||
.cm-tooltip .dropdown-menu,
|
||||
.cm-tooltip .dropdown {
|
||||
.cm-tooltip .dropdown,
|
||||
.cm-tooltip .editor-floating-menu {
|
||||
@include dark-dropdown-menu;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
@import 'editor/tabs';
|
||||
@import 'editor/tags-input';
|
||||
@import 'editor/review-panel';
|
||||
@import 'editor/editor-floating-menu';
|
||||
@import 'editor/table-generator-column-width-modal';
|
||||
@import 'editor/math-preview';
|
||||
@import 'editor/references-search';
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
.editor-floating-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 2px 4px 0 #1e253029;
|
||||
background-color: var(--dropdown-background);
|
||||
color: var(--dropdown-text-color);
|
||||
border: 1px solid var(--border-divider);
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: var(--spacing-02);
|
||||
gap: var(--spacing-02);
|
||||
transition: opacity 0.05s ease-in;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.editor-floating-menu-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// Module actions self-gate and may render nothing, so hide the pill when it
|
||||
// has no buttons rather than showing an empty box.
|
||||
.editor-floating-menu:not(:has(button)) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.editor-floating-menu-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: inherit;
|
||||
color: inherit;
|
||||
border: none;
|
||||
padding: var(--spacing-01);
|
||||
border-radius: var(--border-radius-base);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--dropdown-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.editor-floating-menu-divider {
|
||||
height: 1px;
|
||||
background-color: var(--dropdown-border-divider);
|
||||
|
||||
// Hide dividers at the menu edges, or with no following button (e.g. a module
|
||||
// action rendered nothing). `~ * button` keeps it wrapper-agnostic.
|
||||
&:first-child,
|
||||
&:last-child,
|
||||
&:not(:has(~ button, ~ * button)) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten AppWritefullContainer's `<div class="writefull">` wrapper so the button
|
||||
// is a direct flex item of the menu.
|
||||
.editor-floating-menu .writefull {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
@include theme('default') {
|
||||
.cm-tooltip .editor-floating-menu {
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
import CodeMirrorEditor from '../../../../frontend/js/features/source-editor/components/codemirror-editor'
|
||||
import { EditorProviders, USER_ID } from '../../helpers/editor-providers'
|
||||
import { mockScope } from '../source-editor/helpers/mock-scope'
|
||||
import { TestContainer } from '../source-editor/helpers/test-container'
|
||||
import { docId } from '../source-editor/helpers/mock-doc'
|
||||
|
||||
describe('<EditorFloatingMenu />', function () {
|
||||
function mountEditor({
|
||||
migrationEnabled,
|
||||
trackChangesVisible = true,
|
||||
}: {
|
||||
migrationEnabled: boolean
|
||||
trackChangesVisible?: boolean
|
||||
}) {
|
||||
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
|
||||
window.metaAttributesCache.set('ol-splitTestVariants', {
|
||||
'writefull-toolbar-migration': migrationEnabled ? 'enabled' : 'default',
|
||||
})
|
||||
|
||||
cy.interceptEvents()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders scope={scope} features={{ trackChangesVisible }}>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
// Create a non-empty selection so the selection tooltip is shown.
|
||||
cy.findByText('contentLine 12').type(
|
||||
'{home}{shift}' + '{rightArrow}'.repeat(6),
|
||||
{
|
||||
scrollBehavior: false,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function mountEditorWithChanges() {
|
||||
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
|
||||
window.metaAttributesCache.set('ol-splitTestVariants', {
|
||||
'writefull-toolbar-migration': 'enabled',
|
||||
})
|
||||
|
||||
cy.interceptEvents()
|
||||
cy.intercept('POST', `/project/*/doc/${docId}/changes/accept`, {}).as(
|
||||
'acceptChange'
|
||||
)
|
||||
|
||||
const changes = [
|
||||
{
|
||||
metadata: {
|
||||
user_id: USER_ID,
|
||||
ts: new Date('2025-01-01T00:00:00.000Z'),
|
||||
},
|
||||
id: 'inserted-op-id',
|
||||
op: { p: 166, t: 'inserted-op-id', i: 'introduction' },
|
||||
},
|
||||
{
|
||||
metadata: {
|
||||
user_id: USER_ID,
|
||||
ts: new Date('2025-01-01T01:00:00.000Z'),
|
||||
},
|
||||
id: 'deleted-op-id',
|
||||
op: { p: 110, t: 'deleted-op-id', d: 'beautiful ' },
|
||||
},
|
||||
]
|
||||
const getChanges = cy.stub().as('getChanges').returns([])
|
||||
const removeChangeIds = cy.stub().as('removeChangeIds')
|
||||
|
||||
const scope = mockScope(undefined, {
|
||||
docOptions: {
|
||||
rangesOptions: { changes, getChanges, removeChangeIds },
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders scope={scope} features={{ trackChangesVisible: true }}>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
// Select a deletion and an insertion so the bulk-action controls appear.
|
||||
cy.findByText('\\maketitle').type(
|
||||
'{home}{shift}' + '{downArrow}'.repeat(10),
|
||||
{ scrollBehavior: false }
|
||||
)
|
||||
}
|
||||
|
||||
describe('when the migration split test is enabled', function () {
|
||||
it('shows the unified floating menu with Add comment, not the legacy tooltip', function () {
|
||||
mountEditor({ migrationEnabled: true })
|
||||
|
||||
cy.get('.editor-floating-menu').within(() => {
|
||||
cy.findByLabelText('Add comment').should('exist')
|
||||
})
|
||||
// Legacy tooltip is replaced.
|
||||
cy.get('.review-tooltip-menu').should('not.exist')
|
||||
})
|
||||
|
||||
it('clicking Add comment dispatches the add-new-review-comment event', function () {
|
||||
mountEditor({ migrationEnabled: true })
|
||||
|
||||
cy.window().then(win => {
|
||||
win.addEventListener(
|
||||
'add-new-review-comment',
|
||||
cy.stub().as('addComment')
|
||||
)
|
||||
})
|
||||
|
||||
cy.get('.editor-floating-menu').within(() => {
|
||||
cy.findByLabelText('Add comment').click({ scrollBehavior: false })
|
||||
})
|
||||
|
||||
cy.get('@addComment').should('have.been.called')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the migration split test is disabled (control)', function () {
|
||||
it('keeps the legacy review tooltip and does not render the unified menu', function () {
|
||||
mountEditor({ migrationEnabled: false })
|
||||
|
||||
cy.get('.review-tooltip-menu').should('exist')
|
||||
cy.get('.editor-floating-menu').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('bulk tracked-change actions', function () {
|
||||
beforeEach(function () {
|
||||
mountEditorWithChanges()
|
||||
cy.findByLabelText('Accept selected changes').as(
|
||||
'accept-selected-changes'
|
||||
)
|
||||
cy.findByLabelText('Reject selected changes').as(
|
||||
'reject-selected-changes'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the accept and reject controls in the unified menu', function () {
|
||||
cy.get('.editor-floating-menu').should('exist')
|
||||
cy.get('@accept-selected-changes').should('exist')
|
||||
cy.get('@reject-selected-changes').should('exist')
|
||||
})
|
||||
|
||||
it('accepts the selected changes', function () {
|
||||
cy.get('@accept-selected-changes').click({ scrollBehavior: false })
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.findByText(
|
||||
'Are you sure you want to accept the selected 2 changes?'
|
||||
).should('exist')
|
||||
cy.findByRole('button', { name: 'OK' }).click({ scrollBehavior: false })
|
||||
})
|
||||
cy.wait('@acceptChange')
|
||||
cy.get('@removeChangeIds').should('have.been.calledWith', [
|
||||
'inserted-op-id',
|
||||
'deleted-op-id',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects the selected changes', function () {
|
||||
cy.get('@reject-selected-changes').click({ scrollBehavior: false })
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.findByText(
|
||||
'Are you sure you want to reject the selected 2 changes?'
|
||||
).should('exist')
|
||||
cy.findByRole('button', { name: 'OK' }).click({ scrollBehavior: false })
|
||||
})
|
||||
cy.get('@getChanges').should('have.been.calledWith', [
|
||||
'inserted-op-id',
|
||||
'deleted-op-id',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the menu visible when cancelling the confirmation modal', function () {
|
||||
cy.get('@accept-selected-changes').click({ scrollBehavior: false })
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.findByRole('button', { name: 'Cancel' }).click({
|
||||
scrollBehavior: false,
|
||||
})
|
||||
})
|
||||
cy.get('.editor-floating-menu').should('exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user