mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-17 01:56:27 +08:00
feat(core): comment panel (#12989)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced a full-featured document comment system with sidebar for viewing, filtering, replying, resolving, and managing comments and replies. * Added a rich text comment editor supporting editable and read-only modes. * Enabled comment-based navigation and highlighting within documents. * Integrated comment functionality into the workspace sidebar (excluding local workspaces). * Added internationalization support and new UI strings for comment features. * Added new feature flag `enable_comment` for toggling comment functionality. * Enhanced editor focus to support comment-related selections. * Added snapshot and store helpers for comment content management. * Implemented backend GraphQL support for comment and reply operations. * Added services for comment entity management and comment panel behavior. * Extended comment configuration to support optional framework providers. * Added preview generation from user selections when creating comments. * Enabled automatic sidebar opening on new pending comments. * Added comment-related query parameter support for navigation. * Included inline comment module exports for integration. * Improved comment provider implementation with full lifecycle management and UI integration. * Added comment highlight state tracking and refined selection handling in inline comments. * **Style** * Added comprehensive styles for the comment editor and sidebar components. * **Chores** * Updated language completeness percentages for multiple locales. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: L-Sun <zover.v@gmail.com>
This commit is contained in:
@@ -174,6 +174,7 @@
|
||||
"./inlines/footnote": "./src/inlines/footnote/index.ts",
|
||||
"./inlines/footnote/view": "./src/inlines/footnote/view.ts",
|
||||
"./inlines/footnote/store": "./src/inlines/footnote/store.ts",
|
||||
"./inlines/comment": "./src/inlines/comment/index.ts",
|
||||
"./inlines/latex": "./src/inlines/latex/index.ts",
|
||||
"./inlines/latex/store": "./src/inlines/latex/store.ts",
|
||||
"./inlines/latex/view": "./src/inlines/latex/view.ts",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from '@blocksuite/affine-inline-comment';
|
||||
@@ -1 +1,2 @@
|
||||
export * from './inline-spec';
|
||||
export * from './utils';
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
TextSelection,
|
||||
} from '@blocksuite/std';
|
||||
import type { BaseSelection, BlockModel } from '@blocksuite/store';
|
||||
import { signal } from '@preact/signals-core';
|
||||
|
||||
import { extractCommentIdFromDelta, findCommentedTexts } from './utils';
|
||||
|
||||
@@ -20,6 +21,8 @@ export class InlineCommentManager extends LifeCycleWatcher {
|
||||
|
||||
private readonly _disposables = new DisposableGroup();
|
||||
|
||||
private readonly _highlightedCommentId$ = signal<CommentId | null>(null);
|
||||
|
||||
private get _provider() {
|
||||
return this.std.getOptional(CommentProviderIdentifier);
|
||||
}
|
||||
@@ -35,6 +38,9 @@ export class InlineCommentManager extends LifeCycleWatcher {
|
||||
this._disposables.add(
|
||||
provider.onCommentResolved(this._handleDeleteAndResolve)
|
||||
);
|
||||
this._disposables.add(
|
||||
provider.onCommentHighlighted(this._handleHighlightComment)
|
||||
);
|
||||
this._disposables.add(
|
||||
this.std.selection.slots.changed.subscribe(this._handleSelectionChanged)
|
||||
);
|
||||
@@ -131,14 +137,20 @@ export class InlineCommentManager extends LifeCycleWatcher {
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _handleHighlightComment = (id: CommentId | null) => {
|
||||
this._highlightedCommentId$.value = id;
|
||||
};
|
||||
|
||||
private readonly _handleSelectionChanged = (selections: BaseSelection[]) => {
|
||||
const currentHighlightedCommentId = this._highlightedCommentId$.peek();
|
||||
|
||||
if (selections.length === 1) {
|
||||
const selection = selections[0];
|
||||
|
||||
// InlineCommentManager only handle text selection
|
||||
if (!selection.is(TextSelection)) return;
|
||||
|
||||
if (!selection.isCollapsed()) {
|
||||
if (!selection.isCollapsed() && currentHighlightedCommentId !== null) {
|
||||
this._provider?.highlightComment(null);
|
||||
return;
|
||||
}
|
||||
@@ -156,6 +168,8 @@ export class InlineCommentManager extends LifeCycleWatcher {
|
||||
if (commentIds.length !== 0) return;
|
||||
}
|
||||
|
||||
this._provider?.highlightComment(null);
|
||||
if (currentHighlightedCommentId !== null) {
|
||||
this._provider?.highlightComment(null);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export const ReferenceParamsSchema = z
|
||||
databaseId: z.string().optional(),
|
||||
databaseRowId: z.string().optional(),
|
||||
xywh: SerializedXYWHSchema.optional(),
|
||||
commentId: z.string().optional(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface BlockSuiteFlags {
|
||||
enable_table_virtual_scroll: boolean;
|
||||
enable_turbo_renderer: boolean;
|
||||
enable_dom_renderer: boolean;
|
||||
enable_comment: boolean;
|
||||
}
|
||||
|
||||
export class FeatureFlagService extends StoreExtension {
|
||||
@@ -46,6 +47,7 @@ export class FeatureFlagService extends StoreExtension {
|
||||
enable_table_virtual_scroll: false,
|
||||
enable_turbo_renderer: false,
|
||||
enable_dom_renderer: false,
|
||||
enable_comment: false,
|
||||
});
|
||||
|
||||
setFlag(key: keyof BlockSuiteFlags, value: boolean) {
|
||||
|
||||
@@ -109,7 +109,7 @@ const usePatchSpecs = (mode: DocMode) => {
|
||||
.electron(framework)
|
||||
.linkPreview(framework)
|
||||
.codeBlockHtmlPreview(framework)
|
||||
.comment(enableComment).value;
|
||||
.comment(enableComment, framework).value;
|
||||
|
||||
if (BUILD_CONFIG.isMobileEdition) {
|
||||
if (mode === 'page') {
|
||||
|
||||
@@ -57,7 +57,10 @@ type Configure = {
|
||||
electron: (framework?: FrameworkProvider) => Configure;
|
||||
linkPreview: (framework?: FrameworkProvider) => Configure;
|
||||
codeBlockHtmlPreview: (framework?: FrameworkProvider) => Configure;
|
||||
comment: (enableComment?: boolean) => Configure;
|
||||
comment: (
|
||||
enableComment?: boolean,
|
||||
framework?: FrameworkProvider
|
||||
) => Configure;
|
||||
|
||||
value: ViewExtensionManager;
|
||||
};
|
||||
@@ -92,6 +95,7 @@ class ViewProvider {
|
||||
ElectronViewExtension,
|
||||
AffineLinkPreviewExtension,
|
||||
AffineDatabaseViewExtension,
|
||||
CommentViewExtension,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -328,8 +332,14 @@ class ViewProvider {
|
||||
return this.config;
|
||||
};
|
||||
|
||||
private readonly _configureComment = (enableComment?: boolean) => {
|
||||
this._manager.configure(CommentViewExtension, { enableComment });
|
||||
private readonly _configureComment = (
|
||||
enableComment?: boolean,
|
||||
framework?: FrameworkProvider
|
||||
) => {
|
||||
this._manager.configure(CommentViewExtension, {
|
||||
enableComment,
|
||||
framework,
|
||||
});
|
||||
return this.config;
|
||||
};
|
||||
}
|
||||
|
||||
+174
-12
@@ -1,14 +1,176 @@
|
||||
import { noop } from '@blocksuite/affine/global/utils';
|
||||
import { CommentProviderExtension } from '@blocksuite/affine/shared/services';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { getSelectedBlocksCommand } from '@blocksuite/affine/shared/commands';
|
||||
import type { CommentProvider } from '@blocksuite/affine/shared/services';
|
||||
import { CommentProviderIdentifier } from '@blocksuite/affine/shared/services';
|
||||
import type { BlockStdScope } from '@blocksuite/affine/std';
|
||||
import { StdIdentifier } from '@blocksuite/affine/std';
|
||||
import type { BaseSelection, ExtensionType } from '@blocksuite/affine/store';
|
||||
import { type Container } from '@blocksuite/global/di';
|
||||
import { BlockSelection, TextSelection } from '@blocksuite/std';
|
||||
import type { FrameworkProvider } from '@toeverything/infra';
|
||||
|
||||
export const AffineCommentProvider = CommentProviderExtension({
|
||||
addComment: noop,
|
||||
resolveComment: noop,
|
||||
highlightComment: noop,
|
||||
getComments: () => [],
|
||||
import { DocCommentManagerService } from '../../../modules/comment/services/doc-comment-manager';
|
||||
|
||||
onCommentAdded: () => noop,
|
||||
onCommentResolved: () => noop,
|
||||
onCommentDeleted: () => noop,
|
||||
onCommentHighlighted: () => noop,
|
||||
});
|
||||
function getPreviewFromSelections(
|
||||
std: BlockStdScope,
|
||||
selections: BaseSelection[]
|
||||
): string {
|
||||
if (!selections || selections.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const previews: string[] = [];
|
||||
|
||||
for (const selection of selections) {
|
||||
if (selection instanceof TextSelection) {
|
||||
// Extract text from TextSelection
|
||||
const textPreview = extractTextFromSelection(std, selection);
|
||||
if (textPreview) {
|
||||
previews.push(textPreview);
|
||||
}
|
||||
} else if (selection instanceof BlockSelection) {
|
||||
// Get block flavour for BlockSelection
|
||||
const block = std.store.getBlock(selection.blockId);
|
||||
if (block?.model) {
|
||||
const flavour = block.model.flavour.replace('affine:', '');
|
||||
previews.push(`<${flavour}>`);
|
||||
}
|
||||
} else if (selection.type === 'image') {
|
||||
// Return <"Image"> for ImageSelection
|
||||
previews.push('<Image>');
|
||||
}
|
||||
// Skip other types
|
||||
}
|
||||
|
||||
return previews.length > 0 ? previews.join(' ') : 'New comment';
|
||||
}
|
||||
|
||||
function extractTextFromSelection(
|
||||
std: BlockStdScope,
|
||||
selection: TextSelection
|
||||
): string | null {
|
||||
try {
|
||||
const [_, ctx] = std.command
|
||||
.chain()
|
||||
.pipe(getSelectedBlocksCommand, {
|
||||
currentTextSelection: selection,
|
||||
types: ['text'],
|
||||
})
|
||||
.run();
|
||||
|
||||
const blocks = ctx.selectedBlocks;
|
||||
if (!blocks || blocks.length === 0) return null;
|
||||
|
||||
const { from, to } = selection;
|
||||
const quote = blocks.reduce((acc, block, index) => {
|
||||
const text = block.model.text;
|
||||
if (!text) return acc;
|
||||
|
||||
if (index === 0) {
|
||||
// First block: extract from 'from.index' for 'from.length' characters
|
||||
const startText = text.yText
|
||||
.toString()
|
||||
.slice(from.index, from.index + from.length);
|
||||
return acc + startText;
|
||||
}
|
||||
|
||||
if (index === blocks.length - 1 && to) {
|
||||
// Last block: extract from start to 'to.index + to.length'
|
||||
const endText = text.yText.toString().slice(0, to.index + to.length);
|
||||
return acc + (acc ? ' ' : '') + endText;
|
||||
}
|
||||
|
||||
// Middle blocks: get all text
|
||||
const blockText = text.yText.toString();
|
||||
return acc + (acc ? ' ' : '') + blockText;
|
||||
}, '');
|
||||
|
||||
// Trim and limit length for preview
|
||||
const trimmed = quote.trim();
|
||||
return trimmed.length > 200 ? trimmed.substring(0, 200) + '...' : trimmed;
|
||||
} catch (error) {
|
||||
console.warn('Failed to extract text from selection:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class AffineCommentService implements CommentProvider {
|
||||
private readonly docCommentManager: DocCommentManagerService;
|
||||
|
||||
constructor(
|
||||
private readonly std: BlockStdScope,
|
||||
private readonly framework: FrameworkProvider
|
||||
) {
|
||||
this.docCommentManager = framework.get(DocCommentManagerService);
|
||||
}
|
||||
|
||||
private get currentDocId(): string {
|
||||
return this.std.store.id;
|
||||
}
|
||||
|
||||
// todo: need to handle resource leak
|
||||
private get commentEntityRef() {
|
||||
return this.docCommentManager.get(this.currentDocId);
|
||||
}
|
||||
|
||||
private get commentEntity() {
|
||||
return this.commentEntityRef.obj;
|
||||
}
|
||||
|
||||
addComment(selections: BaseSelection[]): void {
|
||||
const workbench = this.framework.get(WorkbenchService).workbench;
|
||||
workbench.setSidebarOpen(true);
|
||||
workbench.activeView$.value.activeSidebarTab('comment');
|
||||
const preview = getPreviewFromSelections(this.std, selections);
|
||||
this.commentEntity.addComment(selections, preview).catch(console.error);
|
||||
}
|
||||
|
||||
resolveComment(id: string): void {
|
||||
this.commentEntity.resolveComment(id, true).catch(console.error);
|
||||
}
|
||||
|
||||
highlightComment(id: string | null): void {
|
||||
if (id !== null) {
|
||||
const workbench = this.framework.get(WorkbenchService).workbench;
|
||||
workbench.setSidebarOpen(true);
|
||||
workbench.activeView$.value.activeSidebarTab('comment');
|
||||
}
|
||||
this.commentEntity.highlightComment(id);
|
||||
}
|
||||
|
||||
getComments(): string[] {
|
||||
return this.commentEntity.getComments();
|
||||
}
|
||||
|
||||
onCommentAdded(callback: (id: string, selections: BaseSelection[]) => void) {
|
||||
return this.commentEntity.onCommentAdded((id, selections) => {
|
||||
callback(id, selections);
|
||||
});
|
||||
}
|
||||
|
||||
onCommentResolved(callback: (id: string) => void) {
|
||||
return this.commentEntity.onCommentResolved(callback);
|
||||
}
|
||||
|
||||
onCommentDeleted(callback: (id: string) => void) {
|
||||
return this.commentEntity.onCommentDeleted(callback);
|
||||
}
|
||||
|
||||
onCommentHighlighted(callback: (id: string | null) => void) {
|
||||
return this.commentEntity.onCommentHighlighted(callback);
|
||||
}
|
||||
}
|
||||
|
||||
export function AffineCommentProvider(
|
||||
framework: FrameworkProvider
|
||||
): ExtensionType {
|
||||
return {
|
||||
setup: (di: Container) => {
|
||||
di.addImpl(
|
||||
CommentProviderIdentifier,
|
||||
provider =>
|
||||
new AffineCommentService(provider.get(StdIdentifier), framework)
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,26 +2,30 @@ import {
|
||||
type ViewExtensionContext,
|
||||
ViewExtensionProvider,
|
||||
} from '@blocksuite/affine/ext-loader';
|
||||
import { FrameworkProvider } from '@toeverything/infra';
|
||||
import z from 'zod';
|
||||
|
||||
import { AffineCommentProvider } from './comment-provider';
|
||||
|
||||
const optionsSchema = z.object({
|
||||
enableComment: z.boolean().optional(),
|
||||
framework: z.instanceof(FrameworkProvider).optional(),
|
||||
});
|
||||
|
||||
export class CommentViewExtension extends ViewExtensionProvider {
|
||||
type CommentViewOptions = z.infer<typeof optionsSchema>;
|
||||
|
||||
export class CommentViewExtension extends ViewExtensionProvider<CommentViewOptions> {
|
||||
override name = 'comment';
|
||||
|
||||
override schema = optionsSchema;
|
||||
|
||||
override setup(
|
||||
context: ViewExtensionContext,
|
||||
options?: z.infer<typeof optionsSchema>
|
||||
) {
|
||||
override setup(context: ViewExtensionContext, options?: CommentViewOptions) {
|
||||
super.setup(context, options);
|
||||
if (!options?.enableComment) return;
|
||||
|
||||
context.register([AffineCommentProvider]);
|
||||
const framework = options.framework;
|
||||
if (!framework) return;
|
||||
|
||||
context.register([AffineCommentProvider(framework)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding';
|
||||
import { GlobalPageHistoryModal } from '@affine/core/components/affine/page-history-modal';
|
||||
import { CommentSidebar } from '@affine/core/components/comment/sidebar';
|
||||
import { useGuard } from '@affine/core/components/guard';
|
||||
import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper';
|
||||
import { useEnableAI } from '@affine/core/components/hooks/affine/use-enable-ai';
|
||||
@@ -37,6 +38,7 @@ import { DisposableGroup } from '@blocksuite/affine/global/disposable';
|
||||
import { RefNodeSlotsProvider } from '@blocksuite/affine/inlines/reference';
|
||||
import {
|
||||
AiIcon,
|
||||
CommentIcon,
|
||||
ExportIcon,
|
||||
FrameIcon,
|
||||
PropertyIcon,
|
||||
@@ -381,6 +383,17 @@ const DetailPageImpl = memo(function DetailPageImpl() {
|
||||
</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 />
|
||||
{/* FIXME: wait for better ai, <PageAIOnboarding /> */}
|
||||
</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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { DefaultTool } from '@blocksuite/affine/blocks/surface';
|
||||
import type { DocTitle } from '@blocksuite/affine/fragments/doc-title';
|
||||
import { findCommentedTexts } from '@blocksuite/affine/inlines/comment';
|
||||
import type { DocMode, ReferenceParams } from '@blocksuite/affine/model';
|
||||
import { HighlightSelection } from '@blocksuite/affine/shared/selection';
|
||||
import { DocModeProvider } from '@blocksuite/affine/shared/services';
|
||||
import {
|
||||
DocModeProvider,
|
||||
findCommentedBlocks,
|
||||
} from '@blocksuite/affine/shared/services';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/affine/std/gfx';
|
||||
import type { InlineEditor } from '@blocksuite/std/inline';
|
||||
import { effect } from '@preact/signals-core';
|
||||
@@ -51,6 +55,7 @@ export class Editor extends Entity {
|
||||
const selector = get(this.selector$);
|
||||
const mode = get(this.mode$);
|
||||
let id = selector?.blockIds?.[0];
|
||||
let commentId = selector?.commentId;
|
||||
let key = 'blockIds';
|
||||
|
||||
if (mode === 'edgeless') {
|
||||
@@ -61,9 +66,15 @@ export class Editor extends Entity {
|
||||
}
|
||||
}
|
||||
|
||||
if (!id) return null;
|
||||
if (!id && !commentId) return null;
|
||||
|
||||
return { id, key, mode, refreshKey: selector?.refreshKey };
|
||||
return {
|
||||
id,
|
||||
key,
|
||||
mode,
|
||||
refreshKey: selector?.refreshKey,
|
||||
commentId: commentId,
|
||||
};
|
||||
});
|
||||
|
||||
isPresenting$ = new LiveData<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(
|
||||
editorContainer: AffineEditorContainer,
|
||||
docTitle?: DocTitle | null,
|
||||
@@ -228,18 +284,7 @@ export class Editor extends Entity {
|
||||
title?.inlineEditor?.focusEnd();
|
||||
}
|
||||
} else {
|
||||
const selection = editorContainer.host?.std.selection;
|
||||
|
||||
const { id, key, mode } = initialFocusAt;
|
||||
|
||||
if (mode === this.mode$.value) {
|
||||
selection?.setGroup('scene', [
|
||||
selection?.create(HighlightSelection, {
|
||||
mode,
|
||||
[key]: [id],
|
||||
}),
|
||||
]);
|
||||
}
|
||||
this.handleFocusAt(initialFocusAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,18 +324,7 @@ export class Editor extends Entity {
|
||||
.pipe(skip(1))
|
||||
.subscribe(anchor => {
|
||||
if (!anchor) return;
|
||||
|
||||
const selection = editorContainer.host?.std.selection;
|
||||
if (!selection) return;
|
||||
|
||||
const { id, key, mode } = anchor;
|
||||
|
||||
selection.setGroup('scene', [
|
||||
selection.create(HighlightSelection, {
|
||||
mode,
|
||||
[key]: [id],
|
||||
}),
|
||||
]);
|
||||
this.handleFocusAt(anchor);
|
||||
});
|
||||
unsubs.push(subscription.unsubscribe.bind(subscription));
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type EditorSelector = {
|
||||
blockIds?: string[];
|
||||
elementIds?: string[];
|
||||
commentId?: string;
|
||||
refreshKey?: string;
|
||||
};
|
||||
|
||||
@@ -265,7 +265,8 @@ export const AFFINE_FLAGS = {
|
||||
defaultState: false,
|
||||
},
|
||||
enable_comment: {
|
||||
category: 'affine',
|
||||
category: 'blocksuite',
|
||||
bsFlag: 'enable_comment',
|
||||
displayName: 'Enable Comment',
|
||||
description: 'Enable comment',
|
||||
configurable: isCanaryBuild,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { configureBlobManagementModule } from './blob-management';
|
||||
import { configureCloudModule } from './cloud';
|
||||
import { configureCollectionModule } from './collection';
|
||||
import { configureCollectionRulesModule } from './collection-rules';
|
||||
import { configureCommentModule } from './comment';
|
||||
import { configureWorkspaceDBModule } from './db';
|
||||
import { configureDialogModule } from './dialogs';
|
||||
import { configureDndModule } from './dnd';
|
||||
@@ -118,4 +119,5 @@ export function configureCommonModules(framework: Framework) {
|
||||
configureWorkspacePropertyModule(framework);
|
||||
configureCollectionRulesModule(framework);
|
||||
configureIndexerEmbeddingModule(framework);
|
||||
configureCommentModule(framework);
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ export const preprocessParams = (
|
||||
'databaseId',
|
||||
'databaseRowId',
|
||||
'refreshKey',
|
||||
'commentId',
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -171,6 +172,7 @@ export const paramsParseOptions: ParseOptions = {
|
||||
databaseId: 'string',
|
||||
databaseRowId: 'string',
|
||||
refreshKey: 'string',
|
||||
commentId: 'string',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
{
|
||||
"ar": 100,
|
||||
"ar": 99,
|
||||
"ca": 4,
|
||||
"da": 4,
|
||||
"de": 100,
|
||||
"el-GR": 100,
|
||||
"de": 99,
|
||||
"el-GR": 99,
|
||||
"en": 100,
|
||||
"es-AR": 100,
|
||||
"es-AR": 99,
|
||||
"es-CL": 100,
|
||||
"es": 100,
|
||||
"fa": 100,
|
||||
"fr": 100,
|
||||
"es": 99,
|
||||
"fa": 99,
|
||||
"fr": 99,
|
||||
"hi": 2,
|
||||
"it-IT": 100,
|
||||
"it-IT": 99,
|
||||
"it": 1,
|
||||
"ja": 100,
|
||||
"ja": 99,
|
||||
"ko": 53,
|
||||
"pl": 100,
|
||||
"pt-BR": 100,
|
||||
"ru": 100,
|
||||
"sv-SE": 100,
|
||||
"uk": 100,
|
||||
"pl": 99,
|
||||
"pt-BR": 99,
|
||||
"ru": 99,
|
||||
"sv-SE": 99,
|
||||
"uk": 99,
|
||||
"ur": 2,
|
||||
"zh-Hans": 100,
|
||||
"zh-Hant": 100
|
||||
"zh-Hans": 99,
|
||||
"zh-Hant": 99
|
||||
}
|
||||
|
||||
@@ -8212,6 +8212,50 @@ export function useAFFiNEI18N(): {
|
||||
* `Migrate data`
|
||||
*/
|
||||
["com.affine.migration-all-docs-notification.button"](): string;
|
||||
/**
|
||||
* `Comments`
|
||||
*/
|
||||
["com.affine.comment.comments"](): string;
|
||||
/**
|
||||
* `No comments yet`
|
||||
*/
|
||||
["com.affine.comment.no-comments"](): string;
|
||||
/**
|
||||
* `Delete the thread?`
|
||||
*/
|
||||
["com.affine.comment.delete.confirm.title"](): string;
|
||||
/**
|
||||
* `All comments will also be deleted, and this action cannot be undone.`
|
||||
*/
|
||||
["com.affine.comment.delete.confirm.description"](): string;
|
||||
/**
|
||||
* `Delete this reply?`
|
||||
*/
|
||||
["com.affine.comment.reply.delete.confirm.title"](): string;
|
||||
/**
|
||||
* `Delete this reply? This action cannot be undone.`
|
||||
*/
|
||||
["com.affine.comment.reply.delete.confirm.description"](): string;
|
||||
/**
|
||||
* `Show resolved comments`
|
||||
*/
|
||||
["com.affine.comment.filter.show-resolved"](): string;
|
||||
/**
|
||||
* `Only my replies and mentions`
|
||||
*/
|
||||
["com.affine.comment.filter.only-my-replies"](): string;
|
||||
/**
|
||||
* `Only current mode`
|
||||
*/
|
||||
["com.affine.comment.filter.only-current-mode"](): string;
|
||||
/**
|
||||
* `Reply`
|
||||
*/
|
||||
["com.affine.comment.reply"](): string;
|
||||
/**
|
||||
* `Copy link`
|
||||
*/
|
||||
["com.affine.comment.copy-link"](): string;
|
||||
/**
|
||||
* `An internal error occurred.`
|
||||
*/
|
||||
|
||||
@@ -2059,6 +2059,17 @@
|
||||
"com.affine.migration-all-docs-notification.desc": "We are updating the local data to facilitate the recording and filtering of created by and Last edited by information. Please click the “Migrate Data” button and ensure a stable network connection during the process.",
|
||||
"com.affine.migration-all-docs-notification.error": "Migration failed: {{errorMessage}}",
|
||||
"com.affine.migration-all-docs-notification.button": "Migrate data",
|
||||
"com.affine.comment.comments": "Comments",
|
||||
"com.affine.comment.no-comments": "No comments yet",
|
||||
"com.affine.comment.delete.confirm.title": "Delete the thread?",
|
||||
"com.affine.comment.delete.confirm.description": "All comments will also be deleted, and this action cannot be undone.",
|
||||
"com.affine.comment.reply.delete.confirm.title": "Delete this reply?",
|
||||
"com.affine.comment.reply.delete.confirm.description": "Delete this reply? This action cannot be undone.",
|
||||
"com.affine.comment.filter.show-resolved": "Show resolved comments",
|
||||
"com.affine.comment.filter.only-my-replies": "Only my replies and mentions",
|
||||
"com.affine.comment.filter.only-current-mode": "Only current mode",
|
||||
"com.affine.comment.reply": "Reply",
|
||||
"com.affine.comment.copy-link": "Copy link",
|
||||
"error.INTERNAL_SERVER_ERROR": "An internal error occurred.",
|
||||
"error.NETWORK_ERROR": "Network error.",
|
||||
"error.TOO_MANY_REQUEST": "Too many requests.",
|
||||
|
||||
Reference in New Issue
Block a user