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

View File

@@ -109,7 +109,7 @@ const usePatchSpecs = (mode: DocMode) => {
.electron(framework)
.linkPreview(framework)
.codeBlockHtmlPreview(framework)
.comment(enableComment).value;
.comment(enableComment, framework).value;
if (BUILD_CONFIG.isMobileEdition) {
if (mode === 'page') {

View File

@@ -57,7 +57,10 @@ type Configure = {
electron: (framework?: FrameworkProvider) => Configure;
linkPreview: (framework?: FrameworkProvider) => Configure;
codeBlockHtmlPreview: (framework?: FrameworkProvider) => Configure;
comment: (enableComment?: boolean) => Configure;
comment: (
enableComment?: boolean,
framework?: FrameworkProvider
) => Configure;
value: ViewExtensionManager;
};
@@ -92,6 +95,7 @@ class ViewProvider {
ElectronViewExtension,
AffineLinkPreviewExtension,
AffineDatabaseViewExtension,
CommentViewExtension,
]);
}
@@ -328,8 +332,14 @@ class ViewProvider {
return this.config;
};
private readonly _configureComment = (enableComment?: boolean) => {
this._manager.configure(CommentViewExtension, { enableComment });
private readonly _configureComment = (
enableComment?: boolean,
framework?: FrameworkProvider
) => {
this._manager.configure(CommentViewExtension, {
enableComment,
framework,
});
return this.config;
};
}

View File

@@ -1,14 +1,176 @@
import { noop } from '@blocksuite/affine/global/utils';
import { CommentProviderExtension } from '@blocksuite/affine/shared/services';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { getSelectedBlocksCommand } from '@blocksuite/affine/shared/commands';
import type { CommentProvider } from '@blocksuite/affine/shared/services';
import { CommentProviderIdentifier } from '@blocksuite/affine/shared/services';
import type { BlockStdScope } from '@blocksuite/affine/std';
import { StdIdentifier } from '@blocksuite/affine/std';
import type { BaseSelection, ExtensionType } from '@blocksuite/affine/store';
import { type Container } from '@blocksuite/global/di';
import { BlockSelection, TextSelection } from '@blocksuite/std';
import type { FrameworkProvider } from '@toeverything/infra';
export const AffineCommentProvider = CommentProviderExtension({
addComment: noop,
resolveComment: noop,
highlightComment: noop,
getComments: () => [],
import { DocCommentManagerService } from '../../../modules/comment/services/doc-comment-manager';
onCommentAdded: () => noop,
onCommentResolved: () => noop,
onCommentDeleted: () => noop,
onCommentHighlighted: () => noop,
});
function getPreviewFromSelections(
std: BlockStdScope,
selections: BaseSelection[]
): string {
if (!selections || selections.length === 0) {
return '';
}
const previews: string[] = [];
for (const selection of selections) {
if (selection instanceof TextSelection) {
// Extract text from TextSelection
const textPreview = extractTextFromSelection(std, selection);
if (textPreview) {
previews.push(textPreview);
}
} else if (selection instanceof BlockSelection) {
// Get block flavour for BlockSelection
const block = std.store.getBlock(selection.blockId);
if (block?.model) {
const flavour = block.model.flavour.replace('affine:', '');
previews.push(`<${flavour}>`);
}
} else if (selection.type === 'image') {
// Return <"Image"> for ImageSelection
previews.push('<Image>');
}
// Skip other types
}
return previews.length > 0 ? previews.join(' ') : 'New comment';
}
function extractTextFromSelection(
std: BlockStdScope,
selection: TextSelection
): string | null {
try {
const [_, ctx] = std.command
.chain()
.pipe(getSelectedBlocksCommand, {
currentTextSelection: selection,
types: ['text'],
})
.run();
const blocks = ctx.selectedBlocks;
if (!blocks || blocks.length === 0) return null;
const { from, to } = selection;
const quote = blocks.reduce((acc, block, index) => {
const text = block.model.text;
if (!text) return acc;
if (index === 0) {
// First block: extract from 'from.index' for 'from.length' characters
const startText = text.yText
.toString()
.slice(from.index, from.index + from.length);
return acc + startText;
}
if (index === blocks.length - 1 && to) {
// Last block: extract from start to 'to.index + to.length'
const endText = text.yText.toString().slice(0, to.index + to.length);
return acc + (acc ? ' ' : '') + endText;
}
// Middle blocks: get all text
const blockText = text.yText.toString();
return acc + (acc ? ' ' : '') + blockText;
}, '');
// Trim and limit length for preview
const trimmed = quote.trim();
return trimmed.length > 200 ? trimmed.substring(0, 200) + '...' : trimmed;
} catch (error) {
console.warn('Failed to extract text from selection:', error);
return null;
}
}
class AffineCommentService implements CommentProvider {
private readonly docCommentManager: DocCommentManagerService;
constructor(
private readonly std: BlockStdScope,
private readonly framework: FrameworkProvider
) {
this.docCommentManager = framework.get(DocCommentManagerService);
}
private get currentDocId(): string {
return this.std.store.id;
}
// todo: need to handle resource leak
private get commentEntityRef() {
return this.docCommentManager.get(this.currentDocId);
}
private get commentEntity() {
return this.commentEntityRef.obj;
}
addComment(selections: BaseSelection[]): void {
const workbench = this.framework.get(WorkbenchService).workbench;
workbench.setSidebarOpen(true);
workbench.activeView$.value.activeSidebarTab('comment');
const preview = getPreviewFromSelections(this.std, selections);
this.commentEntity.addComment(selections, preview).catch(console.error);
}
resolveComment(id: string): void {
this.commentEntity.resolveComment(id, true).catch(console.error);
}
highlightComment(id: string | null): void {
if (id !== null) {
const workbench = this.framework.get(WorkbenchService).workbench;
workbench.setSidebarOpen(true);
workbench.activeView$.value.activeSidebarTab('comment');
}
this.commentEntity.highlightComment(id);
}
getComments(): string[] {
return this.commentEntity.getComments();
}
onCommentAdded(callback: (id: string, selections: BaseSelection[]) => void) {
return this.commentEntity.onCommentAdded((id, selections) => {
callback(id, selections);
});
}
onCommentResolved(callback: (id: string) => void) {
return this.commentEntity.onCommentResolved(callback);
}
onCommentDeleted(callback: (id: string) => void) {
return this.commentEntity.onCommentDeleted(callback);
}
onCommentHighlighted(callback: (id: string | null) => void) {
return this.commentEntity.onCommentHighlighted(callback);
}
}
export function AffineCommentProvider(
framework: FrameworkProvider
): ExtensionType {
return {
setup: (di: Container) => {
di.addImpl(
CommentProviderIdentifier,
provider =>
new AffineCommentService(provider.get(StdIdentifier), framework)
);
},
};
}

View File

@@ -2,26 +2,30 @@ import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine/ext-loader';
import { FrameworkProvider } from '@toeverything/infra';
import z from 'zod';
import { AffineCommentProvider } from './comment-provider';
const optionsSchema = z.object({
enableComment: z.boolean().optional(),
framework: z.instanceof(FrameworkProvider).optional(),
});
export class CommentViewExtension extends ViewExtensionProvider {
type CommentViewOptions = z.infer<typeof optionsSchema>;
export class CommentViewExtension extends ViewExtensionProvider<CommentViewOptions> {
override name = 'comment';
override schema = optionsSchema;
override setup(
context: ViewExtensionContext,
options?: z.infer<typeof optionsSchema>
) {
override setup(context: ViewExtensionContext, options?: CommentViewOptions) {
super.setup(context, options);
if (!options?.enableComment) return;
context.register([AffineCommentProvider]);
const framework = options.framework;
if (!framework) return;
context.register([AffineCommentProvider(framework)]);
}
}