feat(editor): comment extension (#12948)

#### PR Dependency Tree


* **PR #12948** 👈
  * **PR #12980**

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**
* Introduced inline comment functionality, allowing users to add,
resolve, and highlight comments directly within text.
  * Added a new toolbar action for inserting comments when supported.
* Inline comments are visually highlighted and can be interacted with in
the editor.

* **Enhancements**
  * Integrated a feature flag to enable or disable the comment feature.
* Improved inline manager rendering to support wrapper specs for
advanced formatting.

* **Developer Tools**
* Added mock comment provider for testing and development environments.

* **Chores**
* Updated dependencies and project references to support the new inline
comment module.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
L-Sun
2025-07-02 17:14:34 +08:00
committed by GitHub
parent a66096cdf9
commit 8ce85f708d
40 changed files with 760 additions and 14 deletions

View File

@@ -0,0 +1,11 @@
import { InlineComment } from './inline-comment';
export function effects() {
customElements.define('inline-comment', InlineComment);
}
declare global {
interface HTMLElementTagNameMap {
'inline-comment': InlineComment;
}
}

View File

@@ -0,0 +1 @@
export * from './inline-spec';

View File

@@ -0,0 +1,161 @@
import { getInlineEditorByModel } from '@blocksuite/affine-rich-text';
import { getSelectedBlocksCommand } from '@blocksuite/affine-shared/commands';
import {
type CommentId,
CommentProviderIdentifier,
} from '@blocksuite/affine-shared/services';
import type { AffineInlineEditor } from '@blocksuite/affine-shared/types';
import { DisposableGroup } from '@blocksuite/global/disposable';
import {
LifeCycleWatcher,
type TextRangePoint,
TextSelection,
} from '@blocksuite/std';
import type { BaseSelection, BlockModel } from '@blocksuite/store';
import { extractCommentIdFromDelta, findCommentedTexts } from './utils';
export class InlineCommentManager extends LifeCycleWatcher {
static override key = 'inline-comment-manager';
private readonly _disposables = new DisposableGroup();
private get _provider() {
return this.std.getOptional(CommentProviderIdentifier);
}
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(
this.std.selection.slots.changed.subscribe(this._handleSelectionChanged)
);
}
override unmounted() {
this._disposables.dispose();
}
private readonly _handleAddComment = (
id: CommentId,
selections: BaseSelection[]
) => {
const needCommentTexts = selections
.map(selection => {
if (!selection.is(TextSelection)) return [];
const [_, { selectedBlocks }] = this.std.command
.chain()
.pipe(getSelectedBlocksCommand, {
textSelection: selection,
})
.run();
if (!selectedBlocks) return [];
type MakeRequired<T, K extends keyof T> = T & {
[key in K]: NonNullable<T[key]>;
};
return selectedBlocks
.map(
({ model }) =>
[model, getInlineEditorByModel(this.std, model)] as const
)
.filter(
(
pair
): pair is [MakeRequired<BlockModel, 'text'>, AffineInlineEditor] =>
!!pair[0].text && !!pair[1]
)
.map(([model, inlineEditor]) => {
let from: TextRangePoint;
let to: TextRangePoint | null;
if (model.id === selection.from.blockId) {
from = selection.from;
to = null;
} else if (model.id === selection.to?.blockId) {
from = selection.to;
to = null;
} else {
from = {
blockId: model.id,
index: 0,
length: model.text.yText.length,
};
to = null;
}
return [new TextSelection({ from, to }), inlineEditor] as const;
});
})
.flat();
if (needCommentTexts.length === 0) return;
needCommentTexts.forEach(([selection, inlineEditor]) => {
inlineEditor.formatText(
selection.from,
{
[`comment-${id}`]: true,
},
{
withoutTransact: true,
}
);
});
};
private readonly _handleDeleteAndResolve = (id: CommentId) => {
const commentedTexts = findCommentedTexts(this.std, id);
if (commentedTexts.length === 0) return;
this.std.store.withoutTransact(() => {
commentedTexts.forEach(([selection, inlineEditor]) => {
inlineEditor.formatText(
selection.from,
{
[`comment-${id}`]: null,
},
{
withoutTransact: true,
}
);
});
});
};
private readonly _handleSelectionChanged = (selections: BaseSelection[]) => {
if (selections.length === 1) {
const selection = selections[0];
// InlineCommentManager only handle text selection
if (!selection.is(TextSelection)) return;
if (!selection.isCollapsed()) {
this._provider?.highlightComment(null);
return;
}
const model = this.std.store.getModelById(selection.from.blockId);
if (!model) return;
const inlineEditor = getInlineEditorByModel(this.std, model);
if (!inlineEditor) return;
const delta = inlineEditor.getDeltaByRangeIndex(selection.from.index);
if (!delta) return;
const commentIds = extractCommentIdFromDelta(delta);
if (commentIds.length !== 0) return;
}
this._provider?.highlightComment(null);
};
}

View File

