Skip to content

Commit

Permalink
feat(comments): introduce inline commenting (#5606)
Browse files Browse the repository at this point in the history
* feat(comments): add inline comments

Co-Authored-By: Per-Kristian Nordnes <[email protected]>

* feat(comments): enhance optimistic updates by tracking the transaction ID

Co-Authored-By: Per-Kristian Nordnes <[email protected]>

* fix(comments): clean up, remove unused code

* fix(comments): remove unneccesary memoization, fix broken query

* fix(comments): enable check

* fix(comments): disable comments for ai assist and when in upsell mode

* feat(comments): use DMP to track commented ranges across edits

* test(comments): update test and workshop

* feat(comments): add optional throttle option to update operation

* fix(comments): boundary element for inline commenting popovers

* fix(comments): only patch comment document when range decorators are moved by local changes

* fix(comments): throttled updates

* fix(comments): recalculate ranges when `rangeDecorationMove` is triggered but only proceed with updating the comment document if the current user made changes

* feat(comments): add `contentSnapshot`

fix(comments): type

* feat(comments): inline decorator color

* feat(comments): omit inline comments without referenced value (#5760)

* feat(comments): re-calculate range decoration for individual changed comment

Co-Authored-By: Per-Kristian Nordnes <[email protected]>

* fix(comments): clean up throttle id map in update operation

* refactor(comments): add selection type, correct variable names

* refactor(comments): disable logic that omit inline comments without any referenced value

* feat(comments): store document revision id + add design when inline comment do not have a referenced value

* dev(test-studio): add `commentsCI` document type

* test(comments): add test ids

* test(comments): add e2e test for inline comment creation

* test(comments): add missing property in range decoration test

fix

fix

* test(comments): update inline comment creation e2e

* feat(comments): include optimistic update in throttled function

* feat(comments): add `CommentDisabledIcon`

* feat(comments): improve perf when editing content with comments

* feat(comments): implement UI when inline comment creation is disabled

* dev(comments): add `CommentsListItemLayout` story

* dev(comments): update `CommentsListItemReferencedValue` story

* feat(comments): add UI when inline comment is referencing a deleted value

* fix(comments): fix some issues with the range tracking algo

* Fixes an issue where adding text inside single word ranges didn't expand correctly
* Fixes an issue where bolding partially inside a range expanded the range incorrectly
* Replaces child and comment range start/end indicators with unused utf-8 chars to avoid conflicts with user content

* test(comments): break apart inline comment range tests

Have each test in it's own file to make them easier to write and read.
Ditch snapshot testing and test against hard coded values that makes
it easier to undstand what is going on.

* test(comments): temporarily only run tests in Chromium due to flakiness

* refactor(comments): refactor buildRangeDecorationSelectionsFromComments

Improve invalidation of range decorations when text is edited

* refactor(comments): refactor CommentsPortableTextInput

Improve how we move and update range decorations when content is edited

* test(comments): update workshop story for CommentInlineHighlightDebug

* test(comments): add new tests (skipped for now)

* fix(comments): check if selection overlaps with added comments

* fix(comments): referenced value UI

* feat(comments): update referenced value design

* test(comments): use test over it

* feat(comments): localize strings

* feat(comments): preserve local dirty flags when receiving comments from the server

* fix(comments): improve performance on finding text ranges in side panel

* feat(comments): show comment button on mouse up

* chore(comments): temporarily disable inline comments

* fix(comments): remove `console.log` in `CommentsPortableTextInput`

---------

Co-authored-by: Per-Kristian Nordnes <[email protected]>
  • Loading branch information
hermanwikner and skogsmaskin authored Mar 1, 2024
1 parent fa330a0 commit 7ed2b0f
Show file tree
Hide file tree
Showing 70 changed files with 3,988 additions and 598 deletions.
36 changes: 36 additions & 0 deletions dev/test-studio/schema/ci/comments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {defineField, type PortableTextBlock} from 'sanity'

const INITIAL_VALUE: PortableTextBlock[] = [
{
_key: 'ROOT_KEY',
children: [
{
_type: 'span',
marks: [],
text: 'This is some text in the body field',
_key: 'CHILD_KEY',
},
],
markDefs: [],
_type: 'block',
style: 'normal',
},
]

export const commentsCI = defineField({
type: 'document',
name: 'commentsCI',
title: 'Comments CI',
fields: [
{
name: 'body',
type: 'array',
initialValue: INITIAL_VALUE,
of: [
{
type: 'block',
},
],
},
],
})
5 changes: 5 additions & 0 deletions dev/test-studio/schema/debug/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export const commentsDebug = defineType({
type: 'string',
title: 'String title',
},
{
type: 'array',
name: 'body',
of: [{type: 'block'}],
},
{
name: 'hideFields',
type: 'boolean',
Expand Down
18 changes: 10 additions & 8 deletions dev/test-studio/schema/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {allTypes} from './allTypes'
import author from './author'
import book from './book'
import {commentsCI} from './ci/comments'
import conditionalFieldset from './ci/conditionalFieldset'
import validationTest from './ci/validationCI'
import actions from './debug/actions'
Expand Down Expand Up @@ -177,15 +178,16 @@ export const schemaTypes = [
// Test documents for debugging
actions,
button,
collapsibleObjects,
commentsDebug,
conditionalFields,
customInputs,
customInputsWithPatches,
customNumber,
collapsibleObjects,
dateValidation,
dateTimeValidation,
deprecatedFields,
dateValidation,
deprecatedDocument,
deprecatedFields,
documentActions,
empty,
experiment,
Expand Down Expand Up @@ -264,16 +266,16 @@ export const schemaTypes = [
playlistTrack,

// CI documents
allNativeInputComponents,
allTypes,
circularCrossDatasetReferenceTest,
commentsCI,
conditionalFieldset,
validationTest,
crossDatasetReference,
crossDatasetSubtype,
circularCrossDatasetReferenceTest,
allNativeInputComponents,
allTypes,
fieldGroupsWithFieldsets,
ptReference,
commentsDebug,
validationTest,

// Test documents for docs
...v3docs.types,
Expand Down
2 changes: 1 addition & 1 deletion dev/test-studio/structure/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export const DEBUG_INPUT_TYPES = [
'virtualizationInObject',
]

export const CI_INPUT_TYPES = ['conditionalFieldset', 'validationCI', 'textsTest']
export const CI_INPUT_TYPES = ['conditionalFieldset', 'validationCI', 'textsTest', 'commentsCI']
export const DEBUG_FIELD_GROUP_TYPES = [
'fieldGroups',
'fieldGroupsDefault',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ test.describe('Comments', () => {
await mount(<CommentsInputStory />)
const $editable = page.getByTestId('comment-input-editable')
await $editable.waitFor({state: 'visible'})
const $mentionButton = page.getByTestId('comment-mention-button')
const $mentionButton = page.getByTestId('comment-input-mention-button')
await $mentionButton.click()
await expect(page.getByTestId('comments-mentions-menu')).toBeVisible()
await expect($editable).toBeFocused()
Expand Down
8 changes: 7 additions & 1 deletion packages/sanity/src/structure/comments/i18n/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ const commentsLocaleStrings = defineLocalesResources('comments', {
/** Text shown in popover when hovering the button above fields to add a comment, when the field currently do not have any comments */
'field-button.title': 'Add comment',

/* The text shown in the inline comment button when the button is disabled due to overlap */
'inline-add-comment-button.disabled-overlap-title': 'Comments cannot overlap',
/** The text shown in the inline comment button */
'inline-add-comment-button.title': 'Add comment',

/** Aria label for the breadcrumb button showing the field path. `{{field}}` is the last (most specific) field. */
'list-item.breadcrumb-button-go-to-field-aria-label': 'Go to {{field}} field',
/** The button tooltip content for the add reaction button */
Expand Down Expand Up @@ -110,9 +115,10 @@ const commentsLocaleStrings = defineLocalesResources('comments', {
'list-item.layout-posting': 'Posting...',
/** The text for retrying posting a comment */
'list-item.layout-retry': 'Retry',
/** The text shown when the value a comment references has been deleted */
'list-item.missing-referenced-value-tooltip-content': 'The commented text has been deleted',
/** The aria label for the comments menu button to open the actions menu */
'list-item.open-menu-aria-label': 'Open comment actions menu',

/** The button text to re-open a resolved comment */
'list-item.re-open-resolved': 'Re-open',
/** The button aria label to re-open a comment that is resolved */
Expand Down
122 changes: 54 additions & 68 deletions packages/sanity/src/structure/comments/plugin/field/CommentsField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,22 @@ import {Stack, useBoundaryElement} from '@sanity/ui'
import * as PathUtils from '@sanity/util/paths'
import {uuid} from '@sanity/uuid'
import {AnimatePresence, motion, type Variants} from 'framer-motion'
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {useCallback, useMemo, useRef, useState} from 'react'
import {type FieldProps, getSchemaTypeTitle, useCurrentUser} from 'sanity'
import scrollIntoViewIfNeeded, {type Options} from 'scroll-into-view-if-needed'
import styled, {css} from 'styled-components'

import {
applyCommentsFieldAttr,
type CommentCreatePayload,
type CommentsUIMode,
isTextSelectionComment,
useComments,
useCommentsEnabled,
useCommentsScroll,
useCommentsSelectedPath,
useCommentsUpsell,
} from '../../src'
import {COMMENTS_HIGHLIGHT_HUE_KEY} from '../../src/constants'
import {CommentsFieldButton} from './CommentsFieldButton'

const HIGHLIGHT_BLOCK_VARIANTS: Variants = {
Expand All @@ -41,14 +44,9 @@ export function CommentsField(props: FieldProps) {
return <CommentFieldInner {...props} mode={mode} />
}

const SCROLL_INTO_VIEW_OPTIONS: ScrollIntoViewOptions = {
behavior: 'smooth',
block: 'start',
}

const HighlightDiv = styled(motion.div)(({theme}) => {
const {radius, space, color} = theme.sanity
const bg = hues.yellow[color.dark ? 900 : 50].hex
const bg = hues[COMMENTS_HIGHLIGHT_HUE_KEY][color.dark ? 900 : 50].hex

return css`
mix-blend-mode: ${color.dark ? 'screen' : 'multiply'};
Expand Down Expand Up @@ -78,12 +76,11 @@ function CommentFieldInner(
const {mode} = props
const [open, setOpen] = useState<boolean>(false)
const [value, setValue] = useState<PortableTextBlock[] | null>(null)
const rootElementRef = useRef<HTMLDivElement | null>(null)
const [threadIdToScrollTo, setThreadIdToScrollTo] = useState<string | null>(null)

const currentUser = useCurrentUser()
const {element: boundaryElement} = useBoundaryElement()

const currentUser = useCurrentUser()
const rootRef = useRef<HTMLDivElement | null>(null)

const {
comments,
Expand All @@ -97,6 +94,9 @@ function CommentFieldInner(
} = useComments()
const {upsellData, handleOpenDialog} = useCommentsUpsell()
const {selectedPath, setSelectedPath} = useCommentsSelectedPath()
const {scrollToGroup} = useCommentsScroll({
boundaryElement,
})

const fieldTitle = useMemo(() => getSchemaTypeTitle(props.schemaType), [props.schemaType])

Expand All @@ -107,6 +107,12 @@ function CommentFieldInner(
return selectedPath?.fieldPath === PathUtils.toString(props.path)
}, [isCommentsOpen, props.path, selectedPath?.fieldPath, selectedPath?.origin])

const isInlineCommentThread = useMemo(() => {
return comments.data.open
.filter((c) => c.threadId === selectedPath?.threadId)
.some((x) => isTextSelectionComment(x.parentComment))
}, [comments.data.open, selectedPath?.threadId])

// Total number of comments for the current field
const count = useMemo(() => {
const stringPath = PathUtils.toString(props.path)
Expand All @@ -120,19 +126,6 @@ function CommentFieldInner(

const hasComments = Boolean(count > 0)

const handleSetThreadToScrollTo = useCallback(
(threadId: string | null) => {
setSelectedPath({
threadId,
origin: 'form',
fieldPath: PathUtils.toString(props.path),
})

setThreadIdToScrollTo(threadId)
},
[props.path, setSelectedPath],
)

const handleClick = useCallback(() => {
// When clicking a comment button when the field has comments, we want to:
if (hasComments) {
Expand All @@ -152,10 +145,17 @@ function CommentFieldInner(
(c) => c.fieldPath === PathUtils.toString(props.path),
)?.threadId

// 5. Set the thread ID to scroll to in a state and then scroll to it
// in the `useEffect` below.
// 5. Set the latest thread ID as the selected thread ID
// and scroll to the it.
if (scrollToThreadId) {
handleSetThreadToScrollTo(scrollToThreadId)
// handleSetThreadToScrollTo(scrollToThreadId)
setSelectedPath({
threadId: scrollToThreadId,
origin: 'form',
fieldPath: PathUtils.toString(props.path),
})

scrollToGroup(scrollToThreadId)
}

return
Expand All @@ -174,15 +174,16 @@ function CommentFieldInner(
// Else, toggle the comment input open/closed
setOpen((v) => !v)
}, [
comments.data.open,
handleOpenDialog,
hasComments,
status,
mode,
onCommentsOpen,
comments.data.open,
setStatus,
props.path,
handleSetThreadToScrollTo,
mode,
handleOpenDialog,
scrollToGroup,
setSelectedPath,
setStatus,
status,
upsellData,
])

Expand Down Expand Up @@ -218,42 +219,27 @@ function CommentFieldInner(
// Reset the value
setValue(null)

// Set the thread ID to scroll to
handleSetThreadToScrollTo(newThreadId)
}
}, [handleSetThreadToScrollTo, onCommentsOpen, operation, props.path, setStatus, status, value])

const handleDiscard = useCallback(() => setValue(null), [])

const scrollIntoViewIfNeededOpts = useMemo(
() =>
({
...SCROLL_INTO_VIEW_OPTIONS,
boundary: boundaryElement,
scrollMode: 'if-needed',
block: 'start',
}) satisfies Options,
[boundaryElement],
)
// Scroll to the thread
setSelectedPath({
threadId: newThreadId,
origin: 'form',
fieldPath: PathUtils.toString(props.path),
})

// Effect that handles scroll the field into view when it's selected
useEffect(() => {
if (isSelected && rootElementRef.current && isCommentsOpen) {
scrollIntoViewIfNeeded(rootElementRef.current, scrollIntoViewIfNeededOpts)
scrollToGroup(newThreadId)
}
}, [boundaryElement, isCommentsOpen, isSelected, props.path, scrollIntoViewIfNeededOpts])

// // Effect that handles scroll the comment thread into view when it's selected
useEffect(() => {
if (isCommentsOpen && threadIdToScrollTo) {
const node = document.querySelector(`[data-group-id="${threadIdToScrollTo}"]`)
}, [
onCommentsOpen,
operation,
props.path,
scrollToGroup,
setSelectedPath,
setStatus,
status,
value,
])

if (node) {
node.scrollIntoView(SCROLL_INTO_VIEW_OPTIONS)
setThreadIdToScrollTo(null)
}
}
}, [isCommentsOpen, threadIdToScrollTo])
const handleDiscard = useCallback(() => setValue(null), [])

const internalComments: FieldProps['__internal_comments'] = useMemo(
() => ({
Expand Down Expand Up @@ -292,15 +278,15 @@ function CommentFieldInner(
)

return (
<FieldStack ref={rootElementRef}>
<FieldStack {...applyCommentsFieldAttr(PathUtils.toString(props.path))} ref={rootRef}>
{props.renderDefault({
...props,
// eslint-disable-next-line camelcase
__internal_comments: internalComments,
})}

<AnimatePresence>
{isSelected && (
{isSelected && !isInlineCommentThread && (
<HighlightDiv
animate="animate"
exit="exit"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {AddCommentIcon} from '@sanity/icons'
import {
// eslint-disable-next-line no-restricted-imports
Button as SanityUIButton,
Expand All @@ -19,7 +20,6 @@ import styled from 'styled-components'
import {Button, Popover, Tooltip} from '../../../../ui-components'
import {commentsLocaleNamespace} from '../../i18n'
import {
AddCommentIcon,
CommentIcon,
CommentInput,
type CommentInputHandle,
Expand Down
3 changes: 3 additions & 0 deletions packages/sanity/src/structure/comments/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {definePlugin} from 'sanity'
import {commentsUsEnglishLocaleBundle} from '../i18n'
import {CommentsDocumentLayout} from './document-layout'
import {CommentsField} from './field'
// import {CommentsInput} from './input'
import {commentsInspector} from './inspector'
import {CommentsStudioLayout} from './studio-layout'

Expand All @@ -19,6 +20,8 @@ export const comments = definePlugin({
form: {
components: {
field: CommentsField,
// The `CommentsInput` will be enabled when it is ready to be used.
// input: CommentsInput,
},
},

Expand Down
Loading

0 comments on commit 7ed2b0f

Please sign in to comment.