From 7092bc1faa1ee2a825ae0900e10111b27aeba810 Mon Sep 17 00:00:00 2001 From: bluecloud <96812901+pitb2022@users.noreply.github.com> Date: Wed, 22 May 2024 17:02:37 +0800 Subject: [PATCH 1/6] feat(ArticleDetail): restore quote comment --- src/views/ArticleDetail/Content/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/views/ArticleDetail/Content/index.tsx b/src/views/ArticleDetail/Content/index.tsx index 903691a45c..940e6cc9e5 100644 --- a/src/views/ArticleDetail/Content/index.tsx +++ b/src/views/ArticleDetail/Content/index.tsx @@ -6,8 +6,8 @@ import { useContext, useEffect, useRef, useState } from 'react' import { TEST_ID } from '~/common/enums' import { captureClicks, initAudioPlayers, optimizeEmbed } from '~/common/utils' import { - // Media, - // TextSelectionPopover, + Media, + TextSelectionPopover, useMutation, ViewerContext, } from '~/components' @@ -126,13 +126,13 @@ const Content = ({ ref={contentContainer} data-test-id={TEST_ID.ARTICLE_CONTENT} /> - {/* + {contentContainer.current && ( )} - */} + ) } From 04a24405572145c2ad02d00e6a622b457516b5a1 Mon Sep 17 00:00:00 2001 From: bluecloud <96812901+pitb2022@users.noreply.github.com> Date: Wed, 22 May 2024 17:47:14 +0800 Subject: [PATCH 2/6] fix(TextSelectionPopover): hide popover when no text is selected ref: PR-056 --- src/components/TextSelectionPopover/index.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/components/TextSelectionPopover/index.tsx b/src/components/TextSelectionPopover/index.tsx index 590117b032..059563082a 100644 --- a/src/components/TextSelectionPopover/index.tsx +++ b/src/components/TextSelectionPopover/index.tsx @@ -42,6 +42,14 @@ export const TextSelectionPopover = ({ setSelection(undefined) } + const onSelectChange = () => { + const activeSelection = document.getSelection() + + if (!activeSelection || !activeSelection.toString()) { + setSelection(undefined) + } + } + const onSelectEnd = () => { const activeSelection = document.getSelection() @@ -89,9 +97,11 @@ export const TextSelectionPopover = ({ useEffect(() => { document.addEventListener('selectstart', onSelectStart) document.addEventListener('mouseup', onSelectEnd) + document.addEventListener('selectionchange', onSelectChange) return () => { document.removeEventListener('selectstart', onSelectStart) document.removeEventListener('mouseup', onSelectEnd) + document.removeEventListener('selectionchange', onSelectChange) } }, []) From 39b1cd69af53c3df8c74b3b4ad15d596dcd4879a Mon Sep 17 00:00:00 2001 From: bluecloud <96812901+pitb2022@users.noreply.github.com> Date: Thu, 23 May 2024 19:00:42 +0800 Subject: [PATCH 3/6] feat(TextSelectionPopover): enhance cross-paragraph selection detection --- src/components/TextSelectionPopover/index.tsx | 70 ++++++++++++------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/src/components/TextSelectionPopover/index.tsx b/src/components/TextSelectionPopover/index.tsx index 059563082a..c6879d3f65 100644 --- a/src/components/TextSelectionPopover/index.tsx +++ b/src/components/TextSelectionPopover/index.tsx @@ -11,11 +11,49 @@ interface TextSelectionPopoverProps { targetElement: HTMLElement } +const isSelectionCrossingParagraphs = (selection: Selection): boolean => { + if (!selection.rangeCount) { + return false + } + + const range = selection.getRangeAt(0) + const commonAncestor = range.commonAncestorContainer as Element + + const allowedNodeNames = ['p', '#text'] + return ( + /\n/.test(selection.toString() || '') && + !allowedNodeNames.includes(commonAncestor.nodeName.toLowerCase()) + ) +} + +const isValidSelection = ( + selection: Selection | null, + targetElement: HTMLElement, + ref: React.RefObject +): boolean => { + if (!selection || !selection.toString() || !ref.current) { + return false + } + + if (isSelectionCrossingParagraphs(selection)) { + return false + } + + const range = selection.getRangeAt(0) + const commonAncestor = range.commonAncestorContainer + + if (!targetElement.contains(commonAncestor)) { + return false + } + + return true +} + export const TextSelectionPopover = ({ targetElement, }: TextSelectionPopoverProps) => { const [selection, setSelection] = useState() - const [position, setPosition] = useState>() // { x, y, width, height } + const [position, setPosition] = useState>() // { x, y } const ref = useRef(null) const { editor } = useContext(ActiveCommentEditorContext) const [quote, setQuote] = useState(null) @@ -53,44 +91,26 @@ export const TextSelectionPopover = ({ const onSelectEnd = () => { const activeSelection = document.getSelection() - // Check if it's the same paragraph - if (/\n/.test(activeSelection?.toString() || '')) { - return - } - const text = activeSelection?.toString() - if (!activeSelection || !text) { + if (!isValidSelection(activeSelection, targetElement, ref)) { setSelection(undefined) return } - if (!ref.current) { - return - } - - if ( - !targetElement || - !targetElement.contains( - activeSelection.getRangeAt(0).commonAncestorContainer - ) - ) { - return - } - setSelection(text) - const rect = activeSelection.getRangeAt(0).getBoundingClientRect() - const targetRect = ref.current.getBoundingClientRect() + const rect = (activeSelection as Selection) + .getRangeAt(0) + .getBoundingClientRect() + const targetRect = (ref.current as HTMLDivElement).getBoundingClientRect() - const tooltipHeight = 44 + 16 + const tooltipHeight = 60 // 44 + 16 (height + margin) const tooltipWidth = 44 setPosition({ x: rect.left - targetRect.left + rect.width / 2 - tooltipWidth / 2, y: rect.top + window.scrollY - tooltipHeight, - width: rect.width, - height: rect.height, }) } From 2b5038cef02e83253cce304f0aa07ef5509c2d7a Mon Sep 17 00:00:00 2001 From: bluecloud <96812901+pitb2022@users.noreply.github.com> Date: Fri, 24 May 2024 17:18:50 +0800 Subject: [PATCH 4/6] fix(Comments): revise quote comment --- src/common/utils/dom.test.ts | 63 ++++++++++++++++++- src/common/utils/dom.ts | 11 ++++ .../CommentBeta/FooterActions/index.tsx | 19 +++++- .../Context/ActiveCommentEditor/index.tsx | 23 ------- .../Context/CommentEditor/index.tsx | 49 +++++++++++++++ src/components/Context/index.ts | 2 +- src/components/Editor/Comment/index.tsx | 21 ++++--- .../Forms/CommentFormBeta/index.tsx | 21 +++++-- src/components/TextSelectionPopover/index.tsx | 17 +++-- .../Comments/LatestComments/index.tsx | 2 +- src/views/ArticleDetail/index.tsx | 6 +- 11 files changed, 185 insertions(+), 49 deletions(-) delete mode 100644 src/components/Context/ActiveCommentEditor/index.tsx create mode 100644 src/components/Context/CommentEditor/index.tsx diff --git a/src/common/utils/dom.test.ts b/src/common/utils/dom.test.ts index acb9bc194f..e88220d95c 100644 --- a/src/common/utils/dom.test.ts +++ b/src/common/utils/dom.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { dom } from './dom' +import { dom, isElementInViewport } from './dom' describe('utils/dom/getAttributes', () => { it('should return an empty array if no matches are found', () => { @@ -27,3 +27,62 @@ describe('utils/dom/getAttributes', () => { expect(result).toEqual(['image.jpg']) }) }) + +describe('utils/detect/isElementInViewport', () => { + let element: HTMLElement + + beforeEach(() => { + element = document.createElement('div') + document.body.appendChild(element) + + element.getBoundingClientRect = vi.fn(() => ({ + top: 0, + left: 0, + bottom: 0, + right: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON: () => ({}), + })) + }) + + afterEach(() => { + document.body.removeChild(element) + }) + + it('should return true if the element is in the viewport', () => { + element.getBoundingClientRect = vi.fn(() => ({ + top: 100, + left: 100, + bottom: 200, + right: 200, + width: 100, + height: 100, + x: 100, + y: 100, + toJSON: () => ({}), + })) + + const result = isElementInViewport(element) + expect(result).toBe(true) + }) + + it('should return false if the element is not in the viewport', () => { + element.getBoundingClientRect = vi.fn(() => ({ + top: 900, + left: 700, + bottom: 1000, + right: 800, + width: 100, + height: 100, + x: 700, + y: 900, + toJSON: () => ({}), + })) + + const result = isElementInViewport(element) + expect(result).toBe(false) + }) +}) diff --git a/src/common/utils/dom.ts b/src/common/utils/dom.ts index e008f9a0d1..0b49930362 100644 --- a/src/common/utils/dom.ts +++ b/src/common/utils/dom.ts @@ -17,3 +17,14 @@ export const dom = { $$, getAttributes, } + +export const isElementInViewport = (element: Element): boolean => { + const rect = element.getBoundingClientRect() + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= + (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= (window.innerWidth || document.documentElement.clientWidth) + ) +} diff --git a/src/components/CommentBeta/FooterActions/index.tsx b/src/components/CommentBeta/FooterActions/index.tsx index 171f3d0e60..b9d75da199 100644 --- a/src/components/CommentBeta/FooterActions/index.tsx +++ b/src/components/CommentBeta/FooterActions/index.tsx @@ -1,3 +1,4 @@ +import { Editor } from '@matters/matters-editor' import gql from 'graphql-tag' import { useContext, useEffect, useRef, useState } from 'react' import { FormattedMessage, useIntl } from 'react-intl' @@ -16,6 +17,7 @@ import { Media, Spacer, toast, + useCommentEditorContext, ViewerContext, } from '~/components' import { @@ -97,6 +99,8 @@ const BaseFooterActions = ({ const [showForm, setShowForm] = useState(false) const toggleShowForm = () => setShowForm(!showForm) + const [editor, setEditor] = useState(null) + const { setActiveEditor, activeEditor } = useCommentEditorContext() const { state, node } = comment const article = node.__typename === 'Article' ? node : undefined @@ -224,7 +228,12 @@ const BaseFooterActions = ({ {...buttonProps} {...replyButtonProps} {...replyCustomButtonProps} - onClick={toggleShowForm} + onClick={() => { + if (editor === activeEditor) { + setActiveEditor(null) + } + toggleShowForm() + }} /> @@ -237,11 +246,17 @@ const BaseFooterActions = ({ setShowForm(false)} + closeCallback={() => { + if (editor === activeEditor) { + setActiveEditor(null) + } + setShowForm(false) + }} isInCommentDetail={isInCommentDetail} defaultContent={defaultContent} /> diff --git a/src/components/Context/ActiveCommentEditor/index.tsx b/src/components/Context/ActiveCommentEditor/index.tsx deleted file mode 100644 index 46c2e215b3..0000000000 --- a/src/components/Context/ActiveCommentEditor/index.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Editor } from '@matters/matters-editor' -import { createContext, useState } from 'react' - -export const ActiveCommentEditorContext = createContext( - {} as { - editor: Editor | null - setEditor: (editor: Editor | null) => void - } -) - -export const ActiveCommentEditorProvider = ({ - children, -}: { - children: React.ReactNode -}) => { - const [editor, setEditor] = useState(null) - - return ( - - {children} - - ) -} diff --git a/src/components/Context/CommentEditor/index.tsx b/src/components/Context/CommentEditor/index.tsx new file mode 100644 index 0000000000..788522da19 --- /dev/null +++ b/src/components/Context/CommentEditor/index.tsx @@ -0,0 +1,49 @@ +import { Editor } from '@matters/matters-editor' +import { createContext, ReactNode, useContext, useState } from 'react' + +interface CommentEditorContextProps { + activeEditor: Editor | null + fallbackEditor: Editor | null + setActiveEditor: (editor: Editor | null) => void + setFallbackEditor: (editor: Editor | null) => void + getCurrentEditor: () => Editor | null +} + +const CommentEditorContext = createContext< + CommentEditorContextProps | undefined +>(undefined) + +export const useCommentEditorContext = (): CommentEditorContextProps => { + const context = useContext(CommentEditorContext) + if (!context) { + throw new Error( + 'useCommentEditorContext must be used within a CommentEditorProvider' + ) + } + return context +} + +export const CommentEditorProvider = ({ + children, +}: { + children: ReactNode +}) => { + const [activeEditor, setActiveEditor] = useState(null) + const [fallbackEditor, setFallbackEditor] = useState(null) + + const getCurrentEditor = () => activeEditor || fallbackEditor + + return ( + + {children} + + ) +} diff --git a/src/components/Context/index.ts b/src/components/Context/index.ts index a67da27b5b..5468da83c4 100644 --- a/src/components/Context/index.ts +++ b/src/components/Context/index.ts @@ -1,6 +1,6 @@ -export * from './ActiveCommentEditor' export * from './ArticleAppreciation' export * from './CommentDrafts' +export * from './CommentEditor' export * from './DraftDetailState' export * from './Features' export * from './Language' diff --git a/src/components/Editor/Comment/index.tsx b/src/components/Editor/Comment/index.tsx index c08af371b4..7c05790c71 100644 --- a/src/components/Editor/Comment/index.tsx +++ b/src/components/Editor/Comment/index.tsx @@ -4,11 +4,11 @@ import { EditorContent, useCommentEditor, } from '@matters/matters-editor' -import { useContext, useEffect } from 'react' +import { useEffect } from 'react' import { useIntl } from 'react-intl' import { BYPASS_SCROLL_LOCK, ENBABLE_SCROLL_LOCK } from '~/common/enums' -import { ActiveCommentEditorContext } from '~/components/Context' +import { useCommentEditorContext } from '~/components/Context' import { makeMentionSuggestion } from '../Article/extensions' import styles from './styles.module.css' @@ -18,7 +18,7 @@ interface Props { update: (params: { content: string }) => void placeholder?: string setEditor?: (editor: Editor | null) => void - syncQuote?: boolean + isFallbackEditor?: boolean } const CommentEditor: React.FC = ({ @@ -26,11 +26,11 @@ const CommentEditor: React.FC = ({ update, placeholder, setEditor, - syncQuote, + isFallbackEditor, }) => { const client = useApolloClient() const intl = useIntl() - const { setEditor: setActiveEditor } = useContext(ActiveCommentEditorContext) + const { setActiveEditor, setFallbackEditor } = useCommentEditorContext() const editor = useCommentEditor({ placeholder: @@ -60,8 +60,8 @@ const CommentEditor: React.FC = ({ useEffect(() => { setEditor?.(editor) - if (syncQuote) { - setActiveEditor(editor) + if (isFallbackEditor) { + setFallbackEditor(editor) } }, [editor]) @@ -70,7 +70,12 @@ const CommentEditor: React.FC = ({ className={styles.commentEditor} id="editor" // anchor for mention plugin > - + { + setActiveEditor(editor) + }} + /> ) } diff --git a/src/components/Forms/CommentFormBeta/index.tsx b/src/components/Forms/CommentFormBeta/index.tsx index 2ec9e5fef0..7f612a8ae4 100644 --- a/src/components/Forms/CommentFormBeta/index.tsx +++ b/src/components/Forms/CommentFormBeta/index.tsx @@ -13,6 +13,7 @@ import { CommentDraftsContext, SpinnerBlock, TextIcon, + useCommentEditorContext, useMutation, useRoute, ViewerContext, @@ -31,11 +32,12 @@ import styles from './styles.module.css' export type CommentFormBetaType = 'article' export interface CommentFormBetaProps { + type: CommentFormBetaType + commentId?: string replyToId?: string parentId?: string articleId?: string - type: CommentFormBetaType isInCommentDetail?: boolean defaultContent?: string | null @@ -44,7 +46,9 @@ export interface CommentFormBetaProps { showClear?: boolean placeholder?: string - syncQuote?: boolean + + isFallbackEditor?: boolean + setEditor?: (editor: Editor | null) => void } export const CommentFormBeta: React.FC = ({ @@ -59,15 +63,21 @@ export const CommentFormBeta: React.FC = ({ closeCallback, showClear, placeholder, - syncQuote, + isFallbackEditor, + setEditor: propsSetEditor, }) => { const intl = useIntl() const viewer = useContext(ViewerContext) const { getDraft, updateDraft, removeDraft } = useContext(CommentDraftsContext) const { getQuery, routerLang } = useRoute() + const { setActiveEditor } = useCommentEditorContext() + const [editor, localSetEditor] = useState(null) + const setEditor = (editor: Editor | null) => { + localSetEditor(editor) + propsSetEditor?.(editor) + } const shortHash = getQuery('shortHash') - const [editor, setEditor] = useState(null) // retrieve comment draft const commentDraftId = `${articleId}-${type}-${commentId || 0}-${ @@ -172,6 +182,7 @@ export const CommentFormBeta: React.FC = ({ editor.commands.setContent('') } removeDraft(commentDraftId) + setActiveEditor(null) } const onUpdate = ({ content: newContent }: { content: string }) => { @@ -195,7 +206,7 @@ export const CommentFormBeta: React.FC = ({ content={content} update={onUpdate} placeholder={placeholder} - syncQuote={syncQuote} + isFallbackEditor={isFallbackEditor} setEditor={(editor) => { setEditor(editor) }} diff --git a/src/components/TextSelectionPopover/index.tsx b/src/components/TextSelectionPopover/index.tsx index c6879d3f65..48349fcc4d 100644 --- a/src/components/TextSelectionPopover/index.tsx +++ b/src/components/TextSelectionPopover/index.tsx @@ -1,9 +1,10 @@ import classNames from 'classnames' -import { useContext, useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { ReactComponent as IconComment } from '@/public/static/icons/24px/comment.svg' import { OPEN_COMMENT_LIST_DRAWER } from '~/common/enums' -import { ActiveCommentEditorContext, Icon } from '~/components' +import { isElementInViewport } from '~/common/utils' +import { Icon, useCommentEditorContext } from '~/components' import styles from './styles.module.css' @@ -55,14 +56,22 @@ export const TextSelectionPopover = ({ const [selection, setSelection] = useState() const [position, setPosition] = useState>() // { x, y } const ref = useRef(null) - const { editor } = useContext(ActiveCommentEditorContext) + const { fallbackEditor, getCurrentEditor } = useCommentEditorContext() const [quote, setQuote] = useState(null) useEffect(() => { + const editor = getCurrentEditor() if (!editor || !quote) { return } + if (!isElementInViewport(editor.view.dom)) { + editor.view.dom.scrollIntoView({ + behavior: 'instant', + block: 'center', + }) + } + setTimeout(() => { editor.commands.focus('end') editor.commands.insertContent(quote) @@ -74,7 +83,7 @@ export const TextSelectionPopover = ({ // wait for the drawer animation to complete }, 100) - }, [editor, quote]) + }, [quote, fallbackEditor]) const onSelectStart = () => { setSelection(undefined) diff --git a/src/views/ArticleDetail/Comments/LatestComments/index.tsx b/src/views/ArticleDetail/Comments/LatestComments/index.tsx index 5b590d79cd..0381185015 100644 --- a/src/views/ArticleDetail/Comments/LatestComments/index.tsx +++ b/src/views/ArticleDetail/Comments/LatestComments/index.tsx @@ -149,7 +149,7 @@ const LatestComments = ({ id, lock }: { id: string; lock: boolean }) => { diff --git a/src/views/ArticleDetail/index.tsx b/src/views/ArticleDetail/index.tsx index 1ab575a8b0..4ba735f233 100644 --- a/src/views/ArticleDetail/index.tsx +++ b/src/views/ArticleDetail/index.tsx @@ -10,11 +10,11 @@ import { } from '~/common/enums' import { analytics, normalizeTag, toPath } from '~/common/utils' import { - ActiveCommentEditorProvider, ArticleAppreciationContext, ArticleAppreciationProvider, BackToHomeButton, BackToHomeMobileButton, + CommentEditorProvider, EmptyLayout, Error, Head, @@ -653,9 +653,9 @@ const ArticleDetail = ({ */ return ( - + - + ) } From 328489127c8daf3fdfd22590842a8e2d02405cd6 Mon Sep 17 00:00:00 2001 From: bluecloud <96812901+pitb2022@users.noreply.github.com> Date: Fri, 24 May 2024 17:45:58 +0800 Subject: [PATCH 5/6] fix(CommentDrawer): clear active editor --- src/views/ArticleDetail/CommentDrawer/index.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/views/ArticleDetail/CommentDrawer/index.tsx b/src/views/ArticleDetail/CommentDrawer/index.tsx index 12228cab14..6d5e15154f 100644 --- a/src/views/ArticleDetail/CommentDrawer/index.tsx +++ b/src/views/ArticleDetail/CommentDrawer/index.tsx @@ -4,7 +4,12 @@ import { useIntl } from 'react-intl' import { ReactComponent as IconLeft } from '@/public/static/icons/24px/left.svg' import { analytics } from '~/common/utils' -import { CommentDraftsProvider, Drawer, Icon } from '~/components' +import { + CommentDraftsProvider, + Drawer, + Icon, + useCommentEditorContext, +} from '~/components' import { Placeholder as CommentsPlaceholder } from '../Comments/Placeholder' @@ -43,6 +48,7 @@ export const CommentDrawer: React.FC = ({ const intl = useIntl() const isCommentDetail = step === 'commentDetail' const isCommentList = step === 'commentList' + const { setActiveEditor } = useCommentEditorContext() useEffect(() => { if (isOpen) { @@ -51,6 +57,10 @@ export const CommentDrawer: React.FC = ({ id: id, }) } + + if (!isOpen && isCommentList) { + setActiveEditor(null) + } }, [isOpen]) return ( From fb761e8a9e39dc9dd76a093dacf2390b1bf5a2cd Mon Sep 17 00:00:00 2001 From: bluecloud <96812901+pitb2022@users.noreply.github.com> Date: Mon, 27 May 2024 17:16:15 +0800 Subject: [PATCH 6/6] fix(Context): revise default value --- src/components/Context/CommentEditor/index.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/components/Context/CommentEditor/index.tsx b/src/components/Context/CommentEditor/index.tsx index 788522da19..37c6869be3 100644 --- a/src/components/Context/CommentEditor/index.tsx +++ b/src/components/Context/CommentEditor/index.tsx @@ -9,17 +9,12 @@ interface CommentEditorContextProps { getCurrentEditor: () => Editor | null } -const CommentEditorContext = createContext< - CommentEditorContextProps | undefined ->(undefined) +const CommentEditorContext = createContext( + {} as CommentEditorContextProps +) export const useCommentEditorContext = (): CommentEditorContextProps => { const context = useContext(CommentEditorContext) - if (!context) { - throw new Error( - 'useCommentEditorContext must be used within a CommentEditorProvider' - ) - } return context }