snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 00:31:07 +05:00
commit 50f788e799
7907 changed files with 988785 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
{
"name": "@blocksuite/affine-inline-comment",
"description": "Inline comment for BlockSuite.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-ext-loader": "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",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lit-html": "^3.2.1",
"lodash-es": "^4.17.23",
"zod": "^3.25.76"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts",
"./store": "./src/store.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

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

View File

@@ -0,0 +1,4 @@
export { InlineCommentManager } from './inline-comment-manager';
export * from './inline-spec';
export * from './utils';
export * from './view';

View File

@@ -0,0 +1,230 @@
import { getInlineEditorByModel } from '@blocksuite/affine-rich-text';
import { getSelectedBlocksCommand } from '@blocksuite/affine-shared/commands';
import {
BlockElementCommentManager,
type CommentId,
CommentProviderIdentifier,
findAllCommentedBlocks,
findAllCommentedElements,
} 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 { signal } from '@preact/signals-core';
import difference from 'lodash-es/difference';
import {
extractCommentIdFromDelta,
findAllCommentedTexts,
findCommentedTexts,
} from './utils';
export class InlineCommentManager extends LifeCycleWatcher {
static override key = 'inline-comment-manager';
private readonly _disposables = new DisposableGroup();
private readonly _highlightedCommentId$ = signal<CommentId | null>(null);
private get _provider() {
return this.std.getOptional(CommentProviderIdentifier);
}
override mounted() {
const provider = this._provider;
if (!provider) return;
this._init().catch(console.error);
this._disposables.add(provider.onCommentAdded(this._handleAddComment));
this._disposables.add(
provider.onCommentDeleted(id =>
this._handleDeleteAndResolve(id, 'delete')
)
);
this._disposables.add(
provider.onCommentResolved(id =>
this._handleDeleteAndResolve(id, 'resolve')
)
);
this._disposables.add(
provider.onCommentHighlighted(this._handleHighlightComment)
);
this._disposables.add(
this.std.selection.slots.changed.subscribe(this._handleSelectionChanged)
);
}
override unmounted() {
this._disposables.dispose();
}
private async _init() {
const provider = this._provider;
if (!provider) return;
const commentsInProvider = await provider.getComments('all');
const commentsInEditor = this.getCommentsInEditor();
// remove comments that are in editor but not in provider
// which means the comment may be removed or resolved in provider side
difference(commentsInEditor, commentsInProvider).forEach(comment => {
this.std
.get(BlockElementCommentManager)
.handleDeleteAndResolve(comment, 'delete');
});
}
getCommentsInEditor() {
const inlineComments = [...findAllCommentedTexts(this.std.store).values()];
const blockComments = findAllCommentedBlocks(this.std.store).flatMap(
block => Object.keys(block.props.comments)
);
const surfaceComments = findAllCommentedElements(this.std.store).flatMap(
element => Object.keys(element.comments)
);
const commentsInEditor = [
...new Set([...inlineComments, ...blockComments, ...surfaceComments]),
];
return commentsInEditor;
}
private readonly _handleAddComment = (
id: CommentId,
selections: BaseSelection[]
) => {
const needCommentTexts = selections.flatMap(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;
});
});
if (needCommentTexts.length === 0) return;
needCommentTexts.forEach(([selection, inlineEditor]) => {
inlineEditor.formatText(
selection.from,
{
[`comment-${id}`]: true,
},
{
withoutTransact: true,
}
);
});
};
private readonly _handleDeleteAndResolve = (
id: CommentId,
type: 'delete' | 'resolve'
) => {
const commentedTexts = findCommentedTexts(this.std.store, id);
if (commentedTexts.length === 0) return;
this.std.store.withoutTransact(() => {
commentedTexts.forEach(selection => {
const inlineEditor = getInlineEditorByModel(
this.std,
selection.from.blockId
);
inlineEditor?.formatText(
selection.from,
{
[`comment-${id}`]: type === 'delete' ? null : false,
},
{
withoutTransact: true,
}
);
});
});
};
private readonly _handleHighlightComment = (id: CommentId | null) => {
this._highlightedCommentId$.value = id;
};
private readonly _handleSelectionChanged = (selections: BaseSelection[]) => {
const currentHighlightedCommentId = this._highlightedCommentId$.peek();
if (selections.length === 1) {
const selection = selections[0];
// InlineCommentManager only handle text selection
if (!selection.is(TextSelection)) return;
if (!selection.isCollapsed() && currentHighlightedCommentId !== null) {
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;
}
if (currentHighlightedCommentId !== null) {
this._provider?.highlightComment(null);
}
};
}

