From a21f1c943eff129375df5f6e16306bec97134fa5 Mon Sep 17 00:00:00 2001 From: Peng Xiao Date: Wed, 2 Jul 2025 23:47:00 +0800 Subject: [PATCH] feat(core): comment panel (#12989) ## 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. --------- Co-authored-by: L-Sun --- blocksuite/affine/all/package.json | 1 + .../affine/all/src/inlines/comment/index.ts | 1 + .../affine/inlines/comment/src/index.ts | 1 + .../comment/src/inline-comment-manager.ts | 18 +- blocksuite/affine/model/src/consts/doc.ts | 1 + .../src/services/feature-flag-service.ts | 2 + .../block-suite-editor/lit-adaper.tsx | 2 +- .../core/src/blocksuite/manager/view.ts | 16 +- .../comment/comment-provider.ts | 186 +++++- .../view-extensions/comment/index.ts | 16 +- .../comment/comment-editor/index.tsx | 210 ++++++ .../comment/comment-editor/style.css.ts | 60 ++ .../src/components/comment/sidebar/index.tsx | 616 ++++++++++++++++++ .../components/comment/sidebar/style.css.ts | 187 ++++++ .../workspace/detail-page/detail-page.tsx | 13 + .../comment/entities/doc-comment-store.ts | 304 +++++++++ .../modules/comment/entities/doc-comment.ts | 499 ++++++++++++++ .../core/src/modules/comment/index.ts | 29 + .../comment/services/comment-panel-service.ts | 54 ++ .../comment/services/doc-comment-manager.ts | 29 + .../comment/services/snapshot-helper.ts | 158 +++++ .../core/src/modules/comment/types.ts | 54 ++ .../src/modules/editor/entities/editor.ts | 88 ++- .../frontend/core/src/modules/editor/types.ts | 1 + .../core/src/modules/feature-flag/constant.ts | 3 +- packages/frontend/core/src/modules/index.ts | 2 + .../core/src/modules/navigation/utils.ts | 2 + .../i18n/src/i18n-completenesses.json | 32 +- packages/frontend/i18n/src/i18n.gen.ts | 44 ++ packages/frontend/i18n/src/resources/en.json | 11 + 30 files changed, 2572 insertions(+), 68 deletions(-) create mode 100644 blocksuite/affine/all/src/inlines/comment/index.ts create mode 100644 packages/frontend/core/src/components/comment/comment-editor/index.tsx create mode 100644 packages/frontend/core/src/components/comment/comment-editor/style.css.ts create mode 100644 packages/frontend/core/src/components/comment/sidebar/index.tsx create mode 100644 packages/frontend/core/src/components/comment/sidebar/style.css.ts create mode 100644 packages/frontend/core/src/modules/comment/entities/doc-comment-store.ts create mode 100644 packages/frontend/core/src/modules/comment/entities/doc-comment.ts create mode 100644 packages/frontend/core/src/modules/comment/index.ts create mode 100644 packages/frontend/core/src/modules/comment/services/comment-panel-service.ts create mode 100644 packages/frontend/core/src/modules/comment/services/doc-comment-manager.ts create mode 100644 packages/frontend/core/src/modules/comment/services/snapshot-helper.ts create mode 100644 packages/frontend/core/src/modules/comment/types.ts diff --git a/blocksuite/affine/all/package.json b/blocksuite/affine/all/package.json index 3a3f30b77..a9d14f869 100644 --- a/blocksuite/affine/all/package.json +++ b/blocksuite/affine/all/package.json @@ -174,6 +174,7 @@ "./inlines/footnote": "./src/inlines/footnote/index.ts", "./inlines/footnote/view": "./src/inlines/footnote/view.ts", "./inlines/footnote/store": "./src/inlines/footnote/store.ts", + "./inlines/comment": "./src/inlines/comment/index.ts", "./inlines/latex": "./src/inlines/latex/index.ts", "./inlines/latex/store": "./src/inlines/latex/store.ts", "./inlines/latex/view": "./src/inlines/latex/view.ts", diff --git a/blocksuite/affine/all/src/inlines/comment/index.ts b/blocksuite/affine/all/src/inlines/comment/index.ts new file mode 100644 index 000000000..8e7c6d9f0 --- /dev/null +++ b/blocksuite/affine/all/src/inlines/comment/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-inline-comment'; diff --git a/blocksuite/affine/inlines/comment/src/index.ts b/blocksuite/affine/inlines/comment/src/index.ts index 9b17d501e..e451c726a 100644 --- a/blocksuite/affine/inlines/comment/src/index.ts +++ b/blocksuite/affine/inlines/comment/src/index.ts @@ -1 +1,2 @@ export * from './inline-spec'; +export * from './utils'; diff --git a/blocksuite/affine/inlines/comment/src/inline-comment-manager.ts b/blocksuite/affine/inlines/comment/src/inline-comment-manager.ts index 76711ee41..a7367d72f 100644 --- a/blocksuite/affine/inlines/comment/src/inline-comment-manager.ts +++ b/blocksuite/affine/inlines/comment/src/inline-comment-manager.ts @@ -12,6 +12,7 @@ import { TextSelection, } from '@blocksuite/std'; import type { BaseSelection, BlockModel } from '@blocksuite/store'; +import { signal } from '@preact/signals-core'; import { extractCommentIdFromDelta, findCommentedTexts } from './utils'; @@ -20,6 +21,8 @@ export class InlineCommentManager extends LifeCycleWatcher { private readonly _disposables = new DisposableGroup(); + private readonly _highlightedCommentId$ = signal(null); + private get _provider() { return this.std.getOptional(CommentProviderIdentifier); } @@ -35,6 +38,9 @@ export class InlineCommentManager extends LifeCycleWatcher { this._disposables.add( provider.onCommentResolved(this._handleDeleteAndResolve) ); + this._disposables.add( + provider.onCommentHighlighted(this._handleHighlightComment) + ); this._disposables.add( this.std.selection.slots.changed.subscribe(this._handleSelectionChanged) ); @@ -131,14 +137,20 @@ export class InlineCommentManager extends LifeCycleWatcher { }); }; + private readonly _handleHighlightComment = (id: CommentId | null) => { + this._highlightedCommentId$.value = id; + }; + private readonly _handleSelectionChanged = (selections: BaseSelection[]) => { + const currentHighlightedCommentId = this._highlightedCommentId$.peek(); + if (selections.length === 1) { const selection = selections[0]; // InlineCommentManager only handle text selection if (!selection.is(TextSelection)) return; - if (!selection.isCollapsed()) { + if (!selection.isCollapsed() && currentHighlightedCommentId !== null) { this._provider?.highlightComment(null); return; } @@ -156,6 +168,8 @@ export class InlineCommentManager extends LifeCycleWatcher { if (commentIds.length !== 0) return; } - this._provider?.highlightComment(null); + if (currentHighlightedCommentId !== null) { + this._provider?.highlightComment(null); + } }; } diff --git a/blocksuite/affine/model/src/consts/doc.ts b/blocksuite/affine/model/src/consts/doc.ts index 6f60a14ea..34da474c1 100644 --- a/blocksuite/affine/model/src/consts/doc.ts +++ b/blocksuite/affine/model/src/consts/doc.ts @@ -43,6 +43,7 @@ export const ReferenceParamsSchema = z databaseId: z.string().optional(), databaseRowId: z.string().optional(), xywh: SerializedXYWHSchema.optional(), + commentId: z.string().optional(), }) .partial(); diff --git a/blocksuite/affine/shared/src/services/feature-flag-service.ts b/blocksuite/affine/shared/src/services/feature-flag-service.ts index bdbf5c55b..5bbd6837f 100644 --- a/blocksuite/affine/shared/src/services/feature-flag-service.ts +++ b/blocksuite/affine/shared/src/services/feature-flag-service.ts @@ -21,6 +21,7 @@ export interface BlockSuiteFlags { enable_table_virtual_scroll: boolean; enable_turbo_renderer: boolean; enable_dom_renderer: boolean; + enable_comment: boolean; } export class FeatureFlagService extends StoreExtension { @@ -46,6 +47,7 @@ export class FeatureFlagService extends StoreExtension { enable_table_virtual_scroll: false, enable_turbo_renderer: false, enable_dom_renderer: false, + enable_comment: false, }); setFlag(key: keyof BlockSuiteFlags, value: boolean) { 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 6712ffdc1..42a205030 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 @@ -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') { diff --git a/packages/frontend/core/src/blocksuite/manager/view.ts b/packages/frontend/core/src/blocksuite/manager/view.ts index 0fabf0b49..9c6bfbabd 100644 --- a/packages/frontend/core/src/blocksuite/manager/view.ts +++ b/packages/frontend/core/src/blocksuite/manager/view.ts @@ -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; }; } 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 130a050d0..a1d0152f1 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 @@ -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(''); + } + // 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) + ); + }, + }; +} diff --git a/packages/frontend/core/src/blocksuite/view-extensions/comment/index.ts b/packages/frontend/core/src/blocksuite/view-extensions/comment/index.ts index 21a0d661e..ba4f0fe5d 100644 --- a/packages/frontend/core/src/blocksuite/view-extensions/comment/index.ts +++ b/packages/frontend/core/src/blocksuite/view-extensions/comment/index.ts @@ -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; + +export class CommentViewExtension extends ViewExtensionProvider { override name = 'comment'; override schema = optionsSchema; - override setup( - context: ViewExtensionContext, - options?: z.infer - ) { + 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)]); } } diff --git a/packages/frontend/core/src/components/comment/comment-editor/index.tsx b/packages/frontend/core/src/components/comment/comment-editor/index.tsx new file mode 100644 index 000000000..30f85c10a --- /dev/null +++ b/packages/frontend/core/src/components/comment/comment-editor/index.tsx @@ -0,0 +1,210 @@ +import { useConfirmModal, useLitPortalFactory } from '@affine/component'; +import { LitDocEditor, type PageEditor } from '@affine/core/blocksuite/editors'; +import { getViewManager } from '@affine/core/blocksuite/manager/view'; +import { SnapshotHelper } from '@affine/core/modules/comment/services/snapshot-helper'; +import type { RichText } from '@blocksuite/affine/rich-text'; +import { ViewportElementExtension } from '@blocksuite/affine/shared/services'; +import { type DocSnapshot, Store } from '@blocksuite/affine/store'; +import { ArrowUpBigIcon } from '@blocksuite/icons/rc'; +import { useFramework, useService } from '@toeverything/infra'; +import clsx from 'clsx'; +import { + forwardRef, + Fragment, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; + +import * as styles from './style.css'; + +const usePatchSpecs = (readonly: boolean) => { + const [reactToLit, portals] = useLitPortalFactory(); + const framework = useFramework(); + const confirmModal = useConfirmModal(); + + const patchedSpecs = useMemo(() => { + const manager = getViewManager() + .config.init() + .foundation(framework) + .theme(framework) + .editorConfig(framework) + .editorView({ + framework, + reactToLit, + confirmModal, + }) + .linkedDoc(framework) + .paragraph(false) + .codeBlockHtmlPreview(framework).value; + return manager + .get(readonly ? 'preview-page' : 'page') + .concat([ViewportElementExtension('.comment-editor-viewport')]); + }, [confirmModal, framework, reactToLit, readonly]); + + return [ + patchedSpecs, + useMemo( + () => ( + <> + {portals.map(p => ( + {p.portal} + ))} + + ), + [portals] + ), + ] as const; +}; + +interface CommentEditorProps { + readonly?: boolean; + doc?: Store; + defaultSnapshot?: DocSnapshot; + // for performance, we only update the snapshot when the editor blurs + onChange?: (snapshot: DocSnapshot) => void; + onCommit?: () => void; + onCancel?: () => void; + autoFocus?: boolean; +} + +export interface CommentEditorRef { + getSnapshot: () => DocSnapshot | null | undefined; +} + +// todo: get rid of circular data changes +const useSnapshotDoc = ( + defaultSnapshotOrDoc: DocSnapshot | Store, + readonly?: boolean +) => { + const snapshotHelper = useService(SnapshotHelper); + const [doc, setDoc] = useState( + defaultSnapshotOrDoc instanceof Store ? defaultSnapshotOrDoc : undefined + ); + + useEffect(() => { + if (defaultSnapshotOrDoc instanceof Store) { + return; + } + + snapshotHelper + .createStore(defaultSnapshotOrDoc) + .then(d => { + if (d) { + setDoc(d); + d.readonly = readonly ?? false; + } + }) + .catch(e => { + console.error(e); + }); + }, [defaultSnapshotOrDoc, readonly, snapshotHelper]); + + return doc; +}; + +export const CommentEditor = forwardRef( + function CommentEditor( + { readonly, defaultSnapshot, doc: userDoc, onChange, onCommit, autoFocus }, + ref + ) { + const defaultSnapshotOrDoc = defaultSnapshot ?? userDoc; + if (!defaultSnapshotOrDoc) { + throw new Error('Either defaultSnapshot or doc must be provided'); + } + const [specs, portals] = usePatchSpecs(!!readonly); + const doc = useSnapshotDoc(defaultSnapshotOrDoc, readonly); + const snapshotHelper = useService(SnapshotHelper); + const editorRef = useRef(null); + + useImperativeHandle( + ref, + () => ({ + getSnapshot: () => { + if (!doc) { + return null; + } + return snapshotHelper.getSnapshot(doc); + }, + }), + [doc, snapshotHelper] + ); + + useEffect(() => { + let cancel = false; + if (autoFocus && editorRef.current && doc) { + // fixme: the following does not work + // Wait for editor to be fully loaded before focusing + editorRef.current.updateComplete + .then(async () => { + if (cancel) return; + const richText = editorRef.current?.querySelector( + 'rich-text' + ) as unknown as RichText; + if (!richText) return; + + // Finally focus the inline editor + const inlineEditor = richText.inlineEditor; + richText.focus(); + + richText.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + + inlineEditor?.focusEnd(); + }) + .catch(console.error); + } + return () => { + cancel = true; + }; + }, [autoFocus, doc]); + + useEffect(() => { + if (doc && onChange) { + const subscription = doc.slots.blockUpdated.subscribe(() => { + const snapshot = snapshotHelper.getSnapshot(doc); + if (snapshot) { + onChange?.(snapshot); + } + }); + return () => { + subscription?.unsubscribe(); + }; + } + return; + }, [doc, onChange, snapshotHelper]); + + const handleClickEditor = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (editorRef.current) { + editorRef.current.focus(); + } + }, + [editorRef] + ); + + return ( +
+ {doc && } + {portals} + {!readonly && ( +
+ +
+ )} +
+ ); + } +); diff --git a/packages/frontend/core/src/components/comment/comment-editor/style.css.ts b/packages/frontend/core/src/components/comment/comment-editor/style.css.ts new file mode 100644 index 000000000..f25571297 --- /dev/null +++ b/packages/frontend/core/src/components/comment/comment-editor/style.css.ts @@ -0,0 +1,60 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { globalStyle, style } from '@vanilla-extract/css'; + +export const container = style({ + width: '100%', + height: '100%', + border: `1px solid transparent`, + selectors: { + '&[data-readonly="false"]': { + borderColor: cssVarV2('layer/insideBorder/border'), + borderRadius: 16, + padding: '0 8px', + }, + '&[data-readonly="false"]:focus-within': { + borderColor: cssVarV2('layer/insideBorder/primaryBorder'), + boxShadow: cssVar('activeShadow'), + }, + }, + vars: { + '--affine-paragraph-margin': '0', + '--affine-font-base': '14px', + }, +}); + +export const footer = style({ + display: 'flex', + justifyContent: 'flex-end', + gap: 8, + paddingBottom: 8, +}); + +globalStyle(`${container} .affine-page-root-block-container`, { + padding: '8px 0', + minHeight: '32px', +}); + +globalStyle(`${container} .affine-paragraph-rich-text-wrapper`, { + margin: 0, +}); + +export const commitButton = style({ + background: cssVarV2('button/primary'), + border: 'none', + cursor: 'pointer', + color: cssVarV2('layer/pureWhite'), + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '28px', + height: '28px', + fontSize: 20, + selectors: { + '&[data-disabled="true"]': { + background: cssVarV2('button/disable'), + cursor: 'default', + }, + }, +}); diff --git a/packages/frontend/core/src/components/comment/sidebar/index.tsx b/packages/frontend/core/src/components/comment/sidebar/index.tsx new file mode 100644 index 000000000..0074ad869 --- /dev/null +++ b/packages/frontend/core/src/components/comment/sidebar/index.tsx @@ -0,0 +1,616 @@ +import { + Avatar, + IconButton, + Loading, + Menu, + MenuItem, + notify, + useConfirmModal, +} from '@affine/component'; +import { ServerService } from '@affine/core/modules/cloud'; +import { AuthService } from '@affine/core/modules/cloud/services/auth'; +import { type DocCommentEntity } from '@affine/core/modules/comment/entities/doc-comment'; +import { CommentPanelService } from '@affine/core/modules/comment/services/comment-panel-service'; +import { DocCommentManagerService } from '@affine/core/modules/comment/services/doc-comment-manager'; +import type { DocComment } from '@affine/core/modules/comment/types'; +import { DocService } from '@affine/core/modules/doc'; +import { toDocSearchParams } from '@affine/core/modules/navigation'; +import { WorkbenchService } from '@affine/core/modules/workbench'; +import { copyTextToClipboard } from '@affine/core/utils/clipboard'; +import { i18nTime, useI18n } from '@affine/i18n'; +import type { DocSnapshot } from '@blocksuite/affine/store'; +import { DoneIcon, FilterIcon, MoreHorizontalIcon } from '@blocksuite/icons/rc'; +import { + useLiveData, + useService, + useServiceOptional, +} from '@toeverything/infra'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useAsyncCallback } from '../../hooks/affine-async-hooks'; +import { CommentEditor } from '../comment-editor'; +import * as styles from './style.css'; + +interface CommentFilterState { + showResolvedComments: boolean; + onlyMyReplies: boolean; + onlyCurrentMode: boolean; +} + +const SortFilterButton = ({ + filterState, + onFilterChange, +}: { + filterState: CommentFilterState; + onFilterChange: (key: keyof CommentFilterState, value: boolean) => void; +}) => { + const t = useI18n(); + + return ( + + + onFilterChange( + 'showResolvedComments', + !filterState.showResolvedComments + ) + } + > + {t['com.affine.comment.filter.show-resolved']()} + + + onFilterChange('onlyMyReplies', !filterState.onlyMyReplies) + } + > + {t['com.affine.comment.filter.only-my-replies']()} + + + onFilterChange('onlyCurrentMode', !filterState.onlyCurrentMode) + } + > + {t['com.affine.comment.filter.only-current-mode']()} + + + } + > + } /> + + ); +}; + +const ReadonlyCommentRenderer = ({ + avatarUrl, + name, + time, + snapshot, +}: { + avatarUrl: string | null; + name: string; + time: number; + snapshot: DocSnapshot; +}) => { + return ( +
+
+ +
{name}
+
+ {i18nTime(time, { + absolute: { accuracy: 'minute' }, + })} +
+
+
+ +
+
+ ); +}; + +const CommentItem = ({ + comment, + entity, +}: { + comment: DocComment; + entity: DocCommentEntity; +}) => { + const workbench = useService(WorkbenchService); + const serverService = useService(ServerService); + const highlighting = useLiveData(entity.commentHighlighted$) === comment.id; + const t = useI18n(); + const { openConfirmModal } = useConfirmModal(); + + const session = useService(AuthService).session; + const account = useLiveData(session.account$); + + const pendingReply = useLiveData(entity.pendingReply$); + // Check if the pending reply belongs to this comment + const isReplyingToThisComment = pendingReply?.commentId === comment.id; + + const commentRef = useRef(null); + + // Loading state for any async operation + const [isMutating, setIsMutating] = useState(false); + + const handleDelete = useAsyncCallback( + async (e: React.MouseEvent) => { + e.stopPropagation(); + openConfirmModal({ + title: t['com.affine.comment.delete.confirm.title'](), + description: t['com.affine.comment.delete.confirm.description'](), + confirmText: t['Delete'](), + cancelText: t['Cancel'](), + confirmButtonOptions: { + variant: 'error', + }, + onConfirm: async () => { + setIsMutating(true); + try { + await entity.deleteComment(comment.id); + } finally { + setIsMutating(false); + } + }, + }); + }, + [entity, comment.id, openConfirmModal, t] + ); + + const handleResolve = useAsyncCallback( + async (e: React.MouseEvent) => { + e.stopPropagation(); + setIsMutating(true); + try { + await entity.resolveComment(comment.id, !comment.resolved); + } finally { + setIsMutating(false); + } + }, + [entity, comment.id, comment.resolved] + ); + + const handleReply = useAsyncCallback( + async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!comment.resolved) { + await entity.addReply(comment.id); + entity.highlightComment(comment.id); + } + }, + [entity, comment.id, comment.resolved] + ); + + const handleCopyLink = useAsyncCallback( + async (e: React.MouseEvent) => { + e.stopPropagation(); + // Create a URL with the comment ID + + const search = toDocSearchParams({ + mode: comment.content.mode, + commentId: comment.id, + }); + + const url = new URL( + workbench.workbench.basename$.value + '/' + entity.props.docId, + serverService.server.baseUrl + ); + + if (search?.size) url.search = search.toString(); + await copyTextToClipboard(url.toString()); + notify.success({ title: t['Copied link to clipboard']() }); + }, + [ + comment.content.mode, + comment.id, + entity.props.docId, + serverService.server.baseUrl, + t, + workbench.workbench.basename$.value, + ] + ); + + const handleCommitReply = useAsyncCallback(async () => { + if (!pendingReply?.id) return; + + setIsMutating(true); + try { + await entity.commitReply(pendingReply.id); + } finally { + setIsMutating(false); + } + }, [entity, pendingReply]); + + const handleCancelReply = useCallback(() => { + if (!pendingReply?.id) return; + + entity.dismissDraftReply(); + }, [entity, pendingReply]); + + const handleClickPreview = useCallback(() => { + workbench.workbench.openDoc( + { + docId: entity.props.docId, + mode: comment.content.mode, + commentId: comment.id, + refreshKey: 'comment-' + Date.now(), + }, + { + show: true, + } + ); + entity.highlightComment(comment.id); + }, [comment.id, comment.content.mode, entity, workbench.workbench]); + + useEffect(() => { + const subscription = entity.commentHighlighted$ + .distinctUntilChanged() + .subscribe(id => { + if (id === comment.id && commentRef.current) { + commentRef.current.scrollIntoView({ behavior: 'smooth' }); + + // Auto-start reply when comment becomes highlighted, but only if not resolved + if (!isReplyingToThisComment && !comment.resolved) { + entity.addReply(comment.id).catch(() => { + // Handle error if adding reply fails + console.error('Failed to add reply for comment:', comment.id); + }); + } + } else if ( + id !== comment.id && + isReplyingToThisComment && + pendingReply + ) { + // Cancel reply when comment is no longer highlighted + entity.dismissDraftReply(); + } + }); + + return () => { + subscription.unsubscribe(); + }; + }, [ + comment.id, + comment.resolved, + entity.commentHighlighted$, + isReplyingToThisComment, + pendingReply, + entity, + ]); + + // Clean up pending reply if comment becomes resolved + useEffect(() => { + if (comment.resolved && isReplyingToThisComment && pendingReply) { + entity.dismissDraftReply(); + } + }, [comment.resolved, isReplyingToThisComment, pendingReply, entity]); + + const [menuOpen, setMenuOpen] = useState(false); + + return ( +
+
+ } + disabled={isMutating} + /> + { + setMenuOpen(v); + }, + }} + items={ + <> + + {t['com.affine.comment.reply']()} + + + {t['com.affine.comment.copy-link']()} + + + {t['Delete']()} + + + } + > + } + disabled={isMutating} + /> + +
+
{comment.content.preview}
+ +
+ + + {/* unlike comment, replies are sorted by createdAt in ascending order */} + {comment.replies + ?.toSorted((a, b) => a.createdAt - b.createdAt) + .map(reply => ( + + ))} +
+ + {highlighting && + isReplyingToThisComment && + pendingReply && + account && + !comment.resolved && ( +
+
+ +
+ +
+ )} +
+ ); +}; + +const CommentList = ({ entity }: { entity: DocCommentEntity }) => { + const comments = useLiveData(entity.comments$); + const session = useService(AuthService).session; + const account = useLiveData(session.account$); + const t = useI18n(); + + const docMode = useLiveData(entity.docMode$); + + // Filter state management + const [filterState, setFilterState] = useState({ + showResolvedComments: false, + onlyMyReplies: false, + onlyCurrentMode: true, + }); + + const onFilterChange = useCallback( + (key: keyof CommentFilterState, value: boolean) => { + setFilterState(prev => ({ ...prev, [key]: value })); + }, + [] + ); + + // Filter and sort comments based on filter state + const filteredAndSortedComments = useMemo(() => { + let filteredComments = comments; + + // Filter by resolved status + if (!filterState.showResolvedComments) { + filteredComments = filteredComments.filter(comment => !comment.resolved); + } + + // Filter by only my replies and mentions + if (filterState.onlyMyReplies) { + filteredComments = filteredComments.filter(comment => { + return ( + comment.user.id === account?.id || + comment.replies?.some(reply => reply.user.id === account?.id) + ); + }); + } + + // Filter by only current mode + if (filterState.onlyCurrentMode) { + filteredComments = filteredComments.filter(comment => { + return ( + !comment.content.mode || !docMode || comment.content.mode === docMode + ); + }); + } + return filteredComments.toSorted((a, b) => b.createdAt - a.createdAt); + }, [ + comments, + filterState.showResolvedComments, + filterState.onlyMyReplies, + filterState.onlyCurrentMode, + account?.id, + docMode, + ]); + + const newPendingComment = useLiveData(entity.pendingComment$); + const loading = useLiveData(entity.loading$); + + return ( + <> +
+
+ {t['com.affine.comment.comments']()} +
+ {comments.length > 0 && ( + + )} +
+ + {filteredAndSortedComments.length === 0 && + !newPendingComment && + !loading && ( +
+ {t['com.affine.comment.no-comments']()} +
+ )} + {loading && + filteredAndSortedComments.length === 0 && + !newPendingComment && ( +
+ +
+ )} +
+ {filteredAndSortedComments.map(comment => ( + + ))} +
+ + ); +}; + +// handling pending comment +const CommentInput = ({ entity }: { entity: DocCommentEntity }) => { + const newPendingComment = useLiveData(entity.pendingComment$); + const pendingPreview = newPendingComment?.preview; + const [isMutating, setIsMutating] = useState(false); + + const handleCommit = useAsyncCallback(async () => { + if (!newPendingComment?.id) return; + setIsMutating(true); + try { + await entity.commitComment(newPendingComment.id); + } finally { + setIsMutating(false); + } + }, [entity, newPendingComment]); + + const handleCancel = useCallback(() => { + if (!newPendingComment?.id) return; + + entity.dismissDraftComment(); + }, [entity, newPendingComment]); + + const session = useService(AuthService).session; + const account = useLiveData(session.account$); + + if (!newPendingComment || !account) { + return null; + } + + return ( +
+ {pendingPreview && ( +
{pendingPreview}
+ )} +
+
+ +
+ +
+
+ ); +}; + +const useCommentEntity = (docId: string | undefined) => { + const docCommentManager = useService(DocCommentManagerService); + const commentPanelService = useService(CommentPanelService); + const [entity, setEntity] = useState(null); + + useEffect(() => { + if (!docId) { + return; + } + + const entityRef = docCommentManager.get(docId); + setEntity(entityRef.obj); + entityRef.obj.start(); + entityRef.obj.revalidate(); + + // Set up pending comment watching to auto-open sidebar + const unwatchPending = commentPanelService.watchForPendingComments( + entityRef.obj + ); + + return () => { + unwatchPending(); + entityRef.release(); + }; + }, [docCommentManager, commentPanelService, docId]); + + return entity; +}; + +export const CommentSidebar = () => { + const doc = useServiceOptional(DocService)?.doc; + const entity = useCommentEntity(doc?.id); + + const containerRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + // dismiss the highlight when ESC is pressed + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + entity?.highlightComment(null); + } + }; + const handleContainerClick = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (!target.closest('[data-comment-id]')) { + entity?.highlightComment(null); + } + // if creating a new comment, dismiss the comment input + if ( + entity?.pendingComment$.value && + !target.closest('[data-pending-comment]') + ) { + entity.dismissDraftComment(); + } + }; + document.addEventListener('keydown', handleKeyDown); + container?.addEventListener('click', handleContainerClick); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + container?.removeEventListener('click', handleContainerClick); + entity?.highlightComment(null); + }; + }, [entity]); + + if (!entity) { + return null; + } + + return ( +
+ +
+ ); +}; diff --git a/packages/frontend/core/src/components/comment/sidebar/style.css.ts b/packages/frontend/core/src/components/comment/sidebar/style.css.ts new file mode 100644 index 000000000..db7c5c506 --- /dev/null +++ b/packages/frontend/core/src/components/comment/sidebar/style.css.ts @@ -0,0 +1,187 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const container = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + paddingTop: '8px', + paddingBottom: '64px', + position: 'relative', + minHeight: '100%', +}); + +export const header = style({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '0 16px', + gap: '8px', + height: '32px', +}); + +export const headerTitle = style({ + fontSize: cssVar('fontSm'), + fontWeight: '500', + color: cssVarV2('text/secondary'), +}); + +export const commentList = style({ + display: 'flex', + flexDirection: 'column', + gap: '8px', +}); + +export const empty = style({ + height: '100%', + flex: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: cssVarV2('text/secondary'), +}); + +export const commentItem = style({ + padding: '12px', + position: 'relative', + display: 'flex', + flexDirection: 'column', + gap: '8px', + ':hover': { + backgroundColor: cssVarV2('layer/background/hoverOverlay'), + }, + selectors: { + '&[data-highlighting="true"]:before': { + content: '', + display: 'block', + width: '2px', + height: '100%', + backgroundColor: cssVarV2('layer/insideBorder/primaryBorder'), + position: 'absolute', + top: '0', + left: '0', + }, + '&[data-highlighting="true"]': { + backgroundColor: cssVarV2('block/comment/hanelActive'), + }, + '&[data-resolved="true"]': { + opacity: 0.5, + }, + }, +}); + +export const loading = style({ + height: '100%', + flex: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}); + +export const repliesContainer = style({ + display: 'flex', + flexDirection: 'column', + gap: '8px', +}); + +export const pendingComment = style({ + padding: '12px', + position: 'relative', + display: 'flex', + flexDirection: 'column', + gap: '8px', + background: cssVarV2('block/comment/hanelActive'), + selectors: { + '&:before': { + content: '', + display: 'block', + width: '2px', + height: '100%', + backgroundColor: cssVarV2('layer/insideBorder/primaryBorder'), + position: 'absolute', + left: '0', + top: '0', + bottom: '0', + }, + }, +}); + +export const previewContainer = style({ + fontSize: cssVar('fontSm'), + color: cssVarV2('text/secondary'), + paddingLeft: '10px', + position: 'relative', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + lineHeight: '22px', + selectors: { + '&:before': { + content: '', + display: 'block', + width: '2px', + height: '100%', + position: 'absolute', + left: '0', + top: '0', + backgroundColor: cssVarV2('layer/insideBorder/primaryBorder'), + }, + }, +}); + +export const commentActions = style({ + display: 'flex', + opacity: 0, + gap: '8px', + marginTop: '8px', + position: 'absolute', + right: 12, + top: 4, + zIndex: 1, + pointerEvents: 'none', + selectors: { + [`${commentItem}:hover &`]: { + opacity: 1, + pointerEvents: 'auto', + }, + '&[data-menu-open="true"]': { + opacity: 1, + }, + }, +}); + +export const readonlyCommentContainer = style({ + display: 'flex', + flexDirection: 'column', + gap: '4px', + paddingLeft: '8px', +}); + +export const userContainer = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-start', + marginTop: '5px', + gap: 10, +}); + +export const commentInputContainer = style({ + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'flex-start', + gap: '4px', + paddingLeft: '8px', +}); + +export const userName = style({ + fontSize: cssVar('fontSm'), + color: cssVarV2('text/primary'), + fontWeight: '500', +}); + +export const time = style({ + fontSize: cssVar('fontSm'), + color: cssVarV2('text/secondary'), + fontWeight: '500', +}); diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx index f71c3bb2b..d2fbdd068 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx @@ -7,6 +7,7 @@ import { EditorOutlineViewer } from '@affine/core/blocksuite/outline-viewer'; import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary'; // import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding'; import { GlobalPageHistoryModal } from '@affine/core/components/affine/page-history-modal'; +import { CommentSidebar } from '@affine/core/components/comment/sidebar'; import { useGuard } from '@affine/core/components/guard'; import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper'; import { useEnableAI } from '@affine/core/components/hooks/affine/use-enable-ai'; @@ -37,6 +38,7 @@ import { DisposableGroup } from '@blocksuite/affine/global/disposable'; import { RefNodeSlotsProvider } from '@blocksuite/affine/inlines/reference'; import { AiIcon, + CommentIcon, ExportIcon, FrameIcon, PropertyIcon, @@ -381,6 +383,17 @@ const DetailPageImpl = memo(function DetailPageImpl() { )} + {workspace.flavour !== 'local' && ( + }> + + + + + + + + )} + {/* FIXME: wait for better ai, */} 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 new file mode 100644 index 000000000..7e468367e --- /dev/null +++ b/packages/frontend/core/src/modules/comment/entities/doc-comment-store.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const graphql = this.graphqlService; + if (!graphql) { + throw new Error('GraphQL service not found'); + } + + await graphql.gql({ + query: deleteReplyMutation, + variables: { + id: replyId, + }, + }); + } +} diff --git a/packages/frontend/core/src/modules/comment/entities/doc-comment.ts b/packages/frontend/core/src/modules/comment/entities/doc-comment.ts new file mode 100644 index 000000000..1b85006dc --- /dev/null +++ b/packages/frontend/core/src/modules/comment/entities/doc-comment.ts @@ -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(false); + comments$ = new LiveData([]); + + // Only one pending comment at a time (for new comments) + readonly pendingComment$ = new LiveData(null); + + // Only one pending reply at a time + readonly pendingReply$ = new LiveData(null); + + private readonly commentAdded$ = new Subject<{ + id: CommentId; + selections: BaseSelection[]; + }>(); + private readonly commentResolved$ = new Subject(); + private readonly commentDeleted$ = new Subject(); + readonly commentHighlighted$ = new LiveData(null); + + private pollingDisposable?: DisposeCallback; + private startCursor?: string; + + async addComment( + selections?: BaseSelection[], + preview?: string + ): Promise { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + } +} diff --git a/packages/frontend/core/src/modules/comment/index.ts b/packages/frontend/core/src/modules/comment/index.ts new file mode 100644 index 000000000..8c45467d8 --- /dev/null +++ b/packages/frontend/core/src/modules/comment/index.ts @@ -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, + ]); +} diff --git a/packages/frontend/core/src/modules/comment/services/comment-panel-service.ts b/packages/frontend/core/src/modules/comment/services/comment-panel-service.ts new file mode 100644 index 000000000..dfd0ab6e7 --- /dev/null +++ b/packages/frontend/core/src/modules/comment/services/comment-panel-service.ts @@ -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(); + } +} 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 new file mode 100644 index 000000000..9a70124d5 --- /dev/null +++ b/packages/frontend/core/src/modules/comment/services/doc-comment-manager.ts @@ -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({ + 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; + } +} diff --git a/packages/frontend/core/src/modules/comment/services/snapshot-helper.ts b/packages/frontend/core/src/modules/comment/services/snapshot-helper.ts new file mode 100644 index 000000000..7b06526da --- /dev/null +++ b/packages/frontend/core/src/modules/comment/services/snapshot-helper.ts @@ -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 { + 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; + } + } +} diff --git a/packages/frontend/core/src/modules/comment/types.ts b/packages/frontend/core/src/modules/comment/types.ts new file mode 100644 index 000000000..c53a88b04 --- /dev/null +++ b/packages/frontend/core/src/modules/comment/types.ts @@ -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[]; diff --git a/packages/frontend/core/src/modules/editor/entities/editor.ts b/packages/frontend/core/src/modules/editor/entities/editor.ts index 224d4ff44..e2761498c 100644 --- a/packages/frontend/core/src/modules/editor/entities/editor.ts +++ b/packages/frontend/core/src/modules/editor/entities/editor.ts @@ -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(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)); diff --git a/packages/frontend/core/src/modules/editor/types.ts b/packages/frontend/core/src/modules/editor/types.ts index f5e4eae7d..88bcb9a53 100644 --- a/packages/frontend/core/src/modules/editor/types.ts +++ b/packages/frontend/core/src/modules/editor/types.ts @@ -1,5 +1,6 @@ export type EditorSelector = { blockIds?: string[]; elementIds?: string[]; + commentId?: string; refreshKey?: string; }; diff --git a/packages/frontend/core/src/modules/feature-flag/constant.ts b/packages/frontend/core/src/modules/feature-flag/constant.ts index b2b6d2442..8cdd4cc7c 100644 --- a/packages/frontend/core/src/modules/feature-flag/constant.ts +++ b/packages/frontend/core/src/modules/feature-flag/constant.ts @@ -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, diff --git a/packages/frontend/core/src/modules/index.ts b/packages/frontend/core/src/modules/index.ts index 7140dd510..461a81218 100644 --- a/packages/frontend/core/src/modules/index.ts +++ b/packages/frontend/core/src/modules/index.ts @@ -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); } diff --git a/packages/frontend/core/src/modules/navigation/utils.ts b/packages/frontend/core/src/modules/navigation/utils.ts index ad1c3a7ee..7a49885ae 100644 --- a/packages/frontend/core/src/modules/navigation/utils.ts +++ b/packages/frontend/core/src/modules/navigation/utils.ts @@ -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', }, }; diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index 88fc0ffc1..83275b4f5 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -1,26 +1,26 @@ { - "ar": 100, + "ar": 99, "ca": 4, "da": 4, - "de": 100, - "el-GR": 100, + "de": 99, + "el-GR": 99, "en": 100, - "es-AR": 100, + "es-AR": 99, "es-CL": 100, - "es": 100, - "fa": 100, - "fr": 100, + "es": 99, + "fa": 99, + "fr": 99, "hi": 2, - "it-IT": 100, + "it-IT": 99, "it": 1, - "ja": 100, + "ja": 99, "ko": 53, - "pl": 100, - "pt-BR": 100, - "ru": 100, - "sv-SE": 100, - "uk": 100, + "pl": 99, + "pt-BR": 99, + "ru": 99, + "sv-SE": 99, + "uk": 99, "ur": 2, - "zh-Hans": 100, - "zh-Hant": 100 + "zh-Hans": 99, + "zh-Hant": 99 } diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 769d48977..c83b603c2 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -8212,6 +8212,50 @@ export function useAFFiNEI18N(): { * `Migrate data` */ ["com.affine.migration-all-docs-notification.button"](): string; + /** + * `Comments` + */ + ["com.affine.comment.comments"](): string; + /** + * `No comments yet` + */ + ["com.affine.comment.no-comments"](): string; + /** + * `Delete the thread?` + */ + ["com.affine.comment.delete.confirm.title"](): string; + /** + * `All comments will also be deleted, and this action cannot be undone.` + */ + ["com.affine.comment.delete.confirm.description"](): string; + /** + * `Delete this reply?` + */ + ["com.affine.comment.reply.delete.confirm.title"](): string; + /** + * `Delete this reply? This action cannot be undone.` + */ + ["com.affine.comment.reply.delete.confirm.description"](): string; + /** + * `Show resolved comments` + */ + ["com.affine.comment.filter.show-resolved"](): string; + /** + * `Only my replies and mentions` + */ + ["com.affine.comment.filter.only-my-replies"](): string; + /** + * `Only current mode` + */ + ["com.affine.comment.filter.only-current-mode"](): string; + /** + * `Reply` + */ + ["com.affine.comment.reply"](): string; + /** + * `Copy link` + */ + ["com.affine.comment.copy-link"](): string; /** * `An internal error occurred.` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 659f75856..be3ba7ea1 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -2059,6 +2059,17 @@ "com.affine.migration-all-docs-notification.desc": "We are updating the local data to facilitate the recording and filtering of created by and Last edited by information. Please click the “Migrate Data” button and ensure a stable network connection during the process.", "com.affine.migration-all-docs-notification.error": "Migration failed: {{errorMessage}}", "com.affine.migration-all-docs-notification.button": "Migrate data", + "com.affine.comment.comments": "Comments", + "com.affine.comment.no-comments": "No comments yet", + "com.affine.comment.delete.confirm.title": "Delete the thread?", + "com.affine.comment.delete.confirm.description": "All comments will also be deleted, and this action cannot be undone.", + "com.affine.comment.reply.delete.confirm.title": "Delete this reply?", + "com.affine.comment.reply.delete.confirm.description": "Delete this reply? This action cannot be undone.", + "com.affine.comment.filter.show-resolved": "Show resolved comments", + "com.affine.comment.filter.only-my-replies": "Only my replies and mentions", + "com.affine.comment.filter.only-current-mode": "Only current mode", + "com.affine.comment.reply": "Reply", + "com.affine.comment.copy-link": "Copy link", "error.INTERNAL_SERVER_ERROR": "An internal error occurred.", "error.NETWORK_ERROR": "Network error.", "error.TOO_MANY_REQUEST": "Too many requests.",