0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-20 22:42:53 -05:00

Added highlight animation when scrolling to replied-to comment (#21781)

REF https://linear.app/ghost/issue/PLG-284

When clicking on a replied-to reference, you scroll up to the parent comment. To guide the eye, the parent comment is highlighted with a yellow background.

- added `dispatchAction` to the `ActionHandler` function call arguments, allowing actions to call other actions
- added `commentIdToHighlight` app context state and associated `highlightComment` action to set it
- updated `Comment` (and related sub-components) to match `commentIdToHighlight` when rendering to determine whether to apply highlighting of comment contents
- for the highlight, `<mark>` is used to wrap any paragraphs inside the comment contents and appropriate tailwind highlight animation classes applied
  - uses the inline `<mark>` element so that background highlight only applies to the text bounding boxes rather than the entire wrapping element

---------

Co-authored-by: Ronald Langeveld <hi@ronaldlangeveld.com>
This commit is contained in:
Sanne de Vries 2024-12-05 18:46:16 +01:00 committed by GitHub
parent 428eebeaf8
commit f06de99410
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 167 additions and 14 deletions

View file

@ -30,7 +30,8 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
labs: {},
order: 'count__likes desc, created_at desc',
adminApi: null,
commentsIsLoading: false
commentsIsLoading: false,
commentIdToHighlight: null
});
const iframeRef = React.createRef<HTMLIFrameElement>();
@ -76,7 +77,7 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
// allow for async actions within it's updater function so this is the best option.
return new Promise((resolve) => {
setState((state) => {
ActionHandler({action, data, state, api, adminApi: state.adminApi!, options}).then((updatedState) => {
ActionHandler({action, data, state, api, adminApi: state.adminApi!, options, dispatchAction: dispatchAction as DispatchActionType}).then((updatedState) => {
const newState = {...updatedState};
resolve(newState);
setState(newState);
@ -175,7 +176,8 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
commentCount: count,
order,
labs: labs,
commentsIsLoading: false
commentsIsLoading: false,
commentIdToHighlight: null
};
setState(state);

View file

@ -83,6 +83,7 @@ export type EditableAppContext = {
order: string,
adminApi: AdminApi | null,
commentsIsLoading?: boolean
commentIdToHighlight: string | null
}
export type TranslationFunction = (key: string, replacements?: Record<string, string | number>) => string;
@ -119,3 +120,4 @@ export const useLabs = () => {
return {};
}
};

View file

@ -1,4 +1,4 @@
import {AddComment, Comment, CommentsOptions, EditableAppContext, OpenCommentForm} from './AppContext';
import {AddComment, Comment, CommentsOptions, DispatchActionType, EditableAppContext, OpenCommentForm} from './AppContext';
import {AdminApi} from './utils/adminApi';
import {GhostApi} from './utils/api';
import {Page} from './pages';
@ -408,6 +408,29 @@ async function openCommentForm({data: newForm, api, state}: {data: OpenCommentFo
};
}
function setHighlightComment({data: commentId}: {data: string | null}) {
return {
commentIdToHighlight: commentId
};
}
function highlightComment({
data: {commentId},
dispatchAction
}: {
data: { commentId: string | null };
state: EditableAppContext;
dispatchAction: DispatchActionType;
}) {
setTimeout(() => {
dispatchAction('setHighlightComment', null);
}, 3000);
return {
commentIdToHighlight: commentId
};
}
function setCommentFormHasUnsavedChanges({data: {id, hasUnsavedChanges}, state}: {data: {id: string, hasUnsavedChanges: boolean}, state: EditableAppContext}) {
const updatedForms = state.openCommentForms.map((f) => {
if (f.id === id) {
@ -449,7 +472,9 @@ export const Actions = {
loadMoreReplies,
updateMember,
setOrder,
openCommentForm
openCommentForm,
highlightComment,
setHighlightComment
};
export type ActionType = keyof typeof Actions;
@ -459,10 +484,10 @@ export function isSyncAction(action: string): action is SyncActionType {
}
/** Handle actions in the App, returns updated state */
export async function ActionHandler({action, data, state, api, adminApi, options}: {action: ActionType, data: any, state: EditableAppContext, options: CommentsOptions, api: GhostApi, adminApi: AdminApi}): Promise<Partial<EditableAppContext>> {
export async function ActionHandler({action, data, state, api, adminApi, options, dispatchAction}: {action: ActionType, data: any, state: EditableAppContext, options: CommentsOptions, api: GhostApi, adminApi: AdminApi, dispatchAction: DispatchActionType}): Promise<Partial<EditableAppContext>> {
const handler = Actions[action];
if (handler) {
return await handler({data, state, api, adminApi, options} as any) || {};
return await handler({data, state, api, adminApi, options, dispatchAction} as any) || {};
}
return {};
}

View file

@ -93,7 +93,7 @@ type PublishedCommentProps = CommentProps & {
openEditMode: () => void;
}
const PublishedComment: React.FC<PublishedCommentProps> = ({comment, parent, openEditMode}) => {
const {dispatchAction, openCommentForms, admin} = useAppContext();
const {dispatchAction, openCommentForms, admin, commentIdToHighlight} = useAppContext();
const labs = useLabs();
// Determine if the comment should be displayed with reduced opacity
@ -149,7 +149,7 @@ const PublishedComment: React.FC<PublishedCommentProps> = ({comment, parent, ope
) : (
<>
<CommentHeader className={hiddenClass} comment={comment} />
<CommentBody className={hiddenClass} html={comment.html} />
<CommentBody className={hiddenClass} html={comment.html} isHighlighted={comment.id === commentIdToHighlight} />
<CommentMenu
comment={comment}
highlightReplyButton={highlightReplyButton}
@ -285,7 +285,7 @@ type CommentHeaderProps = {
}
const CommentHeader: React.FC<CommentHeaderProps> = ({comment, className = ''}) => {
const {comments, t} = useAppContext();
const {comments, t, dispatchAction} = useAppContext();
const labs = useLabs();
const createdAtRelative = useRelativeTime(comment.created_at);
const {member} = useAppContext();
@ -310,6 +310,7 @@ const CommentHeader: React.FC<CommentHeaderProps> = ({comment, className = ''})
const element = (e.target as HTMLElement).ownerDocument.getElementById(comment.in_reply_to_id);
if (element) {
dispatchAction('highlightComment', {commentId: comment.in_reply_to_id});
element.scrollIntoView({behavior: 'smooth', block: 'center'});
}
};
@ -328,7 +329,7 @@ const CommentHeader: React.FC<CommentHeaderProps> = ({comment, className = ''})
</div>
{(isReplyToReply &&
<div className="mb-2 line-clamp-1 font-sans text-base leading-snug text-neutral-900/50 sm:text-sm dark:text-white/60">
<span>{t('Replied to')}</span>:&nbsp;<a className="font-semibold text-neutral-900/60 transition-colors hover:text-neutral-900/70 dark:text-white/70 dark:hover:text-white/80" data-testid="comment-in-reply-to" href={`#${comment.in_reply_to_id}`} onClick={scrollRepliedToCommentIntoView}>{inReplyToSnippet}</a>
<span>{t('Replied to')}</span>:&nbsp;<a className="font-semibold text-neutral-900/60 transition-colors hover:text-neutral-900/75 dark:text-white/70 dark:hover:text-white/85" data-testid="comment-in-reply-to" href={`#${comment.in_reply_to_id}`} onClick={scrollRepliedToCommentIntoView}>{inReplyToSnippet}</a>
</div>
)}
</>
@ -338,13 +339,38 @@ const CommentHeader: React.FC<CommentHeaderProps> = ({comment, className = ''})
type CommentBodyProps = {
html: string;
className?: string;
isHighlighted?: boolean;
}
const CommentBody: React.FC<CommentBodyProps> = ({html, className = ''}) => {
const dangerouslySetInnerHTML = {__html: html};
const CommentBody: React.FC<CommentBodyProps> = ({html, className = '', isHighlighted}) => {
let commentHtml = html;
if (isHighlighted) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const paragraphs = doc.querySelectorAll('p');
paragraphs.forEach((p) => {
const mark = doc.createElement('mark');
mark.className =
'animate-[highlight_2.5s_ease-out] [animation-delay:1s] bg-yellow-300/40 -my-0.5 py-0.5 dark:text-white/85 dark:bg-yellow-500/40';
while (p.firstChild) {
mark.appendChild(p.firstChild);
}
p.appendChild(mark);
});
// Serialize the modified html back to a string
commentHtml = doc.body.innerHTML;
}
const dangerouslySetInnerHTML = {__html: commentHtml};
return (
<div className={`mt mb-2 flex flex-row items-center gap-4 pr-4 ${className}`}>
<p dangerouslySetInnerHTML={dangerouslySetInnerHTML} className="gh-comment-content text-md text-pretty font-sans leading-normal text-neutral-900 [overflow-wrap:anywhere] sm:text-lg dark:text-white/85" data-testid="comment-content"/>
<p dangerouslySetInnerHTML={dangerouslySetInnerHTML} className="gh-comment-content text-md -mx-1 text-pretty rounded-md px-1 font-sans leading-normal text-neutral-900 [overflow-wrap:anywhere] sm:text-lg dark:text-white/85" data-testid="comment-content"/>
</div>
);
};

View file

@ -168,6 +168,19 @@ module.exports = {
'0 57.7px 33.4px rgba(0, 0, 0, 0.072)',
'0 138px 80px rgba(0, 0, 0, 0.1)'
]
},
animation: {
heartbeat: 'heartbeat 0.35s ease-in-out forwards',
highlight: 'highlight 1s steps(1) forwards'
},
keyframes: {
heartbeat: {
'0%, 100%': {transform: 'scale(1)'},
'50%': {transform: 'scale(1.3)'}
},
highlight: {
'100%': {backgroundColor: 'transparent'}
}
}
},
content: [

View file

@ -200,10 +200,13 @@ test.describe('Actions', async () => {
// Should indicate this was a reply to a reply
await expect(frame.getByTestId('comment-in-reply-to')).toHaveText('This is a reply to 1');
return {frame};
}
test('Can reply to a reply', async ({page}) => {
mockedApi.addComment({
id: '1',
html: '<p>This is comment 1</p>',
replies: [
mockedApi.buildReply({
@ -215,6 +218,88 @@ test.describe('Actions', async () => {
await testReplyToReply(page);
});
test('Can highlight reply when clicking on reply to: snippet', async ({page}) => {
mockedApi.addComment({
html: '<p>This is comment 1</p>',
replies: [
mockedApi.buildReply({
id: '2',
html: '<p>This is a reply to 1</p>'
}),
mockedApi.buildReply({
id: '3',
html: '<p>This is a reply to a reply</p>',
in_reply_to_id: '2',
in_reply_to_snippet: 'This is a reply to 1'
})
]
});
const {frame} = await initializeTest(page, {labs: true});
await frame.getByTestId('comment-in-reply-to').click();
// get the first reply which contains This is a reply to 1
const commentComponent = frame.getByTestId('comment-component').nth(1);
const replyComment = commentComponent.getByTestId('comment-content').nth(0);
// check that replyComment contains the text This is a reply to 1
await expect(replyComment).toHaveText('This is a reply to 1');
const markElement = await replyComment.locator('mark');
await expect(markElement).toBeVisible();
// Check that the mark element has the expected classes
await expect(markElement).toHaveClass(/animate-\[highlight_2\.5s_ease-out\]/);
await expect(markElement).toHaveClass(/\[animation-delay:1s\]/);
await expect(markElement).toHaveClass(/bg-yellow-300\/40/);
await expect(markElement).toHaveClass(/dark:bg-yellow-500\/40/);
});
test('Reply highlight disappears after a bit', async ({page}) => {
mockedApi.addComment({
html: '<p>This is comment 1</p>',
replies: [
mockedApi.buildReply({
id: '2',
html: '<p>This is a reply to 1</p>'
}),
mockedApi.buildReply({
id: '3',
html: '<p>This is a reply to a reply</p>',
in_reply_to_id: '2',
in_reply_to_snippet: 'This is a reply to 1'
})
]
});
const {frame} = await initializeTest(page, {labs: true});
await frame.getByTestId('comment-in-reply-to').click();
// get the first reply which contains This is a reply to 1
const commentComponent = frame.getByTestId('comment-component').nth(1);
const replyComment = commentComponent.getByTestId('comment-content').nth(0);
// check that replyComment contains the text This is a reply to 1
await expect(replyComment).toHaveText('This is a reply to 1');
const markElement = await replyComment.locator('mark');
await expect(markElement).toBeVisible();
// Check that the mark element has the expected classes
await expect(markElement).toHaveClass(/animate-\[highlight_2\.5s_ease-out\]/);
await expect(markElement).toHaveClass(/\[animation-delay:1s\]/);
await expect(markElement).toHaveClass(/bg-yellow-300\/40/);
await expect(markElement).toHaveClass(/dark:bg-yellow-500\/40/);
const timeout = 3000;
await page.waitForTimeout(timeout);
await expect(markElement).not.toBeVisible();
});
test('Can reply to a reply with a deleted parent comment', async function ({page}) {
mockedApi.addComment({
html: '<p>This is comment 1</p>',