Skip to content

Commit

Permalink
fix(comments) Prevent Slate errors by unifying the comment popups.
Browse files Browse the repository at this point in the history
- Remove `NewCommentPopup` component.
- Modified the rendering of `CommentThread` to handle a null for
  `thread`.
- Created `getLiveblocksEditorElement` and `triggerAutoFocus` utility
  callbacks.
- Added `triggerAutoFocus` to `onSubmitComment` so that creating a new
  thread auto focuses the new thread edit field.
  • Loading branch information
seanparsons committed Jan 29, 2024
1 parent f2fe68c commit 5ca5c99
Showing 1 changed file with 103 additions and 191 deletions.
294 changes: 103 additions & 191 deletions editor/src/components/canvas/controls/comment-popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,27 @@ const CommentThread = React.memo(({ comment }: CommentThreadProps) => {
}
}, [])

const getLiveblocksEditorElement = React.useCallback(() => {
if (composerRef.current == null) {
return null
}

const composerTextbox = getComposerTextbox()
if (composerTextbox == null) {
return null
}

scrollToBottom()

return composerTextbox
}, [scrollToBottom])

const triggerAutoFocus = React.useCallback(() => {
setTimeout(() => {
getLiveblocksEditorElement()?.focus()
}, 0)
}, [getLiveblocksEditorElement])

const onCreateThread = React.useCallback(
({ body }: ComposerSubmitComment, event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
Expand Down Expand Up @@ -207,32 +228,25 @@ const CommentThread = React.memo(({ comment }: CommentThreadProps) => {
switchEditorMode(EditorModes.commentMode(existingComment(newThread.id), 'not-dragging')),
setRightMenuTab(RightMenuTab.Comments),
])
triggerAutoFocus()
},
[createThread, comment, dispatch, remixSceneRoutes, scenes, createNewThreadReadStatus],
[
comment,
createNewThreadReadStatus,
dispatch,
triggerAutoFocus,
createThread,
scenes,
remixSceneRoutes,
],
)

const onSubmitComment = React.useCallback(() => {
if (threadId != null) {
createNewThreadReadStatus(threadId, 'read')
}
function getLiveblocksEditorElement(): HTMLDivElement | null {
if (composerRef.current == null) {
return null
}

const composerTextbox = getComposerTextbox()
if (composerTextbox == null) {
return null
}

scrollToBottom()

return composerTextbox
}
setTimeout(() => {
getLiveblocksEditorElement()?.focus()
}, 0)
}, [threadId, createNewThreadReadStatus, scrollToBottom])
triggerAutoFocus()
}, [threadId, triggerAutoFocus, createNewThreadReadStatus])