@@ -0,0 +1,95 @@
import {
type CommentId,
CommentProviderIdentifier,
} from '@blocksuite/affine-shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { WithDisposable } from '@blocksuite/global/lit';
import {
type BlockStdScope,
PropTypes,
requiredProperties,
ShadowlessElement,
stdContext,
} from '@blocksuite/std';
import { consume } from '@lit/context';
import { css, type PropertyValues } from 'lit';
import { property, state } from 'lit/decorators.js';
import { html } from 'lit-html';
import { isEqual } from 'lodash-es';
@requiredProperties({
commentIds: PropTypes.arrayOf(id => typeof id === 'string'),
})
export class InlineComment extends WithDisposable(ShadowlessElement) {
static override styles = css`
inline-comment {
display: inline-block;
background-color: ${unsafeCSSVarV2('block/comment/highlightDefault')};
border-bottom: 2px solid
${unsafeCSSVarV2('block/comment/highlightUnderline')};
}
inline-comment.highlighted {
background-color: ${unsafeCSSVarV2('block/comment/highlightActive')};
}
`;
@property({
attribute: false,
hasChanged: (newVal: string[], oldVal: string[]) =>
!isEqual(newVal, oldVal),
})
accessor commentIds!: string[];
@consume({ context: stdContext })
private accessor _std!: BlockStdScope;
@state()
accessor highlighted = false;
private get _provider() {
return this._std.getOptional(CommentProviderIdentifier);
}
private readonly _handleClick = () => {
const provider = this._provider;
provider && this.commentIds.forEach(id => provider.highlightComment(id));
};
private readonly _handleHighlight = (id: CommentId | null) => {
if (this.highlighted) {
if (!id || !this.commentIds.includes(id)) {
this.highlighted = false;
}
} else {
if (id && this.commentIds.includes(id)) {
this.highlighted = true;
}
}
};
override connectedCallback() {
super.connectedCallback();
const provider = this._provider;
if (provider) {
this.disposables.addFromEvent(this, 'click', this._handleClick);
this.disposables.add(
provider.onCommentHighlighted(this._handleHighlight)
);
}
}
override willUpdate(_changedProperties: PropertyValues<this>) {
if (_changedProperties.has('highlighted')) {
if (this.highlighted) {
this.classList.add('highlighted');
} else {
this.classList.remove('highlighted');
}
}
}
override render() {
return html`<slot></slot>`;
}
}

View File

@@ -0,0 +1,38 @@
import { type CommentId } from '@blocksuite/affine-shared/services';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { dynamicSchema, InlineSpecExtension } from '@blocksuite/std/inline';
import { html, nothing } from 'lit-html';
import { when } from 'lit-html/directives/when.js';
import { z } from 'zod';
import { extractCommentIdFromDelta } from './utils';
type InlineCommendId = `comment-${CommentId}`;
function isInlineCommendId(key: string): key is InlineCommendId {
return key.startsWith('comment-');
}
export const CommentInlineSpecExtension =
InlineSpecExtension<AffineTextAttributes>({
name: 'comment',
schema: dynamicSchema(
isInlineCommendId,
z.boolean().optional().nullable().catch(undefined)
),
match: delta => {
if (!delta.attributes) return false;
const comments = Object.entries(delta.attributes).filter(
([key, value]) => isInlineCommendId(key) && value === true
);
return comments.length > 0;
},
renderer: ({ delta, children }) =>
html`<inline-comment .commentIds=${extractCommentIdFromDelta(delta)}
>${when(
children,
() => html`${children}`,
() => nothing
)}</inline-comment
>`,
wrapper: true,
});

View File

@@ -0,0 +1,53 @@
import { getInlineEditorByModel } from '@blocksuite/affine-rich-text';
import type { CommentId } from '@blocksuite/affine-shared/services';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { type BlockStdScope, TextSelection } from '@blocksuite/std';
import type { InlineEditor } from '@blocksuite/std/inline';
import type { DeltaInsert } from '@blocksuite/store';
export function findCommentedTexts(std: BlockStdScope, commentId: CommentId) {
const selections: [TextSelection, InlineEditor][] = [];
std.store.getAllModels().forEach(model => {
const inlineEditor = getInlineEditorByModel(std, model);
if (!inlineEditor) return;
inlineEditor.mapDeltasInInlineRange(
{
index: 0,
length: inlineEditor.yTextLength,
},
(delta, rangeIndex) => {
if (
delta.attributes &&
Object.keys(delta.attributes).some(
key => key === `comment-${commentId}`
)
) {
selections.push([
new TextSelection({
from: {
blockId: model.id,
index: rangeIndex,
length: delta.insert.length,
},
to: null,
}),
inlineEditor,
]);
}
}
);
});
return selections;
}
export function extractCommentIdFromDelta(
delta: DeltaInsert<AffineTextAttributes>
) {
if (!delta.attributes) return [];
return Object.keys(delta.attributes)
.filter(key => key.startsWith('comment-'))
.map(key => key.replace('comment-', ''));
}

View File

@@ -0,0 +1,22 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { effects } from './effects';
import { InlineCommentManager } from './inline-comment-manager';
import { CommentInlineSpecExtension } from './inline-spec';
export class InlineCommentViewExtension extends ViewExtensionProvider {
override name = 'affine-inline-comment';
override effect(): void {
super.effect();
effects();
}
override setup(context: ViewExtensionContext) {
super.setup(context);
context.register([CommentInlineSpecExtension, InlineCommentManager]);
}
}