From f832400e3ea006d18271fd15613ff31956aa321d Mon Sep 17 00:00:00 2001 From: fundon Date: Wed, 7 May 2025 01:45:26 +0000 Subject: [PATCH] feat(editor): add resource controller (#12121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes: [BS-3398](https://linear.app/affine-design/issue/BS-3398/实现资源控制器) ## Summary by CodeRabbit ## Summary by CodeRabbit - **New Features** - Introduced a ResourceController for centralized and reactive management of attachment resource states, improving error handling, loading indicators, and UI state resolution for attachments. - Added public access to resource management utilities via new export paths. - **Refactor** - Streamlined attachment state management by replacing manual state tracking with the new ResourceController, simplifying code and enhancing maintainability. - Updated rendering logic for attachments to use unified state objects for clearer UI feedback. - Centralized blob URL creation and download state management within the ResourceController. - **Chores** - Updated dependencies and internal references to reflect the new resource management approach, ensuring consistency across packages. --- blocksuite/affine/all/package.json | 1 + .../affine/all/src/components/resource.ts | 1 + .../affine/blocks/attachment/package.json | 1 - .../blocks/attachment/src/attachment-block.ts | 138 +++++----------- .../affine/blocks/attachment/src/utils.ts | 34 ++-- .../affine/blocks/attachment/tsconfig.json | 3 +- blocksuite/affine/components/package.json | 2 + .../affine/components/src/resource/index.ts | 153 ++++++++++++++++++ blocksuite/affine/components/tsconfig.json | 3 +- tools/utils/src/workspace.gen.ts | 2 +- yarn.lock | 2 +- 11 files changed, 211 insertions(+), 129 deletions(-) create mode 100644 blocksuite/affine/all/src/components/resource.ts create mode 100644 blocksuite/affine/components/src/resource/index.ts diff --git a/blocksuite/affine/all/package.json b/blocksuite/affine/all/package.json index 44d87d4d71..7b504d08c9 100644 --- a/blocksuite/affine/all/package.json +++ b/blocksuite/affine/all/package.json @@ -258,6 +258,7 @@ "./components/toolbar": "./src/components/toolbar.ts", "./components/view-dropdown-menu": "./src/components/view-dropdown-menu.ts", "./components/tooltip-content-with-shortcut": "./src/components/tooltip-content-with-shortcut.ts", + "./components/resource": "./src/components/resource.ts", "./rich-text": "./src/rich-text/index.ts", "./rich-text/effects": "./src/rich-text/effects.ts", "./shared/adapters": "./src/shared/adapters.ts", diff --git a/blocksuite/affine/all/src/components/resource.ts b/blocksuite/affine/all/src/components/resource.ts new file mode 100644 index 0000000000..e23d212531 --- /dev/null +++ b/blocksuite/affine/all/src/components/resource.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/resource'; diff --git a/blocksuite/affine/blocks/attachment/package.json b/blocksuite/affine/blocks/attachment/package.json index 5b379a9a50..ae9547f08d 100644 --- a/blocksuite/affine/blocks/attachment/package.json +++ b/blocksuite/affine/blocks/attachment/package.json @@ -20,7 +20,6 @@ "@blocksuite/icons": "^2.2.12", "@blocksuite/std": "workspace:*", "@blocksuite/store": "workspace:*", - "@blocksuite/sync": "workspace:*", "@floating-ui/dom": "^1.6.13", "@lit/context": "^1.1.2", "@preact/signals-core": "^1.8.0", diff --git a/blocksuite/affine/blocks/attachment/src/attachment-block.ts b/blocksuite/affine/blocks/attachment/src/attachment-block.ts index 53e1f3b85a..8a60110b7c 100644 --- a/blocksuite/affine/blocks/attachment/src/attachment-block.ts +++ b/blocksuite/affine/blocks/attachment/src/attachment-block.ts @@ -7,6 +7,10 @@ import { getLoadingIconWith, } from '@blocksuite/affine-components/icons'; import { Peekable } from '@blocksuite/affine-components/peek'; +import { + type ResolvedStateInfo, + ResourceController, +} from '@blocksuite/affine-components/resource'; import { toast } from '@blocksuite/affine-components/toast'; import { type AttachmentBlockModel, @@ -25,8 +29,7 @@ import { } from '@blocksuite/icons/lit'; import { BlockSelection } from '@blocksuite/std'; import { Slice } from '@blocksuite/store'; -import { type BlobState } from '@blocksuite/sync'; -import { effect, signal } from '@preact/signals-core'; +import { computed } from '@preact/signals-core'; import { html, type TemplateResult } from 'lit'; import { property } from 'lit/decorators.js'; import { choose } from 'lit/directives/choose.js'; @@ -38,8 +41,6 @@ import { AttachmentEmbedProvider } from './embed'; import { styles } from './styles'; import { downloadAttachmentBlob, refreshData } from './utils'; -type State = 'loading' | 'uploading' | 'warning' | 'oversize' | 'none'; - @Peekable({ enableOn: ({ model }: AttachmentBlockComponent) => { return !model.doc.readonly && model.props.type.endsWith('pdf'); @@ -50,7 +51,9 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent>({}); + resourceController = new ResourceController( + computed(() => this.model.props.sourceId$.value) + ); protected containerStyleMap = styleMap({ position: 'relative', @@ -98,24 +101,7 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent { - refreshData(this.std, this).catch(console.error); - }; - - updateBlobState(state: Partial) { - this.blobState$.value = { ...this.blobState$.value, ...state }; - } - - determineState = ( - downloading: boolean, - uploading: boolean, - overSize: boolean, - error: boolean - ): State => { - if (overSize) return 'oversize'; - if (error) return 'warning'; - if (uploading) return 'uploading'; - if (downloading) return 'loading'; - return 'none'; + refreshData(this).catch(console.error); }; protected get embedView() { @@ -137,29 +123,13 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent { - const blobId = this.model.props.sourceId$.value; - if (!blobId) return; - - const blobState$ = this.std.store.blobSync.blobState$(blobId); - if (!blobState$) return; - - const subscription = blobState$.subscribe(state => { - if (state.overSize || state.errorMessage) { - state.uploading = false; - state.downloading = false; - } - - this.updateBlobState(state); - }); - - return () => subscription.unsubscribe(); - }) - ); - if (!this.model.props.style && !this.doc.readonly) { this.doc.withoutTransact(() => { this.doc.updateBlock(this.model, { @@ -230,11 +200,8 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent
@@ -251,8 +218,8 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent ${choose(state, [ - ['oversize', this.renderUpgradeButton], - ['warning', this.renderReloadButton], + ['error', this.renderReloadButton], + ['error:oversize', this.renderUpgradeButton], ])}
@@ -263,11 +230,8 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent
@@ -287,68 +251,40 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent ${kind} ${choose(state, [ - ['oversize', this.renderUpgradeButton], - ['warning', this.renderReloadButton], + ['error', this.renderReloadButton], + ['error:oversize', this.renderUpgradeButton], ])}
`; } protected renderCard = () => { - const { name, size, style } = this.model.props; - const cardStyle = style ?? AttachmentBlockStyles[1]; - const theme = this.std.get(ThemeProvider).theme$.value; const loadingIcon = getLoadingIconWith(theme); - const blobState = this.blobState$.value; - const { - uploading = false, - downloading = false, - overSize = false, - errorMessage, - } = blobState; - const warning = !overSize && Boolean(errorMessage); - const error = overSize || warning; - const state = this.determineState(downloading, uploading, overSize, error); - const loading = state === 'loading' || state === 'uploading'; + const { name, size, style } = this.model.props; + const cardStyle = style ?? AttachmentBlockStyles[1]; + const kind = getAttachmentFileIcon(name.split('.').pop() ?? ''); + + const resolvedState = this.resourceController.resolveStateWith({ + loadingIcon, + errorIcon: WarningIcon(), + icon: AttachmentIcon(), + title: name, + description: humanFileSize(size), + }); const classInfo = { 'affine-attachment-card': true, [cardStyle]: true, - error, - loading, + loading: resolvedState.loading, + error: resolvedState.error, }; - const icon = loading - ? loadingIcon - : error - ? WarningIcon() - : AttachmentIcon(); - const title = uploading ? 'Uploading...' : loading ? 'Loading...' : name; - const description = errorMessage || humanFileSize(size); - const kind = getAttachmentFileIcon(name.split('.').pop() ?? ''); - return when( cardStyle === 'cubeThick', - () => - this.renderWithVertical( - classInfo, - icon, - title, - description, - kind, - state - ), - () => - this.renderWithHorizontal( - classInfo, - icon, - title, - description, - kind, - state - ) + () => this.renderWithVertical(classInfo, resolvedState, kind), + () => this.renderWithHorizontal(classInfo, resolvedState, kind) ); }; diff --git a/blocksuite/affine/blocks/attachment/src/utils.ts b/blocksuite/affine/blocks/attachment/src/utils.ts index 901b3f4424..760b0951d4 100644 --- a/blocksuite/affine/blocks/attachment/src/utils.ts +++ b/blocksuite/affine/blocks/attachment/src/utils.ts @@ -41,9 +41,9 @@ export async function getAttachmentBlob(model: AttachmentBlockModel) { * the download process may take a long time! */ export function downloadAttachmentBlob(block: AttachmentBlockComponent) { - const { host, model, blobUrl, blobState$ } = block; + const { host, model, blobUrl, resourceController } = block; - if (blobState$.peek().downloading) { + if (resourceController.state$.peek().downloading) { toast(host, 'Download in progress...'); return; } @@ -56,7 +56,7 @@ export function downloadAttachmentBlob(block: AttachmentBlockComponent) { return; } - block.updateBlobState({ downloading: true }); + resourceController.updateState({ downloading: true }); toast(host, `Downloading ${shortName}`); @@ -67,34 +67,24 @@ export function downloadAttachmentBlob(block: AttachmentBlockComponent) { tmpLink.dispatchEvent(event); tmpLink.remove(); - block.updateBlobState({ downloading: false }); + resourceController.updateState({ downloading: false }); } -export async function refreshData( - std: BlockStdScope, - block: AttachmentBlockComponent -) { +export async function refreshData(block: AttachmentBlockComponent) { const model = block.model; const sourceId = model.props.sourceId$.peek(); if (!sourceId) return; - const blobUrl = block.blobUrl; - if (blobUrl) { - URL.revokeObjectURL(blobUrl); - block.blobUrl = null; - } - - let blob = await std.store.blobSync.get(sourceId); - if (!blob) { - block.updateBlobState({ errorMessage: 'File not found' }); - return; - } - const type = model.props.type$.peek(); - blob = new Blob([blob], { type }); + const url = await block.resourceController.createBlobUrlWith(type); + if (!url) return; - block.blobUrl = URL.createObjectURL(blob); + // Releases the previous url. + const prevUrl = block.blobUrl; + if (prevUrl) URL.revokeObjectURL(prevUrl); + + block.blobUrl = url; } export async function getFileType(file: File) { diff --git a/blocksuite/affine/blocks/attachment/tsconfig.json b/blocksuite/affine/blocks/attachment/tsconfig.json index d5d90342e1..f2718e7752 100644 --- a/blocksuite/affine/blocks/attachment/tsconfig.json +++ b/blocksuite/affine/blocks/attachment/tsconfig.json @@ -15,7 +15,6 @@ { "path": "../../widgets/slash-menu" }, { "path": "../../../framework/global" }, { "path": "../../../framework/std" }, - { "path": "../../../framework/store" }, - { "path": "../../../framework/sync" } + { "path": "../../../framework/store" } ] } diff --git a/blocksuite/affine/components/package.json b/blocksuite/affine/components/package.json index 166fc4c6c1..876a223e47 100644 --- a/blocksuite/affine/components/package.json +++ b/blocksuite/affine/components/package.json @@ -16,6 +16,7 @@ "@blocksuite/icons": "^2.2.12", "@blocksuite/std": "workspace:*", "@blocksuite/store": "workspace:*", + "@blocksuite/sync": "workspace:*", "@floating-ui/dom": "^1.6.13", "@lit/context": "^1.1.2", "@lottiefiles/dotlottie-wc": "^0.5.0", @@ -42,6 +43,7 @@ "./color-picker": "./src/color-picker/index.ts", "./icons": "./src/icons/index.ts", "./peek": "./src/peek/index.ts", + "./resource": "./src/resource/index.ts", "./portal": "./src/portal/index.ts", "./hover": "./src/hover/index.ts", "./icon-button": "./src/icon-button/index.ts", diff --git a/blocksuite/affine/components/src/resource/index.ts b/blocksuite/affine/components/src/resource/index.ts new file mode 100644 index 0000000000..b1fa949a4c --- /dev/null +++ b/blocksuite/affine/components/src/resource/index.ts @@ -0,0 +1,153 @@ +import type { BlobEngine, BlobState } from '@blocksuite/sync'; +import { effect, type ReadonlySignal, signal } from '@preact/signals-core'; +import type { TemplateResult } from 'lit-html'; + +export type ResourceKind = 'Blob' | 'File' | 'Image'; + +export type StateKind = + | 'loading' + | 'uploading' + | 'error' + | 'error:oversize' + | 'none'; + +export type StateInfo = { + icon: TemplateResult; + title?: string; + description?: string; +}; + +export type ResolvedStateInfo = StateInfo & { + loading: boolean; + error: boolean; + state: StateKind; +}; + +export class ResourceController { + readonly state$ = signal>({}); + + private engine?: BlobEngine; + + constructor( + readonly blobId$: ReadonlySignal, + readonly kind: ResourceKind = 'File' + ) {} + + setEngine(engine: BlobEngine) { + this.engine = engine; + return this; + } + + determineState( + hasExceeded: boolean, + hasError: boolean, + uploading: boolean, + downloading: boolean + ): StateKind { + if (hasExceeded) return 'error:oversize'; + if (hasError) return 'error'; + if (uploading) return 'uploading'; + if (downloading) return 'loading'; + return 'none'; + } + + resolveStateWith( + info: { + loadingIcon: TemplateResult; + errorIcon?: TemplateResult; + } & StateInfo + ): ResolvedStateInfo { + const { + uploading = false, + downloading = false, + overSize = false, + errorMessage, + } = this.state$.value; + const hasExceeded = overSize; + const hasError = hasExceeded || Boolean(errorMessage); + const state = this.determineState( + hasExceeded, + hasError, + uploading, + downloading + ); + const loading = state === 'uploading' || state === 'loading'; + + const { icon, title, description, loadingIcon, errorIcon } = info; + + const result = { + error: hasError, + loading, + state, + icon, + title, + description, + }; + + if (loading) { + result.icon = loadingIcon ?? icon; + result.title = state === 'uploading' ? 'Uploading...' : 'Loading...'; + } else if (hasError) { + result.icon = errorIcon ?? icon; + result.description = errorMessage ?? description; + } + + return result; + } + + updateState(state: Partial) { + this.state$.value = { ...this.state$.value, ...state }; + } + + subscribe() { + return effect(() => { + const blobId = this.blobId$.value; + if (!blobId) return; + + const blobState$ = this.engine?.blobState$(blobId); + if (!blobState$) return; + + const subscription = blobState$.subscribe(state => { + let { uploading, downloading } = state; + if (state.overSize || state.errorMessage) { + uploading = false; + downloading = false; + } + + this.updateState({ ...state, uploading, downloading }); + }); + + return () => subscription.unsubscribe(); + }); + } + + async blob() { + const blobId = this.blobId$.peek(); + if (!blobId) return null; + + let blob: Blob | null = null; + let errorMessage: string | null = null; + + try { + blob = (await this.engine?.get(blobId)) ?? null; + + if (!blob) errorMessage = `${this.kind} not found`; + } catch (err) { + console.error(err); + errorMessage = `Failed to retrieve ${this.kind}`; + } + + if (errorMessage) this.updateState({ errorMessage }); + + return blob; + } + + async createBlobUrlWith(type?: string) { + let blob = await this.blob(); + if (!blob) return null; + + if (type) blob = new Blob([blob], { type }); + + return URL.createObjectURL(blob); + } +} diff --git a/blocksuite/affine/components/tsconfig.json b/blocksuite/affine/components/tsconfig.json index 22ffcb4d98..4b6893ccd1 100644 --- a/blocksuite/affine/components/tsconfig.json +++ b/blocksuite/affine/components/tsconfig.json @@ -11,6 +11,7 @@ { "path": "../shared" }, { "path": "../../framework/global" }, { "path": "../../framework/std" }, - { "path": "../../framework/store" } + { "path": "../../framework/store" }, + { "path": "../../framework/sync" } ] } diff --git a/tools/utils/src/workspace.gen.ts b/tools/utils/src/workspace.gen.ts index 464c75d8d4..7b170e99f3 100644 --- a/tools/utils/src/workspace.gen.ts +++ b/tools/utils/src/workspace.gen.ts @@ -84,7 +84,6 @@ export const PackageList = [ 'blocksuite/framework/global', 'blocksuite/framework/std', 'blocksuite/framework/store', - 'blocksuite/framework/sync', ], }, { @@ -437,6 +436,7 @@ export const PackageList = [ 'blocksuite/framework/global', 'blocksuite/framework/std', 'blocksuite/framework/store', + 'blocksuite/framework/sync', ], }, { diff --git a/yarn.lock b/yarn.lock index ce29cf3d23..664aca0f12 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2394,7 +2394,6 @@ __metadata: "@blocksuite/icons": "npm:^2.2.12" "@blocksuite/std": "workspace:*" "@blocksuite/store": "workspace:*" - "@blocksuite/sync": "workspace:*" "@floating-ui/dom": "npm:^1.6.13" "@lit/context": "npm:^1.1.2" "@preact/signals-core": "npm:^1.8.0" @@ -2992,6 +2991,7 @@ __metadata: "@blocksuite/icons": "npm:^2.2.12" "@blocksuite/std": "workspace:*" "@blocksuite/store": "workspace:*" + "@blocksuite/sync": "workspace:*" "@floating-ui/dom": "npm:^1.6.13" "@lit/context": "npm:^1.1.2" "@lottiefiles/dotlottie-wc": "npm:^0.5.0"