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:
46
blocksuite/affine/inlines/comment/package.json
Normal file
46
blocksuite/affine/inlines/comment/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-inline-comment",
|
||||
"description": "Inline comment for BlockSuite.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-ext-loader": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.15",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lit-html": "^3.2.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"rxjs": "^7.8.1",
|
||||
"yjs": "^13.6.21",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "3.1.3"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./view": "./src/view.ts",
|
||||
"./store": "./src/store.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.21.0"
|
||||
}
|
||||
11
blocksuite/affine/inlines/comment/src/effects.ts
Normal file
11
blocksuite/affine/inlines/comment/src/effects.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1
blocksuite/affine/inlines/comment/src/index.ts
Normal file
1
blocksuite/affine/inlines/comment/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './inline-spec';
|
||||
161
blocksuite/affine/inlines/comment/src/inline-comment-manager.ts
Normal file
161
blocksuite/affine/inlines/comment/src/inline-comment-manager.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
95
blocksuite/affine/inlines/comment/src/inline-comment.ts
Normal file
95
blocksuite/affine/inlines/comment/src/inline-comment.ts
Normal 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>`;
|
||||
}
|
||||
}
|
||||
38
blocksuite/affine/inlines/comment/src/inline-spec.ts
Normal file
38
blocksuite/affine/inlines/comment/src/inline-spec.ts
Normal 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,
|
||||
});
|
||||
53
blocksuite/affine/inlines/comment/src/utils.ts
Normal file
53
blocksuite/affine/inlines/comment/src/utils.ts
Normal 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-', ''));
|
||||
}
|
||||
22
blocksuite/affine/inlines/comment/src/view.ts
Normal file
22
blocksuite/affine/inlines/comment/src/view.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
18
blocksuite/affine/inlines/comment/tsconfig.json
Normal file
18
blocksuite/affine/inlines/comment/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../../ext-loader" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../rich-text" },
|
||||
{ "path": "../../shared" },
|
||||
{ "path": "../../../framework/global" },
|
||||
{ "path": "../../../framework/std" },
|
||||
{ "path": "../../../framework/store" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user