mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 10:22:55 +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:
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user