diff --git a/blocksuite/affine/blocks/root/src/page/page-root-block.ts b/blocksuite/affine/blocks/root/src/page/page-root-block.ts index e5f9b7ab4..11b468dad 100644 --- a/blocksuite/affine/blocks/root/src/page/page-root-block.ts +++ b/blocksuite/affine/blocks/root/src/page/page-root-block.ts @@ -305,7 +305,10 @@ export class PageRootBlockComponent extends BlockComponent { ); // make sure there is a block can be focused - if (notes.length === 0 || notes[notes.length - 1].children.length === 0) { + if ( + !this.store.readonly$.value && + (notes.length === 0 || notes[notes.length - 1].children.length === 0) + ) { this.std.command.exec(appendParagraphCommand); return; } @@ -322,7 +325,7 @@ export class PageRootBlockComponent extends BlockComponent { parseFloat(paddingLeft), parseFloat(paddingRight) ); - if (!isClickOnBlankArea) { + if (!isClickOnBlankArea && !this.store.readonly$.value) { const lastBlock = notes[notes.length - 1].lastChild(); if ( !lastBlock || diff --git a/blocksuite/affine/inlines/comment/src/index.ts b/blocksuite/affine/inlines/comment/src/index.ts index e451c726a..b777e3745 100644 --- a/blocksuite/affine/inlines/comment/src/index.ts +++ b/blocksuite/affine/inlines/comment/src/index.ts @@ -1,2 +1,4 @@ +export { InlineCommentManager } from './inline-comment-manager'; export * from './inline-spec'; export * from './utils'; +export * from './view'; diff --git a/blocksuite/affine/inlines/comment/src/inline-comment-manager.ts b/blocksuite/affine/inlines/comment/src/inline-comment-manager.ts index 748461d3e..cc753685f 100644 --- a/blocksuite/affine/inlines/comment/src/inline-comment-manager.ts +++ b/blocksuite/affine/inlines/comment/src/inline-comment-manager.ts @@ -5,6 +5,7 @@ import { type CommentId, CommentProviderIdentifier, findAllCommentedBlocks, + findAllCommentedElements, } from '@blocksuite/affine-shared/services'; import type { AffineInlineEditor } from '@blocksuite/affine-shared/types'; import { DisposableGroup } from '@blocksuite/global/disposable'; @@ -64,15 +65,8 @@ export class InlineCommentManager extends LifeCycleWatcher { if (!provider) return; const commentsInProvider = await provider.getComments('unresolved'); - const inlineComments = [...findAllCommentedTexts(this.std.store).values()]; - const blockComments = findAllCommentedBlocks(this.std.store).flatMap( - block => Object.keys(block.props.comments) - ); - - const commentsInEditor = [ - ...new Set([...inlineComments, ...blockComments]), - ]; + const commentsInEditor = this.getCommentsInEditor(); // remove comments that are in editor but not in provider // which means the comment may be removed or resolved in provider side @@ -82,6 +76,24 @@ export class InlineCommentManager extends LifeCycleWatcher { }); } + getCommentsInEditor() { + const inlineComments = [...findAllCommentedTexts(this.std.store).values()]; + + const blockComments = findAllCommentedBlocks(this.std.store).flatMap( + block => Object.keys(block.props.comments) + ); + + const surfaceComments = findAllCommentedElements(this.std.store).flatMap( + element => Object.keys(element.comments) + ); + + const commentsInEditor = [ + ...new Set([...inlineComments, ...blockComments, ...surfaceComments]), + ]; + + return commentsInEditor; + } + private readonly _handleAddComment = ( id: CommentId, selections: BaseSelection[] diff --git a/blocksuite/affine/inlines/comment/src/inline-spec.ts b/blocksuite/affine/inlines/comment/src/inline-spec.ts index f7e42e0dc..b31cb8b17 100644 --- a/blocksuite/affine/inlines/comment/src/inline-spec.ts +++ b/blocksuite/affine/inlines/comment/src/inline-spec.ts @@ -36,3 +36,14 @@ export const CommentInlineSpecExtension = >`, wrapper: true, }); + +export const NullCommentInlineSpecExtension = + InlineSpecExtension({ + name: 'comment', + schema: dynamicSchema( + isInlineCommendId, + z.boolean().optional().nullable().catch(undefined) + ), + match: () => false, + renderer: () => html``, + }); diff --git a/blocksuite/affine/inlines/comment/src/view.ts b/blocksuite/affine/inlines/comment/src/view.ts index 26a8846d2..c756b48bb 100644 --- a/blocksuite/affine/inlines/comment/src/view.ts +++ b/blocksuite/affine/inlines/comment/src/view.ts @@ -2,21 +2,41 @@ import { type ViewExtensionContext, ViewExtensionProvider, } from '@blocksuite/affine-ext-loader'; +import z from 'zod'; import { effects } from './effects'; import { InlineCommentManager } from './inline-comment-manager'; -import { CommentInlineSpecExtension } from './inline-spec'; +import { + CommentInlineSpecExtension, + NullCommentInlineSpecExtension, +} from './inline-spec'; -export class InlineCommentViewExtension extends ViewExtensionProvider { +const optionsSchema = z.object({ + enabled: z.boolean().optional().default(true), +}); + +export class InlineCommentViewExtension extends ViewExtensionProvider< + z.infer +> { override name = 'affine-inline-comment'; + override schema = optionsSchema; + override effect(): void { super.effect(); effects(); } - override setup(context: ViewExtensionContext) { - super.setup(context); - context.register([CommentInlineSpecExtension, InlineCommentManager]); + override setup( + context: ViewExtensionContext, + options?: z.infer + ) { + super.setup(context, options); + context.register([ + options?.enabled + ? CommentInlineSpecExtension + : NullCommentInlineSpecExtension, + InlineCommentManager, + ]); } } diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx b/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx index dbea49d35..1ffe66155 100644 --- a/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx +++ b/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx @@ -59,7 +59,7 @@ interface BlocksuiteEditorProps { defaultOpenProperty?: DefaultOpenProperty; } -const usePatchSpecs = (mode: DocMode) => { +const usePatchSpecs = (mode: DocMode, shared?: boolean) => { const [reactToLit, portals] = useLitPortalFactory(); const { workspaceService, featureFlagService } = useServices({ WorkspaceService, @@ -86,7 +86,8 @@ const usePatchSpecs = (mode: DocMode) => { const serverConfig = useLiveData(serverService.server.config$); // comment may not be supported by the server - const enableComment = serverConfig.features.includes(ServerFeature.Comment); + const enableComment = + serverConfig.features.includes(ServerFeature.Comment) && !shared; const patchedSpecs = useMemo(() => { const manager = getViewManager() @@ -206,7 +207,7 @@ export const BlocksuiteDocEditor = forwardRef< [externalTitleRef] ); - const [specs, portals] = usePatchSpecs('page'); + const [specs, portals] = usePatchSpecs('page', shared); const displayBiDirectionalLink = useLiveData( editorSettingService.editorSetting.settings$.selector( diff --git a/packages/frontend/core/src/blocksuite/manager/view.ts b/packages/frontend/core/src/blocksuite/manager/view.ts index 9c6bfbabd..6d4ddd0a8 100644 --- a/packages/frontend/core/src/blocksuite/manager/view.ts +++ b/packages/frontend/core/src/blocksuite/manager/view.ts @@ -33,6 +33,7 @@ import type { import { ViewExtensionManager } from '@blocksuite/affine/ext-loader'; import { getInternalViewExtensions } from '@blocksuite/affine/extensions/view'; import { FoundationViewExtension } from '@blocksuite/affine/foundation/view'; +import { InlineCommentViewExtension } from '@blocksuite/affine/inlines/comment'; import { AffineCanvasTextFonts } from '@blocksuite/affine/shared/services'; import { LinkedDocViewExtension } from '@blocksuite/affine/widgets/linked-doc/view'; import type { FrameworkProvider } from '@toeverything/infra'; @@ -340,6 +341,11 @@ class ViewProvider { enableComment, framework, }); + + this._manager.configure(InlineCommentViewExtension, { + enabled: enableComment, + }); + return this.config; }; } diff --git a/packages/frontend/core/src/blocksuite/view-extensions/comment/comment-provider.ts b/packages/frontend/core/src/blocksuite/view-extensions/comment/comment-provider.ts index 7fd2a0024..5d6a07f10 100644 --- a/packages/frontend/core/src/blocksuite/view-extensions/comment/comment-provider.ts +++ b/packages/frontend/core/src/blocksuite/view-extensions/comment/comment-provider.ts @@ -128,6 +128,7 @@ class AffineCommentService implements CommentProvider { private readonly framework: FrameworkProvider ) { this.docCommentManager = framework.get(DocCommentManagerService); + this.docCommentManager.std = std; } private get currentDocId(): string { diff --git a/packages/frontend/core/src/components/comment/comment-editor/specs.ts b/packages/frontend/core/src/components/comment/comment-editor/specs.ts index fb1ea2dc2..ee10f2914 100644 --- a/packages/frontend/core/src/components/comment/comment-editor/specs.ts +++ b/packages/frontend/core/src/components/comment/comment-editor/specs.ts @@ -1,6 +1,7 @@ import { CloudViewExtension } from '@affine/core/blocksuite/view-extensions/cloud'; import { AffineEditorViewExtension } from '@affine/core/blocksuite/view-extensions/editor-view/editor-view'; import { AffineThemeViewExtension } from '@affine/core/blocksuite/view-extensions/theme'; +import { I18n } from '@affine/i18n'; import { CodeBlockViewExtension } from '@blocksuite/affine/blocks/code/view'; import { DividerViewExtension } from '@blocksuite/affine/blocks/divider/view'; import { LatexViewExtension as LatexBlockViewExtension } from '@blocksuite/affine/blocks/latex/view'; @@ -154,7 +155,7 @@ export function getCommentEditorViewManager(framework: FrameworkProvider) { manager.configure(ParagraphViewExtension, { getPlaceholder: () => { - return ''; + return I18n.t('com.affine.notification.comment-prompt'); }, }); diff --git a/packages/frontend/core/src/components/comment/sidebar/index.tsx b/packages/frontend/core/src/components/comment/sidebar/index.tsx index e4140070e..568366590 100644 --- a/packages/frontend/core/src/components/comment/sidebar/index.tsx +++ b/packages/frontend/core/src/components/comment/sidebar/index.tsx @@ -377,7 +377,7 @@ const CommentItem = ({ entity.dismissDraftReply(); }, [entity, pendingReply]); - const handleClickPreview = useCallback(() => { + const handleClick = useCallback(() => { workbench.workbench.openDoc( { docId: entity.props.docId, @@ -470,6 +470,10 @@ const CommentItem = ({ const canDelete = (isMyComment && canCreateComment) || (!isMyComment && canDeleteComment); + const isCommentInEditor = useLiveData(entity.commentsInEditor$).includes( + comment.id + ); + // invalid comment, should not happen if (!comment.content) { return null; @@ -477,7 +481,7 @@ const CommentItem = ({ return (
-
{comment.content?.preview}
+
+ {comment.content?.preview} +
{isEditing && editingDoc ? ( diff --git a/packages/frontend/core/src/components/comment/sidebar/style.css.ts b/packages/frontend/core/src/components/comment/sidebar/style.css.ts index a80923de3..4c1941a34 100644 --- a/packages/frontend/core/src/components/comment/sidebar/style.css.ts +++ b/packages/frontend/core/src/components/comment/sidebar/style.css.ts @@ -130,6 +130,9 @@ export const previewContainer = style({ top: '0', backgroundColor: cssVarV2('block/comment/highlightUnderline'), }, + '&[data-deleted="true"]': { + textDecoration: 'line-through', + }, }, }); diff --git a/packages/frontend/core/src/modules/comment/entities/doc-comment-store.ts b/packages/frontend/core/src/modules/comment/entities/doc-comment-store.ts index 27fcf6262..54972d1c5 100644 --- a/packages/frontend/core/src/modules/comment/entities/doc-comment-store.ts +++ b/packages/frontend/core/src/modules/comment/entities/doc-comment-store.ts @@ -179,13 +179,14 @@ export class DocCommentStore extends Entity<{ async createComment(commentInput: { content: DocCommentContent; + mentions?: string[]; }): Promise { const graphql = this.graphqlService; if (!graphql) { throw new Error('GraphQL service not found'); } - const mentions = findMentions(commentInput.content.snapshot.blocks); + const mentions = commentInput.mentions; const response = await graphql.gql({ query: createCommentMutation, @@ -265,6 +266,7 @@ export class DocCommentStore extends Entity<{ commentId: string, replyInput: { content: DocCommentContent; + mentions?: string[]; } ): Promise { const graphql = this.graphqlService; @@ -272,8 +274,6 @@ export class DocCommentStore extends Entity<{ throw new Error('GraphQL service not found'); } - const mentions = findMentions(replyInput.content.snapshot.blocks); - const response = await graphql.gql({ query: createReplyMutation, variables: { @@ -282,7 +282,7 @@ export class DocCommentStore extends Entity<{ content: replyInput.content, docMode: this.props.getDocMode(), docTitle: this.props.getDocTitle(), - mentions: mentions, + mentions: replyInput.mentions, }, }, }); diff --git a/packages/frontend/core/src/modules/comment/entities/doc-comment.ts b/packages/frontend/core/src/modules/comment/entities/doc-comment.ts index 26fd3d084..cf3311fa2 100644 --- a/packages/frontend/core/src/modules/comment/entities/doc-comment.ts +++ b/packages/frontend/core/src/modules/comment/entities/doc-comment.ts @@ -1,9 +1,12 @@ import { type CommentChangeAction, DocMode } from '@affine/graphql'; +import { track } from '@affine/track'; +import { InlineCommentManager } from '@blocksuite/affine/inlines/comment'; import type { BaseSelection, DocSnapshot, Store, } from '@blocksuite/affine/store'; +import type { BlockStdScope } from '@blocksuite/std'; import { effect, Entity, @@ -38,6 +41,7 @@ import type { PendingComment, } from '../types'; import { DocCommentStore } from './doc-comment-store'; +import { findMentions } from './utils'; type DisposeCallback = () => void; @@ -50,6 +54,7 @@ type EditingDraft = { export class DocCommentEntity extends Entity<{ docId: string; + std: BlockStdScope | null; }> { constructor( private readonly snapshotHelper: SnapshotHelper, @@ -69,6 +74,8 @@ export class DocCommentEntity extends Entity<{ loading$ = new LiveData(false); comments$ = new LiveData([]); + commentsInEditor$ = new LiveData([]); + // Only one pending comment at a time (for new comments) readonly pendingComment$ = new LiveData(null); @@ -195,7 +202,9 @@ export class DocCommentEntity extends Entity<{ attachments: draft.attachments, }); } - + track.$.commentPanel.$.editComment({ + type: draft.type === 'comment' ? 'root' : 'node', + }); this.editingDraft$.setValue(null); this.revalidate(); } @@ -220,6 +229,7 @@ export class DocCommentEntity extends Entity<{ if (!snapshot) { throw new Error('Failed to get snapshot'); } + const mentions = findMentions(snapshot.blocks); const comment = await this.store.createComment({ content: { snapshot, @@ -227,6 +237,7 @@ export class DocCommentEntity extends Entity<{ mode: this.docMode$.value ?? 'page', attachments, }, + mentions, }); const currentComments = this.comments$.value; this.comments$.setValue([...currentComments, comment]); @@ -234,6 +245,19 @@ export class DocCommentEntity extends Entity<{ id: comment.id, selections: pendingComment.selections || [], }); + // for block's preview, it will be something like + // extract the block type from the preview + const blockType = preview?.match(/<([^>]+)>/)?.[1]; + track.$.commentPanel.$.createComment({ + type: 'root', + withAttachment: (attachments?.length ?? 0) > 0, + withMention: mentions.length > 0, + category: blockType + ? blockType + : (this.docMode$.value ?? 'page') === 'page' + ? 'Page' + : 'Note', + }); this.pendingComment$.setValue(null); this.revalidate(); } @@ -254,11 +278,13 @@ export class DocCommentEntity extends Entity<{ throw new Error('Pending reply has no commentId'); } + const mentions = findMentions(snapshot.blocks); const reply = await this.store.createReply(pendingReply.commentId, { content: { snapshot, attachments, }, + mentions, }); const currentComments = this.comments$.value; const updatedComments = currentComments.map(comment => @@ -267,6 +293,12 @@ export class DocCommentEntity extends Entity<{ : comment ); this.comments$.setValue(updatedComments); + track.$.commentPanel.$.createComment({ + type: 'node', + withAttachment: (attachments?.length ?? 0) > 0, + withMention: mentions.length > 0, + category: (this.docMode$.value ?? 'page') === 'page' ? 'Page' : 'Note', + }); this.pendingReply$.setValue(null); this.revalidate(); } @@ -275,6 +307,7 @@ export class DocCommentEntity extends Entity<{ await this.store.deleteComment(id); const currentComments = this.comments$.value; this.comments$.setValue(currentComments.filter(c => c.id !== id)); + track.$.commentPanel.$.deleteComment({ type: 'root' }); this.commentDeleted$.next(id); this.revalidate(); } @@ -289,6 +322,7 @@ export class DocCommentEntity extends Entity<{ }; }); this.comments$.setValue(updatedComments); + track.$.commentPanel.$.deleteComment({ type: 'node' }); this.revalidate(); } @@ -385,6 +419,9 @@ export class DocCommentEntity extends Entity<{ this.comments$.setValue(updatedComments); this.commentResolved$.next(id); + track.$.commentPanel.$.resolveComment({ + type: resolved ? 'on' : 'off', + }); this.revalidate(); } catch (error) { console.error('Failed to resolve comment:', error); @@ -459,6 +496,7 @@ export class DocCommentEntity extends Entity<{ // Initial load this.revalidate(); + this.revalidateCommentsInEditor(); // Set up polling every 10 seconds const polling$ = timer(10000, 10000).pipe( @@ -508,6 +546,7 @@ export class DocCommentEntity extends Entity<{ return allComments; }).pipe( + tap(() => this.revalidateCommentsInEditor()), catchError(error => { console.error('Failed to fetch comments:', error); return of(null); @@ -642,7 +681,7 @@ export class DocCommentEntity extends Entity<{ const allComments: DocComment[] = []; let cursor = ''; let firstResult: DocCommentListResult | null = null; - + this.revalidateCommentsInEditor(); // Fetch all pages of comments while (true) { const result = await this.store.listComments({ after: cursor }); @@ -661,6 +700,7 @@ export class DocCommentEntity extends Entity<{ return allComments; }).pipe( tap(allComments => { + this.revalidateCommentsInEditor(); // Update state with all comments this.comments$.setValue(allComments); }), @@ -675,6 +715,18 @@ export class DocCommentEntity extends Entity<{ }) ); + private readonly revalidateCommentsInEditor = () => { + this.commentsInEditor$.setValue(this.getCommentsInEditor()); + }; + + private getCommentsInEditor(): string[] { + const inlineCommentManager = this.props.std?.get(InlineCommentManager); + if (!inlineCommentManager) { + return []; + } + return inlineCommentManager.getCommentsInEditor(); + } + override dispose(): void { this.stop(); this.commentAdded$.complete(); diff --git a/packages/frontend/core/src/modules/comment/services/doc-comment-manager.ts b/packages/frontend/core/src/modules/comment/services/doc-comment-manager.ts index 9a70124d5..67bb9d321 100644 --- a/packages/frontend/core/src/modules/comment/services/doc-comment-manager.ts +++ b/packages/frontend/core/src/modules/comment/services/doc-comment-manager.ts @@ -1,3 +1,4 @@ +import type { BlockStdScope } from '@blocksuite/std'; import { ObjectPool, Service } from '@toeverything/infra'; import { DocCommentEntity } from '../entities/doc-comment'; @@ -9,6 +10,8 @@ export class DocCommentManagerService extends Service { super(); } + std: BlockStdScope | null = null; + private readonly pool = new ObjectPool({ onDelete: entity => { entity.dispose(); @@ -18,9 +21,21 @@ export class DocCommentManagerService extends Service { get(docId: DocId) { let commentRef = this.pool.get(docId); if (!commentRef) { - const comment = this.framework.createEntity(DocCommentEntity, { - docId, - }); + const props = new Proxy( + { + docId, + std: this.std, + }, + { + get: (target, prop) => { + if (prop === 'std') { + return this.std; + } + return target[prop as keyof typeof target]; + }, + } + ); + const comment = this.framework.createEntity(DocCommentEntity, props); commentRef = this.pool.put(docId, comment); // todo: add LRU cache for the pool? } diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 5c5312ea1..e18bcbc91 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -7754,6 +7754,10 @@ export function useAFFiNEI18N(): { * `Unsupported message` */ ["com.affine.notification.unsupported"](): string; + /** + * `What are your thoughts?` + */ + ["com.affine.notification.comment-prompt"](): string; /** * `No new notifications` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 60db1638e..31a56aab8 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1936,6 +1936,7 @@ "com.affine.notification.mention": "<1>{{username}} mentioned you in <2>{{docTitle}}", "com.affine.notification.comment": "<1>{{username}} commented in <2>{{docTitle}}", "com.affine.notification.comment-mention": "<1>{{username}} mentioned you in a comment in <2>{{docTitle}}", + "com.affine.notification.comment-prompt": "What are your thoughts?", "com.affine.notification.empty": "No new notifications", "com.affine.notification.loading-more": "Loading more...", "com.affine.notification.empty.description": "You'll be notified here for @mentions and workspace invites.", diff --git a/packages/frontend/track/src/events.ts b/packages/frontend/track/src/events.ts index eccc7ccce..6dc259cfb 100644 --- a/packages/frontend/track/src/events.ts +++ b/packages/frontend/track/src/events.ts @@ -198,6 +198,15 @@ type WorkspaceEmbeddingEvents = | 'addIgnoredDocs'; // END SECTION +// SECTION: comment events +// Add events for comment actions +type CommentEvents = + | 'createComment' + | 'editComment' + | 'deleteComment' + | 'resolveComment'; +// END SECTION + type UserEvents = | GeneralEvents | AppEvents @@ -215,6 +224,7 @@ type UserEvents = | PaymentEvents | DNDEvents | AIEvents + | CommentEvents | AttachmentEvents | TemplateEvents | NotificationEvents @@ -421,6 +431,9 @@ interface PageEvents extends PageDivision { chatPanel: { chatPanelInput: ['addEmbeddingDoc']; }; + commentPanel: { + $: ['createComment', 'editComment', 'deleteComment', 'resolveComment']; + }; attachment: { $: [ 'openAttachmentInFullscreen', @@ -807,6 +820,15 @@ export type EventArgs = { navigatePinedCollectionRouter: { control: 'all' | 'user-custom-collection'; }; + resolveComment: { type: 'on' | 'off' }; + createComment: { + type: 'root' | 'node'; + withAttachment: boolean; + withMention: boolean; + category: string; + }; + editComment: { type: 'root' | 'node' }; + deleteComment: { type: 'root' | 'node' }; }; // for type checking