View File

@@ -0,0 +1,112 @@
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;
}
inline-comment.unresolved {
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[];
@property({ attribute: false })
accessor unresolved = false;
private _index: number = 0;
@consume({ context: stdContext })
private accessor _std!: BlockStdScope;
@state()
accessor highlighted = false;
private get _provider() {
return this._std.getOptional(CommentProviderIdentifier);
}
private readonly _handleClick = () => {
if (this.unresolved) {
this._provider?.highlightComment(this.commentIds[this._index]);
this._index = (this._index + 1) % this.commentIds.length;
}
};
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');
}
}
if (_changedProperties.has('unresolved')) {
if (this.unresolved) {
this.classList.add('unresolved');
} else {
this.classList.remove('unresolved');
}
}
}
override render() {
return html`<slot></slot>`;
}
}

View File

@@ -0,0 +1,59 @@
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.keys(delta.attributes).filter(isInlineCommendId);
return comments.length > 0;
},
renderer: ({ delta, children }) => {
if (!delta.attributes) return html`${nothing}`;
const unresolved = Object.entries(delta.attributes).some(
([key, value]) => isInlineCommendId(key) && value === true
);
return html`<inline-comment
.unresolved=${unresolved}
.commentIds=${extractCommentIdFromDelta(delta)}
>${when(
children,
() => html`${children}`,
() => nothing
)}</inline-comment
>`;
},
wrapper: true,
});
export const NullCommentInlineSpecExtension =
InlineSpecExtension<AffineTextAttributes>({
name: 'comment',
schema: dynamicSchema(
isInlineCommendId,
z.boolean().optional().nullable().catch(undefined)
),
match: () => false,
renderer: () => html``,
});
// reuse the same identifier
NullCommentInlineSpecExtension.identifier =
CommentInlineSpecExtension.identifier;

View File

@@ -0,0 +1,64 @@
import type { CommentId } from '@blocksuite/affine-shared/services';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { TextSelection } from '@blocksuite/std';
import type { DeltaInsert, Store } from '@blocksuite/store';
export function findAllCommentedTexts(
store: Store
): Map<TextSelection, CommentId> {
const result = new Map<TextSelection, CommentId>();
store.getAllModels().forEach(model => {
if (!model.text) return;
let index = 0;
model.text.toDelta().forEach(delta => {
if (!delta.insert) return;
const length = delta.insert.length;
if (!delta.attributes) {
index += length;
return;
}
Object.keys(delta.attributes)
.filter(key => key.startsWith('comment-'))
.forEach(key => {
const commentId = key.replace('comment-', '');
const selection = new TextSelection({
from: {
blockId: model.id,
index,
length,
},
to: null,
});
result.set(selection, commentId);
});
index += length;
});
});
return result;
}
export function findCommentedTexts(
store: Store,
commentId: CommentId
): TextSelection[] {
return [...findAllCommentedTexts(store).entries()]
.filter(([_, id]) => id === commentId)
.map(([selection]) => selection);
}
export function extractCommentIdFromDelta(
delta: DeltaInsert<AffineTextAttributes>
) {
if (!delta.attributes) return [];
return Object.keys(delta.attributes)
.filter(key => key.startsWith('comment-'))
.map(key => key.replace('comment-', ''));
}

View File

@@ -0,0 +1,42 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import z from 'zod';
import { effects } from './effects';
import { InlineCommentManager } from './inline-comment-manager';
import {
CommentInlineSpecExtension,
NullCommentInlineSpecExtension,
} from './inline-spec';
const optionsSchema = z.object({
enabled: z.boolean().optional().default(true),
});
export class InlineCommentViewExtension extends ViewExtensionProvider<
z.infer<typeof optionsSchema>
> {
override name = 'affine-inline-comment';
override schema = optionsSchema;
override effect(): void {
super.effect();
effects();
}
override setup(
context: ViewExtensionContext,
options?: z.infer<typeof optionsSchema>
) {
super.setup(context, options);
context.register([
options?.enabled
? CommentInlineSpecExtension
: NullCommentInlineSpecExtension,
InlineCommentManager,
]);
}
}

View File

@@ -0,0 +1,17 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../ext-loader" },
{ "path": "../../rich-text" },
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" },
{ "path": "../../../framework/store" }
]
}