Compare commits

..

3 Commits

Author SHA1 Message Date
yoyoyohamapi 8024172569 feat(core): block diff ui 2025-07-02 10:42:28 +08:00
yoyoyohamapi b434b95548 feat(core): markdown-diff & patch apply 2025-07-02 10:40:23 +08:00
fengmk2 e8bc8f2d63 feat(server): add comment-attachment storage (#12911)
close CLOUD-230












#### PR Dependency Tree


* **PR #12911** 👈
  * **PR #12761**
    * **PR #12924**
      * **PR #12925**

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added support for uploading and retrieving comment attachments in
workspace documents via a new API endpoint.
* Introduced a service for managing comment attachments, including
storage, retrieval, deletion, and URL generation.
* Implemented localized error messages and improved error handling for
missing comment attachments.

* **Bug Fixes**
  * Improved error feedback when comment attachments are not found.

* **Tests**
* Added comprehensive tests for comment attachment storage, retrieval,
deletion, API endpoint behavior, and permission checks.

* **Documentation**
* Updated GraphQL schema and localization files to include new error
types for comment attachments.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-01 07:04:52 +00:00
114 changed files with 3214 additions and 464 deletions
@@ -23,7 +23,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"file-type": "^21.0.0",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
@@ -24,7 +24,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
"rxjs": "^7.8.1",
@@ -25,7 +25,7 @@
"@floating-ui/dom": "^1.6.10",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"emoji-mart": "^5.6.0",
"lit": "^3.2.0",
+1 -1
View File
@@ -27,7 +27,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
@@ -24,7 +24,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
@@ -42,6 +42,7 @@ import { computed, signal } from '@preact/signals-core';
import { css, nothing, unsafeCSS } from 'lit';
import { html } from 'lit/static-html.js';
import { repeat } from 'lit/directives/repeat.js';
import { BlockQueryDataSource } from './data-source.js';
import type { DataViewBlockModel } from './data-view-model.js';
@@ -303,9 +304,16 @@ export class DataViewBlockComponent extends CaptionedBlockComponent<DataViewBloc
},
});
override renderBlock() {
const widgets = html`${repeat(
Object.entries(this.widgets),
([id]) => id,
([_, widget]) => widget
)}`;
return html`
<div contenteditable="false" style="position: relative">
${this.dataViewRootLogic.render()}
${widgets}
</div>
`;
}
@@ -28,7 +28,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"date-fns": "^4.0.0",
"lit": "^3.2.0",
@@ -45,6 +45,7 @@ import { autoUpdate } from '@floating-ui/dom';
import { computed, signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
import { popSideDetail } from './components/layout.js';
import { DatabaseConfigExtension } from './config.js';
import { EditorHostKey } from './context/host-context.js';
@@ -428,9 +429,15 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
})
);
override renderBlock() {
const widgets = html`${repeat(
Object.entries(this.widgets),
([id]) => id,
([_, widget]) => widget
)}`;
return html`
<div contenteditable="false" class="${databaseContentStyles}">
${this.dataViewRootLogic.value.render()}
${this.dataViewRootLogic.value.render()} ${widgets}
</div>
`;
}
@@ -21,7 +21,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
@@ -26,7 +26,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
"rxjs": "^7.8.1",
@@ -26,7 +26,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -26,7 +26,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -25,7 +25,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
+1 -1
View File
@@ -25,7 +25,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"file-type": "^21.0.0",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
+1 -1
View File
@@ -25,7 +25,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/katex": "^0.16.7",
"@types/mdast": "^4.0.4",
"katex": "^0.16.11",
+1 -1
View File
@@ -24,7 +24,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
@@ -23,6 +23,7 @@ import { effect } from '@preact/signals-core';
import { html, nothing, type TemplateResult } from 'lit';
import { query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { correctNumberedListsOrderToPrev } from './commands/utils.js';
@@ -138,6 +139,11 @@ export class ListBlockComponent extends CaptionedBlockComponent<ListBlockModel>
override renderBlock(): TemplateResult<1> {
const { model, _onClickIcon } = this;
const widgets = html`${repeat(
Object.entries(this.widgets),
([id]) => id,
([_, widget]) => widget
)}`;
const collapsed = this.store.readonly
? this._readonlyCollapsed
: model.props.collapsed;
@@ -199,7 +205,7 @@ export class ListBlockComponent extends CaptionedBlockComponent<ListBlockModel>
></rich-text>
</div>
${children}
${children} ${widgets}
</div>
`;
}
+1 -1
View File
@@ -27,7 +27,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"@types/mdast": "^4.0.4",
"@vanilla-extract/css": "^1.17.0",
@@ -23,7 +23,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
@@ -26,6 +26,7 @@ import { computed, effect, signal } from '@preact/signals-core';
import { html, nothing, type TemplateResult } from 'lit';
import { query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
@@ -227,6 +228,12 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
}
override renderBlock(): TemplateResult<1> {
const widgets = html`${repeat(
Object.entries(this.widgets),
([id]) => id,
([_, widget]) => widget
)}`;
const { type$ } = this.model.props;
const collapsed = this.store.readonly
? this._readonlyCollapsed
@@ -340,7 +347,7 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
`}
</div>
${children}
${children} ${widgets}
</div>
`;
}
+1 -1
View File
@@ -44,7 +44,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"dompurify": "^3.2.4",
"html2canvas": "^1.4.1",
@@ -25,7 +25,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"fractional-indexing": "^3.2.0",
"lit": "^3.2.0",
@@ -20,7 +20,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"fractional-indexing": "^3.2.0",
"html2canvas": "^1.4.1",
+1 -1
View File
@@ -21,7 +21,7 @@
"@lit/context": "^1.1.2",
"@lottiefiles/dotlottie-wc": "^0.5.0",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/hast": "^3.0.4",
"@types/katex": "^0.16.7",
"@types/lodash-es": "^4.17.12",
+1 -1
View File
@@ -21,7 +21,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"clsx": "^2.1.1",
"date-fns": "^4.0.0",
+1 -1
View File
@@ -22,7 +22,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
@@ -21,7 +21,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"lit": "^3.2.0",
"rxjs": "^7.8.1"
},
@@ -24,7 +24,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
"rxjs": "^7.8.1",
@@ -24,7 +24,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
@@ -24,7 +24,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@vanilla-extract/css": "^1.17.0",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
+1 -1
View File
@@ -23,7 +23,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -24,7 +24,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -24,7 +24,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -26,7 +26,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -30,7 +30,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -26,7 +26,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -23,7 +23,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -24,7 +24,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -25,7 +25,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -23,7 +23,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
@@ -22,7 +22,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"collapse-white-space": "^2.1.0",
"date-fns": "^4.0.0",
+1 -1
View File
@@ -23,7 +23,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/hast": "^3.0.4",
"@types/katex": "^0.16.7",
"@types/lodash-es": "^4.17.12",
+1 -1
View File
@@ -22,7 +22,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"collapse-white-space": "^2.1.0",
"date-fns": "^4.0.0",
@@ -21,7 +21,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"collapse-white-space": "^2.1.0",
"date-fns": "^4.0.0",
@@ -27,7 +27,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/hast": "^3.0.4",
"@types/katex": "^0.16.7",
"@types/lodash-es": "^4.17.12",
@@ -21,7 +21,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"collapse-white-space": "^2.1.0",
"date-fns": "^4.0.0",
+1 -1
View File
@@ -13,7 +13,7 @@
"@blocksuite/global": "workspace:*",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"fractional-indexing": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -20,7 +20,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"collapse-white-space": "^2.1.0",
"date-fns": "^4.0.0",
+3 -2
View File
@@ -18,7 +18,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/bytes": "^3.1.5",
"@types/hast": "^3.0.4",
"@types/lodash-es": "^4.17.12",
@@ -63,7 +63,8 @@
"./theme": "./src/theme/index.ts",
"./styles": "./src/styles/index.ts",
"./services": "./src/services/index.ts",
"./adapters": "./src/adapters/index.ts"
"./adapters": "./src/adapters/index.ts",
"./test-utils": "./src/test-utils/index.ts"
},
"files": [
"src",
@@ -4,7 +4,7 @@
import { describe, expect, it } from 'vitest';
import { getFirstBlockCommand } from '../../../commands/block-crud/get-first-content-block';
import { affine } from '../../helpers/affine-template';
import { affine } from '../../../test-utils';
describe('commands/block-crud', () => {
describe('getFirstBlockCommand', () => {
@@ -4,7 +4,7 @@
import { describe, expect, it } from 'vitest';
import { getLastBlockCommand } from '../../../commands/block-crud/get-last-content-block';
import { affine } from '../../helpers/affine-template';
import { affine } from '../../../test-utils';
describe('commands/block-crud', () => {
describe('getLastBlockCommand', () => {
@@ -1,13 +1,11 @@
/**
* @vitest-environment happy-dom
*/
import '../../helpers/affine-test-utils';
import type { TextSelection } from '@blocksuite/std';
import { describe, expect, it } from 'vitest';
import { replaceSelectedTextWithBlocksCommand } from '../../../commands/model-crud/replace-selected-text-with-blocks';
import { affine, block } from '../../helpers/affine-template';
import { affine, block } from '../../../test-utils';
describe('commands/model-crud', () => {
describe('replaceSelectedTextWithBlocksCommand', () => {
@@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
import { isNothingSelectedCommand } from '../../../commands/selection/is-nothing-selected';
import { ImageSelection } from '../../../selection';
import { affine } from '../../helpers/affine-template';
import { affine } from '../../../test-utils';
describe('commands/selection', () => {
describe('isNothingSelectedCommand', () => {
@@ -1,298 +0,0 @@
import {
CodeBlockSchemaExtension,
DatabaseBlockSchemaExtension,
ImageBlockSchemaExtension,
ListBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
RootBlockSchemaExtension,
} from '@blocksuite/affine-model';
import { TextSelection } from '@blocksuite/std';
import { type Block, type Store } from '@blocksuite/store';
import { Text } from '@blocksuite/store';
import { TestWorkspace } from '@blocksuite/store/test';
import { createTestHost } from './create-test-host';
// Extensions array
const extensions = [
RootBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
ListBlockSchemaExtension,
ImageBlockSchemaExtension,
DatabaseBlockSchemaExtension,
CodeBlockSchemaExtension,
];
// Mapping from tag names to flavours
const tagToFlavour: Record<string, string> = {
'affine-page': 'affine:page',
'affine-note': 'affine:note',
'affine-paragraph': 'affine:paragraph',
'affine-list': 'affine:list',
'affine-image': 'affine:image',
'affine-database': 'affine:database',
'affine-code': 'affine:code',
};
interface SelectionInfo {
anchorBlockId?: string;
anchorOffset?: number;
focusBlockId?: string;
focusOffset?: number;
cursorBlockId?: string;
cursorOffset?: number;
}
/**
* Parse template strings and build BlockSuite document structure,
* then create a host object with the document
*
* Example:
* ```
* const host = affine`
* <affine-page id="page">
* <affine-note id="note">
* <affine-paragraph id="paragraph-1">Hello, world<anchor /></affine-paragraph>
* <affine-paragraph id="paragraph-2">Hello, world<focus /></affine-paragraph>
* </affine-note>
* </affine-page>
* `;
* ```
*/
export function affine(strings: TemplateStringsArray, ...values: any[]) {
// Merge template strings and values
let htmlString = '';
strings.forEach((str, i) => {
htmlString += str;
if (i < values.length) {
htmlString += values[i];
}
});
// Create a new doc
const workspace = new TestWorkspace({});
workspace.meta.initialize();
const doc = workspace.createDoc('test-doc');
const store = doc.getStore({ extensions });
let selectionInfo: SelectionInfo = {};
// Use DOMParser to parse HTML string
doc.load(() => {
const parser = new DOMParser();
const dom = parser.parseFromString(htmlString.trim(), 'text/html');
const root = dom.body.firstElementChild;
if (!root) {
throw new Error('Template must contain a root element');
}
buildDocFromElement(store, root, null, selectionInfo);
});
// Create host object
const host = createTestHost(store);
// Set selection if needed
if (selectionInfo.anchorBlockId && selectionInfo.focusBlockId) {
const anchorBlock = store.getBlock(selectionInfo.anchorBlockId);
const anchorTextLength = anchorBlock?.model?.text?.length ?? 0;
const focusOffset = selectionInfo.focusOffset ?? 0;
const anchorOffset = selectionInfo.anchorOffset ?? 0;
if (selectionInfo.anchorBlockId === selectionInfo.focusBlockId) {
const selection = host.selection.create(TextSelection, {
from: {
blockId: selectionInfo.anchorBlockId,
index: anchorOffset,
length: focusOffset,
},
to: null,
});
host.selection.setGroup('note', [selection]);
} else {
const selection = host.selection.create(TextSelection, {
from: {
blockId: selectionInfo.anchorBlockId,
index: anchorOffset,
length: anchorTextLength - anchorOffset,
},
to: {
blockId: selectionInfo.focusBlockId,
index: 0,
length: focusOffset,
},
});
host.selection.setGroup('note', [selection]);
}
} else if (selectionInfo.cursorBlockId) {
const selection = host.selection.create(TextSelection, {
from: {
blockId: selectionInfo.cursorBlockId,
index: selectionInfo.cursorOffset ?? 0,
length: 0,
},
to: null,
});
host.selection.setGroup('note', [selection]);
}
return host;
}
/**
* Create a single block from template string
*
* Example:
* ```
* const block = block`<affine-note />`
* ```
*/
export function block(
strings: TemplateStringsArray,
...values: any[]
): Block | null {
// Merge template strings and values
let htmlString = '';
strings.forEach((str, i) => {
htmlString += str;
if (i < values.length) {
htmlString += values[i];
}
});
// Create a temporary doc to hold the block
const workspace = new TestWorkspace({});
workspace.meta.initialize();
const doc = workspace.createDoc('temp-doc');
const store = doc.getStore({ extensions });
let blockId: string | null = null;
const selectionInfo: SelectionInfo = {};
// Use DOMParser to parse HTML string
doc.load(() => {
const parser = new DOMParser();
const dom = parser.parseFromString(htmlString.trim(), 'text/html');
const root = dom.body.firstElementChild;
if (!root) {
throw new Error('Template must contain a root element');
}
// Create a root block if needed
const flavour = tagToFlavour[root.tagName.toLowerCase()];
if (
flavour === 'affine:paragraph' ||
flavour === 'affine:list' ||
flavour === 'affine:code'
) {
const pageId = store.addBlock('affine:page', {});
const noteId = store.addBlock('affine:note', {}, pageId);
blockId = buildDocFromElement(store, root, noteId, selectionInfo);
} else {
blockId = buildDocFromElement(store, root, null, selectionInfo);
}
});
// Return the created block
return blockId ? (store.getBlock(blockId) ?? null) : null;
}
/**
* Recursively build document structure
* @param doc
* @param element
* @param parentId
* @param selectionInfo
* @returns
*/
function buildDocFromElement(
doc: Store,
element: Element,
parentId: string | null,
selectionInfo: SelectionInfo
): string {
const tagName = element.tagName.toLowerCase();
// Handle selection tags
if (tagName === 'anchor') {
if (!parentId) return '';
const parentBlock = doc.getBlock(parentId);
if (parentBlock) {
const textBeforeCursor = element.previousSibling?.textContent ?? '';
selectionInfo.anchorBlockId = parentId;
selectionInfo.anchorOffset = textBeforeCursor.length;
}
return parentId;
} else if (tagName === 'focus') {
if (!parentId) return '';
const parentBlock = doc.getBlock(parentId);
if (parentBlock) {
const textBeforeCursor = element.previousSibling?.textContent ?? '';
selectionInfo.focusBlockId = parentId;
selectionInfo.focusOffset = textBeforeCursor.length;
}
return parentId;
} else if (tagName === 'cursor') {
if (!parentId) return '';
const parentBlock = doc.getBlock(parentId);
if (parentBlock) {
const textBeforeCursor = element.previousSibling?.textContent ?? '';
selectionInfo.cursorBlockId = parentId;
selectionInfo.cursorOffset = textBeforeCursor.length;
}
return parentId;
}
const flavour = tagToFlavour[tagName];
if (!flavour) {
throw new Error(`Unknown tag name: ${tagName}`);
}
const props: Record<string, any> = {};
const customId = element.getAttribute('id');
// If ID is specified, add it to props
if (customId) {
props.id = customId;
}
// Process element attributes
Array.from(element.attributes).forEach(attr => {
if (attr.name !== 'id') {
// Skip id attribute, we already handled it
props[attr.name] = attr.value;
}
});
// Special handling for different block types based on their flavours
switch (flavour) {
case 'affine:paragraph':
case 'affine:list':
if (element.textContent) {
props.text = new Text(element.textContent);
}
break;
}
// Create block
const blockId = doc.addBlock(flavour, props, parentId);
// Process all child nodes, including text nodes
Array.from(element.children).forEach(child => {
if (child.nodeType === Node.ELEMENT_NODE) {
// Handle element nodes
buildDocFromElement(doc, child as Element, blockId, selectionInfo);
} else if (child.nodeType === Node.TEXT_NODE) {
// Handle text nodes
console.log('buildDocFromElement text node:', child.textContent);
}
});
return blockId;
}
@@ -1,7 +1,7 @@
import { TextSelection } from '@blocksuite/std';
import { describe, expect, it } from 'vitest';
import { affine } from './affine-template';
import { affine } from '../../test-utils';
describe('helpers/affine-template', () => {
it('should create a basic document structure from template', () => {
@@ -7,7 +7,7 @@
### Basic Usage
```typescript
import { affine } from '../__tests__/utils/affine-template';
import { affine } from '@blocksuite/affine-shared/test-utils';
// Create a simple document
const doc = affine`
@@ -0,0 +1,316 @@
import {
CodeBlockSchemaExtension,
DatabaseBlockSchemaExtension,
ImageBlockSchemaExtension,
ListBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
RootBlockSchemaExtension,
} from '@blocksuite/affine-model';
import { Container } from '@blocksuite/global/di';
import { TextSelection } from '@blocksuite/std';
import {
type Block,
type ExtensionType,
type Store,
Text,
} from '@blocksuite/store';
import { TestWorkspace } from '@blocksuite/store/test';
import { createTestHost } from './create-test-host';
const DEFAULT_EXTENSIONS = [
RootBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
ListBlockSchemaExtension,
ImageBlockSchemaExtension,
DatabaseBlockSchemaExtension,
CodeBlockSchemaExtension,
];
// Mapping from tag names to flavours
const tagToFlavour: Record<string, string> = {
'affine-page': 'affine:page',
'affine-note': 'affine:note',
'affine-paragraph': 'affine:paragraph',
'affine-list': 'affine:list',
'affine-image': 'affine:image',
'affine-database': 'affine:database',
'affine-code': 'affine:code',
};
interface SelectionInfo {
anchorBlockId?: string;
anchorOffset?: number;
focusBlockId?: string;
focusOffset?: number;
cursorBlockId?: string;
cursorOffset?: number;
}
export function createAffineTemplate(
extensions: ExtensionType[] = DEFAULT_EXTENSIONS
) {
/**
* Parse template strings and build BlockSuite document structure,
* then create a host object with the document
*
* Example:
* ```
* const host = affine`
* <affine-page id="page">
* <affine-note id="note">
* <affine-paragraph id="paragraph-1">Hello, world<anchor /></affine-paragraph>
* <affine-paragraph id="paragraph-2">Hello, world<focus /></affine-paragraph>
* </affine-note>
* </affine-page>
* `;
* ```
*/
function affine(strings: TemplateStringsArray, ...values: any[]) {
// Merge template strings and values
let htmlString = '';
strings.forEach((str, i) => {
htmlString += str;
if (i < values.length) {
htmlString += values[i];
}
});
// Create a new doc
const workspace = new TestWorkspace({});
workspace.meta.initialize();
const doc = workspace.createDoc('test-doc');
const container = new Container();
extensions.forEach(extension => {
extension.setup(container);
});
const store = doc.getStore({ extensions, provider: container.provider() });
let selectionInfo: SelectionInfo = {};
// Use DOMParser to parse HTML string
doc.load(() => {
const parser = new DOMParser();
const dom = parser.parseFromString(htmlString.trim(), 'text/html');
const root = dom.body.firstElementChild;
if (!root) {
throw new Error('Template must contain a root element');
}
buildDocFromElement(store, root, null, selectionInfo);
});
// Create host object
const host = createTestHost(store);
// Set selection if needed
if (selectionInfo.anchorBlockId && selectionInfo.focusBlockId) {
const anchorBlock = store.getBlock(selectionInfo.anchorBlockId);
const anchorTextLength = anchorBlock?.model?.text?.length ?? 0;
const focusOffset = selectionInfo.focusOffset ?? 0;
const anchorOffset = selectionInfo.anchorOffset ?? 0;
if (selectionInfo.anchorBlockId === selectionInfo.focusBlockId) {
const selection = host.selection.create(TextSelection, {
from: {
blockId: selectionInfo.anchorBlockId,
index: anchorOffset,
length: focusOffset,
},
to: null,
});
host.selection.setGroup('note', [selection]);
} else {
const selection = host.selection.create(TextSelection, {
from: {
blockId: selectionInfo.anchorBlockId,
index: anchorOffset,
length: anchorTextLength - anchorOffset,
},
to: {
blockId: selectionInfo.focusBlockId,
index: 0,
length: focusOffset,
},
});
host.selection.setGroup('note', [selection]);
}
} else if (selectionInfo.cursorBlockId) {
const selection = host.selection.create(TextSelection, {
from: {
blockId: selectionInfo.cursorBlockId,
index: selectionInfo.cursorOffset ?? 0,
length: 0,
},
to: null,
});
host.selection.setGroup('note', [selection]);
}
return host;
}
/**
* Create a single block from template string
*
* Example:
* ```
* const block = block`<affine-note />`
* ```
*/
function block(
strings: TemplateStringsArray,
...values: any[]
): Block | null {
// Merge template strings and values
let htmlString = '';
strings.forEach((str, i) => {
htmlString += str;
if (i < values.length) {
htmlString += values[i];
}
});
// Create a temporary doc to hold the block
const workspace = new TestWorkspace({});
workspace.meta.initialize();
const doc = workspace.createDoc('temp-doc');
const store = doc.getStore({ extensions });
let blockId: string | null = null;
const selectionInfo: SelectionInfo = {};
// Use DOMParser to parse HTML string
doc.load(() => {
const parser = new DOMParser();
const dom = parser.parseFromString(htmlString.trim(), 'text/html');
const root = dom.body.firstElementChild;
if (!root) {
throw new Error('Template must contain a root element');
}
// Create a root block if needed
const flavour = tagToFlavour[root.tagName.toLowerCase()];
if (
flavour === 'affine:paragraph' ||
flavour === 'affine:list' ||
flavour === 'affine:code'
) {
const pageId = store.addBlock('affine:page', {});
const noteId = store.addBlock('affine:note', {}, pageId);
blockId = buildDocFromElement(store, root, noteId, selectionInfo);
} else {
blockId = buildDocFromElement(store, root, null, selectionInfo);
}
});
// Return the created block
return blockId ? (store.getBlock(blockId) ?? null) : null;
}
return {
affine,
block,
};
}
export const { affine, block } = createAffineTemplate();
/**
* Recursively build document structure
* @param doc
* @param element
* @param parentId
* @param selectionInfo
* @returns
*/
function buildDocFromElement(
doc: Store,
element: Element,
parentId: string | null,
selectionInfo: SelectionInfo
): string {
const tagName = element.tagName.toLowerCase();
// Handle selection tags
if (tagName === 'anchor') {
if (!parentId) return '';
const parentBlock = doc.getBlock(parentId);
if (parentBlock) {
const textBeforeCursor = element.previousSibling?.textContent ?? '';
selectionInfo.anchorBlockId = parentId;
selectionInfo.anchorOffset = textBeforeCursor.length;
}
return parentId;
} else if (tagName === 'focus') {
if (!parentId) return '';
const parentBlock = doc.getBlock(parentId);
if (parentBlock) {
const textBeforeCursor = element.previousSibling?.textContent ?? '';
selectionInfo.focusBlockId = parentId;
selectionInfo.focusOffset = textBeforeCursor.length;
}
return parentId;
} else if (tagName === 'cursor') {
if (!parentId) return '';
const parentBlock = doc.getBlock(parentId);
if (parentBlock) {
const textBeforeCursor = element.previousSibling?.textContent ?? '';
selectionInfo.cursorBlockId = parentId;
selectionInfo.cursorOffset = textBeforeCursor.length;
}
return parentId;
}
const flavour = tagToFlavour[tagName];
if (!flavour) {
throw new Error(`Unknown tag name: ${tagName}`);
}
const props: Record<string, any> = {};
const customId = element.getAttribute('id');
// If ID is specified, add it to props
if (customId) {
props.id = customId;
}
// Process element attributes
Array.from(element.attributes).forEach(attr => {
if (attr.name !== 'id') {
// Skip id attribute, we already handled it
props[attr.name] = attr.value;
}
});
// Special handling for different block types based on their flavours
switch (flavour) {
case 'affine:paragraph':
case 'affine:list':
if (element.textContent) {
props.text = new Text(element.textContent);
}
break;
}
// Create block
const blockId = doc.addBlock(flavour, props, parentId);
// Process all child nodes, including text nodes
Array.from(element.children).forEach(child => {
if (child.nodeType === Node.ELEMENT_NODE) {
// Handle element nodes
buildDocFromElement(doc, child as Element, blockId, selectionInfo);
} else if (child.nodeType === Node.TEXT_NODE) {
// Handle text nodes
console.log('buildDocFromElement text node:', child.textContent);
}
});
return blockId;
}
@@ -63,10 +63,8 @@ function compareBlocks(
if (JSON.stringify(actualProps) !== JSON.stringify(expectedProps))
return false;
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < actual.children.length; i++) {
if (!compareBlocks(actual.children[i], expected.children[i], compareId))
return false;
for (const [i, child] of actual.children.entries()) {
if (!compareBlocks(child, expected.children[i], compareId)) return false;
}
return true;
@@ -240,7 +240,7 @@ export function createTestHost(doc: Store): EditorHost {
std.selection = new MockSelectionStore();
std.command = new CommandManager(std as any);
// @ts-expect-error
// @ts-expect-error dev-only
host.command = std.command;
host.selection = std.selection;
@@ -0,0 +1,3 @@
export * from './affine-template';
export * from './affine-test-utils';
export * from './create-test-host';
@@ -27,7 +27,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
@@ -20,7 +20,7 @@
"@blocksuite/icons": "^2.2.12",
"@blocksuite/std": "workspace:*",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"lit": "^3.2.0",
"rxjs": "^7.8.1"
},
@@ -21,7 +21,7 @@
"@blocksuite/std": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"lit": "^3.2.0",
"rxjs": "^7.8.1",
"yjs": "^13.6.21"
@@ -25,7 +25,7 @@
"@blocksuite/std": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"lit": "^3.2.0",
"rxjs": "^7.8.1",
"yjs": "^13.6.21"
@@ -22,7 +22,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
@@ -20,7 +20,7 @@
"@blocksuite/std": "workspace:*",
"@floating-ui/dom": "^1.6.13",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
@@ -20,7 +20,7 @@
"@blocksuite/std": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"lit": "^3.2.0",
"rxjs": "^7.8.1"
},
@@ -37,7 +37,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"fflate": "^0.8.2",
"lit": "^3.2.0",
@@ -23,7 +23,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"fflate": "^0.8.2",
"lit": "^3.2.0",
@@ -22,7 +22,7 @@
"@blocksuite/std": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"lit": "^3.2.0",
"rxjs": "^7.8.1",
"yjs": "^13.6.21"
@@ -20,7 +20,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"fflate": "^0.8.2",
"lit": "^3.2.0",
@@ -19,7 +19,7 @@
"@blocksuite/icons": "^2.2.12",
"@blocksuite/std": "workspace:*",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
@@ -16,7 +16,7 @@
"@blocksuite/global": "workspace:*",
"@blocksuite/std": "workspace:*",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"lit": "^3.2.0",
"rxjs": "^7.8.1"
},
@@ -20,7 +20,7 @@
"@blocksuite/store": "workspace:*",
"@floating-ui/dom": "^1.6.13",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
@@ -22,7 +22,7 @@
"@blocksuite/std": "workspace:*",
"@floating-ui/dom": "^1.6.13",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
@@ -19,7 +19,7 @@
"@blocksuite/std": "workspace:*",
"@floating-ui/dom": "^1.6.13",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
@@ -28,6 +28,21 @@ import { ShadowlessElement } from './shadowless-element.js';
export const storeContext = createContext<Store>('store');
export const stdContext = createContext<BlockStdScope>('std');
function isMatchFlavour(widgetFlavour: string, block: BlockModel) {
if (widgetFlavour.endsWith('/*')) {
const path = widgetFlavour.slice(0, -2).split('/');
let current: BlockModel | null = block.parent;
for (let i = path.length - 1; i >= 0; i--) {
if (!current || current.flavour !== path[i]) {
return false;
}
current = current.parent;
}
return true;
}
return block.flavour === widgetFlavour;
}
@requiredProperties({
store: PropTypes.instanceOf(Store),
std: PropTypes.object,
@@ -61,7 +76,7 @@ export class EditorHost extends SignalWatcher(
const widgets = Array.from(widgetViews.entries()).reduce(
(mapping, [key, tag]) => {
const [widgetFlavour, id] = key.split('|');
if (widgetFlavour === flavour) {
if (isMatchFlavour(widgetFlavour, model)) {
const template = html`<${tag} ${unsafeStatic(WIDGET_ID_ATTR)}=${id}></${tag}>`;
mapping[id] = template;
}
+1 -1
View File
@@ -19,7 +19,7 @@
"@lit/context": "^1.1.3",
"@lottiefiles/dotlottie-wc": "^0.5.0",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@vanilla-extract/css": "^1.17.0",
"lit": "^3.2.0",
"rxjs": "^7.8.1",
@@ -0,0 +1,118 @@
import { randomUUID } from 'node:crypto';
import { mock } from 'node:test';
import { CommentAttachmentStorage } from '../../../core/storage';
import { Mockers } from '../../mocks';
import { app, e2e } from '../test';
async function createWorkspace() {
const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner,
});
return {
owner,
workspace,
};
}
e2e.afterEach.always(() => {
mock.reset();
});
// #region comment attachment
e2e(
'should get comment attachment not found when key is not exists',
async t => {
const { owner, workspace } = await createWorkspace();
await app.login(owner);
const docId = randomUUID();
const res = await app.GET(
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/not-exists`
);
t.is(res.status, 404);
t.is(res.body.message, 'Comment attachment not found.');
}
);
e2e(
'should get comment attachment no permission when user is not member',
async t => {
const { workspace } = await createWorkspace();
// signup a new user
await app.signup();
const docId = randomUUID();
const res = await app.GET(
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/some-key`
);
t.is(res.status, 403);
t.regex(
res.body.message,
/You do not have permission to perform Doc.Read action on doc /
);
}
);
e2e('should get comment attachment body', async t => {
const { owner, workspace } = await createWorkspace();
await app.login(owner);
const docId = randomUUID();
const key = randomUUID();
const attachment = app.get(CommentAttachmentStorage);
await attachment.put(
workspace.id,
docId,
key,
'test.txt',
Buffer.from('test')
);
const res = await app.GET(
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/${key}`
);
t.is(res.status, 200);
t.is(res.headers['content-type'], 'text/plain');
t.is(res.headers['content-length'], '4');
t.is(res.headers['cache-control'], 'private, max-age=2592000, immutable');
t.regex(
res.headers['last-modified'],
/^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/
);
t.is(res.text, 'test');
});
e2e('should get comment attachment redirect url', async t => {
const { owner, workspace } = await createWorkspace();
await app.login(owner);
const docId = randomUUID();
const key = randomUUID();
const attachment = app.get(CommentAttachmentStorage);
mock.method(attachment, 'get', async () => {
return {
body: null,
metadata: null,
redirectUrl: `https://foo.com/${key}`,
};
});
const res = await app.GET(
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/${key}`
);
t.is(res.status, 302);
t.is(res.headers['location'], `https://foo.com/${key}`);
});
// #endregion
@@ -917,4 +917,8 @@ export const USER_FRIENDLY_ERRORS = {
type: 'resource_not_found',
message: 'Reply not found.',
},
comment_attachment_not_found: {
type: 'resource_not_found',
message: 'Comment attachment not found.',
},
} satisfies Record<string, UserFriendlyErrorOptions>;
@@ -1079,6 +1079,12 @@ export class ReplyNotFound extends UserFriendlyError {
super('resource_not_found', 'reply_not_found', message);
}
}
export class CommentAttachmentNotFound extends UserFriendlyError {
constructor(message?: string) {
super('resource_not_found', 'comment_attachment_not_found', message);
}
}
export enum ErrorNames {
INTERNAL_SERVER_ERROR,
NETWORK_ERROR,
@@ -1216,7 +1222,8 @@ export enum ErrorNames {
INVALID_SEARCH_PROVIDER_REQUEST,
INVALID_INDEXER_INPUT,
COMMENT_NOT_FOUND,
REPLY_NOT_FOUND
REPLY_NOT_FOUND,
COMMENT_ATTACHMENT_NOT_FOUND
}
registerEnumType(ErrorNames, {
name: 'ErrorNames'
@@ -0,0 +1,141 @@
import { randomUUID } from 'node:crypto';
import { Readable } from 'node:stream';
import test from 'ava';
import { createModule } from '../../../__tests__/create-module';
import { Mockers } from '../../../__tests__/mocks';
import { Models } from '../../../models';
import { CommentAttachmentStorage, StorageModule } from '..';
const module = await createModule({
imports: [StorageModule],
});
const storage = module.get(CommentAttachmentStorage);
const models = module.get(Models);
test.before(async () => {
await storage.onConfigInit();
});
test.after.always(async () => {
await module.close();
});
test('should put comment attachment', async t => {
const workspace = await module.create(Mockers.Workspace);
const docId = randomUUID();
const key = randomUUID();
const blob = Buffer.from('test');
await storage.put(workspace.id, docId, key, 'test.txt', blob);
const item = await models.commentAttachment.get(workspace.id, docId, key);
t.truthy(item);
t.is(item?.workspaceId, workspace.id);
t.is(item?.docId, docId);
t.is(item?.key, key);
t.is(item?.mime, 'text/plain');
t.is(item?.size, blob.length);
t.is(item?.name, 'test.txt');
});
test('should get comment attachment', async t => {
const workspace = await module.create(Mockers.Workspace);
const docId = randomUUID();
const key = randomUUID();
const blob = Buffer.from('test');
await storage.put(workspace.id, docId, key, 'test.txt', blob);
const item = await storage.get(workspace.id, docId, key);
t.truthy(item);
t.is(item?.metadata?.contentType, 'text/plain');
t.is(item?.metadata?.contentLength, blob.length);
// body is readable stream
t.truthy(item?.body);
const bytes = await readableToBytes(item?.body as Readable);
t.is(bytes.toString(), 'test');
});
test('should get comment attachment with access url', async t => {
const workspace = await module.create(Mockers.Workspace);
const docId = randomUUID();
const key = randomUUID();
const blob = Buffer.from('test');
await storage.put(workspace.id, docId, key, 'test.txt', blob);
const url = storage.getUrl(workspace.id, docId, key);
t.truthy(url);
t.is(
url,
`http://localhost:3010/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/${key}`
);
});
test('should delete comment attachment', async t => {
const workspace = await module.create(Mockers.Workspace);
const docId = randomUUID();
const key = randomUUID();
const blob = Buffer.from('test');
await storage.put(workspace.id, docId, key, 'test.txt', blob);
await storage.delete(workspace.id, docId, key);
const item = await models.commentAttachment.get(workspace.id, docId, key);
t.is(item, null);
});
test('should handle comment.attachment.delete event', async t => {
const workspace = await module.create(Mockers.Workspace);
const docId = randomUUID();
const key = randomUUID();
const blob = Buffer.from('test');
await storage.put(workspace.id, docId, key, 'test.txt', blob);
await storage.onCommentAttachmentDelete({
workspaceId: workspace.id,
docId,
key,
});
const item = await models.commentAttachment.get(workspace.id, docId, key);
t.is(item, null);
});
test('should handle workspace.deleted event', async t => {
const workspace = await module.create(Mockers.Workspace);
const docId = randomUUID();
const key1 = randomUUID();
const key2 = randomUUID();
const blob1 = Buffer.from('test');
const blob2 = Buffer.from('test2');
await storage.put(workspace.id, docId, key1, 'test.txt', blob1);
await storage.put(workspace.id, docId, key2, 'test.txt', blob2);
const count = module.event.count('comment.attachment.delete');
await storage.onWorkspaceDeleted({
id: workspace.id,
});
t.is(module.event.count('comment.attachment.delete'), count + 2);
});
async function readableToBytes(stream: Readable) {
const chunks: Buffer[] = [];
let chunk: Buffer;
for await (chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
@@ -2,12 +2,16 @@ import './config';
import { Module } from '@nestjs/common';
import { AvatarStorage, WorkspaceBlobStorage } from './wrappers';
import {
AvatarStorage,
CommentAttachmentStorage,
WorkspaceBlobStorage,
} from './wrappers';
@Module({
providers: [WorkspaceBlobStorage, AvatarStorage],
exports: [WorkspaceBlobStorage, AvatarStorage],
providers: [WorkspaceBlobStorage, AvatarStorage, CommentAttachmentStorage],
exports: [WorkspaceBlobStorage, AvatarStorage, CommentAttachmentStorage],
})
export class StorageModule {}
export { AvatarStorage, WorkspaceBlobStorage };
export { AvatarStorage, CommentAttachmentStorage, WorkspaceBlobStorage };
@@ -0,0 +1,128 @@
import { Injectable, Logger } from '@nestjs/common';
import {
autoMetadata,
Config,
EventBus,
OnEvent,
type StorageProvider,
StorageProviderFactory,
URLHelper,
} from '../../../base';
import { Models } from '../../../models';
declare global {
interface Events {
'comment.attachment.delete': {
workspaceId: string;
docId: string;
key: string;
};
}
}
@Injectable()
export class CommentAttachmentStorage {
private readonly logger = new Logger(CommentAttachmentStorage.name);
private provider!: StorageProvider;
get config() {
return this.AFFiNEConfig.storages.blob;
}
constructor(
private readonly AFFiNEConfig: Config,
private readonly event: EventBus,
private readonly storageFactory: StorageProviderFactory,
private readonly models: Models,
private readonly url: URLHelper
) {}
@OnEvent('config.init')
async onConfigInit() {
this.provider = this.storageFactory.create(this.config.storage);
}
@OnEvent('config.changed')
async onConfigChanged(event: Events['config.changed']) {
if (event.updates.storages?.blob?.storage) {
this.provider = this.storageFactory.create(this.config.storage);
}
}
private storageKey(workspaceId: string, docId: string, key: string) {
return `comment-attachments/${workspaceId}/${docId}/${key}`;
}
async put(
workspaceId: string,
docId: string,
key: string,
name: string,
blob: Buffer
) {
const meta = autoMetadata(blob);
await this.provider.put(
this.storageKey(workspaceId, docId, key),
blob,
meta
);
await this.models.commentAttachment.upsert({
workspaceId,
docId,
key,
name,
mime: meta.contentType ?? 'application/octet-stream',
size: blob.length,
});
}
async get(
workspaceId: string,
docId: string,
key: string,
signedUrl?: boolean
) {
return await this.provider.get(
this.storageKey(workspaceId, docId, key),
signedUrl
);
}
async delete(workspaceId: string, docId: string, key: string) {
await this.provider.delete(this.storageKey(workspaceId, docId, key));
await this.models.commentAttachment.delete(workspaceId, docId, key);
this.logger.log(
`deleted comment attachment ${workspaceId}/${docId}/${key}`
);
}
getUrl(workspaceId: string, docId: string, key: string) {
return this.url.link(
`/api/workspaces/${workspaceId}/docs/${docId}/comment-attachments/${key}`
);
}
@OnEvent('workspace.deleted')
async onWorkspaceDeleted({ id }: Events['workspace.deleted']) {
const attachments = await this.models.commentAttachment.list(id);
for (const attachment of attachments) {
this.event.emit('comment.attachment.delete', {
workspaceId: id,
docId: attachment.docId,
key: attachment.key,
});
}
}
@OnEvent('comment.attachment.delete')
async onCommentAttachmentDelete({
workspaceId,
docId,
key,
}: Events['comment.attachment.delete']) {
await this.delete(workspaceId, docId, key);
}
}
@@ -1,2 +1,3 @@
export { AvatarStorage } from './avatar';
export { WorkspaceBlobStorage } from './blob';
export { CommentAttachmentStorage } from './comment-attachment';
@@ -4,6 +4,7 @@ import type { Response } from 'express';
import {
BlobNotFound,
CallMetric,
CommentAttachmentNotFound,
DocHistoryNotFound,
DocNotFound,
InvalidHistoryTimestamp,
@@ -13,7 +14,7 @@ import { CurrentUser, Public } from '../auth';
import { PgWorkspaceDocStorageAdapter } from '../doc';
import { DocReader } from '../doc/reader';
import { AccessController } from '../permission';
import { WorkspaceBlobStorage } from '../storage';
import { CommentAttachmentStorage, WorkspaceBlobStorage } from '../storage';
import { DocID } from '../utils/doc';
@Controller('/api/workspaces')
@@ -21,6 +22,7 @@ export class WorkspacesController {
logger = new Logger(WorkspacesController.name);
constructor(
private readonly storage: WorkspaceBlobStorage,
private readonly commentAttachmentStorage: CommentAttachmentStorage,
private readonly ac: AccessController,
private readonly workspace: PgWorkspaceDocStorageAdapter,
private readonly docReader: DocReader,
@@ -180,4 +182,41 @@ export class WorkspacesController {
});
}
}
@Get('/:id/docs/:docId/comment-attachments/:key')
@CallMetric('controllers', 'workspace_get_comment_attachment')
async commentAttachment(
@CurrentUser() user: CurrentUser,
@Param('id') workspaceId: string,
@Param('docId') docId: string,
@Param('key') key: string,
@Res() res: Response
) {
await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Read');
const { body, metadata, redirectUrl } =
await this.commentAttachmentStorage.get(workspaceId, docId, key);
if (redirectUrl) {
return res.redirect(redirectUrl);
}
if (!body) {
throw new CommentAttachmentNotFound();
}
// metadata should always exists if body is not null
if (metadata) {
res.setHeader('content-type', metadata.contentType);
res.setHeader('last-modified', metadata.lastModified.toUTCString());
res.setHeader('content-length', metadata.contentLength);
} else {
this.logger.warn(
`Comment attachment ${workspaceId}/${docId}/${key} has no metadata`
);
}
res.setHeader('cache-control', 'private, max-age=2592000, immutable');
body.pipe(res);
}
}
+1
View File
@@ -540,6 +540,7 @@ enum ErrorNames {
CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS
CAN_NOT_REVOKE_YOURSELF
CAPTCHA_VERIFICATION_FAILED
COMMENT_ATTACHMENT_NOT_FOUND
COMMENT_NOT_FOUND
COPILOT_ACTION_TAKEN
COPILOT_CONTEXT_FILE_NOT_SUPPORTED
+1
View File
@@ -709,6 +709,7 @@ export enum ErrorNames {
CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS = 'CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS',
CAN_NOT_REVOKE_YOURSELF = 'CAN_NOT_REVOKE_YOURSELF',
CAPTCHA_VERIFICATION_FAILED = 'CAPTCHA_VERIFICATION_FAILED',
COMMENT_ATTACHMENT_NOT_FOUND = 'COMMENT_ATTACHMENT_NOT_FOUND',
COMMENT_NOT_FOUND = 'COMMENT_NOT_FOUND',
COPILOT_ACTION_TAKEN = 'COPILOT_ACTION_TAKEN',
COPILOT_CONTEXT_FILE_NOT_SUPPORTED = 'COPILOT_CONTEXT_FILE_NOT_SUPPORTED',
+1 -1
View File
@@ -39,7 +39,7 @@
"@sentry/react": "^9.2.0",
"@tanstack/react-table": "^8.20.5",
"@toeverything/infra": "workspace:*",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"cmdk": "^1.0.4",
"embla-carousel-react": "^8.5.1",
"input-otp": "^1.4.1",
@@ -19,7 +19,7 @@
"@emotion/react": "^11.14.0",
"@sentry/react": "^9.2.0",
"@toeverything/infra": "workspace:*",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@vanilla-extract/css": "^1.17.0",
"async-call-rpc": "^6.4.2",
"next-themes": "^0.4.4",
+1 -1
View File
@@ -46,7 +46,7 @@
"@radix-ui/react-toast": "^1.2.3",
"@radix-ui/react-tooltip": "^1.1.5",
"@radix-ui/react-visually-hidden": "^1.1.1",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@vanilla-extract/dynamic": "^2.1.2",
"bytes": "^3.1.2",
"check-password-strength": "^3.0.0",
@@ -306,3 +306,15 @@ math {
'STIX Two Math' /* mac */,
math;
}
/* AI Block Diff */
.ai-block-diff-deleted {
background-color: var(--aI-applyDeleteHighlight, #ffeaea) !important;
border-radius: 4px !important;
padding: 8px 0px !important;
margin-bottom: 10px !important;
}
.ai-block-diff-deleted .affine-block-component {
margin: 0 !important;
}
+5 -1
View File
@@ -19,6 +19,9 @@
"@affine/templates": "workspace:*",
"@affine/track": "workspace:*",
"@blocksuite/affine": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.13",
"@blocksuite/std": "workspace:*",
"@dotlottie/player-component": "^2.7.12",
@@ -39,7 +42,7 @@
"@sentry/react": "^9.2.0",
"@toeverything/infra": "workspace:*",
"@toeverything/pdf-viewer": "^0.1.1",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@vanilla-extract/dynamic": "^2.1.2",
"@webcontainer/api": "^1.6.1",
"animejs": "^4.0.0",
@@ -88,6 +91,7 @@
"zod": "^3.24.1"
},
"devDependencies": {
"@blocksuite/affine-ext-loader": "workspace:*",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.1.0",
"@types/animejs": "^3.1.12",
@@ -0,0 +1,114 @@
/**
* @vitest-environment happy-dom
*/
import { getInternalStoreExtensions } from '@blocksuite/affine/extensions/store';
import { StoreExtensionManager } from '@blocksuite/affine-ext-loader';
import { createAffineTemplate } from '@blocksuite/affine-shared/test-utils';
import { describe, expect, it } from 'vitest';
import { applyPatchToDoc } from '../../../../blocksuite/ai/utils/apply-model/apply-patch-to-doc';
import type { PatchOp } from '../../../../blocksuite/ai/utils/apply-model/markdown-diff';
const manager = new StoreExtensionManager(getInternalStoreExtensions());
const { affine } = createAffineTemplate(manager.get('store'));
describe('applyPatchToDoc', () => {
it('should delete a block', async () => {
const host = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hello</affine-paragraph>
<affine-paragraph id="paragraph-2">World</affine-paragraph>
</affine-note>
</affine-page>
`;
const patch: PatchOp[] = [{ op: 'delete', id: 'paragraph-1' }];
await applyPatchToDoc(host.store, patch);
const expected = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-2">World</affine-paragraph>
</affine-note>
</affine-page>
`;
expect(host.store).toEqualDoc(expected.store, {
compareId: true,
});
});
it('should replace a block', async () => {
const host = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hello</affine-paragraph>
<affine-paragraph id="paragraph-2">World</affine-paragraph>
</affine-note>
</affine-page>
`;
const patch: PatchOp[] = [
{
op: 'replace',
id: 'paragraph-1',
content: 'New content',
},
];
await applyPatchToDoc(host.store, patch);
const expected = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">New content</affine-paragraph>
<affine-paragraph id="paragraph-2">World</affine-paragraph>
</affine-note>
</affine-page>
`;
expect(host.store).toEqualDoc(expected.store, {
compareId: true,
});
});
it('should insert a block at index', async () => {
const host = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hello</affine-paragraph>
<affine-paragraph id="paragraph-2">World</affine-paragraph>
</affine-note>
</affine-page>
`;
const patch: PatchOp[] = [
{
op: 'insert',
index: 2,
block: {
id: 'paragraph-3',
type: 'affine:paragraph',
content: 'Inserted',
},
},
];
await applyPatchToDoc(host.store, patch);
const expected = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hello</affine-paragraph>
<affine-paragraph id="paragraph-2">World</affine-paragraph>
<affine-paragraph id="paragraph-3">Inserted</affine-paragraph>
</affine-note>
</affine-page>
`;
expect(host.store).toEqualDoc(expected.store, {
compareId: true,
});
});
});
@@ -0,0 +1,337 @@
import { describe, expect, test } from 'vitest';
import { generateRenderDiff } from '../../../../blocksuite/ai/utils/apply-model/generate-render-diff';
describe('generateRenderDiff', () => {
test('should handle block insertion', () => {
const oldMd = `
<!-- block_id=block-001 flavour=title -->
# Title
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
This is a new paragraph.
`;
const diff = generateRenderDiff(oldMd, newMd);
expect(diff).toEqual({
deletes: [],
inserts: {
'block-001': [
{
id: 'block-002',
type: 'paragraph',
content: 'This is a new paragraph.',
},
],
},
updates: {},
});
});
test('should handle block deletion', () => {
const oldMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
This paragraph will be deleted.
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# Title
`;
const diff = generateRenderDiff(oldMd, newMd);
expect(diff).toEqual({
deletes: ['block-002'],
inserts: {},
updates: {},
});
});
test('should handle block replacement', () => {
const oldMd = `
<!-- block_id=block-001 flavour=title -->
# Old Title
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# New Title
`;
const diff = generateRenderDiff(oldMd, newMd);
expect(diff).toEqual({
deletes: [],
inserts: {},
updates: {
'block-001': '# New Title',
},
});
});
test('should handle mixed changes', () => {
const oldMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
Old paragraph.
<!-- block_id=block-003 flavour=paragraph -->
To be deleted.
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
Updated paragraph.
<!-- block_id=block-004 flavour=paragraph -->
New paragraph.
`;
const diff = generateRenderDiff(oldMd, newMd);
expect(diff).toEqual({
deletes: ['block-003'],
inserts: {
'block-002': [
{
id: 'block-004',
type: 'paragraph',
content: 'New paragraph.',
},
],
},
updates: {
'block-002': 'Updated paragraph.',
},
});
});
test('should handle consecutive block insertions', () => {
const oldMd = `
<!-- block_id=block-001 flavour=title -->
# Title
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
First inserted paragraph.
<!-- block_id=block-003 flavour=paragraph -->
Second inserted paragraph.
`;
const diff = generateRenderDiff(oldMd, newMd);
expect(diff).toEqual({
deletes: [],
inserts: {
'block-001': [
{
id: 'block-002',
type: 'paragraph',
content: 'First inserted paragraph.',
},
{
id: 'block-003',
type: 'paragraph',
content: 'Second inserted paragraph.',
},
],
},
updates: {},
});
});
test('should handle consecutive block deletions', () => {
const oldMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
First paragraph to be deleted.
<!-- block_id=block-003 flavour=paragraph -->
Second paragraph to be deleted.
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# Title
`;
const diff = generateRenderDiff(oldMd, newMd);
expect(diff).toEqual({
deletes: ['block-002', 'block-003'],
inserts: {},
updates: {},
});
});
test('should handle block insertion at the head', () => {
const oldMd = `
<!-- block_id=block-001 flavour=title -->
# Title
`;
const newMd = `
<!-- block_id=block-000 flavour=paragraph -->
Head paragraph.
<!-- block_id=block-001 flavour=title -->
# Title
`;
const diff = generateRenderDiff(oldMd, newMd);
expect(diff).toEqual({
deletes: [],
inserts: {
HEAD: [
{
id: 'block-000',
type: 'paragraph',
content: 'Head paragraph.',
},
],
},
updates: {},
});
});
test('should handle block insertion at the tail', () => {
const oldMd = `
<!-- block_id=block-001 flavour=title -->
# Title
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
Tail paragraph.
`;
const diff = generateRenderDiff(oldMd, newMd);
expect(diff).toEqual({
deletes: [],
inserts: {
'block-001': [
{
id: 'block-002',
type: 'paragraph',
content: 'Tail paragraph.',
},
],
},
updates: {},
});
});
test('should handle delete then insert after', () => {
const oldMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
To be deleted.
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-003 flavour=paragraph -->
Inserted after delete.
`;
const diff = generateRenderDiff(oldMd, newMd);
expect(diff).toEqual({
deletes: ['block-002'],
inserts: {
'block-001': [
{
id: 'block-003',
type: 'paragraph',
content: 'Inserted after delete.',
},
],
},
updates: {},
});
});
test('should handle consecutive insertions', () => {
const oldMd = `
<!-- block_id=block-001 flavour=title -->
# Title
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
First insert.
<!-- block_id=block-003 flavour=paragraph -->
Second insert.
`;
const diff = generateRenderDiff(oldMd, newMd);
expect(diff).toEqual({
deletes: [],
inserts: {
'block-001': [
{
id: 'block-002',
type: 'paragraph',
content: 'First insert.',
},
{
id: 'block-003',
type: 'paragraph',
content: 'Second insert.',
},
],
},
updates: {},
});
});
test('should handle interval insertions', () => {
const oldMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
Paragraph.
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-003 flavour=paragraph -->
Inserted between.
<!-- block_id=block-002 flavour=paragraph -->
Paragraph.
<!-- block_id=block-004 flavour=paragraph -->
Inserted at tail.
`;
const diff = generateRenderDiff(oldMd, newMd);
expect(diff).toEqual({
deletes: [],
inserts: {
'block-001': [
{
id: 'block-003',
type: 'paragraph',
content: 'Inserted between.',
},
],
'block-002': [
{
id: 'block-004',
type: 'paragraph',
content: 'Inserted at tail.',
},
],
},
updates: {},
});
});
});
@@ -0,0 +1,228 @@
import { describe, expect, test } from 'vitest';
import { diffMarkdown } from '../../../../blocksuite/ai/utils/apply-model/markdown-diff';
describe('diffMarkdown', () => {
test('should diff block insertion', () => {
// Only a new block is inserted
const oldMd = `
<!-- block_id=block-001 flavour=title -->
# Title
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
This is a new paragraph.
`;
const { patches } = diffMarkdown(oldMd, newMd);
expect(patches).toEqual([
{
op: 'insert',
index: 1,
block: {
id: 'block-002',
type: 'paragraph',
content: 'This is a new paragraph.',
},
},
]);
});
test('should diff block deletion', () => {
// A block is deleted
const oldMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
This paragraph will be deleted.
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# Title
`;
const { patches } = diffMarkdown(oldMd, newMd);
expect(patches).toEqual([
{
op: 'delete',
id: 'block-002',
},
]);
});
test('should diff block replacement', () => {
// Only content of a block is changed
const oldMd = `
<!-- block_id=block-001 flavour=title -->
# Old Title
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# New Title
`;
const { patches } = diffMarkdown(oldMd, newMd);
expect(patches).toEqual([
{
op: 'replace',
id: 'block-001',
content: '# New Title',
},
]);
});
test('should diff mixed changes', () => {
// Mixed: delete, insert, replace
const oldMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
Old paragraph.
<!-- block_id=block-003 flavour=paragraph -->
To be deleted.
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
Updated paragraph.
<!-- block_id=block-004 flavour=paragraph -->
New paragraph.
`;
const { patches } = diffMarkdown(oldMd, newMd);
expect(patches).toEqual([
{
op: 'replace',
id: 'block-002',
content: 'Updated paragraph.',
},
{
op: 'insert',
index: 2,
block: {
id: 'block-004',
type: 'paragraph',
content: 'New paragraph.',
},
},
{
op: 'delete',
id: 'block-003',
},
]);
});
test('should diff consecutive block insertions', () => {
// Two new blocks are inserted consecutively
const oldMd = `
<!-- block_id=block-001 flavour=title -->
# Title
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
First inserted paragraph.
<!-- block_id=block-003 flavour=paragraph -->
Second inserted paragraph.
`;
const { patches } = diffMarkdown(oldMd, newMd);
expect(patches).toEqual([
{
op: 'insert',
index: 1,
block: {
id: 'block-002',
type: 'paragraph',
content: 'First inserted paragraph.',
},
},
{
op: 'insert',
index: 2,
block: {
id: 'block-003',
type: 'paragraph',
content: 'Second inserted paragraph.',
},
},
]);
});
test('should diff consecutive block deletions', () => {
// Two blocks are deleted consecutively
const oldMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
First paragraph to be deleted.
<!-- block_id=block-003 flavour=paragraph -->
Second paragraph to be deleted.
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# Title
`;
const { patches } = diffMarkdown(oldMd, newMd);
expect(patches).toEqual([
{
op: 'delete',
id: 'block-002',
},
{
op: 'delete',
id: 'block-003',
},
]);
});
test('should diff deletion followed by insertion at the same position', () => {
// A block is deleted and a new block is inserted at the end
const oldMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
This paragraph will be deleted
<!-- block_id=block-003 flavour=paragraph -->
HelloWorld
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-003 flavour=paragraph -->
HelloWorld
<!-- block_id=block-004 flavour=paragraph -->
This is a new paragraph inserted after deletion.
`;
const { patches } = diffMarkdown(oldMd, newMd);
expect(patches).toEqual([
{
op: 'insert',
index: 2,
block: {
id: 'block-004',
type: 'paragraph',
content: 'This is a new paragraph inserted after deletion.',
},
},
{
op: 'delete',
id: 'block-002',
},
]);
});
});
@@ -0,0 +1,32 @@
import {
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
export const blockTagMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = {
flavour: 'affine:page/affine:note/*',
toMatch: () => false,
fromMatch: o => {
const block = o.node;
const parent = o.parent;
if (block.type === 'block' && parent?.node.flavour === 'affine:note') {
return true;
}
return false;
},
toBlockSnapshot: {},
fromBlockSnapshot: {
async enter(block, adapterContext) {
adapterContext.walkerContext
.openNode({
type: 'html',
value: `<!-- block_id=${block.node.id} flavour=${block.node.flavour} -->`,
})
.closeNode();
},
},
};
export const BlockTagMarkdownAdapterExtension = BlockMarkdownAdapterExtension(
blockTagMarkdownAdapterMatcher
);
@@ -77,6 +77,21 @@ import {
} from './widgets/ai-panel/components';
import { AIFinishTip } from './widgets/ai-panel/components/finish-tip';
import { GeneratingPlaceholder } from './widgets/ai-panel/components/generating-placeholder';
import {
AFFINE_BLOCK_DIFF_WIDGET_FOR_BLOCK,
AffineBlockDiffWidgetForBlock,
} from './widgets/block-diff/block';
import { BlockDiffOptions } from './widgets/block-diff/options';
import {
AFFINE_BLOCK_DIFF_WIDGET_FOR_PAGE,
AffineBlockDiffWidgetForPage,
} from './widgets/block-diff/page';
import {
AFFINE_BLOCK_DIFF_PLAYGROUND,
AFFINE_BLOCK_DIFF_PLAYGROUND_MODAL,
BlockDiffPlayground,
BlockDiffPlaygroundModal,
} from './widgets/block-diff/playground';
import {
AFFINE_EDGELESS_COPILOT_WIDGET,
EdgelessCopilotWidget,
@@ -165,6 +180,12 @@ export function registerAIEffects() {
customElements.define('chat-message-action', ChatMessageAction);
customElements.define('chat-message-assistant', ChatMessageAssistant);
customElements.define('chat-message-user', ChatMessageUser);
customElements.define('ai-block-diff-options', BlockDiffOptions);
customElements.define(AFFINE_BLOCK_DIFF_PLAYGROUND, BlockDiffPlayground);
customElements.define(
AFFINE_BLOCK_DIFF_PLAYGROUND_MODAL,
BlockDiffPlaygroundModal
);
customElements.define('tool-call-card', ToolCallCard);
customElements.define('tool-result-card', ToolResultCard);
@@ -174,6 +195,14 @@ export function registerAIEffects() {
customElements.define(AFFINE_AI_PANEL_WIDGET, AffineAIPanelWidget);
customElements.define(AFFINE_EDGELESS_COPILOT_WIDGET, EdgelessCopilotWidget);
customElements.define(
AFFINE_BLOCK_DIFF_WIDGET_FOR_BLOCK,
AffineBlockDiffWidgetForBlock
);
customElements.define(
AFFINE_BLOCK_DIFF_WIDGET_FOR_PAGE,
AffineBlockDiffWidgetForPage
);
customElements.define('edgeless-copilot-panel', EdgelessCopilotPanel);
customElements.define(
@@ -0,0 +1,435 @@
import { LifeCycleWatcher } from '@blocksuite/affine/std';
import { Extension, type Store } from '@blocksuite/affine/store';
import {
BlockMarkdownAdapterMatcherIdentifier,
MarkdownAdapter,
} from '@blocksuite/affine-shared/adapters';
import {
type Container,
createIdentifier,
type ServiceProvider,
} from '@blocksuite/global/di';
import { LiveData } from '@toeverything/infra';
import type { Subscription } from 'rxjs';
import { blockTagMarkdownAdapterMatcher } from '../adapters/block-tag';
import { applyPatchToDoc } from '../utils/apply-model/apply-patch-to-doc';
import {
generateRenderDiff,
type RenderDiffs,
} from '../utils/apply-model/generate-render-diff';
interface RejectMap {
deletes: string[];
inserts: string[];
updates: string[];
}
type AcceptDelete = {
type: 'delete';
payload: {
id: string;
};
};
type AcceptUpdate = {
type: 'update';
payload: {
id: string;
content: string;
};
};
type AcceptInsert = {
type: 'insert';
payload: {
from: string;
offset: number;
content: string;
};
};
type Accept = AcceptDelete | AcceptUpdate | AcceptInsert;
type RejectDelete = {
type: 'delete';
payload: {
id: string;
};
};
type RejectUpdate = {
type: 'update';
payload: {
id: string;
};
};
type RejectInsert = {
type: 'insert';
payload: {
from: string;
offset: number;
};
};
type Reject = RejectDelete | RejectUpdate | RejectInsert;
export interface BlockDiffProvider {
diffMap$: LiveData<RenderDiffs>;
rejects$: LiveData<RejectMap>;
isBatchingApply: boolean;
/**
* Set the original markdown
* @param originalMarkdown - The original markdown
*/
setOriginalMarkdown(originalMarkdown: string): void;
/**
* Set the changed markdown
* @param changedMarkdown - The changed markdown
*/
setChangedMarkdown(changedMarkdown: string): void;
/**
* Clear the diff map
*/
clearDiff(): void;
/**
* Get the diff map
*/
getDiff(): RenderDiffs;
/**
* Check if there is any diff
*/
hasDiff(): boolean;
/**
* Accept all the diffs
*/
acceptAll(doc: Store): Promise<void>;
/**
* Accept a diff
*/
accept(accept: Accept, doc: Store): Promise<void>;
/**
* Reject all the diffs
*/
rejectAll(): void;
/**
* Reject a diff
*/
reject(reject: Reject): void;
/**
* Check if a diff is rejected
*/
isRejected(type: 'delete' | 'update' | 'insert', index: string): boolean;
/**
* Get the total number of diffs
*/
getTotalDiffs(): number;
/**
* Get the markdown from the doc
* @param doc - The doc
* @param provider - The provider
*/
getMarkdownFromDoc(doc: Store, provider: ServiceProvider): Promise<string>;
/**
* Get the index of a block in the doc
* @param doc - The doc
* @param blockId - The id of the block
*/
getBlockIndexById(doc: Store, blockId: string): number;
}
export const BlockDiffProvider = createIdentifier<BlockDiffProvider>(
'AffineBlockDiffService'
);
export class BlockDiffService extends Extension implements BlockDiffProvider {
rejects$ = new LiveData<RejectMap>({
deletes: [],
inserts: [],
updates: [],
});
diffMap$ = new LiveData<RenderDiffs>({
deletes: [],
inserts: {},
updates: {},
});
private originalMarkdown: string | null = null;
private changedMarkdown: string | null = null;
isBatchingApply = false;
static override setup(di: Container) {
di.addImpl(BlockDiffProvider, BlockDiffService);
}
getBlockIndexById(doc: Store, blockId: string): number {
const notes = doc.getBlocksByFlavour('affine:note');
if (notes.length === 0) return 0;
const note = notes[0].model;
return note.children.findIndex(child => child.id === blockId);
}
hasDiff(): boolean {
const { deletes, updates, inserts } = this.diffMap$.value;
if (
deletes.length > 0 ||
Object.keys(updates).length > 0 ||
Object.keys(inserts).length > 0
) {
return true;
}
return false;
}
setOriginalMarkdown(originalMarkdown: string) {
this.originalMarkdown = originalMarkdown;
this._refreshDiff();
}
setChangedMarkdown(changedMarkdown: string) {
this.changedMarkdown = changedMarkdown;
this.clearRejects();
this._refreshDiff();
}
private _refreshDiff(): void {
if (!this.originalMarkdown || !this.changedMarkdown) {
this.clearDiff();
return;
}
const diffMap = generateRenderDiff(
this.originalMarkdown,
this.changedMarkdown
);
this.diffMap$.next(diffMap);
}
getDiff(): RenderDiffs {
return this.diffMap$.value;
}
clearDiff(): void {
this.diffMap$.next({
deletes: [],
inserts: {},
updates: {},
});
}
clearRejects(): void {
this.rejects$.next({
deletes: [],
inserts: [],
updates: [],
});
}
async acceptAll(doc: Store): Promise<void> {
this.isBatchingApply = true;
const { deletes, updates, inserts } = this.diffMap$.value;
try {
for (const [id, content] of Object.entries(updates)) {
await applyPatchToDoc(doc, [{ op: 'replace', id, content }]);
}
for (const [from, blocks] of Object.entries(inserts)) {
let baseIndex = 0;
if (from !== 'HEAD') {
baseIndex = this.getBlockIndexById(doc, from) + 1;
}
for (const [offset, block] of blocks.entries()) {
await applyPatchToDoc(doc, [
{ op: 'insert', index: baseIndex + offset, block },
]);
}
}
for (const id of deletes) {
await applyPatchToDoc(doc, [{ op: 'delete', id }]);
}
this.diffMap$.next({
deletes: [],
inserts: {},
updates: {},
});
} finally {
this.isBatchingApply = false;
}
}
async accept(accept: Accept, doc: Store) {
const { type, payload } = accept;
switch (type) {
case 'delete': {
await applyPatchToDoc(doc, [{ op: 'delete', id: payload.id }]);
break;
}
case 'update': {
await applyPatchToDoc(doc, [
{ op: 'replace', id: payload.id, content: payload.content },
]);
break;
}
case 'insert': {
const block = this.diffMap$.value.inserts[payload.from][payload.offset];
let baseIndex = 0;
if (payload.from !== 'HEAD') {
baseIndex = this.getBlockIndexById(doc, payload.from) + 1;
}
await applyPatchToDoc(doc, [
{ op: 'insert', index: baseIndex + payload.offset, block },
]);
break;
}
}
}
rejectAll(): void {
this.clearDiff();
this.clearRejects();
this.changedMarkdown = null;
}
reject(reject: Reject): void {
const rejects = this.rejects$.value;
switch (reject.type) {
case 'delete':
this.rejects$.next({
...rejects,
deletes: [...rejects.deletes, reject.payload.id],
});
break;
case 'update':
this.rejects$.next({
...rejects,
updates: [...rejects.updates, reject.payload.id],
});
break;
case 'insert':
this.rejects$.next({
...rejects,
inserts: [
...rejects.inserts,
`${reject.payload.from}:${reject.payload.offset}`,
],
});
break;
}
}
isRejected(type: 'delete' | 'update' | 'insert', index: string): boolean {
const rejects = this.rejects$.value;
if (type === 'delete') {
return rejects.deletes.includes(index);
}
if (type === 'update') {
return rejects.updates.includes(index);
}
if (type === 'insert') {
return rejects.inserts.includes(index);
}
return false;
}
getTotalDiffs(): number {
const rejects = this.rejects$.value;
const { deletes, updates, inserts } = this.diffMap$.value;
const insertCount = Object.values(inserts).reduce(
(sum, arr) => sum + arr.length,
0
);
const rejectDeleteCount = rejects.deletes.length;
const rejectUpdateCount = rejects.updates.length;
const rejectInsertCount = rejects.inserts.length;
return (
deletes.length +
Object.keys(updates).length +
insertCount -
rejectDeleteCount -
rejectUpdateCount -
rejectInsertCount
);
}
getMarkdownFromDoc = async (doc: Store, provider: ServiceProvider) => {
const job = doc.getTransformer();
const snapshot = job.docToSnapshot(doc);
const adapter = new MarkdownAdapter(job, provider);
if (!snapshot) {
return 'Failed to get markdown from doc';
}
// FIXME: reverse the block matchers to make the block tag adapter the first one
adapter.blockMatchers.reverse();
const markdown = await adapter.fromDocSnapshot({
snapshot,
assets: job.assetsManager,
});
return markdown.file;
};
}
export class BlockDiffWatcher extends LifeCycleWatcher {
static override key = 'block-diff-watcher';
private _blockUpdatedSubscription: Subscription | null = null;
private _provider: ServiceProvider | null = null;
override created() {
super.created();
const cloned = this.std.store.provider.container.clone();
cloned.addImpl(
BlockMarkdownAdapterMatcherIdentifier,
blockTagMarkdownAdapterMatcher
);
this._provider = cloned.provider();
}
private readonly _refreshOriginalMarkdown = async () => {
const diffService = this.std.get(BlockDiffProvider);
if (
!diffService.hasDiff() ||
!this._provider ||
diffService.isBatchingApply
) {
return;
}
const markdown = await diffService.getMarkdownFromDoc(
this.std.store,
this._provider
);
if (markdown) {
diffService.setOriginalMarkdown(markdown);
}
};
override mounted() {
super.mounted();
this._blockUpdatedSubscription =
this.std.store.slots.blockUpdated.subscribe(() => {
this._refreshOriginalMarkdown().catch(err => {
console.error('Failed to refresh original markdown', err);
});
});
}
override unmounted() {
super.unmounted();
this._blockUpdatedSubscription?.unsubscribe();
}
}
@@ -0,0 +1,60 @@
import type { Store } from '@blocksuite/store';
import { insertFromMarkdown, replaceFromMarkdown } from '../../../utils';
import type { PatchOp } from './markdown-diff';
/**
* Apply a list of PatchOp to the page doc (children of the first note block)
* @param doc The page document Store
* @param patch Array of PatchOp
*/
export async function applyPatchToDoc(
doc: Store,
patch: PatchOp[]
): Promise<void> {
// Get all note blocks
const notes = doc.getBlocksByFlavour('affine:note');
if (notes.length === 0) return;
// Only handle the first note block
const note = notes[0].model;
// Build a map from block_id to BlockModel for quick lookup
const blockIdMap = new Map<string, any>();
note.children.forEach(child => {
blockIdMap.set(child.id, child);
});
for (const op of patch) {
if (op.op === 'delete') {
// Delete block
doc.deleteBlock(op.id);
} else if (op.op === 'replace') {
const oldBlock = blockIdMap.get(op.id);
if (!oldBlock) continue;
const parentId = note.id;
const index = note.children.findIndex(child => child.id === op.id);
if (index === -1) continue;
await replaceFromMarkdown(
undefined,
op.content,
doc,
parentId,
index,
op.id
);
} else if (op.op === 'insert') {
// Insert new block
const parentId = note.id;
const index = op.index;
await insertFromMarkdown(
undefined,
op.block.content,
doc,
parentId,
index,
op.block.id
);
}
}
}

Some files were not shown because too many files have changed in this diff Show More