feat(editor): block comment extension (#12980)

#### PR Dependency Tree


* **PR #12980** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
L-Sun
2025-07-02 17:42:16 +08:00
committed by GitHub
parent 8ce85f708d
commit d768ad4af0
47 changed files with 432 additions and 38 deletions

View File

@@ -0,0 +1,118 @@
import { DividerBlockModel } from '@blocksuite/affine-model';
import { DisposableGroup } from '@blocksuite/global/disposable';
import {
BlockSelection,
LifeCycleWatcher,
TextSelection,
} from '@blocksuite/std';
import type { BaseSelection, BlockModel } from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import { getSelectedBlocksCommand } from '../../commands';
import { ImageSelection } from '../../selection';
import { matchModels } from '../../utils';
import { type CommentId, CommentProviderIdentifier } from './comment-provider';
import { findCommentedBlocks } from './utils';
export class BlockCommentManager extends LifeCycleWatcher {
static override key = 'block-comment-manager';
private readonly _highlightedCommentId$ = signal<CommentId | null>(null);
private readonly _disposables = new DisposableGroup();
private get _provider() {
return this.std.getOptional(CommentProviderIdentifier);
}
isBlockCommentHighlighted(
block: BlockModel<{ comments?: Record<CommentId, boolean> }>
) {
const comments = block.props.comments;
if (!comments) return false;
return (
this._highlightedCommentId$.value !== null &&
Object.keys(comments).includes(this._highlightedCommentId$.value)
);
}
override mounted() {
const provider = this._provider;
if (!provider) return;
this._disposables.add(provider.onCommentAdded(this._handleAddComment));
this._disposables.add(
provider.onCommentDeleted(this._handleDeleteAndResolve)
);
this._disposables.add(
provider.onCommentResolved(this._handleDeleteAndResolve)
);
this._disposables.add(
provider.onCommentHighlighted(this._handleHighlightComment)
);
}
override unmounted() {
this._disposables.dispose();
}
private readonly _handleAddComment = (
id: CommentId,
selections: BaseSelection[]
) => {
const blocksFromTextRange = selections
.filter((s): s is TextSelection => s.is(TextSelection))
.map(s => {
const [_, { selectedBlocks }] = this.std.command.exec(
getSelectedBlocksCommand,
{
textSelection: s,
}
);
if (!selectedBlocks) return [];
return selectedBlocks.map(b => b.model);
});
const needCommentBlocks = [
...blocksFromTextRange.flat(),
...selections
.filter(s => s instanceof BlockSelection || s instanceof ImageSelection)
.map(({ blockId }) => this.std.store.getModelById(blockId))
.filter(
(m): m is BlockModel =>
m !== null && !matchModels(m, [DividerBlockModel])
),
];
if (needCommentBlocks.length === 0) return;
this.std.store.withoutTransact(() => {
needCommentBlocks.forEach(block => {
const comments = (
'comments' in block.props &&
typeof block.props.comments === 'object' &&
block.props.comments !== null
? block.props.comments
: {}
) as Record<CommentId, boolean>;
this.std.store.updateBlock(block, {
comments: { [id]: true, ...comments },
});
});
});
};
private readonly _handleDeleteAndResolve = (id: CommentId) => {
const commentedBlocks = findCommentedBlocks(this.std.store, id);
this.std.store.withoutTransact(() => {
commentedBlocks.forEach(block => {
delete block.props.comments[id];
});
});
};
private readonly _handleHighlightComment = (id: CommentId | null) => {
this._highlightedCommentId$.value = id;
};
}

View File

@@ -1 +1,3 @@
export * from './block-comment-manager';
export * from './comment-provider';
export * from './utils';

View File

@@ -1,9 +1,45 @@
import type { Store } from '@blocksuite/store';
import { CommentIcon } from '@blocksuite/icons/lit';
import { BlockSelection } from '@blocksuite/std';
import type { BlockModel, Store } from '@blocksuite/store';
import type { CommentId } from './comment-provider';
import type { ToolbarAction } from '../toolbar-service';
import { type CommentId, CommentProviderIdentifier } from './comment-provider';
export function findCommentedBlocks(store: Store, commentId: CommentId) {
return store.getAllModels().filter(block => {
return 'comment' in block.props && block.props.comment === commentId;
type CommentedBlock = BlockModel<{ comments: Record<CommentId, boolean> }>;
return store.getAllModels().filter((block): block is CommentedBlock => {
return (
'comments' in block.props &&
typeof block.props.comments === 'object' &&
block.props.comments !== null &&
commentId in block.props.comments
);
});
}
export const blockCommentToolbarButton: Omit<ToolbarAction, 'id'> = {
tooltip: 'Comment',
when: ({ std }) => !!std.getOptional(CommentProviderIdentifier),
icon: CommentIcon(),
run: ctx => {
const commentProvider = ctx.std.getOptional(CommentProviderIdentifier);
if (!commentProvider) return;
const selections = ctx.selection.value;
const model = ctx.getCurrentModel();
if (selections.length > 1) {
commentProvider.addComment(selections);
} else if (model) {
commentProvider.addComment([
new BlockSelection({
blockId: model.id,
}),
]);
} else if (selections.length === 1) {
commentProvider.addComment(selections);
} else {
return;
}
},
};