Customize inline comments
Common ways to change what SDKPortableTextComments does once inline comments are wired into your editor.
Established ways to change what SDKPortableTextComments does, once the basic wiring from Comment on Portable Text is in place. Each pattern is self-contained: take the one you need.
Prerequisites:
- An editor with
SDKPortableTextCommentsmounted. See Comment on Portable Text.
Select a new thread as soon as it exists
When to use this: the panel beside the editor should scroll to and select a thread the moment someone creates it, which is what most authors expect.
onCommentCreated hands you the comment as the SDK returns it:
import {useActiveComment} from '@sanity/collaboration/ui'
import {SDKPortableTextComments} from '@sanity/collaboration/sdk'
const {setActiveComment} = useActiveComment()
<SDKPortableTextComments
{...handle}
path="body"
threads={threads}
onCommentCreated={(comment) => setActiveComment(comment.id)}
/>useActiveComment comes from @sanity/collaboration/ui and throws outside a CommentsProvider, so the editor and the panel go under one of those. See Comment navigation and deep links.
Move or relabel the composer
When to use this: the default "Comment" affordance is in the wrong place for your layout, or its copy is wrong for your product.
The composer is part of the plugin, and writing it out as the plugin's children replaces the default one:
import {InlineCommentInput} from '@sanity/collaboration/ui'
import {SDKPortableTextComments} from '@sanity/collaboration/sdk'
<SDKPortableTextComments {...handle} path="body" threads={threads}>
<InlineCommentInput label="Add note" placement="top" />
</SDKPortableTextComments>placement is where the popovers open relative to the selection, and inputProps forwards the rest to the underlying CommentInput.
`InlineCommentInput` is internal
It is exported and meant to be used, and tagged @internal to say its props may change in a minor release. The plugin renders it for you, so write it out when the default placement or copy stops fitting rather than by preference.
For a composer that is not a popover at all, build your own from useInlineCommentAuthoring and pass that as the children instead. The hook gives you the commentable selection, the pending one, the element to position against, and begin, cancel, and submit. Its full shape is in the Portable Text Storybook reference.
Draw your own highlight
When to use this: the default yellow mark does not fit your design, or the highlight needs to carry something of its own.
renderHighlight is called once per comment with the comment and its state, and returns the function the editor calls to wrap the words:
<SDKPortableTextComments
{...handle}
path="body"
threads={threads}
renderHighlight={(comment, {selected, onSelect}) =>
(props) => (
// The editor owns the keyboard inside the text, so the mark has no
// key handler of its own.
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
<mark
data-comment-id={comment.id}
data-active={selected || undefined}
onClick={onSelect}
>
{props.children}
</mark>
)}
/>Two rules come with it:
- The returned function is not a component. The editor calls it directly rather than mounting it, so it must not use hooks. Anything stateful goes in a component of your own that it renders.
onSelectis absent when navigation is not wired. Check for it rather than assuming it, so a custom mark does not look clickable for nothing.
Threads on overlapping words nest one mark inside the other, and a comment that contains another is drawn outside it, so the innermost mark is the narrowest comment. The default mark steps its fill by how many threads cover a run, up to three deep. A replacement takes that on. The run under a comment being written is drawn by renderPendingHighlight, which keeps the default mark until you pass your own.
Comment on a nested Portable Text field
When to use this: a block inside your Portable Text holds Portable Text of its own, such as a callout with a content array.
That inner array is edited by an editor of its own, and it gets its own plugin with the array's full path. The editor reads and writes the nested path with the same useDocument, useEditDocument, and ValueSync as in Step 3, whose imports and schemaDefinition it reuses:
const path = 'body[_key=="callout-1"].content'
function CalloutField({handle}: {handle: DocumentHandle}) {
const {threads} = useCommentThreads({...handle, status: 'open'})
const {data} = useDocument<PortableTextBlock[] | undefined, string>({
...handle,
path,
})
const editField = useEditDocument<PortableTextBlock[] | undefined>({
...handle,
path,
})
return (
<EditorProvider initialConfig={{schemaDefinition, initialValue: data}}>
<ValueSync value={data} onChange={editField} />
<PortableTextEditable />
<SDKPortableTextComments {...handle} path={path} threads={threads} />
</EditorProvider>
)
}Comments there carry the longer fieldPath, and each plugin draws the comments whose path falls under its own. The plugin on the nested array leaves comments on body itself alone. Both take the same threads. A selection made inside the nested editor produces the nested path when a comment is created from it.
Run without the SDK's writes
When to use this: the host already has its own write path, such as a test, a story, or an app with its own data layer.
PortableTextComments is the same component with the flat comments as props and every write reported to a callback:
import {PortableTextComments} from '@sanity/collaboration/ui'
// `api` is your own write path. Here it takes the same shapes as the SDK's
// `createComment` and `updateCommentAnchor`.
<PortableTextComments
path="body"
comments={comments}
sourceDocumentId="drafts.post-123"
onCreate={({fieldPath, range, fieldValue, message}) =>
api.createComment({
...handle,
fieldPath,
message,
anchor: {type: 'portable-text', ...range, fieldValue},
})
}
onRangeChange={(commentId, change) =>
void api.updateCommentAnchor({
commentId,
anchor:
change.range === null
? null
: {
type: 'portable-text',
...change.range,
fieldValue: change.fieldValue,
},
})
}
/>Writing to the SDK yourself, the range and fieldValue that onCreate hands you become the comment's anchor: {type: 'portable-text', ...range, fieldValue}. In onRangeChange, a range of null means the words are gone: pass anchor: null to updateCommentAnchor, which leaves a field-level comment.
sourceDocumentId is what stops this editor from re-anchoring a comment written against another variant of the document. It is optional here, and SDKPortableTextComments derives it from the handle.
Leaving onRangeChange out turns re-anchoring off. The highlights still follow edits on screen, and only the write stops. Do that only if something else keeps the stored anchors current, since an anchor left behind drifts for the next reader.
Next steps
- Why an anchor survives edits, and what re-anchoring writes: Inline comments.
- Every prop and callback on both components: Portable Text Storybook reference.
Was this page helpful?
Feedback submitted, thanks for helping us improve the docs. Have you tried these alternatives?