From d768ad4af0fa912002467fb61ddd9ff76faa8cb1 Mon Sep 17 00:00:00 2001 From: L-Sun Date: Wed, 2 Jul 2025 17:42:16 +0800 Subject: [PATCH] feat(editor): block comment extension (#12980) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### PR Dependency Tree * **PR #12980** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) --- .../blocks/attachment/src/attachment-block.ts | 8 ++ .../blocks/attachment/src/configs/toolbar.ts | 5 + .../affine/blocks/attachment/src/styles.ts | 4 + .../blocks/bookmark/src/bookmark-block.ts | 7 ++ .../bookmark/src/components/bookmark-card.ts | 1 + .../blocks/bookmark/src/configs/toolbar.ts | 5 + .../affine/blocks/bookmark/src/styles.ts | 6 +- .../affine/blocks/code/src/code-block.ts | 8 ++ .../blocks/code/src/code-toolbar/config.ts | 44 ++++++- blocksuite/affine/blocks/code/src/styles.ts | 5 + .../database/src/components/title/index.ts | 8 ++ .../blocks/database/src/database-block.ts | 23 +++- .../embed-linked-doc-block/configs/toolbar.ts | 5 + .../embed-linked-doc-block.ts | 1 + .../src/embed-linked-doc-block/styles.ts | 4 + .../embed-synced-doc-block/configs/toolbar.ts | 5 + .../embed-synced-doc-block.ts | 1 + .../src/embed-synced-doc-block/styles.ts | 3 + .../embed/src/common/embed-block-element.ts | 22 +++- .../src/common/to-edgeless-embed-block.ts | 8 +- .../blocks/embed/src/configs/toolbar.ts | 5 + .../image/src/components/page-image-block.ts | 13 +- .../blocks/image/src/configs/toolbar.ts | 9 ++ .../affine/blocks/image/src/image-block.ts | 11 +- .../blocks/paragraph/src/paragraph-block.ts | 12 +- .../affine/blocks/paragraph/src/styles.ts | 6 + .../affine/blocks/root/src/configs/toolbar.ts | 22 +--- .../blocks/surface-ref/src/configs/toolbar.ts | 5 + .../surface-ref/src/surface-ref-block.ts | 12 ++ blocksuite/affine/foundation/src/view.ts | 2 + .../src/blocks/attachment/attachment-model.ts | 3 + .../src/blocks/bookmark/bookmark-model.ts | 2 + .../model/src/blocks/code/code-model.ts | 2 + .../src/blocks/database/database-model.ts | 2 + .../edgeless-text/edgeless-text-model.ts | 2 + .../model/src/blocks/frame/frame-model.ts | 2 + .../model/src/blocks/image/image-model.ts | 2 + .../model/src/blocks/latex/latex-model.ts | 2 + .../model/src/blocks/list/list-model.ts | 2 + .../model/src/blocks/note/note-model.ts | 2 + .../src/blocks/paragraph/paragraph-model.ts | 2 + .../blocks/surface-ref/surface-ref-model.ts | 4 +- .../model/src/blocks/table/table-model.ts | 2 + blocksuite/affine/model/src/utils/helper.ts | 7 +- .../comment-service/block-comment-manager.ts | 118 ++++++++++++++++++ .../src/services/comment-service/index.ts | 2 + .../src/services/comment-service/utils.ts | 44 ++++++- 47 files changed, 432 insertions(+), 38 deletions(-) create mode 100644 blocksuite/affine/shared/src/services/comment-service/block-comment-manager.ts diff --git a/blocksuite/affine/blocks/attachment/src/attachment-block.ts b/blocksuite/affine/blocks/attachment/src/attachment-block.ts index d1554c62e..7184ea381 100644 --- a/blocksuite/affine/blocks/attachment/src/attachment-block.ts +++ b/blocksuite/affine/blocks/attachment/src/attachment-block.ts @@ -17,6 +17,7 @@ import { AttachmentBlockStyles, } from '@blocksuite/affine-model'; import { + BlockCommentManager, CitationProvider, DocModeProvider, FileSizeLimitProvider, @@ -92,6 +93,12 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent { return this.std .get(AttachmentEmbedProvider) @@ -499,6 +506,7 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent diff --git a/blocksuite/affine/blocks/attachment/src/configs/toolbar.ts b/blocksuite/affine/blocks/attachment/src/configs/toolbar.ts index 875629ad0..1baa12314 100644 --- a/blocksuite/affine/blocks/attachment/src/configs/toolbar.ts +++ b/blocksuite/affine/blocks/attachment/src/configs/toolbar.ts @@ -10,6 +10,7 @@ import { } from '@blocksuite/affine-shared/consts'; import { ActionPlacement, + blockCommentToolbarButton, type ToolbarAction, type ToolbarActionGroup, type ToolbarModuleConfig, @@ -240,6 +241,10 @@ const builtinToolbarConfig = { replaceAction, downloadAction, captionAction, + { + id: 'f.comment', + ...blockCommentToolbarButton, + }, { placement: ActionPlacement.More, id: 'a.clipboard', diff --git a/blocksuite/affine/blocks/attachment/src/styles.ts b/blocksuite/affine/blocks/attachment/src/styles.ts index e504a0373..95cc751f8 100644 --- a/blocksuite/affine/blocks/attachment/src/styles.ts +++ b/blocksuite/affine/blocks/attachment/src/styles.ts @@ -15,6 +15,10 @@ export const styles = css` } } + .affine-attachment-container.comment-highlighted { + outline: 2px solid ${unsafeCSSVarV2('block/comment/highlightUnderline')}; + } + .affine-attachment-card { display: flex; gap: 12px; diff --git a/blocksuite/affine/blocks/bookmark/src/bookmark-block.ts b/blocksuite/affine/blocks/bookmark/src/bookmark-block.ts index 171987bb0..9f9eed4de 100644 --- a/blocksuite/affine/blocks/bookmark/src/bookmark-block.ts +++ b/blocksuite/affine/blocks/bookmark/src/bookmark-block.ts @@ -8,6 +8,7 @@ import type { } from '@blocksuite/affine-model'; import { ImageProxyService } from '@blocksuite/affine-shared/adapters'; import { + BlockCommentManager, CitationProvider, DocModeProvider, LinkPreviewServiceIdentifier, @@ -128,6 +129,12 @@ export class BookmarkBlockComponent extends CaptionedBlockComponent { event.stopPropagation(); diff --git a/blocksuite/affine/blocks/bookmark/src/components/bookmark-card.ts b/blocksuite/affine/blocks/bookmark/src/components/bookmark-card.ts index 62261a53d..af47694fa 100644 --- a/blocksuite/affine/blocks/bookmark/src/components/bookmark-card.ts +++ b/blocksuite/affine/blocks/bookmark/src/components/bookmark-card.ts @@ -45,6 +45,7 @@ export class BookmarkCard extends SignalWatcher( [style]: true, selected: this.bookmark.selected$.value, edgeless: isGfxBlockComponent(this.bookmark), + 'comment-highlighted': this.bookmark.isCommentHighlighted, }); const domainName = url.match( diff --git a/blocksuite/affine/blocks/bookmark/src/configs/toolbar.ts b/blocksuite/affine/blocks/bookmark/src/configs/toolbar.ts index fea47f1b5..bc79f9877 100644 --- a/blocksuite/affine/blocks/bookmark/src/configs/toolbar.ts +++ b/blocksuite/affine/blocks/bookmark/src/configs/toolbar.ts @@ -17,6 +17,7 @@ import { } from '@blocksuite/affine-shared/consts'; import { ActionPlacement, + blockCommentToolbarButton, EmbedIframeService, EmbedOptionProvider, type LinkEventType, @@ -288,6 +289,10 @@ const builtinToolbarConfig = { }, } satisfies ToolbarActionGroup, captionAction, + { + id: 'e.comment', + ...blockCommentToolbarButton, + }, { placement: ActionPlacement.More, id: 'a.clipboard', diff --git a/blocksuite/affine/blocks/bookmark/src/styles.ts b/blocksuite/affine/blocks/bookmark/src/styles.ts index b754c54d8..76bb5fed8 100644 --- a/blocksuite/affine/blocks/bookmark/src/styles.ts +++ b/blocksuite/affine/blocks/bookmark/src/styles.ts @@ -1,4 +1,4 @@ -import { unsafeCSSVar } from '@blocksuite/affine-shared/theme'; +import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; import { baseTheme } from '@toeverything/theme'; import { css, unsafeCSS } from 'lit'; @@ -158,6 +158,10 @@ export const styles = css` border-radius: 4px; } + .affine-bookmark-card.comment-highlighted { + outline: 2px solid ${unsafeCSSVarV2('block/comment/highlightUnderline')}; + } + .affine-bookmark-card.loading { .affine-bookmark-content-title-text { color: var(--affine-placeholder-color); diff --git a/blocksuite/affine/blocks/code/src/code-block.ts b/blocksuite/affine/blocks/code/src/code-block.ts index f8bcacb7d..d11057f69 100644 --- a/blocksuite/affine/blocks/code/src/code-block.ts +++ b/blocksuite/affine/blocks/code/src/code-block.ts @@ -6,6 +6,7 @@ import { EDGELESS_TOP_CONTENTEDITABLE_SELECTOR, } from '@blocksuite/affine-shared/consts'; import { + BlockCommentManager, DocModeProvider, NotificationProvider, } from '@blocksuite/affine-shared/services'; @@ -390,6 +391,12 @@ export class CodeBlockComponent extends CaptionedBlockComponent }); } + get isCommentHighlighted() { + return this.std + .get(BlockCommentManager) + .isBlockCommentHighlighted(this.model); + } + override async getUpdateComplete() { const result = await super.getUpdateComplete(); await this._richTextElement?.updateComplete; @@ -413,6 +420,7 @@ export class CodeBlockComponent extends CaptionedBlockComponent
[] = [ }; }, }, + { + type: 'comment', + label: 'Comment', + tooltip: 'Comment', + icon: CommentIcon({ + width: '20', + height: '20', + }), + when: ({ std }) => !!std.getOptional(CommentProviderIdentifier), + generate: ({ blockComponent }) => { + return { + action: () => { + const commentProvider = blockComponent.std.getOptional( + CommentProviderIdentifier + ); + if (!commentProvider) return; + + commentProvider.addComment([ + new BlockSelection({ + blockId: blockComponent.model.id, + }), + ]); + }, + render: item => + html` { + e.stopPropagation(); + item.action(); + }} + > + ${item.icon} + `, + }; + }, + }, ], }, ]; diff --git a/blocksuite/affine/blocks/code/src/styles.ts b/blocksuite/affine/blocks/code/src/styles.ts index 962a8d963..78a8448ab 100644 --- a/blocksuite/affine/blocks/code/src/styles.ts +++ b/blocksuite/affine/blocks/code/src/styles.ts @@ -1,4 +1,5 @@ import { scrollbarStyle } from '@blocksuite/affine-shared/styles'; +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; import { css } from 'lit'; export const codeBlockStyles = css` @@ -20,6 +21,10 @@ export const codeBlockStyles = css` padding: 12px; } + .affine-code-block-container.highlight-comment { + outline: 2px solid ${unsafeCSSVarV2('block/comment/highlightUnderline')}; + } + ${scrollbarStyle('.affine-code-block-container rich-text')} .affine-code-block-container .inline-editor { diff --git a/blocksuite/affine/blocks/database/src/components/title/index.ts b/blocksuite/affine/blocks/database/src/components/title/index.ts index 4a49c7d93..867b17f57 100644 --- a/blocksuite/affine/blocks/database/src/components/title/index.ts +++ b/blocksuite/affine/blocks/database/src/components/title/index.ts @@ -1,3 +1,4 @@ +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; import { stopPropagation } from '@blocksuite/affine-shared/utils'; import type { DataViewUILogicBase } from '@blocksuite/data-view'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit'; @@ -72,6 +73,12 @@ export class DatabaseTitle extends SignalWatcher( .affine-database-title [data-title-focus='true']::before { color: var(--affine-placeholder-color); } + + .affine-database-title.comment-highlighted { + border-bottom: 2px solid + ${unsafeCSSVarV2('block/comment/highlightUnderline')}; + background-color: ${unsafeCSSVarV2('block/comment/highlightActive')}; + } `; private readonly compositionEnd = () => { @@ -134,6 +141,7 @@ export class DatabaseTitle extends SignalWatcher( const classList = classMap({ 'affine-database-title': true, ellipsis: !this.isFocus$.value, + 'comment-highlighted': this.database?.isCommentHighlighted ?? false, }); const untitledStyle = styleMap({ height: isEmpty ? 'auto' : 0, diff --git a/blocksuite/affine/blocks/database/src/database-block.ts b/blocksuite/affine/blocks/database/src/database-block.ts index bbb198af5..ead546425 100644 --- a/blocksuite/affine/blocks/database/src/database-block.ts +++ b/blocksuite/affine/blocks/database/src/database-block.ts @@ -10,6 +10,8 @@ import { toast } from '@blocksuite/affine-components/toast'; import type { DatabaseBlockModel } from '@blocksuite/affine-model'; import { EDGELESS_TOP_CONTENTEDITABLE_SELECTOR } from '@blocksuite/affine-shared/consts'; import { + BlockCommentManager, + CommentProviderIdentifier, DocModeProvider, NotificationProvider, type TelemetryEventMap, @@ -34,11 +36,12 @@ import { import { widgetPresets } from '@blocksuite/data-view/widget-presets'; import { Rect } from '@blocksuite/global/gfx'; import { + CommentIcon, CopyIcon, DeleteIcon, MoreHorizontalIcon, } from '@blocksuite/icons/lit'; -import { type BlockComponent } from '@blocksuite/std'; +import { type BlockComponent, BlockSelection } from '@blocksuite/std'; import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline'; import { Slice } from '@blocksuite/store'; import { autoUpdate } from '@floating-ui/dom'; @@ -82,6 +85,18 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent !this.std.getOptional(CommentProviderIdentifier), + select: () => { + this.std.getOptional(CommentProviderIdentifier)?.addComment([ + new BlockSelection({ + blockId: this.blockId, + }), + ]); + }, + }), menu.action({ prefix: CopyIcon(), name: 'Copy', @@ -297,6 +312,12 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent( diff --git a/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/configs/toolbar.ts b/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/configs/toolbar.ts index 7d6854557..e2e1b783d 100644 --- a/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/configs/toolbar.ts +++ b/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/configs/toolbar.ts @@ -11,6 +11,7 @@ import { } from '@blocksuite/affine-shared/consts'; import { ActionPlacement, + blockCommentToolbarButton, DocDisplayMetaProvider, EditorSettingProvider, type LinkEventType, @@ -305,6 +306,10 @@ const builtinToolbarConfig = { }, } satisfies ToolbarActionGroup, captionAction, + { + id: 'e.comment', + ...blockCommentToolbarButton, + }, { placement: ActionPlacement.More, id: 'a.clipboard', diff --git a/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/embed-linked-doc-block.ts b/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/embed-linked-doc-block.ts index 63309df7b..d07627d92 100644 --- a/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/embed-linked-doc-block.ts +++ b/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/embed-linked-doc-block.ts @@ -338,6 +338,7 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent = BlockModel, + Model extends BlockModel = BlockModel, Service extends BlockService = BlockService, WidgetName extends string = string, > extends CaptionedBlockComponent { @@ -59,6 +62,12 @@ export class EmbedBlockComponent< */ protected embedContainerStyle: StyleInfo = {}; + get isCommentHighlighted() { + return this.std + .get(BlockCommentManager) + .isBlockCommentHighlighted(this.model); + } + renderEmbed = (content: () => TemplateResult) => { if ( this._cardStyle === 'horizontal' || @@ -90,6 +99,11 @@ export class EmbedBlockComponent< style=${styleMap({ height: `${this._cardHeight}px`, width: '100%', + ...(this.isCommentHighlighted + ? { + border: `2px solid ${unsafeCSSVarV2('block/comment/highlightUnderline')}`, + } + : {}), ...this.embedContainerStyle, })} > diff --git a/blocksuite/affine/blocks/embed/src/common/to-edgeless-embed-block.ts b/blocksuite/affine/blocks/embed/src/common/to-edgeless-embed-block.ts index 8f26ad98b..cf9af9b17 100644 --- a/blocksuite/affine/blocks/embed/src/common/to-edgeless-embed-block.ts +++ b/blocksuite/affine/blocks/embed/src/common/to-edgeless-embed-block.ts @@ -1,4 +1,5 @@ import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface'; +import type { EmbedProps } from '@blocksuite/affine-model'; import { Bound } from '@blocksuite/global/gfx'; import { blockComponentSymbol, @@ -7,16 +8,13 @@ import { GfxElementSymbol, toGfxBlockComponent, } from '@blocksuite/std'; -import type { - GfxBlockElementModel, - GfxCompatibleProps, -} from '@blocksuite/std/gfx'; +import type { GfxBlockElementModel } from '@blocksuite/std/gfx'; import type { StyleInfo } from 'lit/directives/style-map.js'; import type { EmbedBlockComponent } from './embed-block-element.js'; export function toEdgelessEmbedBlock< - Model extends GfxBlockElementModel, + Model extends GfxBlockElementModel, Service extends BlockService, WidgetName extends string, B extends typeof EmbedBlockComponent, diff --git a/blocksuite/affine/blocks/embed/src/configs/toolbar.ts b/blocksuite/affine/blocks/embed/src/configs/toolbar.ts index 81ce40f61..c055a7b71 100644 --- a/blocksuite/affine/blocks/embed/src/configs/toolbar.ts +++ b/blocksuite/affine/blocks/embed/src/configs/toolbar.ts @@ -13,6 +13,7 @@ import { } from '@blocksuite/affine-shared/consts'; import { ActionPlacement, + blockCommentToolbarButton, EmbedOptionProvider, type LinkEventType, type ToolbarAction, @@ -348,6 +349,10 @@ function createBuiltinToolbarConfigForExternal( }); }, }, + { + id: 'e.comment', + ...blockCommentToolbarButton, + }, { placement: ActionPlacement.More, id: 'a.clipboard', diff --git a/blocksuite/affine/blocks/image/src/components/page-image-block.ts b/blocksuite/affine/blocks/image/src/components/page-image-block.ts index 5d21875e0..298d8f945 100644 --- a/blocksuite/affine/blocks/image/src/components/page-image-block.ts +++ b/blocksuite/affine/blocks/image/src/components/page-image-block.ts @@ -18,6 +18,7 @@ import type { BaseSelection } from '@blocksuite/store'; import { computed } from '@preact/signals-core'; import { css, html, type PropertyValues } from 'lit'; import { property, query } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; import { styleMap } from 'lit/directives/style-map.js'; import { when } from 'lit/directives/when.js'; @@ -76,6 +77,10 @@ export class ImageBlockPageComponent extends SignalWatcher( width: 100%; height: 100%; } + + affine-page-image .comment-highlighted { + outline: 2px solid ${unsafeCSSVarV2('block/comment/highlightUnderline')}; + } `; resizeable$ = computed(() => this.block.resizeable$.value); @@ -364,7 +369,13 @@ export class ImageBlockPageComponent extends SignalWatcher( const { loading, error, icon, description, needUpload } = this.state; return html` -
+
ctx.getSurfaceModelsByType(ImageBlockModel).length === 1, diff --git a/blocksuite/affine/blocks/image/src/image-block.ts b/blocksuite/affine/blocks/image/src/image-block.ts index 4dd0ed147..63409fdb6 100644 --- a/blocksuite/affine/blocks/image/src/image-block.ts +++ b/blocksuite/affine/blocks/image/src/image-block.ts @@ -5,7 +5,10 @@ import { Peekable } from '@blocksuite/affine-components/peek'; import { ResourceController } from '@blocksuite/affine-components/resource'; import type { ImageBlockModel } from '@blocksuite/affine-model'; import { ImageSelection } from '@blocksuite/affine-shared/selection'; -import { ToolbarRegistryIdentifier } from '@blocksuite/affine-shared/services'; +import { + BlockCommentManager, + ToolbarRegistryIdentifier, +} from '@blocksuite/affine-shared/services'; import { formatSize } from '@blocksuite/affine-shared/utils'; import { IS_MOBILE } from '@blocksuite/global/env'; import { BrokenImageIcon, ImageIcon } from '@blocksuite/icons/lit'; @@ -65,6 +68,12 @@ export class ImageBlockComponent extends CaptionedBlockComponent( @@ -268,7 +275,10 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent
- 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, + { + id: 'g.comment', + ...blockCommentToolbarButton, + }, { placement: ActionPlacement.More, id: 'a.clipboard', diff --git a/blocksuite/affine/blocks/surface-ref/src/configs/toolbar.ts b/blocksuite/affine/blocks/surface-ref/src/configs/toolbar.ts index 6eb081cff..1cd84bae5 100644 --- a/blocksuite/affine/blocks/surface-ref/src/configs/toolbar.ts +++ b/blocksuite/affine/blocks/surface-ref/src/configs/toolbar.ts @@ -5,6 +5,7 @@ import { } from '@blocksuite/affine-shared/commands'; import { ActionPlacement, + blockCommentToolbarButton, type ToolbarModuleConfig, } from '@blocksuite/affine-shared/services'; import { CaptionIcon, CopyIcon, DeleteIcon } from '@blocksuite/icons/lit'; @@ -61,6 +62,10 @@ export const surfaceRefToolbarModuleConfig: ToolbarModuleConfig = { surfaceRefBlock.captionElement.show(); }, }, + { + id: 'e.comment', + ...blockCommentToolbarButton, + }, { id: 'a.clipboard', placement: ActionPlacement.More, diff --git a/blocksuite/affine/blocks/surface-ref/src/surface-ref-block.ts b/blocksuite/affine/blocks/surface-ref/src/surface-ref-block.ts index 18840341e..ae1aba95a 100644 --- a/blocksuite/affine/blocks/surface-ref/src/surface-ref-block.ts +++ b/blocksuite/affine/blocks/surface-ref/src/surface-ref-block.ts @@ -13,6 +13,7 @@ import { type SurfaceRefBlockModel, } from '@blocksuite/affine-model'; import { + BlockCommentManager, DocModeProvider, EditPropsStore, type OpenDocMode, @@ -76,6 +77,10 @@ export class SurfaceRefBlockComponent extends BlockComponent { this.selection.update(() => { return [this.selection.create(BlockSelection, { blockId: this.blockId })]; @@ -456,6 +467,7 @@ export class SurfaceRefBlockComponent extends BlockComponent diff --git a/blocksuite/affine/foundation/src/view.ts b/blocksuite/affine/foundation/src/view.ts index 70fb8aee9..7cbe62346 100644 --- a/blocksuite/affine/foundation/src/view.ts +++ b/blocksuite/affine/foundation/src/view.ts @@ -9,6 +9,7 @@ import { } from '@blocksuite/affine-ext-loader'; import { AutoClearSelectionService, + BlockCommentManager, CitationService, DefaultOpenDocExtension, DNDAPIExtension, @@ -78,6 +79,7 @@ export class FoundationViewExtension extends ViewExtensionProvider; } & Omit & BlockMeta; @@ -78,6 +80,7 @@ export const defaultAttachmentProps: AttachmentBlockProps = { 'meta:createdBy': undefined, 'meta:updatedBy': undefined, footnoteIdentifier: null, + comments: undefined, }; export const AttachmentBlockSchema = defineBlockSchema({ diff --git a/blocksuite/affine/model/src/blocks/bookmark/bookmark-model.ts b/blocksuite/affine/model/src/blocks/bookmark/bookmark-model.ts index d3979a202..d3477444d 100644 --- a/blocksuite/affine/model/src/blocks/bookmark/bookmark-model.ts +++ b/blocksuite/affine/model/src/blocks/bookmark/bookmark-model.ts @@ -28,6 +28,7 @@ export type BookmarkBlockProps = { url: string; caption: string | null; footnoteIdentifier: string | null; + comments?: Record; } & LinkPreviewData & Omit & BlockMeta; @@ -52,6 +53,7 @@ const defaultBookmarkProps: BookmarkBlockProps = { 'meta:updatedBy': undefined, footnoteIdentifier: null, + comments: undefined, }; export const BookmarkBlockSchema = defineBlockSchema({ diff --git a/blocksuite/affine/model/src/blocks/code/code-model.ts b/blocksuite/affine/model/src/blocks/code/code-model.ts index abb7f2473..16e354496 100644 --- a/blocksuite/affine/model/src/blocks/code/code-model.ts +++ b/blocksuite/affine/model/src/blocks/code/code-model.ts @@ -14,6 +14,7 @@ type CodeBlockProps = { caption: string; preview?: boolean; lineNumber?: boolean; + comments?: Record; } & BlockMeta; export const CodeBlockSchema = defineBlockSchema({ @@ -26,6 +27,7 @@ export const CodeBlockSchema = defineBlockSchema({ caption: '', preview: undefined, lineNumber: undefined, + comments: undefined, 'meta:createdAt': undefined, 'meta:createdBy': undefined, 'meta:updatedAt': undefined, diff --git a/blocksuite/affine/model/src/blocks/database/database-model.ts b/blocksuite/affine/model/src/blocks/database/database-model.ts index 33fdeb1a7..98c22cd32 100644 --- a/blocksuite/affine/model/src/blocks/database/database-model.ts +++ b/blocksuite/affine/model/src/blocks/database/database-model.ts @@ -16,6 +16,7 @@ export type DatabaseBlockProps = { title: Text; cells: SerializedCells; columns: Array; + comments?: Record; }; export class DatabaseBlockModel extends BlockModel {} @@ -27,6 +28,7 @@ export const DatabaseBlockSchema = defineBlockSchema({ title: internal.Text(), cells: Object.create(null), columns: [], + comments: undefined, }), metadata: { role: 'hub', diff --git a/blocksuite/affine/model/src/blocks/edgeless-text/edgeless-text-model.ts b/blocksuite/affine/model/src/blocks/edgeless-text/edgeless-text-model.ts index f73949492..df52c6198 100644 --- a/blocksuite/affine/model/src/blocks/edgeless-text/edgeless-text-model.ts +++ b/blocksuite/affine/model/src/blocks/edgeless-text/edgeless-text-model.ts @@ -26,6 +26,7 @@ import { DefaultTheme } from '../../themes/default'; type EdgelessTextProps = { hasMaxWidth: boolean; + comments?: Record; } & Omit & GfxCommonBlockProps; @@ -54,6 +55,7 @@ export const EdgelessTextBlockSchema = defineBlockSchema({ scale: 1, rotate: 0, hasMaxWidth: false, + comments: undefined, ...EdgelessTextZodSchema.parse(undefined), }), metadata: { diff --git a/blocksuite/affine/model/src/blocks/frame/frame-model.ts b/blocksuite/affine/model/src/blocks/frame/frame-model.ts index 8683c580e..b99a23b43 100644 --- a/blocksuite/affine/model/src/blocks/frame/frame-model.ts +++ b/blocksuite/affine/model/src/blocks/frame/frame-model.ts @@ -30,6 +30,7 @@ export type FrameBlockProps = { background: Color; childElementIds?: Record; presentationIndex?: string; + comments?: Record; } & GfxCompatibleProps; export const FrameZodSchema = z @@ -50,6 +51,7 @@ export const FrameBlockSchema = defineBlockSchema({ childElementIds: Object.create(null), presentationIndex: generateKeyBetweenV2(null, null), lockedBySelf: false, + comments: undefined, }), metadata: { version: 1, diff --git a/blocksuite/affine/model/src/blocks/image/image-model.ts b/blocksuite/affine/model/src/blocks/image/image-model.ts index a7092b1ad..0cc22fbd4 100644 --- a/blocksuite/affine/model/src/blocks/image/image-model.ts +++ b/blocksuite/affine/model/src/blocks/image/image-model.ts @@ -19,6 +19,7 @@ export type ImageBlockProps = { height?: number; rotate: number; size?: number; + comments?: Record; } & Omit & BlockMeta; @@ -32,6 +33,7 @@ const defaultImageProps: ImageBlockProps = { lockedBySelf: false, rotate: 0, size: -1, + comments: undefined, 'meta:createdAt': undefined, 'meta:createdBy': undefined, 'meta:updatedAt': undefined, diff --git a/blocksuite/affine/model/src/blocks/latex/latex-model.ts b/blocksuite/affine/model/src/blocks/latex/latex-model.ts index c7330dadc..add0cd6dd 100644 --- a/blocksuite/affine/model/src/blocks/latex/latex-model.ts +++ b/blocksuite/affine/model/src/blocks/latex/latex-model.ts @@ -11,6 +11,7 @@ import { export type LatexProps = { latex: string; + comments?: Record; } & GfxCommonBlockProps; export const LatexBlockSchema = defineBlockSchema({ @@ -22,6 +23,7 @@ export const LatexBlockSchema = defineBlockSchema({ scale: 1, rotate: 0, latex: '', + comments: undefined, }), metadata: { version: 1, diff --git a/blocksuite/affine/model/src/blocks/list/list-model.ts b/blocksuite/affine/model/src/blocks/list/list-model.ts index 70dbbf465..edc037869 100644 --- a/blocksuite/affine/model/src/blocks/list/list-model.ts +++ b/blocksuite/affine/model/src/blocks/list/list-model.ts @@ -16,6 +16,7 @@ export type ListProps = { checked: boolean; collapsed: boolean; order: number | null; + comments?: Record; } & BlockMeta; export const ListBlockSchema = defineBlockSchema({ @@ -29,6 +30,7 @@ export const ListBlockSchema = defineBlockSchema({ // number type only for numbered list order: null, + comments: undefined, 'meta:createdAt': undefined, 'meta:createdBy': undefined, 'meta:updatedAt': undefined, diff --git a/blocksuite/affine/model/src/blocks/note/note-model.ts b/blocksuite/affine/model/src/blocks/note/note-model.ts index 293d02b8c..02bae1401 100644 --- a/blocksuite/affine/model/src/blocks/note/note-model.ts +++ b/blocksuite/affine/model/src/blocks/note/note-model.ts @@ -69,6 +69,7 @@ export const NoteBlockSchema = defineBlockSchema({ shadowType: DEFAULT_NOTE_SHADOW, }, }, + comments: undefined, }), metadata: { version: 1, @@ -91,6 +92,7 @@ export type NoteProps = { background: Color; displayMode: NoteDisplayMode; edgeless: NoteEdgelessProps; + comments?: Record; /** * @deprecated * use `displayMode` instead diff --git a/blocksuite/affine/model/src/blocks/paragraph/paragraph-model.ts b/blocksuite/affine/model/src/blocks/paragraph/paragraph-model.ts index f98f55a11..f75ecdf27 100644 --- a/blocksuite/affine/model/src/blocks/paragraph/paragraph-model.ts +++ b/blocksuite/affine/model/src/blocks/paragraph/paragraph-model.ts @@ -21,6 +21,7 @@ export type ParagraphProps = { type: ParagraphType; text: Text; collapsed: boolean; + comments?: Record; } & BlockMeta; export const ParagraphBlockSchema = defineBlockSchema({ @@ -29,6 +30,7 @@ export const ParagraphBlockSchema = defineBlockSchema({ type: 'text', text: internal.Text(), collapsed: false, + comments: undefined, 'meta:createdAt': undefined, 'meta:createdBy': undefined, 'meta:updatedAt': undefined, diff --git a/blocksuite/affine/model/src/blocks/surface-ref/surface-ref-model.ts b/blocksuite/affine/model/src/blocks/surface-ref/surface-ref-model.ts index 7ffd2e57b..4d400e478 100644 --- a/blocksuite/affine/model/src/blocks/surface-ref/surface-ref-model.ts +++ b/blocksuite/affine/model/src/blocks/surface-ref/surface-ref-model.ts @@ -8,14 +8,16 @@ export type SurfaceRefProps = { reference: string; caption: string; refFlavour: string; + comments?: Record; }; export const SurfaceRefBlockSchema = defineBlockSchema({ flavour: 'affine:surface-ref', - props: () => ({ + props: (): SurfaceRefProps => ({ reference: '', caption: '', refFlavour: '', + comments: undefined, }), metadata: { version: 1, diff --git a/blocksuite/affine/model/src/blocks/table/table-model.ts b/blocksuite/affine/model/src/blocks/table/table-model.ts index 6ee88bfe9..f0305f161 100644 --- a/blocksuite/affine/model/src/blocks/table/table-model.ts +++ b/blocksuite/affine/model/src/blocks/table/table-model.ts @@ -29,6 +29,7 @@ export interface TableBlockProps extends BlockMeta { columns: Record; // key = `${rowId}:${columnId}` cells: Record; + comments?: Record; } export interface TableCellSerialized { @@ -51,6 +52,7 @@ export const TableBlockSchema = defineBlockSchema({ rows: {}, columns: {}, cells: {}, + comments: undefined, 'meta:createdAt': undefined, 'meta:createdBy': undefined, 'meta:updatedAt': undefined, diff --git a/blocksuite/affine/model/src/utils/helper.ts b/blocksuite/affine/model/src/utils/helper.ts index bdcbbe34a..341a24102 100644 --- a/blocksuite/affine/model/src/utils/helper.ts +++ b/blocksuite/affine/model/src/utils/helper.ts @@ -10,7 +10,11 @@ import { import type { BlockMeta } from './types'; -export type EmbedProps = Props & GfxCompatibleProps & BlockMeta; +export type EmbedProps = Props & + GfxCompatibleProps & + BlockMeta & { + comments?: Record; + }; export function defineEmbedModel< Props extends object, @@ -52,6 +56,7 @@ export function createEmbedBlockSchema< xywh: '[0,0,0,0]', lockedBySelf: false, rotate: 0, + comments: undefined, 'meta:createdAt': undefined, 'meta:updatedAt': undefined, 'meta:createdBy': undefined, diff --git a/blocksuite/affine/shared/src/services/comment-service/block-comment-manager.ts b/blocksuite/affine/shared/src/services/comment-service/block-comment-manager.ts new file mode 100644 index 000000000..faea93e78 --- /dev/null +++ b/blocksuite/affine/shared/src/services/comment-service/block-comment-manager.ts @@ -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(null); + + private readonly _disposables = new DisposableGroup(); + + private get _provider() { + return this.std.getOptional(CommentProviderIdentifier); + } + + isBlockCommentHighlighted( + block: BlockModel<{ comments?: Record }> + ) { + 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; + + 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; + }; +} diff --git a/blocksuite/affine/shared/src/services/comment-service/index.ts b/blocksuite/affine/shared/src/services/comment-service/index.ts index 18f617bd2..f1eeb78bb 100644 --- a/blocksuite/affine/shared/src/services/comment-service/index.ts +++ b/blocksuite/affine/shared/src/services/comment-service/index.ts @@ -1 +1,3 @@ +export * from './block-comment-manager'; export * from './comment-provider'; +export * from './utils'; diff --git a/blocksuite/affine/shared/src/services/comment-service/utils.ts b/blocksuite/affine/shared/src/services/comment-service/utils.ts index 1003f115f..f7990d5cd 100644 --- a/blocksuite/affine/shared/src/services/comment-service/utils.ts +++ b/blocksuite/affine/shared/src/services/comment-service/utils.ts @@ -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 }>; + 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 = { + 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; + } + }, +};