diff --git a/blocksuite/affine/all/package.json b/blocksuite/affine/all/package.json index 4c7a27f4af..3a3f30b77c 100644 --- a/blocksuite/affine/all/package.json +++ b/blocksuite/affine/all/package.json @@ -48,6 +48,7 @@ "@blocksuite/affine-gfx-template": "workspace:*", "@blocksuite/affine-gfx-text": "workspace:*", "@blocksuite/affine-gfx-turbo-renderer": "workspace:*", + "@blocksuite/affine-inline-comment": "workspace:*", "@blocksuite/affine-inline-footnote": "workspace:*", "@blocksuite/affine-inline-latex": "workspace:*", "@blocksuite/affine-inline-link": "workspace:*", diff --git a/blocksuite/affine/all/src/extensions/view.ts b/blocksuite/affine/all/src/extensions/view.ts index 79c8647ffb..5668ad54f2 100644 --- a/blocksuite/affine/all/src/extensions/view.ts +++ b/blocksuite/affine/all/src/extensions/view.ts @@ -33,6 +33,7 @@ import { PointerViewExtension } from '@blocksuite/affine-gfx-pointer/view'; import { ShapeViewExtension } from '@blocksuite/affine-gfx-shape/view'; import { TemplateViewExtension } from '@blocksuite/affine-gfx-template/view'; import { TextViewExtension } from '@blocksuite/affine-gfx-text/view'; +import { InlineCommentViewExtension } from '@blocksuite/affine-inline-comment/view'; import { FootnoteViewExtension } from '@blocksuite/affine-inline-footnote/view'; import { LatexViewExtension as InlineLatexViewExtension } from '@blocksuite/affine-inline-latex/view'; import { LinkViewExtension } from '@blocksuite/affine-inline-link/view'; @@ -95,6 +96,7 @@ export function getInternalViewExtensions() { RootViewExtension, // Inline + InlineCommentViewExtension, FootnoteViewExtension, LinkViewExtension, ReferenceViewExtension, diff --git a/blocksuite/affine/all/tsconfig.json b/blocksuite/affine/all/tsconfig.json index 8cb7c4be91..0f552f1649 100644 --- a/blocksuite/affine/all/tsconfig.json +++ b/blocksuite/affine/all/tsconfig.json @@ -45,6 +45,7 @@ { "path": "../gfx/template" }, { "path": "../gfx/text" }, { "path": "../gfx/turbo-renderer" }, + { "path": "../inlines/comment" }, { "path": "../inlines/footnote" }, { "path": "../inlines/latex" }, { "path": "../inlines/link" }, diff --git a/blocksuite/affine/blocks/code/package.json b/blocksuite/affine/blocks/code/package.json index 49a556e4ae..f6dcb3430f 100644 --- a/blocksuite/affine/blocks/code/package.json +++ b/blocksuite/affine/blocks/code/package.json @@ -13,6 +13,7 @@ "@blocksuite/affine-components": "workspace:*", "@blocksuite/affine-ext-loader": "workspace:*", "@blocksuite/affine-gfx-turbo-renderer": "workspace:*", + "@blocksuite/affine-inline-comment": "workspace:*", "@blocksuite/affine-inline-latex": "workspace:*", "@blocksuite/affine-inline-link": "workspace:*", "@blocksuite/affine-inline-preset": "workspace:*", diff --git a/blocksuite/affine/blocks/code/src/code-block-inline.ts b/blocksuite/affine/blocks/code/src/code-block-inline.ts index 5c27c92cb1..b7805f00ed 100644 --- a/blocksuite/affine/blocks/code/src/code-block-inline.ts +++ b/blocksuite/affine/blocks/code/src/code-block-inline.ts @@ -1,3 +1,4 @@ +import { CommentInlineSpecExtension } from '@blocksuite/affine-inline-comment'; import { LatexInlineSpecExtension } from '@blocksuite/affine-inline-latex'; import { LinkInlineSpecExtension } from '@blocksuite/affine-inline-link'; import { @@ -44,5 +45,6 @@ export const CodeBlockInlineManagerExtension = LatexInlineSpecExtension.identifier, LinkInlineSpecExtension.identifier, CodeBlockUnitSpecExtension.identifier, + CommentInlineSpecExtension.identifier, ], }); diff --git a/blocksuite/affine/blocks/code/tsconfig.json b/blocksuite/affine/blocks/code/tsconfig.json index b0bfc3d6e1..b3b796ee28 100644 --- a/blocksuite/affine/blocks/code/tsconfig.json +++ b/blocksuite/affine/blocks/code/tsconfig.json @@ -10,6 +10,7 @@ { "path": "../../components" }, { "path": "../../ext-loader" }, { "path": "../../gfx/turbo-renderer" }, + { "path": "../../inlines/comment" }, { "path": "../../inlines/latex" }, { "path": "../../inlines/link" }, { "path": "../../inlines/preset" }, diff --git a/blocksuite/affine/blocks/database/src/properties/rich-text/cell-renderer.ts b/blocksuite/affine/blocks/database/src/properties/rich-text/cell-renderer.ts index b29abbb109..27d280a660 100644 --- a/blocksuite/affine/blocks/database/src/properties/rich-text/cell-renderer.ts +++ b/blocksuite/affine/blocks/database/src/properties/rich-text/cell-renderer.ts @@ -70,7 +70,7 @@ function toggleStyle( return [k, v]; } }) - ); + ) as AffineTextAttributes; inlineEditor.formatText(inlineRange, newAttributes, { mode: 'merge', diff --git a/blocksuite/affine/blocks/root/src/configs/toolbar.ts b/blocksuite/affine/blocks/root/src/configs/toolbar.ts index 5ffda5dd59..bdc889c005 100644 --- a/blocksuite/affine/blocks/root/src/configs/toolbar.ts +++ b/blocksuite/affine/blocks/root/src/configs/toolbar.ts @@ -38,9 +38,13 @@ import type { ToolbarActionGroup, ToolbarModuleConfig, } from '@blocksuite/affine-shared/services'; -import { ActionPlacement } from '@blocksuite/affine-shared/services'; +import { + ActionPlacement, + CommentProviderIdentifier, +} from '@blocksuite/affine-shared/services'; import { tableViewMeta } from '@blocksuite/data-view/view-presets'; import { + CommentIcon, CopyIcon, DatabaseTableViewIcon, DeleteIcon, @@ -161,7 +165,7 @@ const highlightActionGroup = { } as const satisfies ToolbarAction; const turnIntoDatabase = { - id: 'd.convert-to-database', + id: 'e.convert-to-database', tooltip: 'Create Table', icon: DatabaseTableViewIcon(), when({ chain }) { @@ -208,7 +212,7 @@ const turnIntoDatabase = { } as const satisfies ToolbarAction; const turnIntoLinkedDoc = { - id: 'e.convert-to-linked-doc', + id: 'f.convert-to-linked-doc', tooltip: 'Create Linked Doc', icon: LinkedPageIcon(), when({ chain }) { @@ -266,11 +270,26 @@ const turnIntoLinkedDoc = { }, } as const satisfies ToolbarAction; +const commentAction = { + id: 'd.comment', + when: ({ std, chain }) => + isFormatSupported(chain).run()[0] && + !!std.getOptional(CommentProviderIdentifier), + icon: CommentIcon(), + run: ({ std }) => { + const commentProvider = std.getOptional(CommentProviderIdentifier); + if (!commentProvider) return; + + commentProvider.addComment(std.selection.value); + }, +} as const satisfies ToolbarAction; + export const builtinToolbarConfig = { actions: [ conversionsActionGroup, inlineTextActionGroup, highlightActionGroup, + commentAction, turnIntoDatabase, turnIntoLinkedDoc, { diff --git a/blocksuite/affine/inlines/comment/package.json b/blocksuite/affine/inlines/comment/package.json new file mode 100644 index 0000000000..ffa76c1e45 --- /dev/null +++ b/blocksuite/affine/inlines/comment/package.json @@ -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" +} diff --git a/blocksuite/affine/inlines/comment/src/effects.ts b/blocksuite/affine/inlines/comment/src/effects.ts new file mode 100644 index 0000000000..a4872d58cd --- /dev/null +++ b/blocksuite/affine/inlines/comment/src/effects.ts @@ -0,0 +1,11 @@ +import { InlineComment } from './inline-comment'; + +export function effects() { + customElements.define('inline-comment', InlineComment); +} + +declare global { + interface HTMLElementTagNameMap { + 'inline-comment': InlineComment; + } +} diff --git a/blocksuite/affine/inlines/comment/src/index.ts b/blocksuite/affine/inlines/comment/src/index.ts new file mode 100644 index 0000000000..9b17d501ef --- /dev/null +++ b/blocksuite/affine/inlines/comment/src/index.ts @@ -0,0 +1 @@ +export * from './inline-spec'; diff --git a/blocksuite/affine/inlines/comment/src/inline-comment-manager.ts b/blocksuite/affine/inlines/comment/src/inline-comment-manager.ts new file mode 100644 index 0000000000..76711ee41f --- /dev/null +++ b/blocksuite/affine/inlines/comment/src/inline-comment-manager.ts @@ -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 & { + [key in K]: NonNullable; + }; + + return selectedBlocks + .map( + ({ model }) => + [model, getInlineEditorByModel(this.std, model)] as const + ) + .filter( + ( + pair + ): pair is [MakeRequired, 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); + }; +} diff --git a/blocksuite/affine/inlines/comment/src/inline-comment.ts b/blocksuite/affine/inlines/comment/src/inline-comment.ts new file mode 100644 index 0000000000..71964f4aa1 --- /dev/null +++ b/blocksuite/affine/inlines/comment/src/inline-comment.ts @@ -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) { + if (_changedProperties.has('highlighted')) { + if (this.highlighted) { + this.classList.add('highlighted'); + } else { + this.classList.remove('highlighted'); + } + } + } + + override render() { + return html``; + } +} diff --git a/blocksuite/affine/inlines/comment/src/inline-spec.ts b/blocksuite/affine/inlines/comment/src/inline-spec.ts new file mode 100644 index 0000000000..f7e42e0dc3 --- /dev/null +++ b/blocksuite/affine/inlines/comment/src/inline-spec.ts @@ -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({ + 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`${when( + children, + () => html`${children}`, + () => nothing + )}`, + wrapper: true, + }); diff --git a/blocksuite/affine/inlines/comment/src/utils.ts b/blocksuite/affine/inlines/comment/src/utils.ts new file mode 100644 index 0000000000..cfc0c40102 --- /dev/null +++ b/blocksuite/affine/inlines/comment/src/utils.ts @@ -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 +) { + if (!delta.attributes) return []; + + return Object.keys(delta.attributes) + .filter(key => key.startsWith('comment-')) + .map(key => key.replace('comment-', '')); +} diff --git a/blocksuite/affine/inlines/comment/src/view.ts b/blocksuite/affine/inlines/comment/src/view.ts new file mode 100644 index 0000000000..26a8846d27 --- /dev/null +++ b/blocksuite/affine/inlines/comment/src/view.ts @@ -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]); + } +} diff --git a/blocksuite/affine/inlines/comment/tsconfig.json b/blocksuite/affine/inlines/comment/tsconfig.json new file mode 100644 index 0000000000..de7e9fde3a --- /dev/null +++ b/blocksuite/affine/inlines/comment/tsconfig.json @@ -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" } + ] +} diff --git a/blocksuite/affine/inlines/preset/package.json b/blocksuite/affine/inlines/preset/package.json index ca07f42c77..90f928adf3 100644 --- a/blocksuite/affine/inlines/preset/package.json +++ b/blocksuite/affine/inlines/preset/package.json @@ -12,6 +12,7 @@ "dependencies": { "@blocksuite/affine-components": "workspace:*", "@blocksuite/affine-ext-loader": "workspace:*", + "@blocksuite/affine-inline-comment": "workspace:*", "@blocksuite/affine-inline-footnote": "workspace:*", "@blocksuite/affine-inline-latex": "workspace:*", "@blocksuite/affine-inline-link": "workspace:*", diff --git a/blocksuite/affine/inlines/preset/src/default-inline-manager.ts b/blocksuite/affine/inlines/preset/src/default-inline-manager.ts index f589df0790..0d54b34296 100644 --- a/blocksuite/affine/inlines/preset/src/default-inline-manager.ts +++ b/blocksuite/affine/inlines/preset/src/default-inline-manager.ts @@ -1,3 +1,4 @@ +import { CommentInlineSpecExtension } from '@blocksuite/affine-inline-comment'; import { FootNoteInlineSpecExtension } from '@blocksuite/affine-inline-footnote'; import { LatexInlineSpecExtension } from '@blocksuite/affine-inline-latex'; import { LinkInlineSpecExtension } from '@blocksuite/affine-inline-link'; @@ -32,5 +33,6 @@ export const DefaultInlineManagerExtension = LinkInlineSpecExtension.identifier, FootNoteInlineSpecExtension.identifier, MentionInlineSpecExtension.identifier, + CommentInlineSpecExtension.identifier, ], }); diff --git a/blocksuite/affine/inlines/preset/tsconfig.json b/blocksuite/affine/inlines/preset/tsconfig.json index 06e9130beb..58eb4b5706 100644 --- a/blocksuite/affine/inlines/preset/tsconfig.json +++ b/blocksuite/affine/inlines/preset/tsconfig.json @@ -9,6 +9,7 @@ "references": [ { "path": "../../components" }, { "path": "../../ext-loader" }, + { "path": "../comment" }, { "path": "../footnote" }, { "path": "../latex" }, { "path": "../link" }, diff --git a/blocksuite/affine/shared/src/services/comment-service/comment-provider.ts b/blocksuite/affine/shared/src/services/comment-service/comment-provider.ts new file mode 100644 index 0000000000..de9106f8af --- /dev/null +++ b/blocksuite/affine/shared/src/services/comment-service/comment-provider.ts @@ -0,0 +1,41 @@ +import { createIdentifier } from '@blocksuite/global/di'; +import type { DisposableMember } from '@blocksuite/global/disposable'; +import type { BaseSelection, ExtensionType } from '@blocksuite/store'; + +export type CommentId = string; + +/** + * The `CommentProvider` is an interface used to connect external comment services + * with in-editor comment operations and rendering. + * All comment-related actions within the editor are routed through + * this interface to make external requests, and the editor is notified via callbacks. + * In essence, it follows the flow: BlockSuite -> AFFiNE -> BlockSuite. + */ +export interface CommentProvider { + addComment: (selections: BaseSelection[]) => void; + resolveComment: (id: CommentId) => void; + highlightComment: (id: CommentId | null) => void; + getComments: () => CommentId[]; + + onCommentAdded: ( + callback: (id: CommentId, selections: BaseSelection[]) => void + ) => DisposableMember; + onCommentResolved: (callback: (id: CommentId) => void) => DisposableMember; + onCommentDeleted: (callback: (id: CommentId) => void) => DisposableMember; + onCommentHighlighted: ( + callback: (id: CommentId | null) => void + ) => DisposableMember; +} + +export const CommentProviderIdentifier = + createIdentifier('comment-provider'); + +export const CommentProviderExtension = ( + provider: CommentProvider +): ExtensionType => { + return { + setup: di => { + di.addImpl(CommentProviderIdentifier, provider); + }, + }; +}; diff --git a/blocksuite/affine/shared/src/services/comment-service/index.ts b/blocksuite/affine/shared/src/services/comment-service/index.ts new file mode 100644 index 0000000000..18f617bd27 --- /dev/null +++ b/blocksuite/affine/shared/src/services/comment-service/index.ts @@ -0,0 +1 @@ +export * from './comment-provider'; diff --git a/blocksuite/affine/shared/src/services/comment-service/utils.ts b/blocksuite/affine/shared/src/services/comment-service/utils.ts new file mode 100644 index 0000000000..1003f115fb --- /dev/null +++ b/blocksuite/affine/shared/src/services/comment-service/utils.ts @@ -0,0 +1,9 @@ +import type { Store } from '@blocksuite/store'; + +import type { CommentId } from './comment-provider'; + +export function findCommentedBlocks(store: Store, commentId: CommentId) { + return store.getAllModels().filter(block => { + return 'comment' in block.props && block.props.comment === commentId; + }); +} diff --git a/blocksuite/affine/shared/src/services/index.ts b/blocksuite/affine/shared/src/services/index.ts index 2f14c804fe..8768374a54 100644 --- a/blocksuite/affine/shared/src/services/index.ts +++ b/blocksuite/affine/shared/src/services/index.ts @@ -1,6 +1,7 @@ export * from './auto-clear-selection-service'; export * from './block-meta-service'; export * from './citation-service'; +export * from './comment-service'; export * from './doc-display-meta-service'; export * from './doc-mode-service'; export * from './drag-handle-config'; diff --git a/blocksuite/affine/shared/src/types/index.ts b/blocksuite/affine/shared/src/types/index.ts index cfa71eab17..c1f41f86c1 100644 --- a/blocksuite/affine/shared/src/types/index.ts +++ b/blocksuite/affine/shared/src/types/index.ts @@ -58,6 +58,7 @@ export type AffineTextAttributes = AffineTextStyleAttributes & { member: string; notification?: string; } | null; + [key: `comment-${string}`]: boolean | null; }; export type AffineInlineEditor = InlineEditor; diff --git a/blocksuite/framework/std/src/inline/extensions/inline-manager.ts b/blocksuite/framework/std/src/inline/extensions/inline-manager.ts index 810211beaa..cf0df54d2c 100644 --- a/blocksuite/framework/std/src/inline/extensions/inline-manager.ts +++ b/blocksuite/framework/std/src/inline/extensions/inline-manager.ts @@ -32,12 +32,29 @@ export class InlineManager { const renderer: AttributeRenderer = props => { // Priority increases from front to back - for (const spec of this.specs.toReversed()) { + const specs = this.specs.toReversed(); + const wrapperSpecs = specs.filter(spec => spec.wrapper); + const normalSpecs = specs.filter(spec => !spec.wrapper); + + let result = defaultRenderer(props); + + for (const spec of normalSpecs) { if (spec.match(props.delta)) { - return spec.renderer(props); + result = spec.renderer(props); + break; } } - return defaultRenderer(props); + + for (const spec of wrapperSpecs) { + if (spec.match(props.delta)) { + result = spec.renderer({ + ...props, + children: result, + }); + } + } + + return result; }; return renderer; }; diff --git a/blocksuite/framework/std/src/inline/extensions/type.ts b/blocksuite/framework/std/src/inline/extensions/type.ts index b66fa5ccfe..56013bb1ad 100644 --- a/blocksuite/framework/std/src/inline/extensions/type.ts +++ b/blocksuite/framework/std/src/inline/extensions/type.ts @@ -21,6 +21,7 @@ export type InlineSpecs< match: (delta: DeltaInsert) => boolean; renderer: AttributeRenderer; embed?: boolean; + wrapper?: boolean; }; export type InlineMarkdownMatchAction< diff --git a/blocksuite/framework/std/src/inline/inline-editor.ts b/blocksuite/framework/std/src/inline/inline-editor.ts index a67b5103b1..2d3e367c27 100644 --- a/blocksuite/framework/std/src/inline/inline-editor.ts +++ b/blocksuite/framework/std/src/inline/inline-editor.ts @@ -279,7 +279,10 @@ export class InlineEditor< this._isReadonly = isReadonly; } - transact(fn: () => void): void { + /** + * @param withoutTransact Execute a transaction without capturing the history. + */ + transact(fn: () => void, withoutTransact = false): void { const doc = this.yText.doc; if (!doc) { throw new BlockSuiteError( @@ -288,6 +291,6 @@ export class InlineEditor< ); } - doc.transact(fn, doc.clientID); + doc.transact(fn, withoutTransact ? null : doc.clientID); } } diff --git a/blocksuite/framework/std/src/inline/services/text.ts b/blocksuite/framework/std/src/inline/services/text.ts index bbcbb625ed..4bc8875fba 100644 --- a/blocksuite/framework/std/src/inline/services/text.ts +++ b/blocksuite/framework/std/src/inline/services/text.ts @@ -19,11 +19,16 @@ export class InlineTextService { options: { match?: (delta: DeltaInsert, deltaInlineRange: InlineRange) => boolean; mode?: 'replace' | 'merge'; + withoutTransact?: boolean; } = {} ): void => { if (this.editor.isReadonly) return; - const { match = () => true, mode = 'merge' } = options; + const { + match = () => true, + mode = 'merge', + withoutTransact = false, + } = options; const deltas = this.editor.deltaService.getDeltasByInlineRange(inlineRange); deltas @@ -49,7 +54,7 @@ export class InlineTextService { targetInlineRange.length, normalizedAttributes ); - }); + }, withoutTransact); }); }; diff --git a/blocksuite/framework/std/src/inline/types.ts b/blocksuite/framework/std/src/inline/types.ts index ac5b531cfb..2256da54de 100644 --- a/blocksuite/framework/std/src/inline/types.ts +++ b/blocksuite/framework/std/src/inline/types.ts @@ -12,6 +12,7 @@ export type AttributeRenderer< startOffset: number; endOffset: number; lineIndex: number; + children?: TemplateResult<1>; }) => TemplateResult<1>; export interface InlineRange { diff --git a/blocksuite/playground/apps/_common/mock-services.ts b/blocksuite/playground/apps/_common/mock-services.ts index 261fc91e36..d2b08322e6 100644 --- a/blocksuite/playground/apps/_common/mock-services.ts +++ b/blocksuite/playground/apps/_common/mock-services.ts @@ -5,6 +5,8 @@ import { type ReferenceParams, } from '@blocksuite/affine/model'; import { + type CommentId, + type CommentProvider, type DocModeProvider, type EditorSetting, GeneralSettingSchema, @@ -13,7 +15,7 @@ import { type ParseDocUrlService, type ThemeExtension, } from '@blocksuite/affine/shared/services'; -import { type Workspace } from '@blocksuite/affine/store'; +import type { BaseSelection, Workspace } from '@blocksuite/affine/store'; import type { TestAffineEditorContainer } from '@blocksuite/integration-test'; import { Signal, signal } from '@preact/signals-core'; import { Subject } from 'rxjs'; @@ -191,6 +193,86 @@ export function mockEditorSetting() { return signal; } +export function mockCommentProvider() { + class MockCommentProvider implements CommentProvider { + commentId = 0; + + comments = new Map< + CommentId, + { + selections: BaseSelection[]; + resolved: boolean; + } + >(); + + commentAddSubject = new Subject<{ + id: CommentId; + selections: BaseSelection[]; + }>(); + + commentResolveSubject = new Subject(); + + commentHighlightSubject = new Subject(); + + commentDeleteSubject = new Subject(); + + addComment(selections: BaseSelection[]) { + const id: CommentId = `${this.commentId++}`; + this.comments.set(id, { + selections, + resolved: false, + }); + this.commentAddSubject.next({ + id, + selections, + }); + } + + resolveComment(id: CommentId) { + const comment = this.comments.get(id); + if (!comment) return; + comment.resolved = true; + this.commentResolveSubject.next(id); + } + + deleteComment(id: CommentId) { + this.comments.delete(id); + this.commentDeleteSubject.next(id); + } + + highlightComment(id: CommentId | null) { + this.commentHighlightSubject.next(id); + } + + getComments() { + return Array.from(this.comments.keys()); + } + + onCommentAdded( + callback: (id: CommentId, selections: BaseSelection[]) => void + ) { + return this.commentAddSubject.subscribe(({ id, selections }) => { + callback(id, selections); + }); + } + + onCommentResolved(callback: (id: CommentId) => void) { + return this.commentResolveSubject.subscribe(callback); + } + + onCommentDeleted(callback: (id: CommentId) => void) { + return this.commentDeleteSubject.subscribe(callback); + } + + onCommentHighlighted(callback: (id: CommentId | null) => void) { + return this.commentHighlightSubject.subscribe(callback); + } + } + + const provider = new MockCommentProvider(); + return provider; +} + declare global { interface Window { editorSetting$: Signal; diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx b/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx index e201c2cfad..6712ffdc1a 100644 --- a/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx +++ b/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx @@ -80,6 +80,8 @@ const usePatchSpecs = (mode: DocMode) => { featureFlagService.flags.enable_pdf_embed_preview.$ ); + const enableComment = useLiveData(featureFlagService.flags.enable_comment.$); + const patchedSpecs = useMemo(() => { const manager = getViewManager() .config.init() @@ -106,7 +108,8 @@ const usePatchSpecs = (mode: DocMode) => { .mobile(framework) .electron(framework) .linkPreview(framework) - .codeBlockHtmlPreview(framework).value; + .codeBlockHtmlPreview(framework) + .comment(enableComment).value; if (BUILD_CONFIG.isMobileEdition) { if (mode === 'page') { @@ -122,6 +125,7 @@ const usePatchSpecs = (mode: DocMode) => { enableAI, enablePDFEmbedPreview, enableTurboRenderer, + enableComment, framework, isInPeekView, isCloud, diff --git a/packages/frontend/core/src/blocksuite/manager/view.ts b/packages/frontend/core/src/blocksuite/manager/view.ts index 463cd3564c..0fabf0b492 100644 --- a/packages/frontend/core/src/blocksuite/manager/view.ts +++ b/packages/frontend/core/src/blocksuite/manager/view.ts @@ -2,6 +2,7 @@ import type { ReactToLit } from '@affine/component'; import { AIViewExtension } from '@affine/core/blocksuite/view-extensions/ai'; import { CloudViewExtension } from '@affine/core/blocksuite/view-extensions/cloud'; import { CodeBlockPreviewViewExtension } from '@affine/core/blocksuite/view-extensions/code-block-preview'; +import { CommentViewExtension } from '@affine/core/blocksuite/view-extensions/comment'; import { AffineDatabaseViewExtension } from '@affine/core/blocksuite/view-extensions/database'; import { EdgelessBlockHeaderConfigViewExtension, @@ -56,6 +57,7 @@ type Configure = { electron: (framework?: FrameworkProvider) => Configure; linkPreview: (framework?: FrameworkProvider) => Configure; codeBlockHtmlPreview: (framework?: FrameworkProvider) => Configure; + comment: (enableComment?: boolean) => Configure; value: ViewExtensionManager; }; @@ -116,6 +118,7 @@ class ViewProvider { electron: this._configureElectron, linkPreview: this._configureLinkPreview, codeBlockHtmlPreview: this._configureCodeBlockHtmlPreview, + comment: this._configureComment, value: this._manager, }; } @@ -137,7 +140,8 @@ class ViewProvider { .ai() .electron() .linkPreview() - .codeBlockHtmlPreview(); + .codeBlockHtmlPreview() + .comment(); return this.config; }; @@ -323,6 +327,11 @@ class ViewProvider { this._manager.configure(CodeBlockPreviewViewExtension, { framework }); return this.config; }; + + private readonly _configureComment = (enableComment?: boolean) => { + this._manager.configure(CommentViewExtension, { enableComment }); + return this.config; + }; } export function getViewManager() { diff --git a/packages/frontend/core/src/blocksuite/view-extensions/comment/comment-provider.ts b/packages/frontend/core/src/blocksuite/view-extensions/comment/comment-provider.ts new file mode 100644 index 0000000000..130a050d08 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/view-extensions/comment/comment-provider.ts @@ -0,0 +1,14 @@ +import { noop } from '@blocksuite/affine/global/utils'; +import { CommentProviderExtension } from '@blocksuite/affine/shared/services'; + +export const AffineCommentProvider = CommentProviderExtension({ + addComment: noop, + resolveComment: noop, + highlightComment: noop, + getComments: () => [], + + onCommentAdded: () => noop, + onCommentResolved: () => noop, + onCommentDeleted: () => noop, + onCommentHighlighted: () => noop, +}); diff --git a/packages/frontend/core/src/blocksuite/view-extensions/comment/index.ts b/packages/frontend/core/src/blocksuite/view-extensions/comment/index.ts new file mode 100644 index 0000000000..21a0d661e1 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/view-extensions/comment/index.ts @@ -0,0 +1,27 @@ +import { + type ViewExtensionContext, + ViewExtensionProvider, +} from '@blocksuite/affine/ext-loader'; +import z from 'zod'; + +import { AffineCommentProvider } from './comment-provider'; + +const optionsSchema = z.object({ + enableComment: z.boolean().optional(), +}); + +export class CommentViewExtension extends ViewExtensionProvider { + override name = 'comment'; + + override schema = optionsSchema; + + override setup( + context: ViewExtensionContext, + options?: z.infer + ) { + super.setup(context, options); + if (!options?.enableComment) return; + + context.register([AffineCommentProvider]); + } +} diff --git a/packages/frontend/core/src/modules/feature-flag/constant.ts b/packages/frontend/core/src/modules/feature-flag/constant.ts index 0efa9c8736..b2b6d2442b 100644 --- a/packages/frontend/core/src/modules/feature-flag/constant.ts +++ b/packages/frontend/core/src/modules/feature-flag/constant.ts @@ -264,6 +264,13 @@ export const AFFINE_FLAGS = { configurable: isCanaryBuild, defaultState: false, }, + enable_comment: { + category: 'affine', + displayName: 'Enable Comment', + description: 'Enable comment', + configurable: isCanaryBuild, + defaultState: true, + }, } satisfies { [key in string]: FlagInfo }; // oxlint-disable-next-line no-redeclare diff --git a/tests/blocksuite/e2e/embed-synced-doc/edgeless.spec.ts b/tests/blocksuite/e2e/embed-synced-doc/edgeless.spec.ts index 82641f0fd8..e03f91866c 100644 --- a/tests/blocksuite/e2e/embed-synced-doc/edgeless.spec.ts +++ b/tests/blocksuite/e2e/embed-synced-doc/edgeless.spec.ts @@ -111,6 +111,7 @@ test.describe('Embed synced doc in edgeless mode', () => { }, { embedDocId, height } ); + await waitNextFrame(page); }; const embedSyncedBlockInNote = page.locator( diff --git a/tools/utils/src/workspace.gen.ts b/tools/utils/src/workspace.gen.ts index a53d27f902..9d4b0ed926 100644 --- a/tools/utils/src/workspace.gen.ts +++ b/tools/utils/src/workspace.gen.ts @@ -43,6 +43,7 @@ export const PackageList = [ 'blocksuite/affine/gfx/template', 'blocksuite/affine/gfx/text', 'blocksuite/affine/gfx/turbo-renderer', + 'blocksuite/affine/inlines/comment', 'blocksuite/affine/inlines/footnote', 'blocksuite/affine/inlines/latex', 'blocksuite/affine/inlines/link', @@ -130,6 +131,7 @@ export const PackageList = [ 'blocksuite/affine/components', 'blocksuite/affine/ext-loader', 'blocksuite/affine/gfx/turbo-renderer', + 'blocksuite/affine/inlines/comment', 'blocksuite/affine/inlines/latex', 'blocksuite/affine/inlines/link', 'blocksuite/affine/inlines/preset', @@ -724,6 +726,19 @@ export const PackageList = [ 'blocksuite/framework/store', ], }, + { + location: 'blocksuite/affine/inlines/comment', + name: '@blocksuite/affine-inline-comment', + workspaceDependencies: [ + 'blocksuite/affine/ext-loader', + 'blocksuite/affine/model', + 'blocksuite/affine/rich-text', + 'blocksuite/affine/shared', + 'blocksuite/framework/global', + 'blocksuite/framework/std', + 'blocksuite/framework/store', + ], + }, { location: 'blocksuite/affine/inlines/footnote', name: '@blocksuite/affine-inline-footnote', @@ -786,6 +801,7 @@ export const PackageList = [ workspaceDependencies: [ 'blocksuite/affine/components', 'blocksuite/affine/ext-loader', + 'blocksuite/affine/inlines/comment', 'blocksuite/affine/inlines/footnote', 'blocksuite/affine/inlines/latex', 'blocksuite/affine/inlines/link', @@ -1505,6 +1521,7 @@ export type PackageName = | '@blocksuite/affine-gfx-template' | '@blocksuite/affine-gfx-text' | '@blocksuite/affine-gfx-turbo-renderer' + | '@blocksuite/affine-inline-comment' | '@blocksuite/affine-inline-footnote' | '@blocksuite/affine-inline-latex' | '@blocksuite/affine-inline-link' diff --git a/tsconfig.json b/tsconfig.json index 084476102b..d72f3e5412 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -90,6 +90,7 @@ { "path": "./blocksuite/affine/gfx/template" }, { "path": "./blocksuite/affine/gfx/text" }, { "path": "./blocksuite/affine/gfx/turbo-renderer" }, + { "path": "./blocksuite/affine/inlines/comment" }, { "path": "./blocksuite/affine/inlines/footnote" }, { "path": "./blocksuite/affine/inlines/latex" }, { "path": "./blocksuite/affine/inlines/link" }, diff --git a/yarn.lock b/yarn.lock index 3ecb979fec..ed098ad68d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2514,6 +2514,7 @@ __metadata: "@blocksuite/affine-components": "workspace:*" "@blocksuite/affine-ext-loader": "workspace:*" "@blocksuite/affine-gfx-turbo-renderer": "workspace:*" + "@blocksuite/affine-inline-comment": "workspace:*" "@blocksuite/affine-inline-latex": "workspace:*" "@blocksuite/affine-inline-link": "workspace:*" "@blocksuite/affine-inline-preset": "workspace:*" @@ -3509,6 +3510,31 @@ __metadata: languageName: unknown linkType: soft +"@blocksuite/affine-inline-comment@workspace:*, @blocksuite/affine-inline-comment@workspace:blocksuite/affine/inlines/comment": + version: 0.0.0-use.local + resolution: "@blocksuite/affine-inline-comment@workspace:blocksuite/affine/inlines/comment" + 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": "npm:^1.1.2" + "@preact/signals-core": "npm:^1.8.0" + "@toeverything/theme": "npm:^1.1.15" + "@types/lodash-es": "npm:^4.17.12" + lit: "npm:^3.2.0" + lit-html: "npm:^3.2.1" + lodash-es: "npm:^4.17.21" + rxjs: "npm:^7.8.1" + vitest: "npm:3.1.3" + yjs: "npm:^13.6.21" + zod: "npm:^3.23.8" + languageName: unknown + linkType: soft + "@blocksuite/affine-inline-footnote@workspace:*, @blocksuite/affine-inline-footnote@workspace:blocksuite/affine/inlines/footnote": version: 0.0.0-use.local resolution: "@blocksuite/affine-inline-footnote@workspace:blocksuite/affine/inlines/footnote" @@ -3637,6 +3663,7 @@ __metadata: dependencies: "@blocksuite/affine-components": "workspace:*" "@blocksuite/affine-ext-loader": "workspace:*" + "@blocksuite/affine-inline-comment": "workspace:*" "@blocksuite/affine-inline-footnote": "workspace:*" "@blocksuite/affine-inline-latex": "workspace:*" "@blocksuite/affine-inline-link": "workspace:*" @@ -4217,6 +4244,7 @@ __metadata: "@blocksuite/affine-gfx-template": "workspace:*" "@blocksuite/affine-gfx-text": "workspace:*" "@blocksuite/affine-gfx-turbo-renderer": "workspace:*" + "@blocksuite/affine-inline-comment": "workspace:*" "@blocksuite/affine-inline-footnote": "workspace:*" "@blocksuite/affine-inline-latex": "workspace:*" "@blocksuite/affine-inline-link": "workspace:*"