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:
parent
428eebeaf8
commit
f06de99410
6 changed files with 167 additions and 14 deletions
|
@ -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);
|
||||
|
|
|
@ -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 {};
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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 {};
|
||||
}
|
||||
|
|
|
@ -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>: <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>: <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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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>',
|
||||
|
|
Loading…
Add table
Reference in a new issue