mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-26 10:45:57 +08:00
feat(editor): comment for edgeless element (#13098)
#### PR Dependency Tree * **PR #13098** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for comments on graphical elements, allowing users to comment on both blocks and graphical elements within surfaces. * Enhanced comment previews to include graphical elements in selection summaries. * Improved editor navigation to focus on commented graphical elements in addition to blocks and inline texts. * **Bug Fixes** * Updated comment highlighting and management to consistently use the new comment manager across all block and element types. * **Refactor** * Renamed and extended the comment manager to handle both block and element comments. * Streamlined toolbar configurations by removing outdated comment button entries and adding a consolidated comment button in the root toolbar. * **Tests** * Disabled the mock comment provider integration in the test editor environment to refine testing setup. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,118 +0,0 @@
|
||||
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).filter(m => !m.text);
|
||||
});
|
||||
|
||||
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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { DividerBlockModel } from '@blocksuite/affine-model';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import {
|
||||
BlockSelection,
|
||||
LifeCycleWatcher,
|
||||
SurfaceSelection,
|
||||
TextSelection,
|
||||
} from '@blocksuite/std';
|
||||
import {
|
||||
GfxControllerIdentifier,
|
||||
type GfxModel,
|
||||
type GfxPrimitiveElementModel,
|
||||
} from '@blocksuite/std/gfx';
|
||||
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, findCommentedElements } from './utils';
|
||||
|
||||
export class BlockElementCommentManager extends LifeCycleWatcher {
|
||||
static override key = 'block-element-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)
|
||||
);
|
||||
}
|
||||
|
||||
isElementCommentHighlighted(element: GfxPrimitiveElementModel) {
|
||||
const comments = element.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[]
|
||||
) => {
|
||||
// get blocks from text range that some no-text blocks are selected such as image, bookmark, etc.
|
||||
const noTextBlocksFromTextRange = selections
|
||||
.filter((s): s is TextSelection => s.is(TextSelection))
|
||||
.flatMap(s => {
|
||||
const [_, { selectedBlocks }] = this.std.command.exec(
|
||||
getSelectedBlocksCommand,
|
||||
{
|
||||
textSelection: s,
|
||||
}
|
||||
);
|
||||
if (!selectedBlocks) return [];
|
||||
return selectedBlocks.map(b => b.model).filter(m => !m.text);
|
||||
});
|
||||
|
||||
const blocksFromBlockSelection = 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])
|
||||
);
|
||||
|
||||
const needCommentBlocks = [
|
||||
...noTextBlocksFromTextRange,
|
||||
...blocksFromBlockSelection,
|
||||
];
|
||||
|
||||
if (needCommentBlocks.length !== 0) {
|
||||
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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const gfx = this.std.get(GfxControllerIdentifier);
|
||||
const elementsFromSurfaceSelection = selections
|
||||
.filter(s => s instanceof SurfaceSelection)
|
||||
.flatMap(({ blockId, elements }) => {
|
||||
if (blockId !== gfx.surface?.id) return [];
|
||||
return elements
|
||||
.map(id => gfx.getElementById<GfxModel>(id))
|
||||
.filter(m => m !== null);
|
||||
});
|
||||
if (elementsFromSurfaceSelection.length !== 0) {
|
||||
this.std.store.withoutTransact(() => {
|
||||
elementsFromSurfaceSelection.forEach(element => {
|
||||
const comments =
|
||||
'comments' in element &&
|
||||
typeof element.comments === 'object' &&
|
||||
element.comments !== null
|
||||
? element.comments
|
||||
: {};
|
||||
|
||||
gfx.updateElement(element, {
|
||||
comments: { [id]: true, ...comments },
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
readonly handleDeleteAndResolve = (id: CommentId) => {
|
||||
const commentedBlocks = findCommentedBlocks(this.std.store, id);
|
||||
this.std.store.withoutTransact(() => {
|
||||
commentedBlocks.forEach(block => {
|
||||
delete block.props.comments[id];
|
||||
});
|
||||
});
|
||||
|
||||
const commentedElements = findCommentedElements(this.std.store, id);
|
||||
this.std.store.withoutTransact(() => {
|
||||
commentedElements.forEach(element => {
|
||||
delete element.comments[id];
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _handleHighlightComment = (id: CommentId | null) => {
|
||||
this._highlightedCommentId$.value = id;
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from './block-comment-manager';
|
||||
export * from './block-element-comment-manager';
|
||||
export * from './comment-provider';
|
||||
export * from './utils';
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { CommentIcon } from '@blocksuite/icons/lit';
|
||||
import { BlockSelection } from '@blocksuite/std';
|
||||
import type { BlockModel, Store } from '@blocksuite/store';
|
||||
import { BlockSelection, SurfaceSelection } from '@blocksuite/std';
|
||||
import type {
|
||||
GfxPrimitiveElementModel,
|
||||
SurfaceBlockModel,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { BlockModel, type Store } from '@blocksuite/store';
|
||||
|
||||
import type { ToolbarAction } from '../toolbar-service';
|
||||
import { type CommentId, CommentProviderIdentifier } from './comment-provider';
|
||||
@@ -22,6 +26,31 @@ export function findCommentedBlocks(store: Store, commentId: CommentId) {
|
||||
});
|
||||
}
|
||||
|
||||
export function findAllCommentedElements(store: Store) {
|
||||
type CommentedElement = GfxPrimitiveElementModel & {
|
||||
comments: Record<CommentId, boolean>;
|
||||
};
|
||||
const surface = store.getModelsByFlavour('affine:surface')[0] as
|
||||
| SurfaceBlockModel
|
||||
| undefined;
|
||||
if (!surface) return [];
|
||||
|
||||
return surface.elementModels.filter(
|
||||
(element): element is CommentedElement => {
|
||||
return (
|
||||
element.comments !== undefined &&
|
||||
Object.keys(element.comments).length > 0
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function findCommentedElements(store: Store, commentId: CommentId) {
|
||||
return findAllCommentedElements(store).filter(element => {
|
||||
return element.comments[commentId];
|
||||
});
|
||||
}
|
||||
|
||||
export const blockCommentToolbarButton: Omit<ToolbarAction, 'id'> = {
|
||||
tooltip: 'Comment',
|
||||
when: ({ std }) => !!std.getOptional(CommentProviderIdentifier),
|
||||
@@ -29,22 +58,26 @@ export const blockCommentToolbarButton: Omit<ToolbarAction, 'id'> = {
|
||||
run: ctx => {
|
||||
const commentProvider = ctx.std.getOptional(CommentProviderIdentifier);
|
||||
if (!commentProvider) return;
|
||||
const selections = ctx.selection.value;
|
||||
|
||||
const selections = ctx.selection.value;
|
||||
const model = ctx.getCurrentModel();
|
||||
|
||||
if (selections.length > 1) {
|
||||
// may be hover on a block or element, in this case
|
||||
// the selection is empty, so we need to get the current model
|
||||
if (model && selections.length === 0) {
|
||||
if (model instanceof BlockModel) {
|
||||
commentProvider.addComment([
|
||||
new BlockSelection({
|
||||
blockId: model.id,
|
||||
}),
|
||||
]);
|
||||
} else if (ctx.gfx.surface?.id) {
|
||||
commentProvider.addComment([
|
||||
new SurfaceSelection(ctx.gfx.surface.id, [model.id], false),
|
||||
]);
|
||||
}
|
||||
} else if (selections.length > 0) {
|
||||
commentProvider.addComment(selections);
|
||||
} else if (model) {
|
||||
commentProvider.addComment([
|
||||
new BlockSelection({
|
||||
blockId: model.id,
|
||||
}),
|
||||
]);
|
||||
} else if (selections.length === 1) {
|
||||
commentProvider.addComment(selections);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user