From a323f3af755f75fea92f1005946e2ef8b6d839f4 Mon Sep 17 00:00:00 2001 From: David <33458145+davidmcpowell@users.noreply.github.com> Date: Mon, 16 Sep 2024 09:40:59 +0100 Subject: [PATCH] Implement a floating "Add comment" button for the redesigned review panel (#19891) * Implement floating Add comment button * Fix comment typo * Remove unused imports * Make tooltip always appear above cursor Co-authored-by: Domagoj Kriskovic * Refactor how new comment form is positioned * Add missing file * Create new map when rendering positions * Use codemirror state to manage ranges and allow for mutliple in-progress comments * Memoise sorting * Create new ranges map each time it is changed * Add back mutation observer * Only allow single tooltip * Fix typo * Convert state field to store a single tooltip * Make add comment tooltip content a react component * Refactor to remove usages of !important * Use RangeSet to keep track of new comment ranges * Fix logic broken in rebase * Map ranges through document changes * Add decorations for in-progress comments * Use set-review-panel-open rather than an editor event to open review panel * Implement new designs for add comment form * Add padding to textarea * Fix bug where comment was being submitted for incorrect range * Add missing key to ReviewPanelAddComment * Store new comment ranges as a DecorationSet * Small refactor to how ReviewPanelAddCommens are rendered * Make op prop to ReviewPanelEntry required * Add handling for disabling of add comemnt form buttons * Move viewer check inside AddCommentTooltip * Ensure that add comment button doesn't reshow when collaborators edit the document * Remove unneeded op check in ReviewPanelEntry * Update services/web/frontend/js/features/review-panel-new/components/review-panel-add-comment.tsx Co-authored-by: Domagoj Kriskovic --------- Co-authored-by: Domagoj Kriskovic GitOrigin-RevId: 3110845f6a557310f3bf72014689e2f2ab53e966 --- .../components/add-comment-tooltip.tsx | 76 +++++++++ .../components/review-panel-add-comment.tsx | 161 ++++++++++++++---- .../review-panel-comment-content.tsx | 47 +++-- .../components/review-panel-current-file.tsx | 96 +++++++---- .../components/review-panel-entry.tsx | 15 +- .../hooks/use-submittable-text-input.ts | 29 ++++ .../components/codemirror-editor.tsx | 7 +- .../source-editor/extensions/add-comment.ts | 134 +++++++++++++++ .../source-editor/extensions/index.ts | 2 + .../js/shared/context/layout-context.tsx | 12 ++ .../app/editor/review-panel-new.less | 42 ++++- 11 files changed, 523 insertions(+), 98 deletions(-) create mode 100644 services/web/frontend/js/features/review-panel-new/components/add-comment-tooltip.tsx create mode 100644 services/web/frontend/js/features/review-panel-new/hooks/use-submittable-text-input.ts create mode 100644 services/web/frontend/js/features/source-editor/extensions/add-comment.ts diff --git a/services/web/frontend/js/features/review-panel-new/components/add-comment-tooltip.tsx b/services/web/frontend/js/features/review-panel-new/components/add-comment-tooltip.tsx new file mode 100644 index 0000000000..2128adaca5 --- /dev/null +++ b/services/web/frontend/js/features/review-panel-new/components/add-comment-tooltip.tsx @@ -0,0 +1,76 @@ +import { Dispatch, FC, SetStateAction, useEffect, useState } from 'react' +import ReactDOM from 'react-dom' +import MaterialIcon from '@/shared/components/material-icon' +import { useTranslation } from 'react-i18next' +import { + useCodeMirrorStateContext, + useCodeMirrorViewContext, +} from '@/features/source-editor/components/codemirror-editor' +import { + addCommentStateField, + buildAddNewCommentRangeEffect, +} from '@/features/source-editor/extensions/add-comment' +import { getTooltip } from '@codemirror/view' +import useViewerPermissions from '@/shared/hooks/use-viewer-permissions' +import usePreviousValue from '@/shared/hooks/use-previous-value' + +const AddCommentTooltip: FC = () => { + const state = useCodeMirrorStateContext() + const view = useCodeMirrorViewContext() + const isViewer = useViewerPermissions() + const [show, setShow] = useState(true) + + const tooltipState = state.field(addCommentStateField, false)?.tooltip + const previousTooltipState = usePreviousValue(tooltipState) + + useEffect(() => { + if (tooltipState !== null && previousTooltipState === null) { + setShow(true) + } + }, [tooltipState, previousTooltipState]) + + if (isViewer || !show || !tooltipState) { + return null + } + + const tooltipView = getTooltip(view, tooltipState) + + if (!tooltipView) { + return null + } + + return ReactDOM.createPortal( + , + tooltipView.dom + ) +} + +const AddCommentTooltipContent: FC<{ + setShow: Dispatch> +}> = ({ setShow }) => { + const { t } = useTranslation() + const view = useCodeMirrorViewContext() + const state = useCodeMirrorStateContext() + + const handleClick = () => { + window.dispatchEvent( + new CustomEvent<{ isOpen: boolean }>('set-review-panel-open', { + detail: { isOpen: true }, + }) + ) + + view.dispatch({ + effects: buildAddNewCommentRangeEffect(state.selection.main), + }) + setShow(false) + } + + return ( + + ) +} + +export default AddCommentTooltip diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-add-comment.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-add-comment.tsx index 1b3e504507..dd29bdbafd 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-add-comment.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-add-comment.tsx @@ -1,4 +1,4 @@ -import { FC, FormEventHandler, useCallback, useState } from 'react' +import { FC, FormEventHandler, useCallback, useState, useRef } from 'react' import { useCodeMirrorStateContext, useCodeMirrorViewContext, @@ -6,55 +6,158 @@ import { import { EditorSelection } from '@codemirror/state' import { useTranslation } from 'react-i18next' import { useThreadsActionsContext } from '../context/threads-context' +import { removeNewCommentRangeEffect } from '@/features/source-editor/extensions/add-comment' +import useSubmittableTextInput from '../hooks/use-submittable-text-input' +import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area' +import { Button } from 'react-bootstrap' +import { ReviewPanelEntry } from './review-panel-entry' +import { ThreadId } from '../../../../../types/review-panel/review-panel' +import { Decoration } from '@codemirror/view' -export const ReviewPanelAddComment: FC = () => { +export const ReviewPanelAddComment: FC<{ + from: number + to: number + value: Decoration + top: number | undefined +}> = ({ from, to, value, top }) => { const { t } = useTranslation() const view = useCodeMirrorViewContext() - // eslint-disable-next-line no-unused-vars - const _state = useCodeMirrorStateContext() + const state = useCodeMirrorStateContext() const { addComment } = useThreadsActionsContext() const [error, setError] = useState() - const [showForm, setShowForm] = useState(false) + const [submitting, setSubmitting] = useState(false) - const handleSubmit = useCallback( - event => { - event.preventDefault() + const handleClose = useCallback(() => { + view.dispatch({ + effects: removeNewCommentRangeEffect.of(value), + }) + }, [view, value]) + + const submitForm = useCallback( + message => { + setSubmitting(true) - const { from, to } = view.state.selection.main const content = view.state.sliceDoc(from, to) - const formData = new FormData(event.target as HTMLFormElement) - const message = formData.get('message') as string - - addComment(from, content, message).catch(setError) + addComment(from, content, message) + .catch(setError) + .finally(() => setSubmitting(false)) view.dispatch({ selection: EditorSelection.cursor(view.state.selection.main.anchor), }) + + handleClose() }, - [addComment, view] + [addComment, view, handleClose, from, to] ) - const handleElement = useCallback((element: HTMLElement | null) => { - if (element) { - element.dispatchEvent(new Event('review-panel:position')) + const { handleChange, handleKeyPress, content } = + useSubmittableTextInput(submitForm) + + const handleBlur = useCallback(() => { + if (content === '') { + handleClose() + } + }, [content, handleClose]) + + const handleSubmit = useCallback( + event => { + event.preventDefault() + submitForm(content) + }, + [submitForm, content] + ) + + // We only ever want to focus the element once + const hasBeenFocused = useRef(false) + + // Auto-focus the textarea once the element has been correctly positioned. + // We cannot use the autofocus attribute as we need to wait until the parent element + // has been positioned (with the "top" attribute) to avoid scrolling to the initial + // position of the element + const observerCallback = useCallback(mutationList => { + if (hasBeenFocused.current) { + return + } + + for (const mutation of mutationList) { + if (mutation.target.style.top) { + const textArea = mutation.target.getElementsByTagName('textarea')[0] + if (textArea) { + textArea.focus() + hasBeenFocused.current = true + } + } } }, []) - if (!showForm) { - return - } + const handleElement = useCallback( + (element: HTMLElement | null) => { + if (element) { + element.dispatchEvent(new Event('review-panel:position')) + + const observer = new MutationObserver(observerCallback) + const entryWrapper = element.closest('.review-panel-entry') + if (entryWrapper) { + observer.observe(entryWrapper, { + attributes: true, + attributeFilter: ['style'], + }) + return () => observer.disconnect() + } + } + }, + [observerCallback] + ) return ( -
- {/* eslint-disable-next-line jsx-a11y/no-autofocus */} -