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:
Peng Xiao
2025-07-02 23:47:00 +08:00
committed by GitHub
parent a59448ec4b
commit a21f1c943e
30 changed files with 2572 additions and 68 deletions
@@ -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',
},
};