diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 93052ddad2..15564e76ea 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1926,6 +1926,8 @@ de: review: add_comment: Kommentar hinzufügen cancel_add_comment: Abbrechen + hide_comments: Kommentare ausblenden + show_comments: Kommentare einblenden comment_toolbar: Kommentare filter: label: Kommentare filtern diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index acf8f3da58..566ccc3602 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1755,6 +1755,8 @@ en: review: add_comment: Add comment cancel_add_comment: Cancel + hide_comments: Hide comments + show_comments: Show comments comment_toolbar: Comments filter: label: Filter comments diff --git a/entry_types/scrolled/package/spec/frontend/commenting/features/commentVisibility-spec.js b/entry_types/scrolled/package/spec/frontend/commenting/features/commentVisibility-spec.js new file mode 100644 index 0000000000..a928928e77 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/commenting/features/commentVisibility-spec.js @@ -0,0 +1,120 @@ +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; +import {fireEvent} from '@testing-library/react'; + +import {EditableText} from 'frontend/commenting/EditableText'; +import {commentHighlightStyles as highlightStyles} from 'pageflow-scrolled/review'; +import {renderEntry, useCommentingPageObjects} from 'support/pageObjects/commenting'; + +describe('comment visibility', () => { + useCommentingPageObjects(); + + function renderEntryWithComments() { + return renderEntry({ + seed: {contentElements: [{typeName: 'withTestId', configuration: {testId: 5}}]}, + commenting: {currentUser: {id: 42, name: 'Alice'}, commentThreads: []} + }); + } + + it('collapses the toolbar to a show button when hidden', () => { + const entry = renderEntryWithComments(); + + fireEvent.click(entry.getHideCommentsButton()); + + expect(entry.queryCommentToolbar()).toBeNull(); + expect(entry.getShowCommentsButton()).toBeInTheDocument(); + }); + + it('drives the toggle through a view transition when supported', () => { + const startViewTransition = jest.fn(run => { + run(); + return {finished: Promise.resolve(), ready: Promise.resolve(), updateCallbackDone: Promise.resolve()}; + }); + document.startViewTransition = startViewTransition; + + try { + const entry = renderEntryWithComments(); + + fireEvent.click(entry.getHideCommentsButton()); + + expect(startViewTransition).toHaveBeenCalledTimes(1); + expect(entry.queryCommentToolbar()).toBeNull(); + expect(entry.getShowCommentsButton()).toBeInTheDocument(); + } + finally { + delete document.startViewTransition; + } + }); + + it('restores the toolbar from the show button', () => { + const entry = renderEntryWithComments(); + + fireEvent.click(entry.getHideCommentsButton()); + fireEvent.click(entry.getShowCommentsButton()); + + expect(entry.getCommentToolbar()).toBeInTheDocument(); + expect(entry.queryShowCommentsButton()).toBeNull(); + }); + + it('hides comment badges while hidden and restores them when shown', () => { + const entry = renderEntry({ + seed: { + sections: [{id: 1, permaId: 10}], + contentElements: [{typeName: 'withTestId', configuration: {testId: 5}}] + }, + commenting: { + currentUser: {id: 42, name: 'Alice'}, + commentThreads: [ + {id: 1, subjectType: 'ContentElement', subjectId: 1, comments: []}, + {id: 2, subjectType: 'Section', subjectId: 10, comments: []} + ] + } + }); + + expect(entry.queryAllCommentBadges().length).toBeGreaterThan(0); + + fireEvent.click(entry.getHideCommentsButton()); + expect(entry.queryAllCommentBadges()).toHaveLength(0); + + fireEvent.click(entry.getShowCommentsButton()); + expect(entry.queryAllCommentBadges().length).toBeGreaterThan(0); + }); + + it('hides inline comment highlights when hidden', () => { + const value = [{type: 'paragraph', children: [{text: 'Some text to comment on'}]}]; + + const entry = renderEntry({ + contentElement: { + ui: , + typeOptions: {inlineComments: true} + }, + commenting: { + currentUser: {id: 42, name: 'Alice'}, + commentThreads: [ + {id: 1, subjectType: 'ContentElement', subjectId: 10, + subjectRange: {anchor: {path: [0, 0], offset: 5}, focus: {path: [0, 0], offset: 9}}, + comments: []} + ] + } + }); + + expect(document.querySelector(`.${highlightStyles.highlight}`)).toBeInTheDocument(); + + fireEvent.click(entry.getHideCommentsButton()); + + expect(document.querySelector(`.${highlightStyles.highlight}`)).toBeNull(); + expect(entry.getByText('Some text to comment on')).toBeInTheDocument(); + }); + + it('leaves add comment mode when hidden', () => { + const entry = renderEntryWithComments(); + + fireEvent.click(entry.getAddCommentButton()); + expect(entry.getCancelAddCommentButton()).toBeInTheDocument(); + + fireEvent.click(entry.getHideCommentsButton()); + fireEvent.click(entry.getShowCommentsButton()); + + expect(entry.getAddCommentButton()).toBeInTheDocument(); + }); +}); diff --git a/entry_types/scrolled/package/spec/support/pageObjects/commenting.js b/entry_types/scrolled/package/spec/support/pageObjects/commenting.js index 2768ad6000..86082edb58 100644 --- a/entry_types/scrolled/package/spec/support/pageObjects/commenting.js +++ b/entry_types/scrolled/package/spec/support/pageObjects/commenting.js @@ -24,6 +24,10 @@ export function renderEntry({ return { ...result, getCommentToolbar: () => result.getByRole('group', {name: 'Comments'}), + queryCommentToolbar: () => result.queryByRole('group', {name: 'Comments'}), + getHideCommentsButton: () => result.getByRole('button', {name: 'Hide comments'}), + getShowCommentsButton: () => result.getByRole('button', {name: 'Show comments'}), + queryShowCommentsButton: () => result.queryByRole('button', {name: 'Show comments'}), getAddCommentButton: () => result.getByRole('button', {name: 'Add comment'}), getCancelAddCommentButton: () => result.getByRole('button', {name: 'Cancel add comment'}), getNewThreadInput: () => result.getByPlaceholderText('Add a comment...'), @@ -49,6 +53,8 @@ export function useCommentingPageObjects() { useFakeTranslations({ 'pageflow_scrolled.review.add_comment': 'Add comment', 'pageflow_scrolled.review.cancel_add_comment': 'Cancel add comment', + 'pageflow_scrolled.review.hide_comments': 'Hide comments', + 'pageflow_scrolled.review.show_comments': 'Show comments', 'pageflow_scrolled.review.comment_toolbar': 'Comments', 'pageflow_scrolled.review.comment_count': '%{count} comments', 'pageflow_scrolled.review.select_content_element': 'Select to comment', diff --git a/entry_types/scrolled/package/src/frontend/commenting/CommentingVisibilityProvider.js b/entry_types/scrolled/package/src/frontend/commenting/CommentingVisibilityProvider.js new file mode 100644 index 0000000000..0add8853b0 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/commenting/CommentingVisibilityProvider.js @@ -0,0 +1,37 @@ +import React, {createContext, useCallback, useContext, useMemo, useState} from 'react'; +import {flushSync} from 'react-dom'; + +const CommentingVisibilityContext = createContext({ + visible: true, + toggle: () => {} +}); + +export function CommentingVisibilityProvider({children}) { + const [visible, setVisible] = useState(true); + + const toggle = useCallback(() => { + const flip = () => setVisible(previous => !previous); + + // Morph the toolbar and its collapsed puck into each other where the + // browser supports it. flushSync forces React to commit synchronously so + // the transition captures the post-toggle DOM. + if (document.startViewTransition) { + document.startViewTransition(() => flushSync(flip)); + } + else { + flip(); + } + }, []); + + const value = useMemo(() => ({visible, toggle}), [visible, toggle]); + + return ( + + {children} + + ); +} + +export function useCommentingVisibility() { + return useContext(CommentingVisibilityContext); +} diff --git a/entry_types/scrolled/package/src/frontend/commenting/ContentElementDecorator.js b/entry_types/scrolled/package/src/frontend/commenting/ContentElementDecorator.js index 0855727f68..599af1fc7d 100644 --- a/entry_types/scrolled/package/src/frontend/commenting/ContentElementDecorator.js +++ b/entry_types/scrolled/package/src/frontend/commenting/ContentElementDecorator.js @@ -5,6 +5,7 @@ import {api} from '../api'; import {widths} from '../layouts'; import {useAddCommentMode} from './AddCommentModeProvider'; import {AddCommentOverlay} from './AddCommentOverlay'; +import {useCommentingVisibility} from './CommentingVisibilityProvider'; import {Popover} from './Popover'; import {useSelectedSubject} from './SelectedSubjectProvider'; @@ -26,9 +27,14 @@ export function ContentElementDecorator({type, width, customMargin, permaId, chi } function DefaultCommentDecorator({permaId, flush, children}) { + const {visible} = useCommentingVisibility(); const {active} = useAddCommentMode(); const {isSelected} = useSelectedSubject('ContentElement', permaId); + if (!visible) { + return children; + } + return (
{children}
diff --git a/entry_types/scrolled/package/src/frontend/commenting/EditableText.js b/entry_types/scrolled/package/src/frontend/commenting/EditableText.js index d8d07e27c4..0cb5451bce 100644 --- a/entry_types/scrolled/package/src/frontend/commenting/EditableText.js +++ b/entry_types/scrolled/package/src/frontend/commenting/EditableText.js @@ -9,6 +9,7 @@ import {PlainEditableText, renderElement, renderLeaf} from '../EditableText'; import {useContentElementAttributes} from '../useContentElementAttributes'; import {useAddCommentMode} from './AddCommentModeProvider'; import {useCommentDisplayFilter} from './CommentDisplayFilterProvider'; +import {useCommentingVisibility} from './CommentingVisibilityProvider'; import {useSelectedSubject} from './SelectedSubjectProvider'; import {AddCommentHint} from './AddCommentHint'; import {PopoversColumn} from './PopoversColumn'; @@ -25,8 +26,9 @@ const defaultValue = [{ export const EditableText = React.memo(function EditableText(props) { const {inlineComments} = useContentElementAttributes(); + const {visible} = useCommentingVisibility(); - if (inlineComments) { + if (inlineComments && visible) { return ; } diff --git a/entry_types/scrolled/package/src/frontend/commenting/EntryDecorator.js b/entry_types/scrolled/package/src/frontend/commenting/EntryDecorator.js index 3341a4fdd8..18dd142a24 100644 --- a/entry_types/scrolled/package/src/frontend/commenting/EntryDecorator.js +++ b/entry_types/scrolled/package/src/frontend/commenting/EntryDecorator.js @@ -5,6 +5,7 @@ import {createReviewSession} from 'pageflow/review'; import {ReviewStateProvider, ReviewMessageHandler} from 'pageflow-scrolled/review'; import {AddCommentModeProvider} from './AddCommentModeProvider'; import {CommentDisplayFilterProvider} from './CommentDisplayFilterProvider'; +import {CommentingVisibilityProvider} from './CommentingVisibilityProvider'; import {SelectedSubjectProvider} from './SelectedSubjectProvider'; import {FloatingToolbar} from './FloatingToolbar'; @@ -12,14 +13,16 @@ export function EntryDecorator({commentingInitialState, children}) { return ( - - - - {children} - - - - + + + + + {children} + + + + + ); } diff --git a/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.js b/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.js index 2886249e77..41c401ad77 100644 --- a/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.js +++ b/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.js @@ -1,19 +1,34 @@ -import React from 'react'; +import React, {useEffect} from 'react'; import classNames from 'classnames'; import {useLocatedCommentThreads} from 'pageflow-scrolled/review'; import {useI18n} from '../i18n'; import {useAddCommentMode} from './AddCommentModeProvider'; import {useCommentDisplayFilter} from './CommentDisplayFilterProvider'; +import {useCommentingVisibility} from './CommentingVisibilityProvider'; import {useCommentNavigation} from './SelectedSubjectProvider'; import AddCommentIcon from './images/addComment.svg'; import CancelCommentIcon from './images/cancelComment.svg'; import ChevronIcon from './images/chevron.svg'; +import HideCommentsIcon from './images/hideComments.svg'; +import ShowCommentsIcon from './images/showComments.svg'; import styles from './FloatingToolbar.module.css'; export function FloatingToolbar() { const {t} = useI18n({locale: 'ui'}); + const {visible} = useCommentingVisibility(); + const {active, deactivate} = useAddCommentMode(); + + useEffect(() => { + if (!visible && active) { + deactivate(); + } + }, [visible, active, deactivate]); + + if (!visible) { + return ; + } return (
+
); } +function HideCommentsButton() { + const {t} = useI18n({locale: 'ui'}); + const {toggle} = useCommentingVisibility(); + const label = t('pageflow_scrolled.review.hide_comments'); + + return ( + + ); +} + +function ShowCommentsButton() { + const {t} = useI18n({locale: 'ui'}); + const {toggle} = useCommentingVisibility(); + const label = t('pageflow_scrolled.review.show_comments'); + + return ( + + ); +} + function PositionIndicator() { const {t} = useI18n({locale: 'ui'}); const {count, position} = useCommentNavigation(); diff --git a/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.module.css b/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.module.css index adfcccc6c0..9add2f8ee8 100644 --- a/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.module.css +++ b/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.module.css @@ -1,3 +1,13 @@ +/* Collapsing/expanding is animated with a view transition (see + CommentingVisibilityProvider). The toolbar and the puck share + `comment-toolbar`, so the toolbar surface and its content morph into the + puck together (the content deforms and cross-fades along with the rect, + having no counterpart in the puck). The toggle icons (eye and bubble) share + `comment-toolbar-icon` and morph on their own — naming them lifts them out + of the surface snapshot so the icon swap stays crisp. The + ::view-transition old/new overrides below stretch the surface snapshots to + fill the animating box, so it reshapes into the puck rather than scaling + down with a fixed aspect ratio. */ .toolbar { position: fixed; bottom: space(3); @@ -11,6 +21,67 @@ border: 1px solid var(--ui-on-primary-color-lightest); border-radius: rounded(lg); box-shadow: var(--ui-box-shadow-md); + view-transition-name: comment-toolbar; +} + +.toggleIcon { + view-transition-name: comment-toolbar-icon; +} + +:global(::view-transition-old(comment-toolbar)), +:global(::view-transition-new(comment-toolbar)) { + width: 100%; + height: 100%; + object-fit: fill; +} + +/* Collapsed state: a small tab docked flush to the bottom viewport edge, + roughly below where the hide button sits in the expanded toolbar (away from + the corner where custom floating buttons tend to live). + + Idle opacity is driven by an animation rather than a transition so it plays + nicely with the collapse morph: `puckIdle` holds full opacity through its + delay, which spans both the view-transition snapshot capture and the reveal, + so the puck morphs in fully opaque (nothing flips), lingers, then fades to + its semi-transparent rest state. Hovering or focusing cancels the animation + for an instant opaque state; leaving re-runs it, so it only dims again a + moment after the cursor leaves. */ +.puck { + position: fixed; + bottom: 0; + right: space(16); + z-index: 1100; + display: flex; + align-items: center; + justify-content: center; + color: var(--ui-on-primary-color); + background: var(--ui-primary-color); + border: 1px solid var(--ui-on-primary-color-lightest); + border-bottom: none; + border-top-left-radius: rounded(md); + border-top-right-radius: rounded(md); + box-shadow: var(--ui-box-shadow-md); + padding: space(2); + cursor: pointer; + opacity: 0.6; + animation: puckIdle 0.5s ease 0.5s backwards; + view-transition-name: comment-toolbar; +} + +.puck:hover, +.puck:focus-visible { + opacity: 1; + animation: none; +} + +@keyframes puckIdle { + from { + opacity: 1; + } + + to { + opacity: 0.6; + } } .navigation { diff --git a/entry_types/scrolled/package/src/frontend/commenting/SectionDecorator.js b/entry_types/scrolled/package/src/frontend/commenting/SectionDecorator.js index 64da981d2b..d27561c63d 100644 --- a/entry_types/scrolled/package/src/frontend/commenting/SectionDecorator.js +++ b/entry_types/scrolled/package/src/frontend/commenting/SectionDecorator.js @@ -5,6 +5,7 @@ import {useCommentThreads} from 'pageflow-scrolled/review'; import {useI18n} from '../i18n'; import {useAddCommentMode} from './AddCommentModeProvider'; import {useCommentDisplayFilter} from './CommentDisplayFilterProvider'; +import {useCommentingVisibility} from './CommentingVisibilityProvider'; import {useSelectedSubject} from './SelectedSubjectProvider'; import {Popover} from './Popover'; @@ -12,6 +13,7 @@ import AddCommentIcon from './images/addComment.svg'; import styles from './SectionDecorator.module.css'; export function SectionDecorator({section, children}) { + const {visible} = useCommentingVisibility(); const {active} = useAddCommentMode(); const {isSelected} = useSelectedSubject('Section', section.permaId); const {resolution} = useCommentDisplayFilter(); @@ -22,6 +24,10 @@ export function SectionDecorator({section, children}) { }); const hasThreads = threads.length > 0; + if (!visible) { + return children; + } + return (
{children} diff --git a/entry_types/scrolled/package/src/frontend/commenting/images/hideComments.svg b/entry_types/scrolled/package/src/frontend/commenting/images/hideComments.svg new file mode 100644 index 0000000000..78e696996b --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/commenting/images/hideComments.svg @@ -0,0 +1 @@ + diff --git a/entry_types/scrolled/package/src/frontend/commenting/images/showComments.svg b/entry_types/scrolled/package/src/frontend/commenting/images/showComments.svg new file mode 100644 index 0000000000..69b68652f8 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/commenting/images/showComments.svg @@ -0,0 +1 @@ +