mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-21 12:06:35 +08:00
feat(core): comment panel (#12989)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced a full-featured document comment system with sidebar for viewing, filtering, replying, resolving, and managing comments and replies. * Added a rich text comment editor supporting editable and read-only modes. * Enabled comment-based navigation and highlighting within documents. * Integrated comment functionality into the workspace sidebar (excluding local workspaces). * Added internationalization support and new UI strings for comment features. * Added new feature flag `enable_comment` for toggling comment functionality. * Enhanced editor focus to support comment-related selections. * Added snapshot and store helpers for comment content management. * Implemented backend GraphQL support for comment and reply operations. * Added services for comment entity management and comment panel behavior. * Extended comment configuration to support optional framework providers. * Added preview generation from user selections when creating comments. * Enabled automatic sidebar opening on new pending comments. * Added comment-related query parameter support for navigation. * Included inline comment module exports for integration. * Improved comment provider implementation with full lifecycle management and UI integration. * Added comment highlight state tracking and refined selection handling in inline comments. * **Style** * Added comprehensive styles for the comment editor and sidebar components. * **Chores** * Updated language completeness percentages for multiple locales. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: L-Sun <zover.v@gmail.com>
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
import {
|
||||
createCommentMutation,
|
||||
createReplyMutation,
|
||||
deleteCommentMutation,
|
||||
deleteReplyMutation,
|
||||
type DocMode,
|
||||
listCommentChangesQuery,
|
||||
type ListCommentsQuery,
|
||||
listCommentsQuery,
|
||||
resolveCommentMutation,
|
||||
updateCommentMutation,
|
||||
updateReplyMutation,
|
||||
} from '@affine/graphql';
|
||||
import { Entity } from '@toeverything/infra';
|
||||
|
||||
import type { DefaultServerService, WorkspaceServerService } from '../../cloud';
|
||||
import { GraphQLService } from '../../cloud/services/graphql';
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
import type {
|
||||
DocComment,
|
||||
DocCommentChangeListResult,
|
||||
DocCommentContent,
|
||||
DocCommentListResult,
|
||||
DocCommentReply,
|
||||
} from '../types';
|
||||
|
||||
type GQLCommentType =
|
||||
ListCommentsQuery['workspace']['comments']['edges'][number]['node'];
|
||||
type GQLReplyType = GQLCommentType['replies'][number];
|
||||
type GQLUserType = GQLCommentType['user'];
|
||||
|
||||
// Helper functions for normalizing backend responses
|
||||
const normalizeUser = (user: GQLUserType) => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
avatarUrl: user.avatarUrl,
|
||||
});
|
||||
|
||||
const normalizeReply = (reply: GQLReplyType): DocCommentReply => ({
|
||||
id: reply.id,
|
||||
content: reply.content as DocCommentContent,
|
||||
createdAt: new Date(reply.createdAt).getTime(),
|
||||
updatedAt: new Date(reply.updatedAt).getTime(),
|
||||
user: normalizeUser(reply.user),
|
||||
});
|
||||
|
||||
const normalizeComment = (comment: GQLCommentType): DocComment => ({
|
||||
id: comment.id,
|
||||
content: comment.content as DocCommentContent,
|
||||
resolved: comment.resolved,
|
||||
createdAt: new Date(comment.createdAt).getTime(),
|
||||
updatedAt: new Date(comment.updatedAt).getTime(),
|
||||
user: comment.user
|
||||
? normalizeUser(comment.user)
|
||||
: {
|
||||
id: '',
|
||||
name: '',
|
||||
avatarUrl: '',
|
||||
},
|
||||
replies: comment.replies?.map(normalizeReply) ?? [],
|
||||
});
|
||||
|
||||
export class DocCommentStore extends Entity<{
|
||||
docId: string;
|
||||
getDocMode: () => DocMode;
|
||||
getDocTitle: () => string;
|
||||
}> {
|
||||
constructor(
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly workspaceServerService: WorkspaceServerService,
|
||||
private readonly defaultServerService: DefaultServerService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
private get serverService() {
|
||||
return (
|
||||
this.workspaceServerService.server || this.defaultServerService.server
|
||||
);
|
||||
}
|
||||
|
||||
private get graphqlService() {
|
||||
return this.serverService?.scope.get(GraphQLService);
|
||||
}
|
||||
|
||||
private get currentWorkspaceId() {
|
||||
return this.workspaceService.workspace.id;
|
||||
}
|
||||
|
||||
async listComments({
|
||||
after,
|
||||
}: {
|
||||
after?: string;
|
||||
}): Promise<DocCommentListResult> {
|
||||
const graphql = this.graphqlService;
|
||||
if (!graphql) {
|
||||
throw new Error('GraphQL service not found');
|
||||
}
|
||||
const response = await graphql.gql({
|
||||
query: listCommentsQuery,
|
||||
variables: {
|
||||
pagination: {
|
||||
after,
|
||||
},
|
||||
workspaceId: this.currentWorkspaceId,
|
||||
docId: this.props.docId,
|
||||
},
|
||||
});
|
||||
|
||||
const comments = response.workspace?.comments;
|
||||
if (!comments) {
|
||||
return {
|
||||
comments: [],
|
||||
hasNextPage: false,
|
||||
startCursor: '',
|
||||
endCursor: '',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
comments: comments.edges.map(edge => normalizeComment(edge.node)),
|
||||
hasNextPage: comments.pageInfo.hasNextPage,
|
||||
startCursor: comments.pageInfo.startCursor || '',
|
||||
endCursor: comments.pageInfo.endCursor || '',
|
||||
};
|
||||
}
|
||||
|
||||
// pool every 30s
|
||||
async listCommentChanges({
|
||||
after,
|
||||
}: {
|
||||
after?: string;
|
||||
}): Promise<DocCommentChangeListResult> {
|
||||
const graphql = this.graphqlService;
|
||||
if (!graphql) {
|
||||
throw new Error('GraphQL service not found');
|
||||
}
|
||||
|
||||
const response = await graphql.gql({
|
||||
query: listCommentChangesQuery,
|
||||
variables: {
|
||||
pagination: {
|
||||
after,
|
||||
},
|
||||
workspaceId: this.currentWorkspaceId,
|
||||
docId: this.props.docId,
|
||||
},
|
||||
});
|
||||
|
||||
const commentChanges = response.workspace?.commentChanges;
|
||||
if (!commentChanges) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return commentChanges.edges.map(edge => ({
|
||||
id: edge.node.id,
|
||||
action: edge.node.action,
|
||||
comment: normalizeComment(edge.node.item),
|
||||
commentId: edge.node.commentId || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async createComment(commentInput: {
|
||||
content: DocCommentContent;
|
||||
}): Promise<DocComment> {
|
||||
const graphql = this.graphqlService;
|
||||
if (!graphql) {
|
||||
throw new Error('GraphQL service not found');
|
||||
}
|
||||
|
||||
const response = await graphql.gql({
|
||||
query: createCommentMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId: this.currentWorkspaceId,
|
||||
docId: this.props.docId,
|
||||
docMode: this.props.getDocMode(),
|
||||
docTitle: this.props.getDocTitle(),
|
||||
content: commentInput.content,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const comment = response.createComment;
|
||||
return normalizeComment(comment);
|
||||
}
|
||||
|
||||
async updateComment(
|
||||
commentId: string,
|
||||
commentInput: {
|
||||
content: DocCommentContent;
|
||||
}
|
||||
): Promise<void> {
|
||||
const graphql = this.graphqlService;
|
||||
if (!graphql) {
|
||||
throw new Error('GraphQL service not found');
|
||||
}
|
||||
|
||||
await graphql.gql({
|
||||
query: updateCommentMutation,
|
||||
variables: {
|
||||
input: {
|
||||
id: commentId,
|
||||
content: commentInput.content,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async resolveComment(commentId: string, resolved = true): Promise<boolean> {
|
||||
const graphql = this.graphqlService;
|
||||
if (!graphql) {
|
||||
throw new Error('GraphQL service not found');
|
||||
}
|
||||
|
||||
const response = await graphql.gql({
|
||||
query: resolveCommentMutation,
|
||||
variables: {
|
||||
input: {
|
||||
id: commentId,
|
||||
resolved,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return response.resolveComment;
|
||||
}
|
||||
|
||||
async deleteComment(commentId: string): Promise<boolean> {
|
||||
const graphql = this.graphqlService;
|
||||
if (!graphql) {
|
||||
throw new Error('GraphQL service not found');
|
||||
}
|
||||
|
||||
const response = await graphql.gql({
|
||||
query: deleteCommentMutation,
|
||||
variables: {
|
||||
id: commentId,
|
||||
},
|
||||
});
|
||||
return response.deleteComment;
|
||||
}
|
||||
|
||||
async createReply(
|
||||
commentId: string,
|
||||
replyInput: {
|
||||
content: DocCommentContent;
|
||||
}
|
||||
): Promise<DocCommentReply> {
|
||||
const graphql = this.graphqlService;
|
||||
if (!graphql) {
|
||||
throw new Error('GraphQL service not found');
|
||||
}
|
||||
|
||||
const response = await graphql.gql({
|
||||
query: createReplyMutation,
|
||||
variables: {
|
||||
input: {
|
||||
commentId,
|
||||
content: replyInput.content,
|
||||
docMode: this.props.getDocMode(),
|
||||
docTitle: this.props.getDocTitle(),
|
||||
},
|
||||
},
|
||||
});
|
||||
return normalizeReply(response.createReply);
|
||||
}
|
||||
|
||||
async updateReply(
|
||||
replyId: string,
|
||||
replyInput: {
|
||||
content: DocCommentContent;
|
||||
}
|
||||
): Promise<void> {
|
||||
const graphql = this.graphqlService;
|
||||
if (!graphql) {
|
||||
throw new Error('GraphQL service not found');
|
||||
}
|
||||
|
||||
await graphql.gql({
|
||||
query: updateReplyMutation,
|
||||
variables: {
|
||||
input: {
|
||||
id: replyId,
|
||||
content: replyInput.content,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteReply(replyId: string): Promise<void> {
|
||||
const graphql = this.graphqlService;
|
||||
if (!graphql) {
|
||||
throw new Error('GraphQL service not found');
|
||||
}
|
||||
|
||||
await graphql.gql({
|
||||
query: deleteReplyMutation,
|
||||
variables: {
|
||||
id: replyId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
import { type CommentChangeAction, DocMode } from '@affine/graphql';
|
||||
import type { BaseSelection } from '@blocksuite/affine/store';
|
||||
import {
|
||||
effect,
|
||||
Entity,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { catchError, of, Subject, switchMap, tap, timer } from 'rxjs';
|
||||
|
||||
import { type DocDisplayMetaService } from '../../doc-display-meta';
|
||||
import { GlobalContextService } from '../../global-context';
|
||||
import type { SnapshotHelper } from '../services/snapshot-helper';
|
||||
import type {
|
||||
CommentId,
|
||||
DocComment,
|
||||
DocCommentChangeListResult,
|
||||
DocCommentContent,
|
||||
DocCommentListResult,
|
||||
PendingComment,
|
||||
} from '../types';
|
||||
import { DocCommentStore } from './doc-comment-store';
|
||||
|
||||
type DisposeCallback = () => void;
|
||||
|
||||
export class DocCommentEntity extends Entity<{
|
||||
docId: string;
|
||||
}> {
|
||||
constructor(
|
||||
private readonly snapshotHelper: SnapshotHelper,
|
||||
private readonly docDisplayMetaService: DocDisplayMetaService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
private readonly store = this.framework.createEntity(DocCommentStore, {
|
||||
docId: this.props.docId,
|
||||
getDocMode: () =>
|
||||
this.docMode$.value === 'edgeless' ? DocMode.edgeless : DocMode.page,
|
||||
getDocTitle: () => {
|
||||
return this.docDisplayMetaService.title$(this.props.docId).value;
|
||||
},
|
||||
});
|
||||
|
||||
loading$ = new LiveData<boolean>(false);
|
||||
comments$ = new LiveData<DocComment[]>([]);
|
||||
|
||||
// Only one pending comment at a time (for new comments)
|
||||
readonly pendingComment$ = new LiveData<PendingComment | null>(null);
|
||||
|
||||
// Only one pending reply at a time
|
||||
readonly pendingReply$ = new LiveData<PendingComment | null>(null);
|
||||
|
||||
private readonly commentAdded$ = new Subject<{
|
||||
id: CommentId;
|
||||
selections: BaseSelection[];
|
||||
}>();
|
||||
private readonly commentResolved$ = new Subject<CommentId>();
|
||||
private readonly commentDeleted$ = new Subject<CommentId>();
|
||||
readonly commentHighlighted$ = new LiveData<CommentId | null>(null);
|
||||
|
||||
private pollingDisposable?: DisposeCallback;
|
||||
private startCursor?: string;
|
||||
|
||||
async addComment(
|
||||
selections?: BaseSelection[],
|
||||
preview?: string
|
||||
): Promise<string> {
|
||||
// todo: may need to properly bind the doc to the editor
|
||||
const doc = await this.snapshotHelper.createStore();
|
||||
if (!doc) {
|
||||
throw new Error('Failed to create doc');
|
||||
}
|
||||
const id = nanoid();
|
||||
const pendingComment: PendingComment = {
|
||||
id,
|
||||
doc,
|
||||
preview,
|
||||
selections,
|
||||
};
|
||||
|
||||
// Replace any existing pending comment (only one at a time)
|
||||
this.pendingComment$.setValue(pendingComment);
|
||||
return id;
|
||||
}
|
||||
|
||||
async addReply(commentId: string): Promise<string> {
|
||||
const doc = await this.snapshotHelper.createStore();
|
||||
if (!doc) {
|
||||
throw new Error('Failed to create doc');
|
||||
}
|
||||
const id = nanoid();
|
||||
const pendingReply: PendingComment = {
|
||||
id,
|
||||
doc,
|
||||
commentId,
|
||||
};
|
||||
|
||||
// Replace any existing pending reply (only one at a time)
|
||||
this.pendingReply$.setValue(pendingReply);
|
||||
return id;
|
||||
}
|
||||
|
||||
dismissDraftComment(): void {
|
||||
this.pendingComment$.setValue(null);
|
||||
}
|
||||
|
||||
dismissDraftReply(): void {
|
||||
this.pendingReply$.setValue(null);
|
||||
}
|
||||
|
||||
get docMode$() {
|
||||
return this.framework.get(GlobalContextService).globalContext.docMode.$;
|
||||
}
|
||||
|
||||
async commitComment(id: string): Promise<void> {
|
||||
const pendingComment = this.pendingComment$.value;
|
||||
if (!pendingComment || pendingComment.id !== id) {
|
||||
console.warn('Pending comment not found:', id);
|
||||
return;
|
||||
}
|
||||
const { doc, preview } = pendingComment;
|
||||
const snapshot = this.snapshotHelper.getSnapshot(doc);
|
||||
if (!snapshot) {
|
||||
throw new Error('Failed to get snapshot');
|
||||
}
|
||||
const comment = await this.store.createComment({
|
||||
content: {
|
||||
snapshot,
|
||||
preview,
|
||||
mode: this.docMode$.value ?? 'page',
|
||||
},
|
||||
});
|
||||
const currentComments = this.comments$.value;
|
||||
this.comments$.setValue([...currentComments, comment]);
|
||||
this.commentAdded$.next({
|
||||
id: comment.id,
|
||||
selections: pendingComment.selections || [],
|
||||
});
|
||||
this.pendingComment$.setValue(null);
|
||||
this.revalidate();
|
||||
}
|
||||
|
||||
async commitReply(id: string): Promise<void> {
|
||||
const pendingReply = this.pendingReply$.value;
|
||||
if (!pendingReply || pendingReply.id !== id) {
|
||||
console.warn('Pending reply not found:', id);
|
||||
return;
|
||||
}
|
||||
const { doc } = pendingReply;
|
||||
const snapshot = this.snapshotHelper.getSnapshot(doc);
|
||||
if (!snapshot) {
|
||||
throw new Error('Failed to get snapshot');
|
||||
}
|
||||
|
||||
if (!pendingReply.commentId) {
|
||||
throw new Error('Pending reply has no commentId');
|
||||
}
|
||||
|
||||
const reply = await this.store.createReply(pendingReply.commentId, {
|
||||
content: {
|
||||
snapshot,
|
||||
},
|
||||
});
|
||||
const currentComments = this.comments$.value;
|
||||
const updatedComments = currentComments.map(comment =>
|
||||
comment.id === pendingReply.commentId
|
||||
? { ...comment, replies: [...(comment.replies || []), reply] }
|
||||
: comment
|
||||
);
|
||||
this.comments$.setValue(updatedComments);
|
||||
this.pendingReply$.setValue(null);
|
||||
this.revalidate();
|
||||
}
|
||||
|
||||
async deleteComment(id: string): Promise<void> {
|
||||
await this.store.deleteComment(id);
|
||||
const currentComments = this.comments$.value;
|
||||
this.comments$.setValue(currentComments.filter(c => c.id !== id));
|
||||
this.commentDeleted$.next(id);
|
||||
this.revalidate();
|
||||
}
|
||||
|
||||
async deleteReply(id: string): Promise<void> {
|
||||
await this.store.deleteReply(id);
|
||||
const currentComments = this.comments$.value;
|
||||
const updatedComments = currentComments.map(comment => {
|
||||
return {
|
||||
...comment,
|
||||
replies: comment.replies?.filter(r => r.id !== id),
|
||||
};
|
||||
});
|
||||
this.comments$.setValue(updatedComments);
|
||||
this.revalidate();
|
||||
}
|
||||
|
||||
async updateComment(id: string, content: DocCommentContent): Promise<void> {
|
||||
await this.store.updateComment(id, { content });
|
||||
const currentComments = this.comments$.value;
|
||||
const updatedComments = currentComments.map(comment =>
|
||||
comment.id === id ? { ...comment, content } : comment
|
||||
);
|
||||
this.comments$.setValue(updatedComments);
|
||||
this.revalidate();
|
||||
}
|
||||
|
||||
async updateReply(id: string, content: DocCommentContent): Promise<void> {
|
||||
await this.store.updateReply(id, { content });
|
||||
const currentComments = this.comments$.value;
|
||||
const updatedComments = currentComments.map(comment =>
|
||||
comment.id === id ? { ...comment, content } : comment
|
||||
);
|
||||
this.comments$.setValue(updatedComments);
|
||||
this.revalidate();
|
||||
}
|
||||
|
||||
async resolveComment(id: CommentId, resolved: boolean): Promise<void> {
|
||||
try {
|
||||
await this.store.resolveComment(id, resolved);
|
||||
|
||||
// Update local state
|
||||
const currentComments = this.comments$.value;
|
||||
const updatedComments = currentComments.map(comment =>
|
||||
comment.id === id ? { ...comment, resolved } : comment
|
||||
);
|
||||
this.comments$.setValue(updatedComments);
|
||||
|
||||
this.commentResolved$.next(id);
|
||||
this.revalidate();
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve comment:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
highlightComment(id: CommentId | null): void {
|
||||
this.commentHighlighted$.next(id);
|
||||
}
|
||||
|
||||
getComments(): CommentId[] {
|
||||
return this.comments$.value.map(comment => comment.id);
|
||||
}
|
||||
|
||||
onCommentAdded(
|
||||
callback: (id: CommentId, selections: BaseSelection[]) => void
|
||||
): DisposeCallback {
|
||||
const subscription = this.commentAdded$.subscribe(({ id, selections }) =>
|
||||
callback(id, selections)
|
||||
);
|
||||
return () => subscription.unsubscribe();
|
||||
}
|
||||
|
||||
onCommentResolved(callback: (id: CommentId) => void): DisposeCallback {
|
||||
const subscription = this.commentResolved$.subscribe(callback);
|
||||
return () => subscription.unsubscribe();
|
||||
}
|
||||
|
||||
onCommentDeleted(callback: (id: CommentId) => void): DisposeCallback {
|
||||
const subscription = this.commentDeleted$.subscribe(callback);
|
||||
return () => subscription.unsubscribe();
|
||||
}
|
||||
|
||||
onCommentHighlighted(
|
||||
callback: (id: CommentId | null) => void
|
||||
): DisposeCallback {
|
||||
const subscription = this.commentHighlighted$.subscribe(callback);
|
||||
return () => subscription.unsubscribe();
|
||||
}
|
||||
|
||||
// Start polling comments every 30s
|
||||
// 1. when comments$ is empty, fetch all comments
|
||||
// 2. when comments$ is not empty, fetch changes (using end cursor)
|
||||
// 3. loop. when doc is not loaded, skip
|
||||
start(): void {
|
||||
if (this.pollingDisposable) {
|
||||
this.pollingDisposable();
|
||||
}
|
||||
|
||||
// Initial load
|
||||
this.revalidate();
|
||||
|
||||
// Set up polling every 10 seconds
|
||||
const polling$ = timer(10000, 10000).pipe(
|
||||
switchMap(() => {
|
||||
// If we have comments, fetch changes; otherwise fetch all
|
||||
if (this.comments$.value.length > 0) {
|
||||
return fromPromise(async () => {
|
||||
return await this.store.listCommentChanges({
|
||||
after: this.startCursor,
|
||||
});
|
||||
}).pipe(
|
||||
tap(changes => {
|
||||
if (changes) {
|
||||
this.handleCommentChanges(changes);
|
||||
}
|
||||
}),
|
||||
catchError(error => {
|
||||
console.error('Failed to fetch comment changes:', error);
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
return fromPromise(async () => {
|
||||
const allComments: DocComment[] = [];
|
||||
let cursor = '';
|
||||
let firstResult: DocCommentListResult | null = null;
|
||||
|
||||
// Fetch all pages of comments
|
||||
while (true) {
|
||||
const result = await this.store.listComments({ after: cursor });
|
||||
if (!firstResult) {
|
||||
firstResult = result;
|
||||
// Store the startCursor from the first page for future polling
|
||||
this.startCursor = result.startCursor;
|
||||
}
|
||||
allComments.push(...result.comments);
|
||||
cursor = result.endCursor;
|
||||
if (!result.hasNextPage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Update state with all comments
|
||||
this.comments$.setValue(allComments);
|
||||
|
||||
return allComments;
|
||||
}).pipe(
|
||||
catchError(error => {
|
||||
console.error('Failed to fetch comments:', error);
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const subscription = polling$.subscribe();
|
||||
this.pollingDisposable = () => subscription.unsubscribe();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.pollingDisposable) {
|
||||
this.pollingDisposable();
|
||||
}
|
||||
}
|
||||
|
||||
private handleCommentChanges(changes: DocCommentChangeListResult): void {
|
||||
if (!changes || changes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentComments = [...this.comments$.value];
|
||||
let commentsUpdated = false;
|
||||
|
||||
for (const change of changes) {
|
||||
const { id, action, comment, commentId } = change;
|
||||
|
||||
if (commentId) {
|
||||
// This is a reply change - handle separately
|
||||
this.handleReplyChange(currentComments, action, comment, commentId);
|
||||
commentsUpdated = true;
|
||||
} else {
|
||||
// This is a top-level comment change
|
||||
switch (action) {
|
||||
case 'update': {
|
||||
// Update existing comment or add new comment if it doesn't exist
|
||||
const updateIndex = currentComments.findIndex(c => c.id === id);
|
||||
if (updateIndex !== -1) {
|
||||
// Update existing comment
|
||||
currentComments[updateIndex] = comment;
|
||||
commentsUpdated = true;
|
||||
} else {
|
||||
// Add new comment if it doesn't exist (create event)
|
||||
currentComments.push(comment);
|
||||
commentsUpdated = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
// Remove comment
|
||||
const deleteIndex = currentComments.findIndex(c => c.id === id);
|
||||
if (deleteIndex !== -1) {
|
||||
currentComments.splice(deleteIndex, 1);
|
||||
commentsUpdated = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.warn('Unknown comment change action:', action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the comments list if any changes were made
|
||||
if (commentsUpdated) {
|
||||
this.comments$.setValue(currentComments);
|
||||
}
|
||||
}
|
||||
|
||||
private handleReplyChange(
|
||||
currentComments: DocComment[],
|
||||
action: CommentChangeAction,
|
||||
reply: DocComment,
|
||||
parentCommentId: string
|
||||
): void {
|
||||
const parentIndex = currentComments.findIndex(
|
||||
c => c.id === parentCommentId
|
||||
);
|
||||
if (parentIndex === -1) {
|
||||
console.warn('Parent comment not found for reply:', parentCommentId);
|
||||
return;
|
||||
}
|
||||
|
||||
const parentComment = currentComments[parentIndex];
|
||||
const replies = [...(parentComment.replies || [])];
|
||||
|
||||
switch (action) {
|
||||
case 'update': {
|
||||
// Update existing reply or add new reply if it doesn't exist
|
||||
const updateIndex = replies.findIndex(r => r.id === reply.id);
|
||||
if (updateIndex !== -1) {
|
||||
// Update existing reply
|
||||
replies[updateIndex] = reply;
|
||||
currentComments[parentIndex] = { ...parentComment, replies };
|
||||
} else {
|
||||
// Add new reply if it doesn't exist (create event)
|
||||
replies.push(reply);
|
||||
currentComments[parentIndex] = { ...parentComment, replies };
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
// Remove reply
|
||||
const deleteIndex = replies.findIndex(r => r.id === reply.id);
|
||||
if (deleteIndex !== -1) {
|
||||
replies.splice(deleteIndex, 1);
|
||||
currentComments[parentIndex] = { ...parentComment, replies };
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.warn('Unknown reply change action:', action);
|
||||
}
|
||||
}
|
||||
|
||||
revalidate = effect(
|
||||
switchMap(() => {
|
||||
return fromPromise(async () => {
|
||||
const allComments: DocComment[] = [];
|
||||
let cursor = '';
|
||||
let firstResult: DocCommentListResult | null = null;
|
||||
|
||||
// Fetch all pages of comments
|
||||
while (true) {
|
||||
const result = await this.store.listComments({ after: cursor });
|
||||
if (!firstResult) {
|
||||
firstResult = result;
|
||||
// Store the startCursor from the first page for polling
|
||||
this.startCursor = result.startCursor;
|
||||
}
|
||||
allComments.push(...result.comments);
|
||||
cursor = result.endCursor;
|
||||
if (!result.hasNextPage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return allComments;
|
||||
}).pipe(
|
||||
tap(allComments => {
|
||||
// Update state with all comments
|
||||
this.comments$.setValue(allComments);
|
||||
}),
|
||||
onStart(() => this.loading$.setValue(true)),
|
||||
onComplete(() => this.loading$.setValue(false)),
|
||||
catchError(error => {
|
||||
console.error('Failed to fetch comments:', error);
|
||||
this.loading$.setValue(false);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
override dispose(): void {
|
||||
this.stop();
|
||||
this.commentAdded$.complete();
|
||||
this.commentResolved$.complete();
|
||||
this.commentDeleted$.complete();
|
||||
this.commentHighlighted$.complete();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Framework } from '@toeverything/infra';
|
||||
|
||||
import { DefaultServerService, WorkspaceServerService } from '../cloud';
|
||||
import { DocDisplayMetaService } from '../doc-display-meta';
|
||||
import { WorkbenchService } from '../workbench';
|
||||
import { WorkspaceScope, WorkspaceService } from '../workspace';
|
||||
import { DocCommentEntity } from './entities/doc-comment';
|
||||
import { DocCommentStore } from './entities/doc-comment-store';
|
||||
import { CommentPanelService } from './services/comment-panel-service';
|
||||
import { DocCommentManagerService } from './services/doc-comment-manager';
|
||||
import { SnapshotHelper } from './services/snapshot-helper';
|
||||
|
||||
export function configureCommentModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(DocCommentManagerService)
|
||||
.service(CommentPanelService, [WorkbenchService])
|
||||
.service(SnapshotHelper, [
|
||||
WorkspaceService,
|
||||
WorkspaceServerService,
|
||||
DefaultServerService,
|
||||
])
|
||||
.entity(DocCommentEntity, [SnapshotHelper, DocDisplayMetaService])
|
||||
.entity(DocCommentStore, [
|
||||
WorkspaceService,
|
||||
WorkspaceServerService,
|
||||
DefaultServerService,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { type WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { Service } from '@toeverything/infra';
|
||||
|
||||
import type { DocCommentEntity } from '../entities/doc-comment';
|
||||
|
||||
export class CommentPanelService extends Service {
|
||||
constructor(private readonly workbenchService: WorkbenchService) {
|
||||
super();
|
||||
}
|
||||
|
||||
private readonly activePendingWatchers = new Set<() => void>();
|
||||
|
||||
/**
|
||||
* Watch for pending comments on a doc comment entity and open the sidebar automatically
|
||||
*/
|
||||
watchForPendingComments(entity: DocCommentEntity): () => void {
|
||||
const subscription = entity.pendingComment$.subscribe(pendingComment => {
|
||||
// If we have a new pending comment, open the comment panel
|
||||
if (pendingComment) {
|
||||
this.openCommentPanel();
|
||||
}
|
||||
});
|
||||
|
||||
const dispose = () => {
|
||||
subscription.unsubscribe();
|
||||
this.activePendingWatchers.delete(dispose);
|
||||
};
|
||||
|
||||
this.activePendingWatchers.add(dispose);
|
||||
return dispose;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the sidebar and activate the comment tab
|
||||
*/
|
||||
openCommentPanel(): void {
|
||||
const workbench = this.workbenchService.workbench;
|
||||
const activeView = workbench.activeView$.value;
|
||||
|
||||
if (activeView) {
|
||||
workbench.openSidebar();
|
||||
activeView.activeSidebarTab('comment');
|
||||
}
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
// Clean up all active watchers
|
||||
for (const dispose of this.activePendingWatchers) {
|
||||
dispose();
|
||||
}
|
||||
this.activePendingWatchers.clear();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ObjectPool, Service } from '@toeverything/infra';
|
||||
|
||||
import { DocCommentEntity } from '../entities/doc-comment';
|
||||
|
||||
type DocId = string;
|
||||
|
||||
export class DocCommentManagerService extends Service {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
private readonly pool = new ObjectPool<DocId, DocCommentEntity>({
|
||||
onDelete: entity => {
|
||||
entity.dispose();
|
||||
},
|
||||
});
|
||||
|
||||
get(docId: DocId) {
|
||||
let commentRef = this.pool.get(docId);
|
||||
if (!commentRef) {
|
||||
const comment = this.framework.createEntity(DocCommentEntity, {
|
||||
docId,
|
||||
});
|
||||
commentRef = this.pool.put(docId, comment);
|
||||
// todo: add LRU cache for the pool?
|
||||
}
|
||||
return commentRef;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { getStoreManager } from '@affine/core/blocksuite/manager/store';
|
||||
import { Container } from '@blocksuite/affine/global/di';
|
||||
import {
|
||||
customImageProxyMiddleware,
|
||||
MarkdownAdapter,
|
||||
} from '@blocksuite/affine/shared/adapters';
|
||||
import {
|
||||
type DocSnapshot,
|
||||
nanoid,
|
||||
type Store,
|
||||
Text,
|
||||
Transformer,
|
||||
} from '@blocksuite/affine/store';
|
||||
import { Service } from '@toeverything/infra';
|
||||
import { Doc as YDoc } from 'yjs';
|
||||
|
||||
import type { DefaultServerService, WorkspaceServerService } from '../../cloud';
|
||||
import {
|
||||
getAFFiNEWorkspaceSchema,
|
||||
type WorkspaceService,
|
||||
} from '../../workspace';
|
||||
import { WorkspaceImpl } from '../../workspace/impls/workspace';
|
||||
|
||||
export class SnapshotHelper extends Service {
|
||||
constructor(
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly workspaceServerService: WorkspaceServerService,
|
||||
private readonly defaultServerService: DefaultServerService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
private get serverService() {
|
||||
return (
|
||||
this.workspaceServerService.server || this.defaultServerService.server
|
||||
);
|
||||
}
|
||||
|
||||
getTempWorkspace() {
|
||||
const collection = new WorkspaceImpl({
|
||||
rootDoc: new YDoc({ guid: 'markdownToDoc' + nanoid() }),
|
||||
blobSource: {
|
||||
name: 'cloud',
|
||||
readonly: true,
|
||||
get: async key => {
|
||||
const record =
|
||||
await this.workspaceService.workspace.engine.blob.get(key);
|
||||
return record ? new Blob([record.data], { type: record.mime }) : null;
|
||||
},
|
||||
set() {
|
||||
return Promise.resolve('');
|
||||
},
|
||||
delete() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
list() {
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
},
|
||||
});
|
||||
collection.meta.initialize();
|
||||
return collection;
|
||||
}
|
||||
|
||||
// todo: cache the transformer?
|
||||
getTransformer() {
|
||||
const collection = this.getTempWorkspace();
|
||||
const schema = getAFFiNEWorkspaceSchema();
|
||||
const imageProxyUrl = new URL(
|
||||
BUILD_CONFIG.imageProxyUrl,
|
||||
this.serverService.baseUrl
|
||||
).toString();
|
||||
|
||||
const middlewares = [customImageProxyMiddleware(imageProxyUrl)];
|
||||
const transformer = new Transformer({
|
||||
schema,
|
||||
blobCRUD: collection.blobSync,
|
||||
docCRUD: {
|
||||
create: (id: string) => {
|
||||
const doc = collection.createDoc(id);
|
||||
return doc.getStore({ id });
|
||||
},
|
||||
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
|
||||
delete: (id: string) => collection.removeDoc(id),
|
||||
},
|
||||
middlewares,
|
||||
});
|
||||
return transformer;
|
||||
}
|
||||
|
||||
getMarkdownAdapter() {
|
||||
const transformer = this.getTransformer();
|
||||
const extensions = getStoreManager().config.init().value.get('store');
|
||||
const container = new Container();
|
||||
extensions.forEach(ext => {
|
||||
ext.setup(container);
|
||||
});
|
||||
const mdAdapter = new MarkdownAdapter(transformer, container.provider());
|
||||
return mdAdapter;
|
||||
}
|
||||
|
||||
getSnapshot(doc: Store): DocSnapshot | undefined {
|
||||
const transformer = this.getTransformer();
|
||||
if (!transformer) {
|
||||
throw new Error('Markdown transformer not found');
|
||||
}
|
||||
const result = transformer.docToSnapshot(doc);
|
||||
return result;
|
||||
}
|
||||
|
||||
async createStore(snapshot?: DocSnapshot): Promise<Store | undefined> {
|
||||
if (snapshot) {
|
||||
const transformer = this.getTransformer();
|
||||
if (!transformer) {
|
||||
throw new Error('Markdown transformer not found');
|
||||
}
|
||||
return await transformer.snapshotToDoc(snapshot);
|
||||
} else {
|
||||
const collection = this.getTempWorkspace();
|
||||
if (!collection) {
|
||||
throw new Error('Temp workspace not found');
|
||||
}
|
||||
|
||||
// Create a temporary doc with proper structure
|
||||
const doc = collection.createDoc();
|
||||
const store = doc.getStore();
|
||||
store.load(() => {
|
||||
// Add root page block with empty title
|
||||
const rootId = store.addBlock('affine:page', {
|
||||
title: new Text(''),
|
||||
});
|
||||
|
||||
// Add surface block
|
||||
store.addBlock('affine:surface', {}, rootId);
|
||||
|
||||
// Add note block
|
||||
const noteId = store.addBlock('affine:note', {}, rootId);
|
||||
|
||||
// Add default paragraph block
|
||||
store.addBlock('affine:paragraph', {}, noteId);
|
||||
});
|
||||
|
||||
// Reset history to prevent initial creation operations from being undone
|
||||
store.resetHistory();
|
||||
|
||||
return store;
|
||||
}
|
||||
}
|
||||
|
||||
async createEmptySnapshot() {
|
||||
const store = await this.createStore();
|
||||
if (store) {
|
||||
return this.getSnapshot(store);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { CommentChangeAction, PublicUserType } from '@affine/graphql';
|
||||
import type { DocMode } from '@blocksuite/affine/model';
|
||||
import type {
|
||||
BaseSelection,
|
||||
DocSnapshot,
|
||||
Store,
|
||||
} from '@blocksuite/affine/store';
|
||||
|
||||
export type CommentId = string;
|
||||
|
||||
export interface BaseComment {
|
||||
id: CommentId;
|
||||
content: DocCommentContent;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
user: PublicUserType;
|
||||
}
|
||||
|
||||
export interface DocComment extends BaseComment {
|
||||
resolved: boolean;
|
||||
replies?: DocCommentReply[];
|
||||
}
|
||||
|
||||
export type PendingComment = {
|
||||
id: CommentId;
|
||||
doc: Store;
|
||||
preview?: string;
|
||||
selections?: BaseSelection[];
|
||||
commentId?: CommentId; // only for replies, points to the parent comment
|
||||
};
|
||||
|
||||
export type DocCommentReply = BaseComment;
|
||||
|
||||
export type DocCommentContent = {
|
||||
snapshot: DocSnapshot; // blocksuite snapshot
|
||||
mode?: DocMode;
|
||||
preview?: string; // text preview of the target
|
||||
};
|
||||
|
||||
export interface DocCommentListResult {
|
||||
comments: DocComment[];
|
||||
hasNextPage: boolean;
|
||||
startCursor: string;
|
||||
endCursor: string;
|
||||
}
|
||||
|
||||
export interface DocCommentChange {
|
||||
action: CommentChangeAction;
|
||||
comment: DocComment;
|
||||
id: CommentId; // the id of the comment or reply
|
||||
commentId?: CommentId; // a change with comment id is a reply
|
||||
}
|
||||
|
||||
export type DocCommentChangeListResult = DocCommentChange[];
|
||||
@@ -3,9 +3,13 @@ import type { DefaultOpenProperty } from '@affine/core/components/properties';
|
||||
import { PresentTool } from '@blocksuite/affine/blocks/frame';
|
||||
import { DefaultTool } from '@blocksuite/affine/blocks/surface';
|
||||
import type { DocTitle } from '@blocksuite/affine/fragments/doc-title';
|
||||
import { findCommentedTexts } from '@blocksuite/affine/inlines/comment';
|
||||
import type { DocMode, ReferenceParams } from '@blocksuite/affine/model';
|
||||
import { HighlightSelection } from '@blocksuite/affine/shared/selection';
|
||||
import { DocModeProvider } from '@blocksuite/affine/shared/services';
|
||||
import {
|
||||
DocModeProvider,
|
||||
findCommentedBlocks,
|
||||
} from '@blocksuite/affine/shared/services';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/affine/std/gfx';
|
||||
import type { InlineEditor } from '@blocksuite/std/inline';
|
||||
import { effect } from '@preact/signals-core';
|
||||
@@ -51,6 +55,7 @@ export class Editor extends Entity {
|
||||
const selector = get(this.selector$);
|
||||
const mode = get(this.mode$);
|
||||
let id = selector?.blockIds?.[0];
|
||||
let commentId = selector?.commentId;
|
||||
let key = 'blockIds';
|
||||
|
||||
if (mode === 'edgeless') {
|
||||
@@ -61,9 +66,15 @@ export class Editor extends Entity {
|
||||
}
|
||||
}
|
||||
|
||||
if (!id) return null;
|
||||
if (!id && !commentId) return null;
|
||||
|
||||
return { id, key, mode, refreshKey: selector?.refreshKey };
|
||||
return {
|
||||
id,
|
||||
key,
|
||||
mode,
|
||||
refreshKey: selector?.refreshKey,
|
||||
commentId: commentId,
|
||||
};
|
||||
});
|
||||
|
||||
isPresenting$ = new LiveData<boolean>(false);
|
||||
@@ -183,6 +194,51 @@ export class Editor extends Entity {
|
||||
};
|
||||
}
|
||||
|
||||
handleFocusAt(focusAt: {
|
||||
key: string;
|
||||
mode: DocMode;
|
||||
id?: string;
|
||||
commentId?: string;
|
||||
}) {
|
||||
const editorContainer = this.editorContainer$.value;
|
||||
if (!editorContainer) return;
|
||||
|
||||
const selection = editorContainer.host?.std.selection;
|
||||
const { id, key, mode, commentId } = focusAt;
|
||||
|
||||
let finalId = id;
|
||||
let finalKey = key;
|
||||
|
||||
// If we have commentId but no blockId, find the block from the comment
|
||||
if (commentId && !id && editorContainer.host?.std) {
|
||||
const std = editorContainer.host.std;
|
||||
|
||||
// First try to find inline commented texts
|
||||
const inlineCommentedSelections = findCommentedTexts(std, commentId);
|
||||
if (inlineCommentedSelections.length > 0) {
|
||||
const firstSelection = inlineCommentedSelections[0][0];
|
||||
finalId = firstSelection.from.blockId;
|
||||
finalKey = 'blockIds';
|
||||
} else {
|
||||
// Then try to find block comments
|
||||
const blockCommentedBlocks = findCommentedBlocks(std.store, commentId);
|
||||
if (blockCommentedBlocks.length > 0) {
|
||||
finalId = blockCommentedBlocks[0].id;
|
||||
finalKey = 'blockIds';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === this.mode$.value && finalId) {
|
||||
selection?.setGroup('scene', [
|
||||
selection?.create(HighlightSelection, {
|
||||
mode,
|
||||
[finalKey]: [finalId],
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
bindEditorContainer(
|
||||
editorContainer: AffineEditorContainer,
|
||||
docTitle?: DocTitle | null,
|
||||
@@ -228,18 +284,7 @@ export class Editor extends Entity {
|
||||
title?.inlineEditor?.focusEnd();
|
||||
}
|
||||
} else {
|
||||
const selection = editorContainer.host?.std.selection;
|
||||
|
||||
const { id, key, mode } = initialFocusAt;
|
||||
|
||||
if (mode === this.mode$.value) {
|
||||
selection?.setGroup('scene', [
|
||||
selection?.create(HighlightSelection, {
|
||||
mode,
|
||||
[key]: [id],
|
||||
}),
|
||||
]);
|
||||
}
|
||||
this.handleFocusAt(initialFocusAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,18 +324,7 @@ export class Editor extends Entity {
|
||||
.pipe(skip(1))
|
||||
.subscribe(anchor => {
|
||||
if (!anchor) return;
|
||||
|
||||
const selection = editorContainer.host?.std.selection;
|
||||
if (!selection) return;
|
||||
|
||||
const { id, key, mode } = anchor;
|
||||
|
||||
selection.setGroup('scene', [
|
||||
selection.create(HighlightSelection, {
|
||||
mode,
|
||||
[key]: [id],
|
||||
}),
|
||||
]);
|
||||
this.handleFocusAt(anchor);
|
||||
});
|
||||
unsubs.push(subscription.unsubscribe.bind(subscription));
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type EditorSelector = {
|
||||
blockIds?: string[];
|
||||
elementIds?: string[];
|
||||
commentId?: string;
|
||||
refreshKey?: string;
|
||||
};
|
||||
|
||||
@@ -265,7 +265,8 @@ export const AFFINE_FLAGS = {
|
||||
defaultState: false,
|
||||
},
|
||||
enable_comment: {
|
||||
category: 'affine',
|
||||
category: 'blocksuite',
|
||||
bsFlag: 'enable_comment',
|
||||
displayName: 'Enable Comment',
|
||||
description: 'Enable comment',
|
||||
configurable: isCanaryBuild,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { configureBlobManagementModule } from './blob-management';
|
||||
import { configureCloudModule } from './cloud';
|
||||
import { configureCollectionModule } from './collection';
|
||||
import { configureCollectionRulesModule } from './collection-rules';
|
||||
import { configureCommentModule } from './comment';
|
||||
import { configureWorkspaceDBModule } from './db';
|
||||
import { configureDialogModule } from './dialogs';
|
||||
import { configureDndModule } from './dnd';
|
||||
@@ -118,4 +119,5 @@ export function configureCommonModules(framework: Framework) {
|
||||
configureWorkspacePropertyModule(framework);
|
||||
configureCollectionRulesModule(framework);
|
||||
configureIndexerEmbeddingModule(framework);
|
||||
configureCommentModule(framework);
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ export const preprocessParams = (
|
||||
'databaseId',
|
||||
'databaseRowId',
|
||||
'refreshKey',
|
||||
'commentId',
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -171,6 +172,7 @@ export const paramsParseOptions: ParseOptions = {
|
||||
databaseId: 'string',
|
||||
databaseRowId: 'string',
|
||||
refreshKey: 'string',
|
||||
commentId: 'string',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user