const onCommentDelete = React.useCallback(
(_deleted: CommentData) => {
Expand Down Expand Up @@ -278,8 +292,6 @@ const CommentThread = React.memo(({ comment }: CommentThreadProps) => {
setThreadReadStatus(thread.id, 'unread')
}, [thread?.id, setThreadReadStatus])

const collabs = useStorage((storage) => storage.collaborators)

const onScroll = () => {
const element = listRef.current
if (element == null) {
Expand Down Expand Up @@ -332,197 +344,97 @@ const CommentThread = React.memo(({ comment }: CommentThreadProps) => {
onKeyUp={stopPropagation}
onMouseUp={stopPropagation}
>
{thread == null ? (
<NewCommentPopup onComposerSubmit={onCreateThread} />
) : (
<>
<FlexRow
<>
<FlexRow
style={{
background: colorTheme.bg1.value,
justifyContent: 'flex-end',
padding: 6,
borderBottom: `1px solid ${colorTheme.bg3.value}`,
gap: 6,
}}
>
{when(
readByMe === 'read',
<Tooltip title='Mark As Unread' placement='top'>
<Button onClick={onClickMarkAsUnread}>
<Icn category='semantic' type='unread' width={18} height={18} color='main' />
</Button>
</Tooltip>,
)}
<Tooltip title='Resolve' placement='top'>
<Button onClick={onClickResolve} data-testid='resolve-thread-button'>
<Icn
category='semantic'
type={thread?.metadata.resolved ? 'resolved' : 'resolve'}
width={18}
height={18}
color='main'
/>
</Button>
</Tooltip>
<Button data-testid='close-comment' onClick={onClickClose}>
<Icn category='semantic' type='cross-large' width={16} height={16} color='main' />
</Button>
</FlexRow>
<div style={{ position: 'relative' }}>
<div
style={{
background: colorTheme.bg1.value,
justifyContent: 'flex-end',
padding: 6,
borderBottom: `1px solid ${colorTheme.bg3.value}`,
gap: 6,
maxHeight: PopupMaxHeight,
overflowY: 'scroll',
maxWidth: PopupMaxWidth,
wordWrap: 'break-word',
whiteSpace: 'normal',
}}
ref={listRef}
onScroll={onScroll}
>
{when(
readByMe === 'read',
<Tooltip title='Mark As Unread' placement='top'>
<Button onClick={onClickMarkAsUnread}>
<Icn category='semantic' type='unread' width={18} height={18} color='main' />
</Button>
</Tooltip>,
)}
<Tooltip title='Resolve' placement='top'>
<Button onClick={onClickResolve} data-testid='resolve-thread-button'>
<Icn
category='semantic'
type={thread?.metadata.resolved ? 'resolved' : 'resolve'}
width={18}
height={18}
color='main'
{(thread?.comments ?? []).map((c) => {
return (
<Comment
key={c.id}
comment={c}
onCommentDelete={onCommentDelete}
style={{ background: colorTheme.bg1.value }}
/>
</Button>
</Tooltip>
<Button data-testid='close-comment' onClick={onClickClose}>
<Icn category='semantic' type='cross-large' width={16} height={16} color='main' />
</Button>
</FlexRow>
<div style={{ position: 'relative' }}>
<div
style={{
maxHeight: PopupMaxHeight,
overflowY: 'scroll',
maxWidth: PopupMaxWidth,
wordWrap: 'break-word',
whiteSpace: 'normal',
}}
ref={listRef}
onScroll={onScroll}
>
{thread.comments.map((c) => {
return (
<Comment
key={c.id}
comment={c}
onCommentDelete={onCommentDelete}
style={{ background: colorTheme.bg1.value }}
/>
)
})}
</div>
<ListShadow position='top' enabled={showShadowTop} />
<ListShadow position='bottom' enabled={showShadowBottom} />
)
})}
</div>
<ListShadow position='top' enabled={showShadowTop} />
<ListShadow position='bottom' enabled={showShadowBottom} />
{thread == null ? null : (
<HeaderComment
enabled={thread.comments.length > 0 && showShadowTop}
comment={thread.comments[0]}
/>
</div>
)}
</div>
{thread == null ? (
<Composer
key={'comment-composer'}
ref={composerRef}
autoFocus
onComposerSubmit={onCreateThread}
style={ComposerStyle}
onKeyDown={onExistingCommentComposerKeyDown}
/>
) : (
<Composer
key={'comment-composer'}
ref={composerRef}
autoFocus
threadId={thread.id}
onComposerSubmit={onSubmitComment}
style={ComposerStyle}
onKeyDown={onExistingCommentComposerKeyDown}
/>
</>
)}
)}
</>
</div>
)
})
CommentThread.displayName = 'CommentThread'

type NewCommentPopupProps = {
onComposerSubmit: (
comment: ComposerSubmitComment,
event: React.FormEvent<HTMLFormElement>,
) => void
}

const NewCommentPopup = React.memo((props: NewCommentPopupProps) => {
const dispatch = useDispatch()

const onNewCommentComposerKeyDown = React.useCallback(
(e: React.KeyboardEvent) => switchToBasicCommentModeOnEscape(e, dispatch),
[dispatch],
)

const newCommentComposerAnimation = useAnimation()

const onClickOutsideNewComment = React.useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()

const composerTextbox = getComposerTextbox()
if (composerTextbox != null) {
function findPlaceholderChild(element: Element) {
if (element == null) {
return false
}
if (element.attributes.getNamedItem('data-placeholder') != null) {
return true
}
if (element.children.length < 1) {
return false
}
return findPlaceholderChild(element.children[0])
}

const isEmpty = composerTextbox.innerText.trim().length === 0
const isPlaceholder = !isEmpty && findPlaceholderChild(composerTextbox.children[0])

// if the contents of the new comment are empty...
if (isEmpty || isPlaceholder) {
// ...just close the popup
dispatch([switchEditorMode(EditorModes.commentMode(null, 'not-dragging'))])
} else {
// ...otherwise, shake the popup and re-focus its text box
const shakeDelta = 4 // px
void newCommentComposerAnimation.start({
x: [-shakeDelta, shakeDelta, -shakeDelta, shakeDelta, 0],
borderColor: [
colorTheme.error.cssValue,
colorTheme.error.cssValue,
colorTheme.error.cssValue,
colorTheme.error.cssValue,
'#00000000', // transparent, animatable
],
transition: { duration: 0.2 },
})
}

composerTextbox.focus()
}
},
[newCommentComposerAnimation, dispatch],
)

const onClickClose = React.useCallback(() => {
dispatch([switchEditorMode(EditorModes.commentMode(null, 'not-dragging'))])
}, [dispatch])

return (
<>
<div
style={{
background: 'transparent',
position: 'fixed',
top: 0,
left: 0,
bottom: 0,
right: 0,
}}
onClick={onClickOutsideNewComment}
/>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
padding: 6,
height: 35,
background: colorTheme.bg1.value,
borderBottom: `1px solid ${colorTheme.bg3.value}`,
}}
>
<Button onClick={onClickClose}>
<Icn category='semantic' type='cross-large' width={16} height={16} color='main' />
</Button>
</div>
<motion.div animate={newCommentComposerAnimation}>
<Composer
autoFocus
onComposerSubmit={props.onComposerSubmit}
style={ComposerStyle}
onKeyDown={onNewCommentComposerKeyDown}
/>
</motion.div>
</>
)
})
NewCommentPopup.displayName = 'NewCommentPopup'

const ListShadow = React.memo(
({ enabled, position }: { enabled: boolean; position: 'top' | 'bottom' }) => {
return (
Expand Down

0 comments on commit 5ca5c99

Please sign in to comment.