feat(core): comment panel (#12989)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced a full-featured document comment system with sidebar for viewing, filtering, replying, resolving, and managing comments and replies. * Added a rich text comment editor supporting editable and read-only modes. * Enabled comment-based navigation and highlighting within documents. * Integrated comment functionality into the workspace sidebar (excluding local workspaces). * Added internationalization support and new UI strings for comment features. * Added new feature flag `enable_comment` for toggling comment functionality. * Enhanced editor focus to support comment-related selections. * Added snapshot and store helpers for comment content management. * Implemented backend GraphQL support for comment and reply operations. * Added services for comment entity management and comment panel behavior. * Extended comment configuration to support optional framework providers. * Added preview generation from user selections when creating comments. * Enabled automatic sidebar opening on new pending comments. * Added comment-related query parameter support for navigation. * Included inline comment module exports for integration. * Improved comment provider implementation with full lifecycle management and UI integration. * Added comment highlight state tracking and refined selection handling in inline comments. * **Style** * Added comprehensive styles for the comment editor and sidebar components. * **Chores** * Updated language completeness percentages for multiple locales. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: L-Sun <zover.v@gmail.com>
This commit is contained in:
@@ -174,6 +174,7 @@
|
|||||||
"./inlines/footnote": "./src/inlines/footnote/index.ts",
|
"./inlines/footnote": "./src/inlines/footnote/index.ts",
|
||||||
"./inlines/footnote/view": "./src/inlines/footnote/view.ts",
|
"./inlines/footnote/view": "./src/inlines/footnote/view.ts",
|
||||||
"./inlines/footnote/store": "./src/inlines/footnote/store.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": "./src/inlines/latex/index.ts",
|
||||||
"./inlines/latex/store": "./src/inlines/latex/store.ts",
|
"./inlines/latex/store": "./src/inlines/latex/store.ts",
|
||||||
"./inlines/latex/view": "./src/inlines/latex/view.ts",
|
"./inlines/latex/view": "./src/inlines/latex/view.ts",
|
||||||
|
|||||||
1
blocksuite/affine/all/src/inlines/comment/index.ts
Normal file
1
blocksuite/affine/all/src/inlines/comment/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from '@blocksuite/affine-inline-comment';
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from './inline-spec';
|
export * from './inline-spec';
|
||||||
|
export * from './utils';
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
TextSelection,
|
TextSelection,
|
||||||
} from '@blocksuite/std';
|
} from '@blocksuite/std';
|
||||||
import type { BaseSelection, BlockModel } from '@blocksuite/store';
|
import type { BaseSelection, BlockModel } from '@blocksuite/store';
|
||||||
|
import { signal } from '@preact/signals-core';
|
||||||
|
|
||||||
import { extractCommentIdFromDelta, findCommentedTexts } from './utils';
|
import { extractCommentIdFromDelta, findCommentedTexts } from './utils';
|
||||||
|
|
||||||
@@ -20,6 +21,8 @@ export class InlineCommentManager extends LifeCycleWatcher {
|
|||||||
|
|
||||||
private readonly _disposables = new DisposableGroup();
|
private readonly _disposables = new DisposableGroup();
|
||||||
|
|
||||||
|
private readonly _highlightedCommentId$ = signal<CommentId | null>(null);
|
||||||
|
|
||||||
private get _provider() {
|
private get _provider() {
|
||||||
return this.std.getOptional(CommentProviderIdentifier);
|
return this.std.getOptional(CommentProviderIdentifier);
|
||||||
}
|
}
|
||||||
@@ -35,6 +38,9 @@ export class InlineCommentManager extends LifeCycleWatcher {
|
|||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
provider.onCommentResolved(this._handleDeleteAndResolve)
|
provider.onCommentResolved(this._handleDeleteAndResolve)
|
||||||
);
|
);
|
||||||
|
this._disposables.add(
|
||||||
|
provider.onCommentHighlighted(this._handleHighlightComment)
|
||||||
|
);
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
this.std.selection.slots.changed.subscribe(this._handleSelectionChanged)
|
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[]) => {
|
private readonly _handleSelectionChanged = (selections: BaseSelection[]) => {
|
||||||
|
const currentHighlightedCommentId = this._highlightedCommentId$.peek();
|
||||||
|
|
||||||
if (selections.length === 1) {
|
if (selections.length === 1) {
|
||||||
const selection = selections[0];
|
const selection = selections[0];
|
||||||
|
|
||||||
// InlineCommentManager only handle text selection
|
// InlineCommentManager only handle text selection
|
||||||
if (!selection.is(TextSelection)) return;
|
if (!selection.is(TextSelection)) return;
|
||||||
|
|
||||||
if (!selection.isCollapsed()) {
|
if (!selection.isCollapsed() && currentHighlightedCommentId !== null) {
|
||||||
this._provider?.highlightComment(null);
|
this._provider?.highlightComment(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -156,6 +168,8 @@ export class InlineCommentManager extends LifeCycleWatcher {
|
|||||||
if (commentIds.length !== 0) return;
|
if (commentIds.length !== 0) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentHighlightedCommentId !== null) {
|
||||||
this._provider?.highlightComment(null);
|
this._provider?.highlightComment(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export const ReferenceParamsSchema = z
|
|||||||
databaseId: z.string().optional(),
|
databaseId: z.string().optional(),
|
||||||
databaseRowId: z.string().optional(),
|
databaseRowId: z.string().optional(),
|
||||||
xywh: SerializedXYWHSchema.optional(),
|
xywh: SerializedXYWHSchema.optional(),
|
||||||
|
commentId: z.string().optional(),
|
||||||
})
|
})
|
||||||
.partial();
|
.partial();
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface BlockSuiteFlags {
|
|||||||
enable_table_virtual_scroll: boolean;
|
enable_table_virtual_scroll: boolean;
|
||||||
enable_turbo_renderer: boolean;
|
enable_turbo_renderer: boolean;
|
||||||
enable_dom_renderer: boolean;
|
enable_dom_renderer: boolean;
|
||||||
|
enable_comment: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FeatureFlagService extends StoreExtension {
|
export class FeatureFlagService extends StoreExtension {
|
||||||
@@ -46,6 +47,7 @@ export class FeatureFlagService extends StoreExtension {
|
|||||||
enable_table_virtual_scroll: false,
|
enable_table_virtual_scroll: false,
|
||||||
enable_turbo_renderer: false,
|
enable_turbo_renderer: false,
|
||||||
enable_dom_renderer: false,
|
enable_dom_renderer: false,
|
||||||
|
enable_comment: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
setFlag(key: keyof BlockSuiteFlags, value: boolean) {
|
setFlag(key: keyof BlockSuiteFlags, value: boolean) {
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ const usePatchSpecs = (mode: DocMode) => {
|
|||||||
.electron(framework)
|
.electron(framework)
|
||||||
.linkPreview(framework)
|
.linkPreview(framework)
|
||||||
.codeBlockHtmlPreview(framework)
|
.codeBlockHtmlPreview(framework)
|
||||||
.comment(enableComment).value;
|
.comment(enableComment, framework).value;
|
||||||
|
|
||||||
if (BUILD_CONFIG.isMobileEdition) {
|
if (BUILD_CONFIG.isMobileEdition) {
|
||||||
if (mode === 'page') {
|
if (mode === 'page') {
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ type Configure = {
|
|||||||
electron: (framework?: FrameworkProvider) => Configure;
|
electron: (framework?: FrameworkProvider) => Configure;
|
||||||
linkPreview: (framework?: FrameworkProvider) => Configure;
|
linkPreview: (framework?: FrameworkProvider) => Configure;
|
||||||
codeBlockHtmlPreview: (framework?: FrameworkProvider) => Configure;
|
codeBlockHtmlPreview: (framework?: FrameworkProvider) => Configure;
|
||||||
comment: (enableComment?: boolean) => Configure;
|
comment: (
|
||||||
|
enableComment?: boolean,
|
||||||
|
framework?: FrameworkProvider
|
||||||
|
) => Configure;
|
||||||
|
|
||||||
value: ViewExtensionManager;
|
value: ViewExtensionManager;
|
||||||
};
|
};
|
||||||
@@ -92,6 +95,7 @@ class ViewProvider {
|
|||||||
ElectronViewExtension,
|
ElectronViewExtension,
|
||||||
AffineLinkPreviewExtension,
|
AffineLinkPreviewExtension,
|
||||||
AffineDatabaseViewExtension,
|
AffineDatabaseViewExtension,
|
||||||
|
CommentViewExtension,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,8 +332,14 @@ class ViewProvider {
|
|||||||
return this.config;
|
return this.config;
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly _configureComment = (enableComment?: boolean) => {
|
private readonly _configureComment = (
|
||||||
this._manager.configure(CommentViewExtension, { enableComment });
|
enableComment?: boolean,
|
||||||
|
framework?: FrameworkProvider
|
||||||
|
) => {
|
||||||
|
this._manager.configure(CommentViewExtension, {
|
||||||
|
enableComment,
|
||||||
|
framework,
|
||||||
|
});
|
||||||
return this.config;
|
return this.config;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,176 @@
|
|||||||
import { noop } from '@blocksuite/affine/global/utils';
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
import { CommentProviderExtension } from '@blocksuite/affine/shared/services';
|
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({
|
import { DocCommentManagerService } from '../../../modules/comment/services/doc-comment-manager';
|
||||||
addComment: noop,
|
|
||||||
resolveComment: noop,
|
|
||||||
highlightComment: noop,
|
|
||||||
getComments: () => [],
|
|
||||||
|
|
||||||
onCommentAdded: () => noop,
|
function getPreviewFromSelections(
|
||||||
onCommentResolved: () => noop,
|
std: BlockStdScope,
|
||||||
onCommentDeleted: () => noop,
|
selections: BaseSelection[]
|
||||||
onCommentHighlighted: () => noop,
|
): string {
|
||||||
});
|
if (!selections || selections.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const previews: string[] = [];
|
||||||
|
|
||||||
|
for (const selection of selections) {
|
||||||
|
if (selection instanceof TextSelection) {
|
||||||
|
// Extract text from TextSelection
|
||||||
|
const textPreview = extractTextFromSelection(std, selection);
|
||||||
|
if (textPreview) {
|
||||||
|
previews.push(textPreview);
|
||||||
|
}
|
||||||
|
} else if (selection instanceof BlockSelection) {
|
||||||
|
// Get block flavour for BlockSelection
|
||||||
|
const block = std.store.getBlock(selection.blockId);
|
||||||
|
if (block?.model) {
|
||||||
|
const flavour = block.model.flavour.replace('affine:', '');
|
||||||
|
previews.push(`<${flavour}>`);
|
||||||
|
}
|
||||||
|
} else if (selection.type === 'image') {
|
||||||
|
// Return <"Image"> for ImageSelection
|
||||||
|
previews.push('<Image>');
|
||||||
|
}
|
||||||
|
// Skip other types
|
||||||
|
}
|
||||||
|
|
||||||
|
return previews.length > 0 ? previews.join(' ') : 'New comment';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTextFromSelection(
|
||||||
|
std: BlockStdScope,
|
||||||
|
selection: TextSelection
|
||||||
|
): string | null {
|
||||||
|
try {
|
||||||
|
const [_, ctx] = std.command
|
||||||
|
.chain()
|
||||||
|
.pipe(getSelectedBlocksCommand, {
|
||||||
|
currentTextSelection: selection,
|
||||||
|
types: ['text'],
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const blocks = ctx.selectedBlocks;
|
||||||
|
if (!blocks || blocks.length === 0) return null;
|
||||||
|
|
||||||
|
const { from, to } = selection;
|
||||||
|
const quote = blocks.reduce((acc, block, index) => {
|
||||||
|
const text = block.model.text;
|
||||||
|
if (!text) return acc;
|
||||||
|
|
||||||
|
if (index === 0) {
|
||||||
|
// First block: extract from 'from.index' for 'from.length' characters
|
||||||
|
const startText = text.yText
|
||||||
|
.toString()
|
||||||
|
.slice(from.index, from.index + from.length);
|
||||||
|
return acc + startText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index === blocks.length - 1 && to) {
|
||||||
|
// Last block: extract from start to 'to.index + to.length'
|
||||||
|
const endText = text.yText.toString().slice(0, to.index + to.length);
|
||||||
|
return acc + (acc ? ' ' : '') + endText;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middle blocks: get all text
|
||||||
|
const blockText = text.yText.toString();
|
||||||
|
return acc + (acc ? ' ' : '') + blockText;
|
||||||
|
}, '');
|
||||||
|
|
||||||
|
// Trim and limit length for preview
|
||||||
|
const trimmed = quote.trim();
|
||||||
|
return trimmed.length > 200 ? trimmed.substring(0, 200) + '...' : trimmed;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to extract text from selection:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AffineCommentService implements CommentProvider {
|
||||||
|
private readonly docCommentManager: DocCommentManagerService;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly std: BlockStdScope,
|
||||||
|
private readonly framework: FrameworkProvider
|
||||||
|
) {
|
||||||
|
this.docCommentManager = framework.get(DocCommentManagerService);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get currentDocId(): string {
|
||||||
|
return this.std.store.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: need to handle resource leak
|
||||||
|
private get commentEntityRef() {
|
||||||
|
return this.docCommentManager.get(this.currentDocId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get commentEntity() {
|
||||||
|
return this.commentEntityRef.obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
addComment(selections: BaseSelection[]): void {
|
||||||
|
const workbench = this.framework.get(WorkbenchService).workbench;
|
||||||
|
workbench.setSidebarOpen(true);
|
||||||
|
workbench.activeView$.value.activeSidebarTab('comment');
|
||||||
|
const preview = getPreviewFromSelections(this.std, selections);
|
||||||
|
this.commentEntity.addComment(selections, preview).catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveComment(id: string): void {
|
||||||
|
this.commentEntity.resolveComment(id, true).catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightComment(id: string | null): void {
|
||||||
|
if (id !== null) {
|
||||||
|
const workbench = this.framework.get(WorkbenchService).workbench;
|
||||||
|
workbench.setSidebarOpen(true);
|
||||||
|
workbench.activeView$.value.activeSidebarTab('comment');
|
||||||
|
}
|
||||||
|
this.commentEntity.highlightComment(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getComments(): string[] {
|
||||||
|
return this.commentEntity.getComments();
|
||||||
|
}
|
||||||
|
|
||||||
|
onCommentAdded(callback: (id: string, selections: BaseSelection[]) => void) {
|
||||||
|
return this.commentEntity.onCommentAdded((id, selections) => {
|
||||||
|
callback(id, selections);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onCommentResolved(callback: (id: string) => void) {
|
||||||
|
return this.commentEntity.onCommentResolved(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
onCommentDeleted(callback: (id: string) => void) {
|
||||||
|
return this.commentEntity.onCommentDeleted(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
onCommentHighlighted(callback: (id: string | null) => void) {
|
||||||
|
return this.commentEntity.onCommentHighlighted(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AffineCommentProvider(
|
||||||
|
framework: FrameworkProvider
|
||||||
|
): ExtensionType {
|
||||||
|
return {
|
||||||
|
setup: (di: Container) => {
|
||||||
|
di.addImpl(
|
||||||
|
CommentProviderIdentifier,
|
||||||
|
provider =>
|
||||||
|
new AffineCommentService(provider.get(StdIdentifier), framework)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,26 +2,30 @@ import {
|
|||||||
type ViewExtensionContext,
|
type ViewExtensionContext,
|
||||||
ViewExtensionProvider,
|
ViewExtensionProvider,
|
||||||
} from '@blocksuite/affine/ext-loader';
|
} from '@blocksuite/affine/ext-loader';
|
||||||
|
import { FrameworkProvider } from '@toeverything/infra';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
import { AffineCommentProvider } from './comment-provider';
|
import { AffineCommentProvider } from './comment-provider';
|
||||||
|
|
||||||
const optionsSchema = z.object({
|
const optionsSchema = z.object({
|
||||||
enableComment: z.boolean().optional(),
|
enableComment: z.boolean().optional(),
|
||||||
|
framework: z.instanceof(FrameworkProvider).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export class CommentViewExtension extends ViewExtensionProvider {
|
type CommentViewOptions = z.infer<typeof optionsSchema>;
|
||||||
|
|
||||||
|
export class CommentViewExtension extends ViewExtensionProvider<CommentViewOptions> {
|
||||||
override name = 'comment';
|
override name = 'comment';
|
||||||
|
|
||||||
override schema = optionsSchema;
|
override schema = optionsSchema;
|
||||||
|
|
||||||
override setup(
|
override setup(context: ViewExtensionContext, options?: CommentViewOptions) {
|
||||||
context: ViewExtensionContext,
|
|
||||||
options?: z.infer<typeof optionsSchema>
|
|
||||||
) {
|
|
||||||
super.setup(context, options);
|
super.setup(context, options);
|
||||||
if (!options?.enableComment) return;
|
if (!options?.enableComment) return;
|
||||||
|
|
||||||
context.register([AffineCommentProvider]);
|
const framework = options.framework;
|
||||||
|
if (!framework) return;
|
||||||
|
|
||||||
|
context.register([AffineCommentProvider(framework)]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 => (
|
||||||
|
<Fragment key={p.id}>{p.portal}</Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
[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<Store | undefined>(
|
||||||
|
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<CommentEditorRef, CommentEditorProps>(
|
||||||
|
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<PageEditor>(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 (
|
||||||
|
<div
|
||||||
|
onClick={readonly ? undefined : handleClickEditor}
|
||||||
|
data-readonly={!!readonly}
|
||||||
|
className={clsx(styles.container, 'comment-editor-viewport')}
|
||||||
|
>
|
||||||
|
{doc && <LitDocEditor ref={editorRef} specs={specs} doc={doc} />}
|
||||||
|
{portals}
|
||||||
|
{!readonly && (
|
||||||
|
<div className={styles.footer}>
|
||||||
|
<button onClick={onCommit} className={styles.commitButton}>
|
||||||
|
<ArrowUpBigIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
616
packages/frontend/core/src/components/comment/sidebar/index.tsx
Normal file
616
packages/frontend/core/src/components/comment/sidebar/index.tsx
Normal file
@@ -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 (
|
||||||
|
<Menu
|
||||||
|
rootOptions={{ modal: false }}
|
||||||
|
items={
|
||||||
|
<>
|
||||||
|
<MenuItem
|
||||||
|
checked={filterState.showResolvedComments}
|
||||||
|
onSelect={() =>
|
||||||
|
onFilterChange(
|
||||||
|
'showResolvedComments',
|
||||||
|
!filterState.showResolvedComments
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t['com.affine.comment.filter.show-resolved']()}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
checked={filterState.onlyMyReplies}
|
||||||
|
onSelect={() =>
|
||||||
|
onFilterChange('onlyMyReplies', !filterState.onlyMyReplies)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t['com.affine.comment.filter.only-my-replies']()}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
checked={filterState.onlyCurrentMode}
|
||||||
|
onSelect={() =>
|
||||||
|
onFilterChange('onlyCurrentMode', !filterState.onlyCurrentMode)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t['com.affine.comment.filter.only-current-mode']()}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton icon={<FilterIcon />} />
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReadonlyCommentRenderer = ({
|
||||||
|
avatarUrl,
|
||||||
|
name,
|
||||||
|
time,
|
||||||
|
snapshot,
|
||||||
|
}: {
|
||||||
|
avatarUrl: string | null;
|
||||||
|
name: string;
|
||||||
|
time: number;
|
||||||
|
snapshot: DocSnapshot;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div data-time={time} className={styles.readonlyCommentContainer}>
|
||||||
|
<div className={styles.userContainer}>
|
||||||
|
<Avatar url={avatarUrl} size={24} />
|
||||||
|
<div className={styles.userName}>{name}</div>
|
||||||
|
<div className={styles.time}>
|
||||||
|
{i18nTime(time, {
|
||||||
|
absolute: { accuracy: 'minute' },
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginLeft: '34px' }}>
|
||||||
|
<CommentEditor readonly defaultSnapshot={snapshot} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
onClick={handleClickPreview}
|
||||||
|
data-comment-id={comment.id}
|
||||||
|
data-resolved={comment.resolved}
|
||||||
|
data-highlighting={highlighting || menuOpen}
|
||||||
|
className={styles.commentItem}
|
||||||
|
ref={commentRef}
|
||||||
|
>
|
||||||
|
<div className={styles.commentActions} data-menu-open={menuOpen}>
|
||||||
|
<IconButton
|
||||||
|
variant="solid"
|
||||||
|
onClick={handleResolve}
|
||||||
|
icon={<DoneIcon />}
|
||||||
|
disabled={isMutating}
|
||||||
|
/>
|
||||||
|
<Menu
|
||||||
|
rootOptions={{
|
||||||
|
open: menuOpen,
|
||||||
|
onOpenChange: v => {
|
||||||
|
setMenuOpen(v);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
items={
|
||||||
|
<>
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleReply}
|
||||||
|
disabled={isMutating || comment.resolved}
|
||||||
|
>
|
||||||
|
{t['com.affine.comment.reply']()}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={handleCopyLink} disabled={isMutating}>
|
||||||
|
{t['com.affine.comment.copy-link']()}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isMutating}
|
||||||
|
style={{ color: 'var(--affine-error-color)' }}
|
||||||
|
>
|
||||||
|
{t['Delete']()}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
variant="solid"
|
||||||
|
icon={<MoreHorizontalIcon />}
|
||||||
|
disabled={isMutating}
|
||||||
|
/>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
<div className={styles.previewContainer}>{comment.content.preview}</div>
|
||||||
|
|
||||||
|
<div className={styles.repliesContainer}>
|
||||||
|
<ReadonlyCommentRenderer
|
||||||
|
avatarUrl={comment.user.avatarUrl}
|
||||||
|
name={comment.user.name}
|
||||||
|
time={comment.createdAt}
|
||||||
|
snapshot={comment.content.snapshot}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* unlike comment, replies are sorted by createdAt in ascending order */}
|
||||||
|
{comment.replies
|
||||||
|
?.toSorted((a, b) => a.createdAt - b.createdAt)
|
||||||
|
.map(reply => (
|
||||||
|
<ReadonlyCommentRenderer
|
||||||
|
key={reply.id}
|
||||||
|
avatarUrl={reply.user.avatarUrl}
|
||||||
|
name={reply.user.name}
|
||||||
|
time={reply.createdAt}
|
||||||
|
snapshot={reply.content.snapshot}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{highlighting &&
|
||||||
|
isReplyingToThisComment &&
|
||||||
|
pendingReply &&
|
||||||
|
account &&
|
||||||
|
!comment.resolved && (
|
||||||
|
<div className={styles.commentInputContainer}>
|
||||||
|
<div className={styles.userContainer}>
|
||||||
|
<Avatar url={account.avatar} size={24} />
|
||||||
|
</div>
|
||||||
|
<CommentEditor
|
||||||
|
autoFocus
|
||||||
|
doc={pendingReply.doc}
|
||||||
|
onCommit={isMutating ? undefined : handleCommitReply}
|
||||||
|
onCancel={isMutating ? undefined : handleCancelReply}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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<CommentFilterState>({
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.headerTitle}>
|
||||||
|
{t['com.affine.comment.comments']()}
|
||||||
|
</div>
|
||||||
|
{comments.length > 0 && (
|
||||||
|
<SortFilterButton
|
||||||
|
filterState={filterState}
|
||||||
|
onFilterChange={onFilterChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CommentInput entity={entity} />
|
||||||
|
{filteredAndSortedComments.length === 0 &&
|
||||||
|
!newPendingComment &&
|
||||||
|
!loading && (
|
||||||
|
<div className={styles.empty}>
|
||||||
|
{t['com.affine.comment.no-comments']()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loading &&
|
||||||
|
filteredAndSortedComments.length === 0 &&
|
||||||
|
!newPendingComment && (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<Loading size={32} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.commentList}>
|
||||||
|
{filteredAndSortedComments.map(comment => (
|
||||||
|
<CommentItem key={comment.id} comment={comment} entity={entity} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className={styles.pendingComment} data-pending-comment>
|
||||||
|
{pendingPreview && (
|
||||||
|
<div className={styles.previewContainer}>{pendingPreview}</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.commentInputContainer}>
|
||||||
|
<div className={styles.userContainer}>
|
||||||
|
<Avatar url={account.avatar} size={24} />
|
||||||
|
</div>
|
||||||
|
<CommentEditor
|
||||||
|
autoFocus
|
||||||
|
doc={newPendingComment.doc}
|
||||||
|
onCommit={isMutating ? undefined : handleCommit}
|
||||||
|
onCancel={isMutating ? undefined : handleCancel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useCommentEntity = (docId: string | undefined) => {
|
||||||
|
const docCommentManager = useService(DocCommentManagerService);
|
||||||
|
const commentPanelService = useService(CommentPanelService);
|
||||||
|
const [entity, setEntity] = useState<DocCommentEntity | null>(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<HTMLDivElement>(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 (
|
||||||
|
<div className={styles.container} ref={containerRef}>
|
||||||
|
<CommentList entity={entity} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import { EditorOutlineViewer } from '@affine/core/blocksuite/outline-viewer';
|
|||||||
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
|
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
|
||||||
// import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding';
|
// import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding';
|
||||||
import { GlobalPageHistoryModal } from '@affine/core/components/affine/page-history-modal';
|
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 { useGuard } from '@affine/core/components/guard';
|
||||||
import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper';
|
import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper';
|
||||||
import { useEnableAI } from '@affine/core/components/hooks/affine/use-enable-ai';
|
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 { RefNodeSlotsProvider } from '@blocksuite/affine/inlines/reference';
|
||||||
import {
|
import {
|
||||||
AiIcon,
|
AiIcon,
|
||||||
|
CommentIcon,
|
||||||
ExportIcon,
|
ExportIcon,
|
||||||
FrameIcon,
|
FrameIcon,
|
||||||
PropertyIcon,
|
PropertyIcon,
|
||||||
@@ -381,6 +383,17 @@ const DetailPageImpl = memo(function DetailPageImpl() {
|
|||||||
</ViewSidebarTab>
|
</ViewSidebarTab>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{workspace.flavour !== 'local' && (
|
||||||
|
<ViewSidebarTab tabId="comment" icon={<CommentIcon />}>
|
||||||
|
<Scrollable.Root className={styles.sidebarScrollArea}>
|
||||||
|
<Scrollable.Viewport>
|
||||||
|
<CommentSidebar />
|
||||||
|
</Scrollable.Viewport>
|
||||||
|
<Scrollable.Scrollbar />
|
||||||
|
</Scrollable.Root>
|
||||||
|
</ViewSidebarTab>
|
||||||
|
)}
|
||||||
|
|
||||||
<GlobalPageHistoryModal />
|
<GlobalPageHistoryModal />
|
||||||
{/* FIXME: wait for better ai, <PageAIOnboarding /> */}
|
{/* FIXME: wait for better ai, <PageAIOnboarding /> */}
|
||||||
</FrameworkScope>
|
</FrameworkScope>
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import {
|
||||||
|
createCommentMutation,
|
||||||
|
createReplyMutation,
|
||||||
|
deleteCommentMutation,
|
||||||
|
deleteReplyMutation,
|
||||||
|
type DocMode,
|
||||||
|
listCommentChangesQuery,
|
||||||
|
type ListCommentsQuery,
|
||||||
|
listCommentsQuery,
|
||||||
|
resolveCommentMutation,
|
||||||
|
updateCommentMutation,
|
||||||
|
updateReplyMutation,
|
||||||
|
} from '@affine/graphql';
|
||||||
|
import { Entity } from '@toeverything/infra';
|
||||||
|
|
||||||
|
import type { DefaultServerService, WorkspaceServerService } from '../../cloud';
|
||||||
|
import { GraphQLService } from '../../cloud/services/graphql';
|
||||||
|
import type { WorkspaceService } from '../../workspace';
|
||||||
|
import type {
|
||||||
|
DocComment,
|
||||||
|
DocCommentChangeListResult,
|
||||||
|
DocCommentContent,
|
||||||
|
DocCommentListResult,
|
||||||
|
DocCommentReply,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
type GQLCommentType =
|
||||||
|
ListCommentsQuery['workspace']['comments']['edges'][number]['node'];
|
||||||
|
type GQLReplyType = GQLCommentType['replies'][number];
|
||||||
|
type GQLUserType = GQLCommentType['user'];
|
||||||
|
|
||||||
|
// Helper functions for normalizing backend responses
|
||||||
|
const normalizeUser = (user: GQLUserType) => ({
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizeReply = (reply: GQLReplyType): DocCommentReply => ({
|
||||||
|
id: reply.id,
|
||||||
|
content: reply.content as DocCommentContent,
|
||||||
|
createdAt: new Date(reply.createdAt).getTime(),
|
||||||
|
updatedAt: new Date(reply.updatedAt).getTime(),
|
||||||
|
user: normalizeUser(reply.user),
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizeComment = (comment: GQLCommentType): DocComment => ({
|
||||||
|
id: comment.id,
|
||||||
|
content: comment.content as DocCommentContent,
|
||||||
|
resolved: comment.resolved,
|
||||||
|
createdAt: new Date(comment.createdAt).getTime(),
|
||||||
|
updatedAt: new Date(comment.updatedAt).getTime(),
|
||||||
|
user: comment.user
|
||||||
|
? normalizeUser(comment.user)
|
||||||
|
: {
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
avatarUrl: '',
|
||||||
|
},
|
||||||
|
replies: comment.replies?.map(normalizeReply) ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
export class DocCommentStore extends Entity<{
|
||||||
|
docId: string;
|
||||||
|
getDocMode: () => DocMode;
|
||||||
|
getDocTitle: () => string;
|
||||||
|
}> {
|
||||||
|
constructor(
|
||||||
|
private readonly workspaceService: WorkspaceService,
|
||||||
|
private readonly workspaceServerService: WorkspaceServerService,
|
||||||
|
private readonly defaultServerService: DefaultServerService
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
private get serverService() {
|
||||||
|
return (
|
||||||
|
this.workspaceServerService.server || this.defaultServerService.server
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get graphqlService() {
|
||||||
|
return this.serverService?.scope.get(GraphQLService);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get currentWorkspaceId() {
|
||||||
|
return this.workspaceService.workspace.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listComments({
|
||||||
|
after,
|
||||||
|
}: {
|
||||||
|
after?: string;
|
||||||
|
}): Promise<DocCommentListResult> {
|
||||||
|
const graphql = this.graphqlService;
|
||||||
|
if (!graphql) {
|
||||||
|
throw new Error('GraphQL service not found');
|
||||||
|
}
|
||||||
|
const response = await graphql.gql({
|
||||||
|
query: listCommentsQuery,
|
||||||
|
variables: {
|
||||||
|
pagination: {
|
||||||
|
after,
|
||||||
|
},
|
||||||
|
workspaceId: this.currentWorkspaceId,
|
||||||
|
docId: this.props.docId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const comments = response.workspace?.comments;
|
||||||
|
if (!comments) {
|
||||||
|
return {
|
||||||
|
comments: [],
|
||||||
|
hasNextPage: false,
|
||||||
|
startCursor: '',
|
||||||
|
endCursor: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
comments: comments.edges.map(edge => normalizeComment(edge.node)),
|
||||||
|
hasNextPage: comments.pageInfo.hasNextPage,
|
||||||
|
startCursor: comments.pageInfo.startCursor || '',
|
||||||
|
endCursor: comments.pageInfo.endCursor || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// pool every 30s
|
||||||
|
async listCommentChanges({
|
||||||
|
after,
|
||||||
|
}: {
|
||||||
|
after?: string;
|
||||||
|
}): Promise<DocCommentChangeListResult> {
|
||||||
|
const graphql = this.graphqlService;
|
||||||
|
if (!graphql) {
|
||||||
|
throw new Error('GraphQL service not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await graphql.gql({
|
||||||
|
query: listCommentChangesQuery,
|
||||||
|
variables: {
|
||||||
|
pagination: {
|
||||||
|
after,
|
||||||
|
},
|
||||||
|
workspaceId: this.currentWorkspaceId,
|
||||||
|
docId: this.props.docId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const commentChanges = response.workspace?.commentChanges;
|
||||||
|
if (!commentChanges) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return commentChanges.edges.map(edge => ({
|
||||||
|
id: edge.node.id,
|
||||||
|
action: edge.node.action,
|
||||||
|
comment: normalizeComment(edge.node.item),
|
||||||
|
commentId: edge.node.commentId || undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createComment(commentInput: {
|
||||||
|
content: DocCommentContent;
|
||||||
|
}): Promise<DocComment> {
|
||||||
|
const graphql = this.graphqlService;
|
||||||
|
if (!graphql) {
|
||||||
|
throw new Error('GraphQL service not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await graphql.gql({
|
||||||
|
query: createCommentMutation,
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
workspaceId: this.currentWorkspaceId,
|
||||||
|
docId: this.props.docId,
|
||||||
|
docMode: this.props.getDocMode(),
|
||||||
|
docTitle: this.props.getDocTitle(),
|
||||||
|
content: commentInput.content,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const comment = response.createComment;
|
||||||
|
return normalizeComment(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateComment(
|
||||||
|
commentId: string,
|
||||||
|
commentInput: {
|
||||||
|
content: DocCommentContent;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const graphql = this.graphqlService;
|
||||||
|
if (!graphql) {
|
||||||
|
throw new Error('GraphQL service not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await graphql.gql({
|
||||||
|
query: updateCommentMutation,
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
id: commentId,
|
||||||
|
content: commentInput.content,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveComment(commentId: string, resolved = true): Promise<boolean> {
|
||||||
|
const graphql = this.graphqlService;
|
||||||
|
if (!graphql) {
|
||||||
|
throw new Error('GraphQL service not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await graphql.gql({
|
||||||
|
query: resolveCommentMutation,
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
id: commentId,
|
||||||
|
resolved,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.resolveComment;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteComment(commentId: string): Promise<boolean> {
|
||||||
|
const graphql = this.graphqlService;
|
||||||
|
if (!graphql) {
|
||||||
|
throw new Error('GraphQL service not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await graphql.gql({
|
||||||
|
query: deleteCommentMutation,
|
||||||
|
variables: {
|
||||||
|
id: commentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.deleteComment;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createReply(
|
||||||
|
commentId: string,
|
||||||
|
replyInput: {
|
||||||
|
content: DocCommentContent;
|
||||||
|
}
|
||||||
|
): Promise<DocCommentReply> {
|
||||||
|
const graphql = this.graphqlService;
|
||||||
|
if (!graphql) {
|
||||||
|
throw new Error('GraphQL service not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await graphql.gql({
|
||||||
|
query: createReplyMutation,
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
commentId,
|
||||||
|
content: replyInput.content,
|
||||||
|
docMode: this.props.getDocMode(),
|
||||||
|
docTitle: this.props.getDocTitle(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return normalizeReply(response.createReply);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateReply(
|
||||||
|
replyId: string,
|
||||||
|
replyInput: {
|
||||||
|
content: DocCommentContent;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const graphql = this.graphqlService;
|
||||||
|
if (!graphql) {
|
||||||
|
throw new Error('GraphQL service not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await graphql.gql({
|
||||||
|
query: updateReplyMutation,
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
id: replyId,
|
||||||
|
content: replyInput.content,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteReply(replyId: string): Promise<void> {
|
||||||
|
const graphql = this.graphqlService;
|
||||||
|
if (!graphql) {
|
||||||
|
throw new Error('GraphQL service not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await graphql.gql({
|
||||||
|
query: deleteReplyMutation,
|
||||||
|
variables: {
|
||||||
|
id: replyId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
import { type CommentChangeAction, DocMode } from '@affine/graphql';
|
||||||
|
import type { BaseSelection } from '@blocksuite/affine/store';
|
||||||
|
import {
|
||||||
|
effect,
|
||||||
|
Entity,
|
||||||
|
fromPromise,
|
||||||
|
LiveData,
|
||||||
|
onComplete,
|
||||||
|
onStart,
|
||||||
|
} from '@toeverything/infra';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { catchError, of, Subject, switchMap, tap, timer } from 'rxjs';
|
||||||
|
|
||||||
|
import { type DocDisplayMetaService } from '../../doc-display-meta';
|
||||||
|
import { GlobalContextService } from '../../global-context';
|
||||||
|
import type { SnapshotHelper } from '../services/snapshot-helper';
|
||||||
|
import type {
|
||||||
|
CommentId,
|
||||||
|
DocComment,
|
||||||
|
DocCommentChangeListResult,
|
||||||
|
DocCommentContent,
|
||||||
|
DocCommentListResult,
|
||||||
|
PendingComment,
|
||||||
|
} from '../types';
|
||||||
|
import { DocCommentStore } from './doc-comment-store';
|
||||||
|
|
||||||
|
type DisposeCallback = () => void;
|
||||||
|
|
||||||
|
export class DocCommentEntity extends Entity<{
|
||||||
|
docId: string;
|
||||||
|
}> {
|
||||||
|
constructor(
|
||||||
|
private readonly snapshotHelper: SnapshotHelper,
|
||||||
|
private readonly docDisplayMetaService: DocDisplayMetaService
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
private readonly store = this.framework.createEntity(DocCommentStore, {
|
||||||
|
docId: this.props.docId,
|
||||||
|
getDocMode: () =>
|
||||||
|
this.docMode$.value === 'edgeless' ? DocMode.edgeless : DocMode.page,
|
||||||
|
getDocTitle: () => {
|
||||||
|
return this.docDisplayMetaService.title$(this.props.docId).value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
loading$ = new LiveData<boolean>(false);
|
||||||
|
comments$ = new LiveData<DocComment[]>([]);
|
||||||
|
|
||||||
|
// Only one pending comment at a time (for new comments)
|
||||||
|
readonly pendingComment$ = new LiveData<PendingComment | null>(null);
|
||||||
|
|
||||||
|
// Only one pending reply at a time
|
||||||
|
readonly pendingReply$ = new LiveData<PendingComment | null>(null);
|
||||||
|
|
||||||
|
private readonly commentAdded$ = new Subject<{
|
||||||
|
id: CommentId;
|
||||||
|
selections: BaseSelection[];
|
||||||
|
}>();
|
||||||
|
private readonly commentResolved$ = new Subject<CommentId>();
|
||||||
|
private readonly commentDeleted$ = new Subject<CommentId>();
|
||||||
|
readonly commentHighlighted$ = new LiveData<CommentId | null>(null);
|
||||||
|
|
||||||
|
private pollingDisposable?: DisposeCallback;
|
||||||
|
private startCursor?: string;
|
||||||
|
|
||||||
|
async addComment(
|
||||||
|
selections?: BaseSelection[],
|
||||||
|
preview?: string
|
||||||
|
): Promise<string> {
|
||||||
|
// todo: may need to properly bind the doc to the editor
|
||||||
|
const doc = await this.snapshotHelper.createStore();
|
||||||
|
if (!doc) {
|
||||||
|
throw new Error('Failed to create doc');
|
||||||
|
}
|
||||||
|
const id = nanoid();
|
||||||
|
const pendingComment: PendingComment = {
|
||||||
|
id,
|
||||||
|
doc,
|
||||||
|
preview,
|
||||||
|
selections,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Replace any existing pending comment (only one at a time)
|
||||||
|
this.pendingComment$.setValue(pendingComment);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addReply(commentId: string): Promise<string> {
|
||||||
|
const doc = await this.snapshotHelper.createStore();
|
||||||
|
if (!doc) {
|
||||||
|
throw new Error('Failed to create doc');
|
||||||
|
}
|
||||||
|
const id = nanoid();
|
||||||
|
const pendingReply: PendingComment = {
|
||||||
|
id,
|
||||||
|
doc,
|
||||||
|
commentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Replace any existing pending reply (only one at a time)
|
||||||
|
this.pendingReply$.setValue(pendingReply);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissDraftComment(): void {
|
||||||
|
this.pendingComment$.setValue(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissDraftReply(): void {
|
||||||
|
this.pendingReply$.setValue(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
get docMode$() {
|
||||||
|
return this.framework.get(GlobalContextService).globalContext.docMode.$;
|
||||||
|
}
|
||||||
|
|
||||||
|
async commitComment(id: string): Promise<void> {
|
||||||
|
const pendingComment = this.pendingComment$.value;
|
||||||
|
if (!pendingComment || pendingComment.id !== id) {
|
||||||
|
console.warn('Pending comment not found:', id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { doc, preview } = pendingComment;
|
||||||
|
const snapshot = this.snapshotHelper.getSnapshot(doc);
|
||||||
|
if (!snapshot) {
|
||||||
|
throw new Error('Failed to get snapshot');
|
||||||
|
}
|
||||||
|
const comment = await this.store.createComment({
|
||||||
|
content: {
|
||||||
|
snapshot,
|
||||||
|
preview,
|
||||||
|
mode: this.docMode$.value ?? 'page',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const currentComments = this.comments$.value;
|
||||||
|
this.comments$.setValue([...currentComments, comment]);
|
||||||
|
this.commentAdded$.next({
|
||||||
|
id: comment.id,
|
||||||
|
selections: pendingComment.selections || [],
|
||||||
|
});
|
||||||
|
this.pendingComment$.setValue(null);
|
||||||
|
this.revalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async commitReply(id: string): Promise<void> {
|
||||||
|
const pendingReply = this.pendingReply$.value;
|
||||||
|
if (!pendingReply || pendingReply.id !== id) {
|
||||||
|
console.warn('Pending reply not found:', id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { doc } = pendingReply;
|
||||||
|
const snapshot = this.snapshotHelper.getSnapshot(doc);
|
||||||
|
if (!snapshot) {
|
||||||
|
throw new Error('Failed to get snapshot');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingReply.commentId) {
|
||||||
|
throw new Error('Pending reply has no commentId');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reply = await this.store.createReply(pendingReply.commentId, {
|
||||||
|
content: {
|
||||||
|
snapshot,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const currentComments = this.comments$.value;
|
||||||
|
const updatedComments = currentComments.map(comment =>
|
||||||
|
comment.id === pendingReply.commentId
|
||||||
|
? { ...comment, replies: [...(comment.replies || []), reply] }
|
||||||
|
: comment
|
||||||
|
);
|
||||||
|
this.comments$.setValue(updatedComments);
|
||||||
|
this.pendingReply$.setValue(null);
|
||||||
|
this.revalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteComment(id: string): Promise<void> {
|
||||||
|
await this.store.deleteComment(id);
|
||||||
|
const currentComments = this.comments$.value;
|
||||||
|
this.comments$.setValue(currentComments.filter(c => c.id !== id));
|
||||||
|
this.commentDeleted$.next(id);
|
||||||
|
this.revalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteReply(id: string): Promise<void> {
|
||||||
|
await this.store.deleteReply(id);
|
||||||
|
const currentComments = this.comments$.value;
|
||||||
|
const updatedComments = currentComments.map(comment => {
|
||||||
|
return {
|
||||||
|
...comment,
|
||||||
|
replies: comment.replies?.filter(r => r.id !== id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
this.comments$.setValue(updatedComments);
|
||||||
|
this.revalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateComment(id: string, content: DocCommentContent): Promise<void> {
|
||||||
|
await this.store.updateComment(id, { content });
|
||||||
|
const currentComments = this.comments$.value;
|
||||||
|
const updatedComments = currentComments.map(comment =>
|
||||||
|
comment.id === id ? { ...comment, content } : comment
|
||||||
|
);
|
||||||
|
this.comments$.setValue(updatedComments);
|
||||||
|
this.revalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateReply(id: string, content: DocCommentContent): Promise<void> {
|
||||||
|
await this.store.updateReply(id, { content });
|
||||||
|
const currentComments = this.comments$.value;
|
||||||
|
const updatedComments = currentComments.map(comment =>
|
||||||
|
comment.id === id ? { ...comment, content } : comment
|
||||||
|
);
|
||||||
|
this.comments$.setValue(updatedComments);
|
||||||
|
this.revalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveComment(id: CommentId, resolved: boolean): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.store.resolveComment(id, resolved);
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
const currentComments = this.comments$.value;
|
||||||
|
const updatedComments = currentComments.map(comment =>
|
||||||
|
comment.id === id ? { ...comment, resolved } : comment
|
||||||
|
);
|
||||||
|
this.comments$.setValue(updatedComments);
|
||||||
|
|
||||||
|
this.commentResolved$.next(id);
|
||||||
|
this.revalidate();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to resolve comment:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightComment(id: CommentId | null): void {
|
||||||
|
this.commentHighlighted$.next(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getComments(): CommentId[] {
|
||||||
|
return this.comments$.value.map(comment => comment.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
onCommentAdded(
|
||||||
|
callback: (id: CommentId, selections: BaseSelection[]) => void
|
||||||
|
): DisposeCallback {
|
||||||
|
const subscription = this.commentAdded$.subscribe(({ id, selections }) =>
|
||||||
|
callback(id, selections)
|
||||||
|
);
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
onCommentResolved(callback: (id: CommentId) => void): DisposeCallback {
|
||||||
|
const subscription = this.commentResolved$.subscribe(callback);
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
onCommentDeleted(callback: (id: CommentId) => void): DisposeCallback {
|
||||||
|
const subscription = this.commentDeleted$.subscribe(callback);
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
onCommentHighlighted(
|
||||||
|
callback: (id: CommentId | null) => void
|
||||||
|
): DisposeCallback {
|
||||||
|
const subscription = this.commentHighlighted$.subscribe(callback);
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start polling comments every 30s
|
||||||
|
// 1. when comments$ is empty, fetch all comments
|
||||||
|
// 2. when comments$ is not empty, fetch changes (using end cursor)
|
||||||
|
// 3. loop. when doc is not loaded, skip
|
||||||
|
start(): void {
|
||||||
|
if (this.pollingDisposable) {
|
||||||
|
this.pollingDisposable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
this.revalidate();
|
||||||
|
|
||||||
|
// Set up polling every 10 seconds
|
||||||
|
const polling$ = timer(10000, 10000).pipe(
|
||||||
|
switchMap(() => {
|
||||||
|
// If we have comments, fetch changes; otherwise fetch all
|
||||||
|
if (this.comments$.value.length > 0) {
|
||||||
|
return fromPromise(async () => {
|
||||||
|
return await this.store.listCommentChanges({
|
||||||
|
after: this.startCursor,
|
||||||
|
});
|
||||||
|
}).pipe(
|
||||||
|
tap(changes => {
|
||||||
|
if (changes) {
|
||||||
|
this.handleCommentChanges(changes);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
catchError(error => {
|
||||||
|
console.error('Failed to fetch comment changes:', error);
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return fromPromise(async () => {
|
||||||
|
const allComments: DocComment[] = [];
|
||||||
|
let cursor = '';
|
||||||
|
let firstResult: DocCommentListResult | null = null;
|
||||||
|
|
||||||
|
// Fetch all pages of comments
|
||||||
|
while (true) {
|
||||||
|
const result = await this.store.listComments({ after: cursor });
|
||||||
|
if (!firstResult) {
|
||||||
|
firstResult = result;
|
||||||
|
// Store the startCursor from the first page for future polling
|
||||||
|
this.startCursor = result.startCursor;
|
||||||
|
}
|
||||||
|
allComments.push(...result.comments);
|
||||||
|
cursor = result.endCursor;
|
||||||
|
if (!result.hasNextPage) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state with all comments
|
||||||
|
this.comments$.setValue(allComments);
|
||||||
|
|
||||||
|
return allComments;
|
||||||
|
}).pipe(
|
||||||
|
catchError(error => {
|
||||||
|
console.error('Failed to fetch comments:', error);
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscription = polling$.subscribe();
|
||||||
|
this.pollingDisposable = () => subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.pollingDisposable) {
|
||||||
|
this.pollingDisposable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleCommentChanges(changes: DocCommentChangeListResult): void {
|
||||||
|
if (!changes || changes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentComments = [...this.comments$.value];
|
||||||
|
let commentsUpdated = false;
|
||||||
|
|
||||||
|
for (const change of changes) {
|
||||||
|
const { id, action, comment, commentId } = change;
|
||||||
|
|
||||||
|
if (commentId) {
|
||||||
|
// This is a reply change - handle separately
|
||||||
|
this.handleReplyChange(currentComments, action, comment, commentId);
|
||||||
|
commentsUpdated = true;
|
||||||
|
} else {
|
||||||
|
// This is a top-level comment change
|
||||||
|
switch (action) {
|
||||||
|
case 'update': {
|
||||||
|
// Update existing comment or add new comment if it doesn't exist
|
||||||
|
const updateIndex = currentComments.findIndex(c => c.id === id);
|
||||||
|
if (updateIndex !== -1) {
|
||||||
|
// Update existing comment
|
||||||
|
currentComments[updateIndex] = comment;
|
||||||
|
commentsUpdated = true;
|
||||||
|
} else {
|
||||||
|
// Add new comment if it doesn't exist (create event)
|
||||||
|
currentComments.push(comment);
|
||||||
|
commentsUpdated = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'delete': {
|
||||||
|
// Remove comment
|
||||||
|
const deleteIndex = currentComments.findIndex(c => c.id === id);
|
||||||
|
if (deleteIndex !== -1) {
|
||||||
|
currentComments.splice(deleteIndex, 1);
|
||||||
|
commentsUpdated = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn('Unknown comment change action:', action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the comments list if any changes were made
|
||||||
|
if (commentsUpdated) {
|
||||||
|
this.comments$.setValue(currentComments);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReplyChange(
|
||||||
|
currentComments: DocComment[],
|
||||||
|
action: CommentChangeAction,
|
||||||
|
reply: DocComment,
|
||||||
|
parentCommentId: string
|
||||||
|
): void {
|
||||||
|
const parentIndex = currentComments.findIndex(
|
||||||
|
c => c.id === parentCommentId
|
||||||
|
);
|
||||||
|
if (parentIndex === -1) {
|
||||||
|
console.warn('Parent comment not found for reply:', parentCommentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentComment = currentComments[parentIndex];
|
||||||
|
const replies = [...(parentComment.replies || [])];
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'update': {
|
||||||
|
// Update existing reply or add new reply if it doesn't exist
|
||||||
|
const updateIndex = replies.findIndex(r => r.id === reply.id);
|
||||||
|
if (updateIndex !== -1) {
|
||||||
|
// Update existing reply
|
||||||
|
replies[updateIndex] = reply;
|
||||||
|
currentComments[parentIndex] = { ...parentComment, replies };
|
||||||
|
} else {
|
||||||
|
// Add new reply if it doesn't exist (create event)
|
||||||
|
replies.push(reply);
|
||||||
|
currentComments[parentIndex] = { ...parentComment, replies };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'delete': {
|
||||||
|
// Remove reply
|
||||||
|
const deleteIndex = replies.findIndex(r => r.id === reply.id);
|
||||||
|
if (deleteIndex !== -1) {
|
||||||
|
replies.splice(deleteIndex, 1);
|
||||||
|
currentComments[parentIndex] = { ...parentComment, replies };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn('Unknown reply change action:', action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidate = effect(
|
||||||
|
switchMap(() => {
|
||||||
|
return fromPromise(async () => {
|
||||||
|
const allComments: DocComment[] = [];
|
||||||
|
let cursor = '';
|
||||||
|
let firstResult: DocCommentListResult | null = null;
|
||||||
|
|
||||||
|
// Fetch all pages of comments
|
||||||
|
while (true) {
|
||||||
|
const result = await this.store.listComments({ after: cursor });
|
||||||
|
if (!firstResult) {
|
||||||
|
firstResult = result;
|
||||||
|
// Store the startCursor from the first page for polling
|
||||||
|
this.startCursor = result.startCursor;
|
||||||
|
}
|
||||||
|
allComments.push(...result.comments);
|
||||||
|
cursor = result.endCursor;
|
||||||
|
if (!result.hasNextPage) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allComments;
|
||||||
|
}).pipe(
|
||||||
|
tap(allComments => {
|
||||||
|
// Update state with all comments
|
||||||
|
this.comments$.setValue(allComments);
|
||||||
|
}),
|
||||||
|
onStart(() => this.loading$.setValue(true)),
|
||||||
|
onComplete(() => this.loading$.setValue(false)),
|
||||||
|
catchError(error => {
|
||||||
|
console.error('Failed to fetch comments:', error);
|
||||||
|
this.loading$.setValue(false);
|
||||||
|
return of([]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
override dispose(): void {
|
||||||
|
this.stop();
|
||||||
|
this.commentAdded$.complete();
|
||||||
|
this.commentResolved$.complete();
|
||||||
|
this.commentDeleted$.complete();
|
||||||
|
this.commentHighlighted$.complete();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
29
packages/frontend/core/src/modules/comment/index.ts
Normal file
29
packages/frontend/core/src/modules/comment/index.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Framework } from '@toeverything/infra';
|
||||||
|
|
||||||
|
import { DefaultServerService, WorkspaceServerService } from '../cloud';
|
||||||
|
import { DocDisplayMetaService } from '../doc-display-meta';
|
||||||
|
import { WorkbenchService } from '../workbench';
|
||||||
|
import { WorkspaceScope, WorkspaceService } from '../workspace';
|
||||||
|
import { DocCommentEntity } from './entities/doc-comment';
|
||||||
|
import { DocCommentStore } from './entities/doc-comment-store';
|
||||||
|
import { CommentPanelService } from './services/comment-panel-service';
|
||||||
|
import { DocCommentManagerService } from './services/doc-comment-manager';
|
||||||
|
import { SnapshotHelper } from './services/snapshot-helper';
|
||||||
|
|
||||||
|
export function configureCommentModule(framework: Framework) {
|
||||||
|
framework
|
||||||
|
.scope(WorkspaceScope)
|
||||||
|
.service(DocCommentManagerService)
|
||||||
|
.service(CommentPanelService, [WorkbenchService])
|
||||||
|
.service(SnapshotHelper, [
|
||||||
|
WorkspaceService,
|
||||||
|
WorkspaceServerService,
|
||||||
|
DefaultServerService,
|
||||||
|
])
|
||||||
|
.entity(DocCommentEntity, [SnapshotHelper, DocDisplayMetaService])
|
||||||
|
.entity(DocCommentStore, [
|
||||||
|
WorkspaceService,
|
||||||
|
WorkspaceServerService,
|
||||||
|
DefaultServerService,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { type WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
|
import { Service } from '@toeverything/infra';
|
||||||
|
|
||||||
|
import type { DocCommentEntity } from '../entities/doc-comment';
|
||||||
|
|
||||||
|
export class CommentPanelService extends Service {
|
||||||
|
constructor(private readonly workbenchService: WorkbenchService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly activePendingWatchers = new Set<() => void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch for pending comments on a doc comment entity and open the sidebar automatically
|
||||||
|
*/
|
||||||
|
watchForPendingComments(entity: DocCommentEntity): () => void {
|
||||||
|
const subscription = entity.pendingComment$.subscribe(pendingComment => {
|
||||||
|
// If we have a new pending comment, open the comment panel
|
||||||
|
if (pendingComment) {
|
||||||
|
this.openCommentPanel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const dispose = () => {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
this.activePendingWatchers.delete(dispose);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.activePendingWatchers.add(dispose);
|
||||||
|
return dispose;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the sidebar and activate the comment tab
|
||||||
|
*/
|
||||||
|
openCommentPanel(): void {
|
||||||
|
const workbench = this.workbenchService.workbench;
|
||||||
|
const activeView = workbench.activeView$.value;
|
||||||
|
|
||||||
|
if (activeView) {
|
||||||
|
workbench.openSidebar();
|
||||||
|
activeView.activeSidebarTab('comment');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override dispose(): void {
|
||||||
|
// Clean up all active watchers
|
||||||
|
for (const dispose of this.activePendingWatchers) {
|
||||||
|
dispose();
|
||||||
|
}
|
||||||
|
this.activePendingWatchers.clear();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ObjectPool, Service } from '@toeverything/infra';
|
||||||
|
|
||||||
|
import { DocCommentEntity } from '../entities/doc-comment';
|
||||||
|
|
||||||
|
type DocId = string;
|
||||||
|
|
||||||
|
export class DocCommentManagerService extends Service {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly pool = new ObjectPool<DocId, DocCommentEntity>({
|
||||||
|
onDelete: entity => {
|
||||||
|
entity.dispose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
get(docId: DocId) {
|
||||||
|
let commentRef = this.pool.get(docId);
|
||||||
|
if (!commentRef) {
|
||||||
|
const comment = this.framework.createEntity(DocCommentEntity, {
|
||||||
|
docId,
|
||||||
|
});
|
||||||
|
commentRef = this.pool.put(docId, comment);
|
||||||
|
// todo: add LRU cache for the pool?
|
||||||
|
}
|
||||||
|
return commentRef;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { getStoreManager } from '@affine/core/blocksuite/manager/store';
|
||||||
|
import { Container } from '@blocksuite/affine/global/di';
|
||||||
|
import {
|
||||||
|
customImageProxyMiddleware,
|
||||||
|
MarkdownAdapter,
|
||||||
|
} from '@blocksuite/affine/shared/adapters';
|
||||||
|
import {
|
||||||
|
type DocSnapshot,
|
||||||
|
nanoid,
|
||||||
|
type Store,
|
||||||
|
Text,
|
||||||
|
Transformer,
|
||||||
|
} from '@blocksuite/affine/store';
|
||||||
|
import { Service } from '@toeverything/infra';
|
||||||
|
import { Doc as YDoc } from 'yjs';
|
||||||
|
|
||||||
|
import type { DefaultServerService, WorkspaceServerService } from '../../cloud';
|
||||||
|
import {
|
||||||
|
getAFFiNEWorkspaceSchema,
|
||||||
|
type WorkspaceService,
|
||||||
|
} from '../../workspace';
|
||||||
|
import { WorkspaceImpl } from '../../workspace/impls/workspace';
|
||||||
|
|
||||||
|
export class SnapshotHelper extends Service {
|
||||||
|
constructor(
|
||||||
|
private readonly workspaceService: WorkspaceService,
|
||||||
|
private readonly workspaceServerService: WorkspaceServerService,
|
||||||
|
private readonly defaultServerService: DefaultServerService
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
private get serverService() {
|
||||||
|
return (
|
||||||
|
this.workspaceServerService.server || this.defaultServerService.server
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTempWorkspace() {
|
||||||
|
const collection = new WorkspaceImpl({
|
||||||
|
rootDoc: new YDoc({ guid: 'markdownToDoc' + nanoid() }),
|
||||||
|
blobSource: {
|
||||||
|
name: 'cloud',
|
||||||
|
readonly: true,
|
||||||
|
get: async key => {
|
||||||
|
const record =
|
||||||
|
await this.workspaceService.workspace.engine.blob.get(key);
|
||||||
|
return record ? new Blob([record.data], { type: record.mime }) : null;
|
||||||
|
},
|
||||||
|
set() {
|
||||||
|
return Promise.resolve('');
|
||||||
|
},
|
||||||
|
delete() {
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
list() {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
collection.meta.initialize();
|
||||||
|
return collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: cache the transformer?
|
||||||
|
getTransformer() {
|
||||||
|
const collection = this.getTempWorkspace();
|
||||||
|
const schema = getAFFiNEWorkspaceSchema();
|
||||||
|
const imageProxyUrl = new URL(
|
||||||
|
BUILD_CONFIG.imageProxyUrl,
|
||||||
|
this.serverService.baseUrl
|
||||||
|
).toString();
|
||||||
|
|
||||||
|
const middlewares = [customImageProxyMiddleware(imageProxyUrl)];
|
||||||
|
const transformer = new Transformer({
|
||||||
|
schema,
|
||||||
|
blobCRUD: collection.blobSync,
|
||||||
|
docCRUD: {
|
||||||
|
create: (id: string) => {
|
||||||
|
const doc = collection.createDoc(id);
|
||||||
|
return doc.getStore({ id });
|
||||||
|
},
|
||||||
|
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
|
||||||
|
delete: (id: string) => collection.removeDoc(id),
|
||||||
|
},
|
||||||
|
middlewares,
|
||||||
|
});
|
||||||
|
return transformer;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMarkdownAdapter() {
|
||||||
|
const transformer = this.getTransformer();
|
||||||
|
const extensions = getStoreManager().config.init().value.get('store');
|
||||||
|
const container = new Container();
|
||||||
|
extensions.forEach(ext => {
|
||||||
|
ext.setup(container);
|
||||||
|
});
|
||||||
|
const mdAdapter = new MarkdownAdapter(transformer, container.provider());
|
||||||
|
return mdAdapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnapshot(doc: Store): DocSnapshot | undefined {
|
||||||
|
const transformer = this.getTransformer();
|
||||||
|
if (!transformer) {
|
||||||
|
throw new Error('Markdown transformer not found');
|
||||||
|
}
|
||||||
|
const result = transformer.docToSnapshot(doc);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createStore(snapshot?: DocSnapshot): Promise<Store | undefined> {
|
||||||
|
if (snapshot) {
|
||||||
|
const transformer = this.getTransformer();
|
||||||
|
if (!transformer) {
|
||||||
|
throw new Error('Markdown transformer not found');
|
||||||
|
}
|
||||||
|
return await transformer.snapshotToDoc(snapshot);
|
||||||
|
} else {
|
||||||
|
const collection = this.getTempWorkspace();
|
||||||
|
if (!collection) {
|
||||||
|
throw new Error('Temp workspace not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary doc with proper structure
|
||||||
|
const doc = collection.createDoc();
|
||||||
|
const store = doc.getStore();
|
||||||
|
store.load(() => {
|
||||||
|
// Add root page block with empty title
|
||||||
|
const rootId = store.addBlock('affine:page', {
|
||||||
|
title: new Text(''),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add surface block
|
||||||
|
store.addBlock('affine:surface', {}, rootId);
|
||||||
|
|
||||||
|
// Add note block
|
||||||
|
const noteId = store.addBlock('affine:note', {}, rootId);
|
||||||
|
|
||||||
|
// Add default paragraph block
|
||||||
|
store.addBlock('affine:paragraph', {}, noteId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset history to prevent initial creation operations from being undone
|
||||||
|
store.resetHistory();
|
||||||
|
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createEmptySnapshot() {
|
||||||
|
const store = await this.createStore();
|
||||||
|
if (store) {
|
||||||
|
return this.getSnapshot(store);
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
packages/frontend/core/src/modules/comment/types.ts
Normal file
54
packages/frontend/core/src/modules/comment/types.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { CommentChangeAction, PublicUserType } from '@affine/graphql';
|
||||||
|
import type { DocMode } from '@blocksuite/affine/model';
|
||||||
|
import type {
|
||||||
|
BaseSelection,
|
||||||
|
DocSnapshot,
|
||||||
|
Store,
|
||||||
|
} from '@blocksuite/affine/store';
|
||||||
|
|
||||||
|
export type CommentId = string;
|
||||||
|
|
||||||
|
export interface BaseComment {
|
||||||
|
id: CommentId;
|
||||||
|
content: DocCommentContent;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
user: PublicUserType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocComment extends BaseComment {
|
||||||
|
resolved: boolean;
|
||||||
|
replies?: DocCommentReply[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PendingComment = {
|
||||||
|
id: CommentId;
|
||||||
|
doc: Store;
|
||||||
|
preview?: string;
|
||||||
|
selections?: BaseSelection[];
|
||||||
|
commentId?: CommentId; // only for replies, points to the parent comment
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocCommentReply = BaseComment;
|
||||||
|
|
||||||
|
export type DocCommentContent = {
|
||||||
|
snapshot: DocSnapshot; // blocksuite snapshot
|
||||||
|
mode?: DocMode;
|
||||||
|
preview?: string; // text preview of the target
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DocCommentListResult {
|
||||||
|
comments: DocComment[];
|
||||||
|
hasNextPage: boolean;
|
||||||
|
startCursor: string;
|
||||||
|
endCursor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocCommentChange {
|
||||||
|
action: CommentChangeAction;
|
||||||
|
comment: DocComment;
|
||||||
|
id: CommentId; // the id of the comment or reply
|
||||||
|
commentId?: CommentId; // a change with comment id is a reply
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocCommentChangeListResult = DocCommentChange[];
|
||||||
@@ -3,9 +3,13 @@ import type { DefaultOpenProperty } from '@affine/core/components/properties';
|
|||||||
import { PresentTool } from '@blocksuite/affine/blocks/frame';
|
import { PresentTool } from '@blocksuite/affine/blocks/frame';
|
||||||
import { DefaultTool } from '@blocksuite/affine/blocks/surface';
|
import { DefaultTool } from '@blocksuite/affine/blocks/surface';
|
||||||
import type { DocTitle } from '@blocksuite/affine/fragments/doc-title';
|
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 type { DocMode, ReferenceParams } from '@blocksuite/affine/model';
|
||||||
import { HighlightSelection } from '@blocksuite/affine/shared/selection';
|
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 { GfxControllerIdentifier } from '@blocksuite/affine/std/gfx';
|
||||||
import type { InlineEditor } from '@blocksuite/std/inline';
|
import type { InlineEditor } from '@blocksuite/std/inline';
|
||||||
import { effect } from '@preact/signals-core';
|
import { effect } from '@preact/signals-core';
|
||||||
@@ -51,6 +55,7 @@ export class Editor extends Entity {
|
|||||||
const selector = get(this.selector$);
|
const selector = get(this.selector$);
|
||||||
const mode = get(this.mode$);
|
const mode = get(this.mode$);
|
||||||
let id = selector?.blockIds?.[0];
|
let id = selector?.blockIds?.[0];
|
||||||
|
let commentId = selector?.commentId;
|
||||||
let key = 'blockIds';
|
let key = 'blockIds';
|
||||||
|
|
||||||
if (mode === 'edgeless') {
|
if (mode === 'edgeless') {
|
||||||
@@ -61,9 +66,15 @@ export class Editor extends Entity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!id) return null;
|
if (!id && !commentId) return null;
|
||||||
|
|
||||||
return { id, key, mode, refreshKey: selector?.refreshKey };
|
return {
|
||||||
|
id,
|
||||||
|
key,
|
||||||
|
mode,
|
||||||
|
refreshKey: selector?.refreshKey,
|
||||||
|
commentId: commentId,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
isPresenting$ = new LiveData<boolean>(false);
|
isPresenting$ = new LiveData<boolean>(false);
|
||||||
@@ -183,6 +194,51 @@ export class Editor extends Entity {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleFocusAt(focusAt: {
|
||||||
|
key: string;
|
||||||
|
mode: DocMode;
|
||||||
|
id?: string;
|
||||||
|
commentId?: string;
|
||||||
|
}) {
|
||||||
|
const editorContainer = this.editorContainer$.value;
|
||||||
|
if (!editorContainer) return;
|
||||||
|
|
||||||
|
const selection = editorContainer.host?.std.selection;
|
||||||
|
const { id, key, mode, commentId } = focusAt;
|
||||||
|
|
||||||
|
let finalId = id;
|
||||||
|
let finalKey = key;
|
||||||
|
|
||||||
|
// If we have commentId but no blockId, find the block from the comment
|
||||||
|
if (commentId && !id && editorContainer.host?.std) {
|
||||||
|
const std = editorContainer.host.std;
|
||||||
|
|
||||||
|
// First try to find inline commented texts
|
||||||
|
const inlineCommentedSelections = findCommentedTexts(std, commentId);
|
||||||
|
if (inlineCommentedSelections.length > 0) {
|
||||||
|
const firstSelection = inlineCommentedSelections[0][0];
|
||||||
|
finalId = firstSelection.from.blockId;
|
||||||
|
finalKey = 'blockIds';
|
||||||
|
} else {
|
||||||
|
// Then try to find block comments
|
||||||
|
const blockCommentedBlocks = findCommentedBlocks(std.store, commentId);
|
||||||
|
if (blockCommentedBlocks.length > 0) {
|
||||||
|
finalId = blockCommentedBlocks[0].id;
|
||||||
|
finalKey = 'blockIds';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === this.mode$.value && finalId) {
|
||||||
|
selection?.setGroup('scene', [
|
||||||
|
selection?.create(HighlightSelection, {
|
||||||
|
mode,
|
||||||
|
[finalKey]: [finalId],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bindEditorContainer(
|
bindEditorContainer(
|
||||||
editorContainer: AffineEditorContainer,
|
editorContainer: AffineEditorContainer,
|
||||||
docTitle?: DocTitle | null,
|
docTitle?: DocTitle | null,
|
||||||
@@ -228,18 +284,7 @@ export class Editor extends Entity {
|
|||||||
title?.inlineEditor?.focusEnd();
|
title?.inlineEditor?.focusEnd();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const selection = editorContainer.host?.std.selection;
|
this.handleFocusAt(initialFocusAt);
|
||||||
|
|
||||||
const { id, key, mode } = initialFocusAt;
|
|
||||||
|
|
||||||
if (mode === this.mode$.value) {
|
|
||||||
selection?.setGroup('scene', [
|
|
||||||
selection?.create(HighlightSelection, {
|
|
||||||
mode,
|
|
||||||
[key]: [id],
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,18 +324,7 @@ export class Editor extends Entity {
|
|||||||
.pipe(skip(1))
|
.pipe(skip(1))
|
||||||
.subscribe(anchor => {
|
.subscribe(anchor => {
|
||||||
if (!anchor) return;
|
if (!anchor) return;
|
||||||
|
this.handleFocusAt(anchor);
|
||||||
const selection = editorContainer.host?.std.selection;
|
|
||||||
if (!selection) return;
|
|
||||||
|
|
||||||
const { id, key, mode } = anchor;
|
|
||||||
|
|
||||||
selection.setGroup('scene', [
|
|
||||||
selection.create(HighlightSelection, {
|
|
||||||
mode,
|
|
||||||
[key]: [id],
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
unsubs.push(subscription.unsubscribe.bind(subscription));
|
unsubs.push(subscription.unsubscribe.bind(subscription));
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export type EditorSelector = {
|
export type EditorSelector = {
|
||||||
blockIds?: string[];
|
blockIds?: string[];
|
||||||
elementIds?: string[];
|
elementIds?: string[];
|
||||||
|
commentId?: string;
|
||||||
refreshKey?: string;
|
refreshKey?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -265,7 +265,8 @@ export const AFFINE_FLAGS = {
|
|||||||
defaultState: false,
|
defaultState: false,
|
||||||
},
|
},
|
||||||
enable_comment: {
|
enable_comment: {
|
||||||
category: 'affine',
|
category: 'blocksuite',
|
||||||
|
bsFlag: 'enable_comment',
|
||||||
displayName: 'Enable Comment',
|
displayName: 'Enable Comment',
|
||||||
description: 'Enable comment',
|
description: 'Enable comment',
|
||||||
configurable: isCanaryBuild,
|
configurable: isCanaryBuild,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { configureBlobManagementModule } from './blob-management';
|
|||||||
import { configureCloudModule } from './cloud';
|
import { configureCloudModule } from './cloud';
|
||||||
import { configureCollectionModule } from './collection';
|
import { configureCollectionModule } from './collection';
|
||||||
import { configureCollectionRulesModule } from './collection-rules';
|
import { configureCollectionRulesModule } from './collection-rules';
|
||||||
|
import { configureCommentModule } from './comment';
|
||||||
import { configureWorkspaceDBModule } from './db';
|
import { configureWorkspaceDBModule } from './db';
|
||||||
import { configureDialogModule } from './dialogs';
|
import { configureDialogModule } from './dialogs';
|
||||||
import { configureDndModule } from './dnd';
|
import { configureDndModule } from './dnd';
|
||||||
@@ -118,4 +119,5 @@ export function configureCommonModules(framework: Framework) {
|
|||||||
configureWorkspacePropertyModule(framework);
|
configureWorkspacePropertyModule(framework);
|
||||||
configureCollectionRulesModule(framework);
|
configureCollectionRulesModule(framework);
|
||||||
configureIndexerEmbeddingModule(framework);
|
configureIndexerEmbeddingModule(framework);
|
||||||
|
configureCommentModule(framework);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ export const preprocessParams = (
|
|||||||
'databaseId',
|
'databaseId',
|
||||||
'databaseRowId',
|
'databaseRowId',
|
||||||
'refreshKey',
|
'refreshKey',
|
||||||
|
'commentId',
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -171,6 +172,7 @@ export const paramsParseOptions: ParseOptions = {
|
|||||||
databaseId: 'string',
|
databaseId: 'string',
|
||||||
databaseRowId: 'string',
|
databaseRowId: 'string',
|
||||||
refreshKey: 'string',
|
refreshKey: 'string',
|
||||||
|
commentId: 'string',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
{
|
{
|
||||||
"ar": 100,
|
"ar": 99,
|
||||||
"ca": 4,
|
"ca": 4,
|
||||||
"da": 4,
|
"da": 4,
|
||||||
"de": 100,
|
"de": 99,
|
||||||
"el-GR": 100,
|
"el-GR": 99,
|
||||||
"en": 100,
|
"en": 100,
|
||||||
"es-AR": 100,
|
"es-AR": 99,
|
||||||
"es-CL": 100,
|
"es-CL": 100,
|
||||||
"es": 100,
|
"es": 99,
|
||||||
"fa": 100,
|
"fa": 99,
|
||||||
"fr": 100,
|
"fr": 99,
|
||||||
"hi": 2,
|
"hi": 2,
|
||||||
"it-IT": 100,
|
"it-IT": 99,
|
||||||
"it": 1,
|
"it": 1,
|
||||||
"ja": 100,
|
"ja": 99,
|
||||||
"ko": 53,
|
"ko": 53,
|
||||||
"pl": 100,
|
"pl": 99,
|
||||||
"pt-BR": 100,
|
"pt-BR": 99,
|
||||||
"ru": 100,
|
"ru": 99,
|
||||||
"sv-SE": 100,
|
"sv-SE": 99,
|
||||||
"uk": 100,
|
"uk": 99,
|
||||||
"ur": 2,
|
"ur": 2,
|
||||||
"zh-Hans": 100,
|
"zh-Hans": 99,
|
||||||
"zh-Hant": 100
|
"zh-Hant": 99
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8212,6 +8212,50 @@ export function useAFFiNEI18N(): {
|
|||||||
* `Migrate data`
|
* `Migrate data`
|
||||||
*/
|
*/
|
||||||
["com.affine.migration-all-docs-notification.button"](): string;
|
["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.`
|
* `An internal error occurred.`
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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.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.error": "Migration failed: {{errorMessage}}",
|
||||||
"com.affine.migration-all-docs-notification.button": "Migrate data",
|
"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.INTERNAL_SERVER_ERROR": "An internal error occurred.",
|
||||||
"error.NETWORK_ERROR": "Network error.",
|
"error.NETWORK_ERROR": "Network error.",
|
||||||
"error.TOO_MANY_REQUEST": "Too many requests.",
|
"error.TOO_MANY_REQUEST": "Too many requests.",
|
||||||
|
|||||||
Reference in New Issue
Block a user