From 7d91fb31ab7c00d666d1d30f204ddb43445c8ac5 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 3 Aug 2026 14:26:24 +0300 Subject: [PATCH 01/26] fix(comments): rebuild the comment composer on the create-post UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reported bug: on mobile a long comment overflowed with nowhere to scroll, so the text ended up hidden behind the virtual keyboard. Root cause was two-fold. `CommentMarkdownInput` drove `RichTextInput` with the default top toolbar, which renders no scroll container and no max-height, so the editor just grew. `CommentModal` then force-sized the form to the visual viewport by writing `style.height` during render — producing the literal "autopx" whenever the maths went negative. The composer is now the same inline box as the watercooler create-post composer, minus title, cover, post type and audience: bottom toolbar, one action bar, and a height capped against `visualViewport` (the part that survives the keyboard) with the body scrolling inside it. Same component on every viewport, so `CommentModal` and the mobile/desktop fork are gone. The mobile floating bar now opens the in-page composer through a window event instead of mounting its own copy inside the fixed footer. Several fixes landed in shared primitives and so reach every composer, including create post: - RichTextInput: rich and markdown modes share one tree, so switching no longer remounts the avatar (which refetched the image and blinked) or the action bar. Heights now match between modes, and the scroll offset and caret are preserved across the toggle. - RichTextToolbar: the overflow budget ignored dividers and row gaps, and the overflow button lived inside the `overflow-hidden` group it exists to protect, so it got sliced. Also swapped to the three-dots icon and kept it beside the formatting items. - Drawer: closing on any click whose target was outside the panel also caught portaled dropdowns, whose synthetic clicks bubble up the React tree. Picking a post type, audience, poll duration or schedule tore the drawer down instead of running the action. Now only a backdrop hit closes it. - Switch: the label could not shrink, so long copy ran off the right edge on mobile. - ProseMirror no longer adds a second min-height inside the editor's own padding. - AudienceChip and the poll option input could not shrink either. Co-Authored-By: Claude Opus 5 --- .../src/companion/CompanionDiscussion.tsx | 11 +- ...mmentInputOrModal.tsx => CommentInput.tsx} | 22 +- .../src/components/comments/MainComment.tsx | 12 +- .../src/components/comments/SubComment.tsx | 12 +- .../shared/src/components/drawers/Drawer.tsx | 12 +- .../CommentMarkdownInput.spec.tsx | 183 +++++++ .../MarkdownInput/CommentMarkdownInput.tsx | 113 ++++- .../fields/RichTextEditor/RichTextToolbar.tsx | 96 ++-- .../fields/RichTextEditor/richtext.module.css | 6 +- .../src/components/fields/RichTextInput.tsx | 446 ++++++++++-------- .../shared/src/components/fields/Switch.tsx | 6 +- .../components/modals/post/CommentModal.tsx | 304 ------------ .../modals/post/SmartComposerModal.tsx | 9 +- .../src/components/post/NewComment.spec.tsx | 8 +- .../shared/src/components/post/NewComment.tsx | 11 +- .../src/components/post/PostEngagements.tsx | 36 +- .../components/post/composer/AudienceChip.tsx | 7 +- .../src/components/post/composer/PollForm.tsx | 2 +- .../post/focus/PostDiscussionPanel.tsx | 6 +- .../components/post/reader/EngagementRail.tsx | 6 +- packages/shared/src/lib/postComment.ts | 22 + .../comments/CommentComposer.stories.tsx | 135 ++++++ .../CommentComposerStates.stories.tsx | 179 +++++++ .../stories/comments/composer.mocks.tsx | 82 ++++ packages/webapp/__tests__/PostPage.tsx | 2 +- .../components/footer/FooterWrapper.tsx | 27 +- 26 files changed, 1095 insertions(+), 660 deletions(-) rename packages/shared/src/components/comments/{CommentInputOrModal.tsx => CommentInput.tsx} (60%) create mode 100644 packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.spec.tsx delete mode 100644 packages/shared/src/components/modals/post/CommentModal.tsx create mode 100644 packages/shared/src/lib/postComment.ts create mode 100644 packages/storybook/stories/comments/CommentComposer.stories.tsx create mode 100644 packages/storybook/stories/comments/CommentComposerStates.stories.tsx create mode 100644 packages/storybook/stories/comments/composer.mocks.tsx diff --git a/packages/extension/src/companion/CompanionDiscussion.tsx b/packages/extension/src/companion/CompanionDiscussion.tsx index 0510f1c223d..adad1707bda 100644 --- a/packages/extension/src/companion/CompanionDiscussion.tsx +++ b/packages/extension/src/companion/CompanionDiscussion.tsx @@ -10,7 +10,7 @@ import { useBackgroundRequest } from '@dailydotdev/shared/src/hooks/companion'; import { generateCommentsQueryKey } from '@dailydotdev/shared/src/lib/query'; import { getCompanionWrapper } from '@dailydotdev/shared/src/lib/extension'; import { ProfileImageSize } from '@dailydotdev/shared/src/components/ProfilePicture'; -import CommentInputOrModal from '@dailydotdev/shared/src/components/comments/CommentInputOrModal'; +import CommentInput from '@dailydotdev/shared/src/components/comments/CommentInput'; interface CompanionDiscussionProps { post: PostBootData; @@ -25,7 +25,6 @@ export function CompanionDiscussion({ className, onShowUpvoted, }: CompanionDiscussionProps): ReactElement { - const commentClasses = { tab: '!min-h-[14.5rem]' }; const { openShareComment } = useShareComment(Origin.Companion); useBackgroundRequest( generateCommentsQueryKey({ postId: post?.id, sortBy: undefined }), @@ -49,11 +48,8 @@ export function CompanionDiscussion({ openShareComment(comment, post)} onClickUpvote={onShowUpvoted} modalParentSelector={getCompanionWrapper} - className={commentClasses} /> diff --git a/packages/shared/src/components/comments/CommentInputOrModal.tsx b/packages/shared/src/components/comments/CommentInput.tsx similarity index 60% rename from packages/shared/src/components/comments/CommentInputOrModal.tsx rename to packages/shared/src/components/comments/CommentInput.tsx index d681890db25..1f5686d927b 100644 --- a/packages/shared/src/components/comments/CommentInputOrModal.tsx +++ b/packages/shared/src/components/comments/CommentInput.tsx @@ -2,30 +2,22 @@ import type { ReactElement } from 'react'; import React from 'react'; import type { CommentMarkdownInputProps } from '../fields/MarkdownInput/CommentMarkdownInput'; import { CommentMarkdownInput } from '../fields/MarkdownInput/CommentMarkdownInput'; -import { ViewSize, useViewSize } from '../../hooks'; -import type { LazyModalCommonProps } from '../modals/common/Modal'; -import CommentModal from '../modals/post/CommentModal'; import { WriteCommentContext } from '../../contexts/WriteCommentContext'; import { useMutateComment } from '../../hooks/post/useMutateComment'; -interface CommentInputOrModalProps - extends Partial, - Omit { +interface CommentInputProps + extends Omit { onClose?: () => void; className?: { input?: CommentMarkdownInputProps['className']; - modal?: string; }; - replyToCommentId?: string; } -export default function CommentInputOrModal({ +export default function CommentInput({ onClose, className, ...props -}: CommentInputOrModalProps): ReactElement { - const isModal = !useViewSize(ViewSize.Tablet); - +}: CommentInputProps): ReactElement { const mutateCommentResult = useMutateComment({ post: props.post, editCommentId: props.editCommentId, @@ -33,17 +25,13 @@ export default function CommentInputOrModal({ onCommented: props.onCommented, }); - if (isModal) { - return ; - } - return ( diff --git a/packages/shared/src/components/comments/MainComment.tsx b/packages/shared/src/components/comments/MainComment.tsx index 6c084772fe6..38376cd5d5c 100644 --- a/packages/shared/src/components/comments/MainComment.tsx +++ b/packages/shared/src/components/comments/MainComment.tsx @@ -27,11 +27,8 @@ import { useEditCommentProps } from '../../hooks/post/useEditCommentProps'; import { useLogContext } from '../../contexts/LogContext'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; -const CommentInputOrModal = dynamic( - () => - import( - /* webpackChunkName: "commentInputOrModal" */ './CommentInputOrModal' - ), +const CommentInput = dynamic( + () => import(/* webpackChunkName: "commentInput" */ './CommentInput'), ); type ClassName = { @@ -196,7 +193,7 @@ export default function MainComment({ )} {editProps && ( - { @@ -209,7 +206,7 @@ export default function MainComment({ )} {commentId === comment.id && (
- { @@ -218,7 +215,6 @@ export default function MainComment({ }} onClose={() => onReplyTo(null)} className={{ input: className?.commentBox }} - replyToCommentId={commentId} />
)} diff --git a/packages/shared/src/components/comments/SubComment.tsx b/packages/shared/src/components/comments/SubComment.tsx index d59ccaa0cf3..54b0f343e8b 100644 --- a/packages/shared/src/components/comments/SubComment.tsx +++ b/packages/shared/src/components/comments/SubComment.tsx @@ -9,11 +9,8 @@ import type { CommentMarkdownInputProps } from '../fields/MarkdownInput/CommentM import { useComments } from '../../hooks/post'; import { useEditCommentProps } from '../../hooks/post/useEditCommentProps'; -const CommentInputOrModal = dynamic( - () => - import( - /* webpackChunkName: "commentInputOrModal" */ './CommentInputOrModal' - ), +const CommentInput = dynamic( + () => import(/* webpackChunkName: "commentInput" */ './CommentInput'), ); export interface SubCommentProps @@ -107,7 +104,7 @@ function SubComment({ )} {editProps && ( - { @@ -120,7 +117,7 @@ function SubComment({ )} {commentId === comment.id && inputProps && (
- onReplyTo(null)} - replyToCommentId={commentId} />
)} diff --git a/packages/shared/src/components/drawers/Drawer.tsx b/packages/shared/src/components/drawers/Drawer.tsx index 75f51091294..c21bedc23b5 100644 --- a/packages/shared/src/components/drawers/Drawer.tsx +++ b/packages/shared/src/components/drawers/Drawer.tsx @@ -102,12 +102,12 @@ function BaseDrawer({ const handleOverlayClick = (e: React.MouseEvent) => { e.stopPropagation(); - if ( - closeOnOutsideClick && - hasAnimated && - container.current && - !container.current.contains(e.target as Node) - ) { + // Only a hit on the backdrop itself closes the drawer. A `contains` check + // fails for portaled children (dropdowns, popovers): they live under + // `document.body`, but React bubbles their synthetic clicks up the React + // tree to this handler, so picking an item read as an outside click and + // tore the drawer down instead of running the item's own action. + if (closeOnOutsideClick && hasAnimated && e.target === e.currentTarget) { onClose(e.nativeEvent); } }; diff --git a/packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.spec.tsx b/packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.spec.tsx new file mode 100644 index 00000000000..d37c0163043 --- /dev/null +++ b/packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.spec.tsx @@ -0,0 +1,183 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { Post } from '../../../graphql/posts'; +import { WriteCommentContext } from '../../../contexts/WriteCommentContext'; +import { CommentMarkdownInput } from './CommentMarkdownInput'; + +const mockRichTextProps = jest.fn(); + +jest.mock('../RichTextInput', () => { + const react = jest.requireActual('react') as typeof React; + + return { + __esModule: true, + default: react.forwardRef((props: Record) => { + mockRichTextProps(props); + return
; + }), + }; +}); + +const post = { + id: 'post-1', + author: { username: 'ido' }, + source: { id: 'source-1', handle: 'webdev' }, +} as Post; + +const renderComposer = ( + props: Partial> = {}, +) => + render( + + + } + {...props} + /> + , + ); + +const setViewportHeight = (height: number) => { + Object.defineProperty(window, 'visualViewport', { + configurable: true, + value: { + height, + width: 375, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }, + }); +}; + +describe('CommentMarkdownInput', () => { + beforeEach(() => { + mockRichTextProps.mockClear(); + }); + + it('caps its height to the visible viewport so the keyboard cannot hide it', () => { + setViewportHeight(360); + renderComposer(); + + expect(screen.getByTestId('composer')).toHaveStyle({ maxHeight: '288px' }); + }); + + it('keeps a workable floor when the visible viewport is tiny', () => { + setViewportHeight(120); + renderComposer(); + + expect(screen.getByTestId('composer')).toHaveStyle({ maxHeight: '224px' }); + }); + + it('does not grow past its cap on a tall desktop viewport', () => { + setViewportHeight(1200); + renderComposer(); + + expect(screen.getByTestId('composer')).toHaveStyle({ maxHeight: '512px' }); + }); + + it('moves the actions into a pinned bottom bar instead of the footer', () => { + setViewportHeight(800); + renderComposer(); + + expect(mockRichTextProps).toHaveBeenCalledWith( + expect.objectContaining({ toolbarPosition: 'bottom', hideFooter: true }), + ); + }); + + // The header is handed to RichTextInput as a node, so it is rendered on its + // own here. Tooltip reaches for the query client, hence the provider. + const renderHeader = () => { + const [{ header }] = mockRichTextProps.mock.calls.at(-1); + + return render( + +
{header}
+
, + ); + }; + + it('names the comment author being replied to', () => { + setViewportHeight(800); + renderComposer({ parentCommentId: 'comment-1', replyTo: 'AmirMushich' }); + renderHeader(); + + expect(screen.getByText(/Replying to/)).toBeInTheDocument(); + expect(screen.getByText('@AmirMushich')).toBeInTheDocument(); + }); + + it('falls back to the post author on a top-level comment', () => { + setViewportHeight(800); + renderComposer(); + renderHeader(); + + expect(screen.getByText('@ido')).toBeInTheDocument(); + }); + + it('falls back to the source when the post has no author', () => { + setViewportHeight(800); + renderComposer({ post: { ...post, author: undefined } as Post }); + renderHeader(); + + expect(screen.getByText('@webdev')).toBeInTheDocument(); + }); + + it('says it is an edit rather than naming someone to reply to', () => { + setViewportHeight(800); + renderComposer({ editCommentId: 'comment-1' }); + renderHeader(); + + expect(screen.getByText('Editing your comment')).toBeInTheDocument(); + expect(screen.queryByText(/Replying to/)).not.toBeInTheDocument(); + }); + + it('puts the cancel action in the header, not the toolbar', () => { + setViewportHeight(800); + renderComposer({ onClose: jest.fn() }); + + const [{ toolbarRightActions }] = mockRichTextProps.mock.calls.at(-1); + expect(toolbarRightActions).toBeUndefined(); + + renderHeader(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + + it('keeps the markdown toggle in the header rather than the toolbar', () => { + setViewportHeight(800); + renderComposer(); + + expect(mockRichTextProps).toHaveBeenCalledWith( + expect.objectContaining({ hideMarkdownToggle: true }), + ); + + renderHeader(); + expect( + screen.getByRole('button', { name: 'Switch to Markdown' }), + ).toBeInTheDocument(); + }); + + it.each([ + [{}, 'Comment'], + [{ parentCommentId: 'comment-1' }, 'Reply'], + [{ editCommentId: 'comment-1' }, 'Update'], + ])('labels the submit action for the context %o', (props, expected) => { + setViewportHeight(800); + renderComposer(props); + + expect(mockRichTextProps).toHaveBeenCalledWith( + expect.objectContaining({ submitCopy: expected }), + ); + }); +}); diff --git a/packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.tsx b/packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.tsx index e0dd5547fcd..8a5b202b477 100644 --- a/packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.tsx +++ b/packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.tsx @@ -4,8 +4,9 @@ import type { FormEventHandler, FormHTMLAttributes, ReactElement, + ReactNode, } from 'react'; -import React, { forwardRef, useRef } from 'react'; +import React, { forwardRef, useRef, useState } from 'react'; import classNames from 'classnames'; import { defaultMarkdownCommands } from '../../../hooks/input'; import type { RichTextInputRef } from '../RichTextInput'; @@ -14,11 +15,14 @@ import type { Comment } from '../../../graphql/comments'; import { formToJson } from '../../../lib/form'; import type { Post } from '../../../graphql/posts'; import { useWriteCommentContext } from '../../../contexts/WriteCommentContext'; -import { ButtonVariant } from '../../buttons/Button'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import CloseButton from '../../CloseButton'; +import { MarkdownIcon } from '../../icons'; +import { Tooltip } from '../../tooltip/Tooltip'; +import { useVisualViewport } from '../../../hooks/utils/useVisualViewport'; export interface CommentClassName { container?: string; - tab?: string; markdownContainer?: string; input?: string; } @@ -37,7 +41,6 @@ export interface CommentMarkdownInputProps { isNew: boolean, parentCommentId?: string, ) => void; - showSubmit?: boolean; showUserAvatar?: boolean; autoFocus?: boolean; onChange?: (value: string) => void; @@ -45,6 +48,12 @@ export interface CommentMarkdownInputProps { onClose?: () => void; } +// The composer grows with its content up to this cap, then scrolls internally +// so the action bar — and the caret — stay put instead of running off-screen. +const MIN_COMPOSER_HEIGHT = 224; +const MAX_COMPOSER_HEIGHT = 512; +const VIEWPORT_HEIGHT_RATIO = 0.8; + export function CommentMarkdownInputComponent( { post, @@ -56,7 +65,6 @@ export function CommentMarkdownInputComponent( className = {}, style, onChange, - showSubmit = true, showUserAvatar = true, autoFocus = true, formProps = {}, @@ -71,11 +79,47 @@ export function CommentMarkdownInputComponent( mutateComment: { mutateComment, isLoading, isSuccess }, } = useWriteCommentContext(); const richTextRef = useRef(null); - let submitCopy: string | undefined; - if (showSubmit) { - submitCopy = editCommentId ? 'Update' : 'Comment'; + const [isMarkdownMode, setIsMarkdownMode] = useState(false); + + // The visual viewport — not the layout viewport — is what stays visible once + // the virtual keyboard opens. Capping against it is what keeps a long comment + // from pushing its own submit button behind the keyboard on mobile. + const { height: viewportHeight } = useVisualViewport(); + const maxHeight = viewportHeight + ? Math.min( + MAX_COMPOSER_HEIGHT, + Math.max( + MIN_COMPOSER_HEIGHT, + Math.round(viewportHeight * VIEWPORT_HEIGHT_RATIO), + ), + ) + : undefined; + + let submitCopy = 'Comment'; + if (editCommentId) { + submitCopy = 'Update'; + } else if (parentCommentId) { + submitCopy = 'Reply'; + } + + // A top-level comment is still a reply to whoever put the post up, so the + // strip falls back to the post author and then to the source that owns it. + const replyingTo = replyTo ?? post?.author?.username ?? post?.source?.handle; + let headerLabel: ReactNode = null; + if (editCommentId) { + headerLabel = 'Editing your comment'; + } else if (replyingTo) { + headerLabel = ( + <> + Replying to @{replyingTo} + + ); } + const markdownToggleLabel = isMarkdownMode + ? 'Switch to rich text' + : 'Switch to Markdown'; + const onSubmitForm: FormEventHandler = async (e) => { e.preventDefault(); @@ -117,8 +161,8 @@ export function CommentMarkdownInputComponent( {...formProps} action="#" onSubmit={onSubmitForm} - className={className?.container} - style={style} + className={classNames('flex min-h-0 flex-col', className?.container)} + style={{ maxHeight, ...style }} ref={ref} > - Reply to - - {replyTo} + toolbarPosition="bottom" + hideMarkdownHeader + hideFooter + hideMarkdownToggle + onMarkdownModeChange={setIsMarkdownMode} + header={ +
+ {headerLabel && ( + + {headerLabel} + )} + + +
} onValueUpdate={onChange} - onClose={onClose} /> ); diff --git a/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx b/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx index f84fb14c74f..27f5312b81c 100644 --- a/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx +++ b/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx @@ -15,10 +15,10 @@ import type { Editor } from '@tiptap/react'; import { getMarkRange } from '@tiptap/core'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; import { - ArrowIcon, BoldIcon, BulletListIcon, ItalicIcon, + MenuIcon, NumberedListIcon, RedoIcon, UndoIcon, @@ -43,6 +43,7 @@ export interface RichTextToolbarProps { position?: 'top' | 'bottom'; className?: string; hideInlineLink?: boolean; + hideFormatting?: boolean; } export interface RichTextToolbarRef { @@ -129,7 +130,7 @@ const OverflowMenu = ({ items }: OverflowMenuProps): ReactElement | null => { type="button" variant={ButtonVariant.Tertiary} size={ButtonSize.Small} - icon={} + icon={} aria-label="More formatting" onMouseDown={(event: React.MouseEvent) => event.preventDefault()} className="shrink-0" @@ -158,6 +159,10 @@ const OverflowMenu = ({ items }: OverflowMenuProps): ReactElement | null => { }; const TOOLBAR_BUTTON_WIDTH = 32; +// `ToolbarDivider` is a 1px rule with `mx-1.5` either side. +const TOOLBAR_DIVIDER_WIDTH = 13; +// `gap-1` between the scrolling group, the overflow menu and the right actions. +const TOOLBAR_ROW_GAP = 4; function RichTextToolbarComponent( { @@ -170,6 +175,7 @@ function RichTextToolbarComponent( position = 'top', className, hideInlineLink = false, + hideFormatting = false, }: RichTextToolbarProps, ref: Ref, ): ReactElement { @@ -234,6 +240,12 @@ function RichTextToolbarComponent( }); const formattingItems = useMemo(() => { + // Markdown mode edits raw text, so the rich-text commands have nothing to + // act on; the bar itself stays mounted so the actions beside it hold still. + if (hideFormatting) { + return []; + } + const items: ToolbarItem[] = []; if (!hideInlineLink) { @@ -328,6 +340,7 @@ function RichTextToolbarComponent( editor, editorState, hideInlineLink, + hideFormatting, openLinkModal, ]); @@ -370,8 +383,20 @@ function RichTextToolbarComponent( inlineActionsRef.current?.getBoundingClientRect().width ?? 0; const rightWidth = rightActionsRef.current?.getBoundingClientRect().width ?? 0; + // Dividers and the row's own gaps are laid out but belong to no ref, so + // leaving them out of the budget is what let the last button — and the + // overflow chevron itself — get sliced by the group's `overflow-hidden`. + const dividerCount = + (leadingActions ? 1 : 0) + + (inlineActions ? 1 : 0) + + Math.max(0, new Set(formattingItems.map((item) => item.group)).size - 1); setReservedWidth( - leadingWidth + inlineWidth + rightWidth + TOOLBAR_BUTTON_WIDTH, + leadingWidth + + inlineWidth + + rightWidth + + TOOLBAR_BUTTON_WIDTH + + dividerCount * TOOLBAR_DIVIDER_WIDTH + + TOOLBAR_ROW_GAP * 2, ); }, [ isOverflowable, @@ -379,6 +404,7 @@ function RichTextToolbarComponent( inlineActions, leadingActions, containerWidth, + formattingItems, ]); const visibleCount = useMemo(() => { @@ -450,34 +476,48 @@ function RichTextToolbarComponent( >
- {leadingActions && ( - <> -
- {leadingActions} -
- - - )} - {inlineActions && ( - <> -
- {inlineActions} -
- {visibleItems.length > 0 && } - - )} - {renderedFormattingItems} +
+ {leadingActions && ( + <> +
+ {leadingActions} +
+ + + )} + {inlineActions && ( + <> +
+ {inlineActions} +
+ {visibleItems.length > 0 && } + + )} + {renderedFormattingItems} +
+ {/* Outside the clipped group on purpose: it is the escape hatch for + everything that did not fit, so it must never be cut itself. */} {isOverflowable && }
{rightActions && ( diff --git a/packages/shared/src/components/fields/RichTextEditor/richtext.module.css b/packages/shared/src/components/fields/RichTextEditor/richtext.module.css index 2072e9a88fd..97b47eaf930 100644 --- a/packages/shared/src/components/fields/RichTextEditor/richtext.module.css +++ b/packages/shared/src/components/fields/RichTextEditor/richtext.module.css @@ -86,7 +86,11 @@ /* Tiptap ProseMirror specific */ & :global(.ProseMirror) { - @apply outline-none min-h-[6rem]; + /* Fills the editor rather than adding a second floor inside its padding. + A floor here stacked on top of the container's `minHeightClassName`, so + the rich editor always sat `p-4` taller than the markdown textarea and + the box jumped whenever the two swapped. */ + @apply outline-none h-full; } & :global(.ProseMirror-focused) { diff --git a/packages/shared/src/components/fields/RichTextInput.tsx b/packages/shared/src/components/fields/RichTextInput.tsx index ce066115802..c10997d4090 100644 --- a/packages/shared/src/components/fields/RichTextInput.tsx +++ b/packages/shared/src/components/fields/RichTextInput.tsx @@ -133,7 +133,7 @@ interface RichTextInputProps { submitButtonVariant?: ButtonVariant; showUserAvatar?: boolean; isUpdatingDraft?: boolean; - timeline?: ReactNode; + header?: ReactNode; isLoading?: boolean; disabledSubmit?: boolean; maxInputLength?: number; @@ -178,7 +178,7 @@ function RichTextInput( submitButtonVariant = ButtonVariant.Float, showUserAvatar, isUpdatingDraft, - timeline, + header, isLoading, disabledSubmit, maxInputLength, @@ -214,6 +214,8 @@ function RichTextInput( const editorContainerRef = useRef(null); const editorRef = useRef(null); const markdownTextareaRef = useRef(null); + const scrollContainerRef = useRef(null); + const scrollOffsetRef = useRef(0); const dirtyRef = useRef(false); const isSyncingRef = useRef(false); const inputRef = useRef(''); @@ -523,21 +525,30 @@ function RichTextInput( upload.insertImage(gifUrl, altText); }; + // Captured before the swap so the view can be put back exactly where it was; + // otherwise one direction snapped to the top and the other to the end, which + // scrolled the avatar out of sight and read as the composer redrawing. + const rememberScroll = useCallback(() => { + scrollOffsetRef.current = scrollContainerRef.current?.scrollTop ?? 0; + }, []); + const switchToMarkdownMode = useCallback(() => { + rememberScroll(); if (editorRef.current) { const markdown = htmlToMarkdownBasic(editorRef.current.getHTML()); updateInput(markdown); } setIsMarkdownMode(true); - }, [updateInput]); + }, [rememberScroll, updateInput]); const switchToRichMode = useCallback(() => { + rememberScroll(); if (editorRef.current) { isSyncingRef.current = true; editorRef.current.commands.setContent(markdownToHtml(inputRef.current)); } setIsMarkdownMode(false); - }, [markdownToHtml]); + }, [markdownToHtml, rememberScroll]); const toggleMarkdownMode = useCallback(() => { if (isMarkdownMode) { @@ -552,6 +563,12 @@ function RichTextInput( }, [isMarkdownMode, onMarkdownModeChange]); const didInitMarkdownRef = useRef(false); + const restoreScroll = useCallback(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = scrollOffsetRef.current; + } + }, []); + useEffect(() => { if (!didInitMarkdownRef.current) { didInitMarkdownRef.current = true; @@ -559,13 +576,26 @@ function RichTextInput( } const frame = requestAnimationFrame(() => { if (isMarkdownMode) { - markdownTextareaRef.current?.focus(); + const textarea = markdownTextareaRef.current; + if (!textarea) { + return; + } + // Land where the rich editor lands. Leaving the textarea caret at 0 + // snapped the box to the top going one way and to the end coming back, + // which read as the avatar vanishing when it had only scrolled off. + textarea.focus({ preventScroll: true }); + const end = textarea.value.length; + textarea.setSelectionRange(end, end); + restoreScroll(); return; } - editorRef.current?.commands.focus(); + // `scrollIntoView: false` or ProseMirror yanks the box to the caret + // straight after the restore below, undoing it. + editorRef.current?.commands.focus('end', { scrollIntoView: false }); + restoreScroll(); }); return () => cancelAnimationFrame(frame); - }, [isMarkdownMode]); + }, [isMarkdownMode, restoreScroll]); useLayoutEffect(() => { if (!isMarkdownMode) { @@ -575,13 +605,14 @@ function RichTextInput( if (!ta) { return; } - if (toolbarPosition === 'bottom') { - ta.style.height = ''; - return; - } - ta.style.height = 'auto'; + // Grow to fit like the rich editor does, so the surrounding box keeps its + // height when the two swap; the scroll container above absorbs the excess. + // Measured from 0 rather than `auto` so the `rows` attribute doesn't act as + // a floor — `minHeightClassName` is what sets the empty height, in both + // modes, which is what keeps them the same size. + ta.style.height = '0px'; ta.style.height = `${ta.scrollHeight}px`; - }, [input, isMarkdownMode, toolbarPosition]); + }, [input, isMarkdownMode]); const onMarkdownInput = useCallback( (event: React.FormEvent) => { @@ -718,6 +749,34 @@ function RichTextInput( : editor?.storage.characterCount?.characters?.() ?? input.length) : null; + const isBottomToolbar = toolbarPosition === 'bottom'; + // Rendered outside the rich/markdown branches so switching editors never + // drops it. `ml-4 mt-4` puts it on the same guideline as the avatar in + // `CommentContainer` (a `p-4` box), so the composer lines up with the + // comments underneath it. + const avatar = showUserAvatar && user && ( + + ); + const renderSubmitButton = (buttonClassName?: string) => + shouldShowSubmit ? ( + + ) : null; + const hasToolbarActions = isUploadEnabled || isLinkEnabled || isMentionEnabled || isGifEnabled; const preventEditorBlur = (event: React.MouseEvent) => event.preventDefault(); @@ -812,6 +871,111 @@ function RichTextInput( /> ) : null; + // Both editors hang off one tree so that toggling markdown swaps only the + // editor element. Building a separate subtree per mode remounted the avatar + // (refetching the image, hence the blink) and the action bar with it. + const rightActionsNode = ( +
+ {savingLabel} + {!hideMarkdownToggle && ( + +
+ ); + + const toolbarNode = hideToolbar ? null : ( + { + if (!editor) { + return; + } + if (!editor.state.selection.empty) { + editor.chain().focus().setLink({ href: url }).run(); + return; + } + const linkText = label || url; + editor + .chain() + .focus() + .insertContent({ + type: 'text', + text: linkText, + marks: [{ type: 'link', attrs: { href: url } }], + }) + .run(); + }} + position={toolbarPosition} + className={isBottomToolbar ? '!gap-3 !px-5 !pb-5 !pt-4' : undefined} + leadingActions={toolbarLeading} + inlineActions={ + hasToolbarActions && !isMarkdownMode ? toolbarActions : null + } + hideInlineLink={isLinkEnabled} + hideFormatting={isMarkdownMode} + rightActions={rightActionsNode} + /> + ); + + const editorBody = ( +
+ {avatar} + {isMarkdownMode ? ( +