diff --git a/blocksuite/affine/blocks/attachment/src/attachment-block.ts b/blocksuite/affine/blocks/attachment/src/attachment-block.ts index 8a60110b7c..98490d6803 100644 --- a/blocksuite/affine/blocks/attachment/src/attachment-block.ts +++ b/blocksuite/affine/blocks/attachment/src/attachment-block.ts @@ -31,7 +31,6 @@ import { BlockSelection } from '@blocksuite/std'; import { Slice } from '@blocksuite/store'; 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'; import { type ClassInfo, classMap } from 'lit/directives/class-map.js'; import { styleMap } from 'lit/directives/style-map.js'; @@ -55,6 +54,10 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent this.model.props.sourceId$.value) ); + get blobUrl() { + return this.resourceController.blobUrl$.value; + } + protected containerStyleMap = styleMap({ position: 'relative', width: '100%', @@ -123,10 +126,10 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent
${icon}
-
${title}
@@ -237,7 +231,6 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent
${icon}
-
${title}
@@ -326,9 +319,6 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent 0 - ? humanFileSize(model.props.size, true, 0) - : null; + const classInfo = { + 'affine-image-fallback-card': true, + 'drag-target': true, + loading, + error, + }; return html` -
-
-
- ${titleIcon} - ${titleText} +
+
+
${icon}
+
+ ${title}
-
${size}
+
+
+ ${description}
`; } @property({ attribute: false }) - accessor error!: boolean; - - @property({ attribute: false }) - accessor loading!: boolean; - - @property({ attribute: false }) - accessor mode!: 'page' | 'edgeless'; - - @property({ attribute: false }) - accessor theme!: ColorScheme; - - @consume({ context: modelContext }) - accessor model!: ImageBlockModel; + accessor state!: ResolvedStateInfo; } declare global { diff --git a/blocksuite/affine/blocks/image/src/components/image-selected-rect.ts b/blocksuite/affine/blocks/image/src/components/image-selected-rect.ts index 225dd43728..a8f454b0c0 100644 --- a/blocksuite/affine/blocks/image/src/components/image-selected-rect.ts +++ b/blocksuite/affine/blocks/image/src/components/image-selected-rect.ts @@ -1,4 +1,5 @@ import { html } from 'lit'; +import { when } from 'lit/directives/when.js'; const styles = html``; export function ImageSelectedRect(readonly: boolean) { - if (readonly) { - return html`${styles} -
`; - } return html` ${styles}
-
-
-
-
-
-
-
-
-
-
-
-
+ ${when( + !readonly, + () => html` +
+
+
+
+
+
+
+
+
+
+
+
+ ` + )}
`; } diff --git a/blocksuite/affine/blocks/image/src/components/page-image-block.ts b/blocksuite/affine/blocks/image/src/components/page-image-block.ts index 64851d90cb..2a365ea4ea 100644 --- a/blocksuite/affine/blocks/image/src/components/page-image-block.ts +++ b/blocksuite/affine/blocks/image/src/components/page-image-block.ts @@ -1,3 +1,4 @@ +import type { ResolvedStateInfo } from '@blocksuite/affine-components/resource'; import { focusBlockEnd, focusBlockStart, @@ -5,6 +6,7 @@ import { getPrevBlockCommand, } from '@blocksuite/affine-shared/commands'; import { ImageSelection } from '@blocksuite/affine-shared/selection'; +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; import { WithDisposable } from '@blocksuite/global/lit'; import type { BlockComponent, UIEventStateContext } from '@blocksuite/std'; import { @@ -16,15 +18,17 @@ import type { BaseSelection } from '@blocksuite/store'; import { css, html, type PropertyValues } from 'lit'; import { property, query, state } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; +import { when } from 'lit/directives/when.js'; -import type { ImageBlockComponent } from '../image-block.js'; -import { ImageResizeManager } from '../image-resize-manager.js'; -import { shouldResizeImage } from '../utils.js'; -import { ImageSelectedRect } from './image-selected-rect.js'; +import type { ImageBlockComponent } from '../image-block'; +import { ImageResizeManager } from '../image-resize-manager'; +import { shouldResizeImage } from '../utils'; +import { ImageSelectedRect } from './image-selected-rect'; export class ImageBlockPageComponent extends WithDisposable(ShadowlessElement) { static override styles = css` affine-page-image { + position: relative; display: flex; flex-direction: column; align-items: center; @@ -33,6 +37,20 @@ export class ImageBlockPageComponent extends WithDisposable(ShadowlessElement) { cursor: pointer; } + affine-page-image .loading { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + top: 4px; + right: 4px; + width: 20px; + height: 20px; + padding: 4px; + border-radius: 4px; + background: ${unsafeCSSVarV2('loading/backgroundLayer')}; + } + affine-page-image .resizable-img { position: relative; max-width: 100%; @@ -182,7 +200,9 @@ export class ImageBlockPageComponent extends WithDisposable(ShadowlessElement) { } private _handleError() { - this.block.error = true; + this.block.resourceController.updateState({ + errorMessage: 'Failed to download image!', + }); } private _handleSelection() { @@ -334,18 +354,23 @@ export class ImageBlockPageComponent extends WithDisposable(ShadowlessElement) { ? ImageSelectedRect(this._doc.readonly) : null; + const { loading, icon } = this.state; + return html`
${this.block.model.props.caption$.value ${imageSelectedRect}
+ + ${when(loading, () => html`
${icon}
`)} `; } @@ -355,6 +380,9 @@ export class ImageBlockPageComponent extends WithDisposable(ShadowlessElement) { @property({ attribute: false }) accessor block!: ImageBlockComponent; + @property({ attribute: false }) + accessor state!: ResolvedStateInfo; + @query('.resizable-img') accessor resizeImg!: HTMLElement; } diff --git a/blocksuite/affine/blocks/image/src/configs/toolbar.ts b/blocksuite/affine/blocks/image/src/configs/toolbar.ts index b513cc3fb8..67b64152ae 100644 --- a/blocksuite/affine/blocks/image/src/configs/toolbar.ts +++ b/blocksuite/affine/blocks/image/src/configs/toolbar.ts @@ -89,7 +89,7 @@ const builtinToolbarConfig = { if (!supported) return false; const block = ctx.getCurrentBlockByType(ImageBlockComponent); - return Boolean(block?.blob); + return Boolean(block?.blobUrl); }, run(ctx) { const block = ctx.getCurrentBlockByType(ImageBlockComponent); diff --git a/blocksuite/affine/blocks/image/src/image-block.ts b/blocksuite/affine/blocks/image/src/image-block.ts index 49435df1d6..53f8bc57b4 100644 --- a/blocksuite/affine/blocks/image/src/image-block.ts +++ b/blocksuite/affine/blocks/image/src/image-block.ts @@ -1,31 +1,44 @@ import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; import { whenHover } from '@blocksuite/affine-components/hover'; +import { getLoadingIconWith } from '@blocksuite/affine-components/icons'; import { Peekable } from '@blocksuite/affine-components/peek'; +import { ResourceController } from '@blocksuite/affine-components/resource'; import type { ImageBlockModel } from '@blocksuite/affine-model'; import { ThemeProvider, ToolbarRegistryIdentifier, } from '@blocksuite/affine-shared/services'; +import { humanFileSize } from '@blocksuite/affine-shared/utils'; import { IS_MOBILE } from '@blocksuite/global/env'; +import { BrokenImageIcon, ImageIcon } from '@blocksuite/icons/lit'; import { BlockSelection } from '@blocksuite/std'; +import { computed } from '@preact/signals-core'; import { html } from 'lit'; -import { property, query, state } from 'lit/decorators.js'; +import { query } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; import { when } from 'lit/directives/when.js'; -import type { ImageBlockFallbackCard } from './components/image-block-fallback.js'; -import type { ImageBlockPageComponent } from './components/page-image-block.js'; +import type { ImageBlockPageComponent } from './components/page-image-block'; import { copyImageBlob, downloadImageBlob, - fetchImageBlob, + refreshData, turnImageIntoCardView, -} from './utils.js'; +} from './utils'; @Peekable({ enableOn: () => !IS_MOBILE, }) export class ImageBlockComponent extends CaptionedBlockComponent { + resourceController = new ResourceController( + computed(() => this.model.props.sourceId$.value), + 'Image' + ); + + get blobUrl() { + return this.resourceController.blobUrl$.value; + } + convertToCardView = () => { turnImageIntoCardView(this).catch(console.error); }; @@ -39,8 +52,7 @@ export class ImageBlockComponent extends CaptionedBlockComponent { - this.retryCount = 0; - fetchImageBlob(this).catch(console.error); + refreshData(this).catch(console.error); }; get resizableImg() { @@ -85,22 +97,14 @@ export class ImageBlockComponent extends CaptionedBlockComponent { - if (key === 'sourceId') { - this.refreshData(); - } - }) - ); - } - override disconnectedCallback() { - if (this.blobUrl) { - URL.revokeObjectURL(this.blobUrl); - } - super.disconnectedCallback(); + this.resourceController.setEngine(this.std.store.blobSync); + + this.disposables.add(this.resourceController.subscribe()); + this.disposables.add(this.resourceController); + + this.refreshData(); } override firstUpdated() { @@ -110,24 +114,38 @@ export class ImageBlockComponent extends CaptionedBlockComponent ${when( - this.loading || this.error, + blobUrl, + () => + html``, () => html``, - () => html`` + .state=${resovledState} + >` )}
@@ -135,42 +153,14 @@ export class ImageBlockComponent extends CaptionedBlockComponent { static override styles = css` + affine-edgeless-image { + position: relative; + } + + affine-edgeless-image .loading { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + top: 4px; + right: 4px; + width: 20px; + height: 20px; + padding: 4px; + border-radius: 4px; + background: ${unsafeCSSVarV2('loading/backgroundLayer')}; + } + affine-edgeless-image .resizable-img, affine-edgeless-image .resizable-img img { width: 100%; @@ -27,6 +49,15 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent this.model.props.sourceId$.value), + 'Image' + ); + + get blobUrl() { + return this.resourceController.blobUrl$.value; + } + convertToCardView = () => { turnImageIntoCardView(this).catch(console.error); }; @@ -40,75 +71,76 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent { - this.retryCount = 0; - fetchImageBlob(this) - .then(() => { - const { width, height } = this.model.props; - if ((!width || !height) && this.blob) { - return resetImageSize(this); - } - - return; - }) - .catch(console.error); + refreshData(this).catch(console.error); }; - private _handleError(error: Error) { - this.dispatchEvent(new CustomEvent('error', { detail: error })); + private _handleError() { + this.resourceController.updateState({ + errorMessage: 'Failed to download image!', + }); } override connectedCallback() { super.connectedCallback(); - this.refreshData(); this.contentEditable = 'false'; - this.disposables.add( - this.model.propsUpdated.subscribe(({ key }) => { - if (key === 'sourceId') { - this.refreshData(); - } - }) - ); - } - override disconnectedCallback() { - if (this.blobUrl) { - URL.revokeObjectURL(this.blobUrl); - } - super.disconnectedCallback(); + this.resourceController.setEngine(this.std.store.blobSync); + + this.disposables.add(this.resourceController.subscribe()); + this.disposables.add(this.resourceController); + + this.refreshData(); } override renderGfxBlock() { - const rotate = this.model.rotate ?? 0; + const theme = this.std.get(ThemeProvider).theme$.value; + const loadingIcon = getLoadingIconWith(theme); + + const blobUrl = this.blobUrl; + const { rotate = 0, size = 0, caption = 'Image' } = this.model.props; + const containerStyleMap = styleMap({ + display: 'flex', position: 'relative', width: '100%', + height: '100%', transform: `rotate(${rotate}deg)`, transformOrigin: 'center', }); - const theme = this.std.get(ThemeProvider).theme$.value; + + const resovledState = this.resourceController.resolveStateWith({ + loadingIcon, + errorIcon: BrokenImageIcon(), + icon: ImageIcon(), + title: 'Image', + description: humanFileSize(size), + }); return html`
${when( - this.loading || this.error || !this.blobUrl, - () => - html``, - () => - html`
+ blobUrl, + () => html` +
${caption} -
` +
+ ${when( + resovledState.loading, + () => html`
${loadingIcon}
` + )} + `, + () => + html`` )}
@@ -118,39 +150,11 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent(); -export function setImageUploading(blockId: string) { - imageUploads.add(blockId); -} -export function setImageUploaded(blockId: string) { - imageUploads.delete(blockId); -} -export function isImageUploading(blockId: string) { - return imageUploads.has(blockId); -} - -export async function uploadBlobForImage( - editorHost: EditorHost, - blockId: string, - blob: Blob -): Promise { - if (isImageUploading(blockId)) { - console.error('The image is already uploading!'); - return; - } - setImageUploading(blockId); - const doc = editorHost.doc; - let sourceId: string | undefined; - - try { - sourceId = await doc.blobSync.set(blob); - } catch (error) { - console.error(error); - if (error instanceof Error) { - toast( - editorHost, - `Failed to upload image! ${error.message || error.toString()}` - ); - } - } finally { - setImageUploaded(blockId); - - const imageModel = doc.getModelById(blockId) as ImageBlockModel | null; - if (sourceId && imageModel) { - const props: Partial = { - sourceId, - // Assign a default size to make sure the image can be displayed correctly. - width: 100, - height: 100, - }; - - const blob = await doc.blobSync.get(sourceId); - if (blob) { - try { - const size = await readImageSize(blob); - props.width = size.width; - props.height = size.height; - } catch { - // Ignore the error - console.warn('Failed to read image size'); - } - } - - doc.withoutTransact(() => { - doc.updateBlock(imageModel, props); - }); - } - } -} - async function getImageBlob(model: ImageBlockModel) { - const sourceId = model.props.sourceId; - if (!sourceId) { - return null; - } + const sourceId = model.props.sourceId$.peek(); + if (!sourceId) return null; const doc = model.doc; - const blob = await doc.blobSync.get(sourceId); - - if (!blob) { - return null; - } + let blob = await doc.blobSync.get(sourceId); + if (!blob) return null; if (!blob.type) { const buffer = await blob.arrayBuffer(); const FileType = await import('file-type'); const fileType = await FileType.fileTypeFromBuffer(buffer); - if (!fileType?.mime.startsWith('image/')) { - return null; - } - return new Blob([buffer], { type: fileType.mime }); + blob = new Blob([buffer], { type: fileType?.mime }); } - if (!blob.type.startsWith('image/')) { - return null; - } + if (!blob.type.startsWith('image/')) return null; return blob; } -export async function fetchImageBlob( +export async function refreshData( block: ImageBlockComponent | ImageEdgelessBlockComponent ) { - try { - if (block.model.props.sourceId !== block.lastSourceId || !block.blobUrl) { - block.loading = true; - block.error = false; - block.blob = undefined; - - if (block.blobUrl) { - URL.revokeObjectURL(block.blobUrl); - block.blobUrl = undefined; - } - } else if (block.blobUrl) { - return; - } - - const { model } = block; - const { sourceId } = model.props; - const { id, doc } = model; - - if (isImageUploading(id)) { - return; - } - - if (!sourceId) { - return; - } - - const blob = await doc.blobSync.get(sourceId); - if (!blob) { - return; - } - - block.loading = false; - block.blob = blob; - block.blobUrl = URL.createObjectURL(blob); - block.lastSourceId = sourceId; - } catch (error) { - block.retryCount++; - console.warn(`${error}, retrying`, block.retryCount); - - if (block.retryCount < MAX_RETRY_COUNT) { - setTimeout(() => { - fetchImageBlob(block).catch(console.error); - // 1s, 2s, 3s - }, 1000 * block.retryCount); - } else { - block.loading = false; - block.error = true; - } - } + await block.resourceController.refreshUrlWith(); } export async function downloadImageBlob( block: ImageBlockComponent | ImageEdgelessBlockComponent ) { - const { host, downloading } = block; - if (downloading) { + const { host, blobUrl, resourceController } = block; + + if (!blobUrl) { + toast(host, 'Failed to download image!'); + return; + } + + if (resourceController.state$.peek().downloading) { toast(host, 'Download in progress...'); return; } - block.downloading = true; + resourceController.updateState({ downloading: true }); - const blob = await getImageBlob(block.model); - if (!blob) { - toast(host, `Unable to download image!`); - return; - } + toast(host, 'Downloading image...'); - toast(host, `Downloading image...`); + const tmpLink = document.createElement('a'); + const event = new MouseEvent('click'); + tmpLink.download = 'image'; + tmpLink.href = blobUrl; + tmpLink.dispatchEvent(event); + tmpLink.remove(); - downloadBlob(blob, 'image'); - - block.downloading = false; + resourceController.updateState({ downloading: false }); } export async function resetImageSize( block: ImageBlockComponent | ImageEdgelessBlockComponent ) { - const { blob, model } = block; + const { model } = block; + + const blob = await getImageBlob(model); if (!blob) { + console.error('Failed to get image blob'); return; } - const file = new File([blob], 'image.png', { type: blob.type }); - const size = await readImageSize(file); - const bound = model.elementBound; - const props: Partial = { - width: size.width, - height: size.height, - }; + const imageSize = await readImageSize(blob); - if (!bound.w || !bound.h) { - bound.w = size.width; - bound.h = size.height; - props.xywh = bound.serialize(); - } + const bound = model.elementBound; + bound.w = imageSize.width; + bound.h = imageSize.height; + + const xywh = bound.serialize(); + const props: Partial = { ...imageSize, xywh }; block.doc.updateBlock(model, props); } @@ -326,7 +200,7 @@ export async function turnImageIntoCardView( } const model = block.model; - const sourceId = model.props.sourceId; + const sourceId = model.props.sourceId$.peek(); const blob = await getImageBlob(model); if (!sourceId || !blob) { console.error('Image data not available'); @@ -432,9 +306,9 @@ export async function addImageBlocks( files.map(file => buildPropsWith(std, file)) ); - const blockIds = propsArray.map(props => - std.store.addBlock(flavour, props, parent, parentIndex) - ); + const blocks = propsArray.map(blockProps => ({ flavour, blockProps })); + + const blockIds = std.store.addBlocks(blocks, parent, parentIndex); return blockIds; } @@ -476,9 +350,7 @@ export async function addImages( const xy = [x, y]; - const blockIds = propsArray.map((props, index) => { - const center = Vec.addScalar(xy, index * gap); - + const blocks = propsArray.map((props, i) => { // If maxWidth is provided, limit the width of the image to maxWidth // Otherwise, use the original width if (maxWidth) { @@ -486,8 +358,11 @@ export async function addImages( props.width = Math.min(props.width, maxWidth); props.height = props.width * p; } - const { width, height } = props; + const center = Vec.addScalar(xy, i * gap); + const index = gfx.layer.generateIndex(); + + const { width, height } = props; const xywh = calcBoundByOrigin( center, inTopLeft, @@ -495,19 +370,20 @@ export async function addImages( height ).serialize(); - return std.store.addBlock( + return { flavour, - { + blockProps: { ...props, width, height, xywh, - index: gfx.layer.generateIndex(), + index, }, - gfx.surface - ); + }; }); + const blockIds = std.store.addBlocks(blocks, gfx.surface); + gfx.selection.set({ elements: blockIds, editing: false, diff --git a/blocksuite/affine/components/src/resource/index.ts b/blocksuite/affine/components/src/resource/index.ts index b1fa949a4c..c040b61cfd 100644 --- a/blocksuite/affine/components/src/resource/index.ts +++ b/blocksuite/affine/components/src/resource/index.ts @@ -1,3 +1,4 @@ +import type { Disposable } from '@blocksuite/global/disposable'; import type { BlobEngine, BlobState } from '@blocksuite/sync'; import { effect, type ReadonlySignal, signal } from '@preact/signals-core'; import type { TemplateResult } from 'lit-html'; @@ -23,7 +24,9 @@ export type ResolvedStateInfo = StateInfo & { state: StateKind; }; -export class ResourceController { +export class ResourceController implements Disposable { + readonly blobUrl$ = signal(null); + readonly state$ = signal>({}); private engine?: BlobEngine; @@ -33,6 +36,7 @@ export class ResourceController { readonly kind: ResourceKind = 'File' ) {} + // This is a tradeoff, initializing `Blob Sync Engine`. setEngine(engine: BlobEngine) { this.engine = engine; return this; @@ -142,7 +146,7 @@ export class ResourceController { return blob; } - async createBlobUrlWith(type?: string) { + async createUrlWith(type?: string) { let blob = await this.blob(); if (!blob) return null; @@ -150,4 +154,26 @@ export class ResourceController { return URL.createObjectURL(blob); } + + async refreshUrlWith(type?: string) { + const url = await this.createUrlWith(type); + if (!url) return; + + const prevUrl = this.blobUrl$.peek(); + + this.blobUrl$.value = url; + + if (!prevUrl) return; + + // Releases the previous url. + URL.revokeObjectURL(prevUrl); + } + + dispose() { + const url = this.blobUrl$.peek(); + if (!url) return; + + // Releases the current url. + URL.revokeObjectURL(url); + } } diff --git a/blocksuite/affine/gfx/mindmap/src/toolbar/basket-elements.ts b/blocksuite/affine/gfx/mindmap/src/toolbar/basket-elements.ts index 07321a5d07..c44569d53a 100644 --- a/blocksuite/affine/gfx/mindmap/src/toolbar/basket-elements.ts +++ b/blocksuite/affine/gfx/mindmap/src/toolbar/basket-elements.ts @@ -18,7 +18,7 @@ import { TelemetryProvider, } from '@blocksuite/affine-shared/services'; import { openFileOrFiles } from '@blocksuite/affine-shared/utils'; -import { Bound } from '@blocksuite/global/gfx'; +import { Bound, type IVec } from '@blocksuite/global/gfx'; import type { BlockComponent } from '@blocksuite/std'; import type { TemplateResult } from 'lit'; import * as Y from 'yjs'; @@ -165,24 +165,22 @@ export const mediaRender: DraggableTool['render'] = async (bound, edgeless) => { } if (!file) return null; + const files = [file]; + const std = edgeless.std; + const point: IVec = [bound.x, bound.y]; + // image if (file.type.startsWith('image/')) { - const [id] = await addImages(edgeless.std, [file], { - point: [bound.x, bound.y], + const [id] = await addImages(std, files, { + point, maxWidth: MAX_IMAGE_WIDTH, shouldTransformPoint: false, }); - if (id) return id; - return null; + return id; } // attachment - const [id] = await addAttachments( - edgeless.std, - [file], - [bound.x, bound.y], - false - ); + const [id] = await addAttachments(std, files, point, false); return id; }; diff --git a/blocksuite/framework/sync/src/blob/source.ts b/blocksuite/framework/sync/src/blob/source.ts index 7b9d8382f1..6cf9239df4 100644 --- a/blocksuite/framework/sync/src/blob/source.ts +++ b/blocksuite/framework/sync/src/blob/source.ts @@ -14,6 +14,6 @@ export interface BlobSource { set: (key: string, value: Blob) => Promise; delete: (key: string) => Promise; list: () => Promise; - // This state is only available when uploading to the cloud or downloading from the cloud. + // This state is only available when uploading to the server or downloading from the server. blobState$?: (key: string) => Observable | null; } diff --git a/blocksuite/playground/apps/_common/sync/blob/mock-server.ts b/blocksuite/playground/apps/_common/sync/blob/mock-server.ts index 65b5580587..a9600522fc 100644 --- a/blocksuite/playground/apps/_common/sync/blob/mock-server.ts +++ b/blocksuite/playground/apps/_common/sync/blob/mock-server.ts @@ -1,4 +1,5 @@ -import type { BlobSource } from '@blocksuite/sync'; +import type { BlobSource, BlobState } from '@blocksuite/sync'; +import { BehaviorSubject, ReplaySubject, share, throttleTime } from 'rxjs'; /** * @internal just for test @@ -10,6 +11,7 @@ import type { BlobSource } from '@blocksuite/sync'; */ export class MockServerBlobSource implements BlobSource { private readonly _cache = new Map(); + private readonly _states = new Map>(); readonly = false; @@ -17,26 +19,45 @@ export class MockServerBlobSource implements BlobSource { async delete(key: string) { this._cache.delete(key); + this._states.delete(key); + await fetch(`/api/collection/${this.name}/blob/${key}`, { method: 'DELETE', }); } async get(key: string) { - if (this._cache.has(key)) { - return this._cache.get(key) as Blob; - } else { - const blob = await fetch(`/api/collection/${this.name}/blob/${key}`, { - method: 'GET', - }).then(response => { - if (!response.ok) { - throw new Error(`Failed to fetch blob ${key}`); - } - return response.blob(); - }); - this._cache.set(key, blob); - return blob; + if (this._cache.has(key)) return this._cache.get(key)!; + + let state$ = this._states.get(key); + if (!state$) { + state$ = new BehaviorSubject(defaultState()); + + this._states.set(key, state$); } + + let blob: Blob | null = null; + + nextState(state$, { downloading: true }); + + try { + const resp = await fetch(`/api/collection/${this.name}/blob/${key}`); + + if (!resp.ok) throw new Error(`Failed to fetch blob ${key}`); + + blob = await resp.blob(); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + nextState(state$, { errorMessage }); + } finally { + nextState(state$, { downloading: false }); + + if (blob) { + this._cache.set(key, blob); + } + } + + return blob; } async list() { @@ -44,11 +65,59 @@ export class MockServerBlobSource implements BlobSource { } async set(key: string, value: Blob) { + let state$ = this._states.get(key); + if (!state$) { + state$ = new BehaviorSubject(defaultState()); + + this._states.set(key, state$); + } + this._cache.set(key, value); - await fetch(`/api/collection/${this.name}/blob/${key}`, { - method: 'PUT', - body: await value.arrayBuffer(), - }); + + nextState(state$, { uploading: true }); + + try { + await fetch(`/api/collection/${this.name}/blob/${key}`, { + method: 'PUT', + body: value, + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + nextState(state$, { errorMessage }); + } finally { + nextState(state$, { uploading: false }); + } + return key; } + + blobState$(key: string) { + let state$ = this._states.get(key); + + if (!state$) { + state$ = new BehaviorSubject(defaultState()); + + this._states.set(key, state$); + + nextState(state$, { errorMessage: 'Blob not found' }); + } + + return state$.pipe( + throttleTime(1000, undefined, { leading: true, trailing: true }), + share({ + connector: () => new ReplaySubject(1), + }) + ); + } +} + +function defaultState(): BlobState { + return { uploading: false, downloading: false, overSize: false }; +} + +function nextState( + state$: BehaviorSubject, + state?: Partial +) { + state$.next({ ...state$.value, ...state }); } diff --git a/packages/frontend/core/src/blocksuite/ai/actions/page-response.ts b/packages/frontend/core/src/blocksuite/ai/actions/page-response.ts index f6377f5878..69bf6a637e 100644 --- a/packages/frontend/core/src/blocksuite/ai/actions/page-response.ts +++ b/packages/frontend/core/src/blocksuite/ai/actions/page-response.ts @@ -1,4 +1,4 @@ -import { uploadBlobForImage } from '@blocksuite/affine/blocks/image'; +import { addSiblingImageBlocks } from '@blocksuite/affine/blocks/image'; import { getSurfaceBlock, SurfaceBlockModel, @@ -178,14 +178,11 @@ export function responseToCreateImage(host: EditorHost, place: Place) { fetchImageToFile(answer, filename, imageProxy) .then(file => { if (!file) return; - host.doc.transact(() => { - const props = { - flavour: 'affine:image', - size: file.size, - }; - const blockId = addSiblingBlocks(host, [props], place)?.[0]; - blockId && uploadBlobForImage(host, blockId, file).catch(console.error); - }); + + const targetModel = getTargetModel(host, place); + if (!targetModel) return; + + return addSiblingImageBlocks(host.std, [file], targetModel, place); }) .catch(console.error); } @@ -284,18 +281,21 @@ function addSurfaceRefBlock(host: EditorHost, bound: Bound, place: Place) { return addSiblingBlocks(host, [props], place); } +function getTargetModel(host: EditorHost, place: Place) { + const { selectedModels } = getSelection(host) || {}; + if (!selectedModels) return; + return place === 'before' + ? selectedModels[0] + : selectedModels[selectedModels.length - 1]; +} + function addSiblingBlocks( host: EditorHost, props: Array>, place: Place ) { - const { selectedModels } = getSelection(host) || {}; - if (!selectedModels) return; - const targetModel = - place === 'before' - ? selectedModels[0] - : selectedModels[selectedModels.length - 1]; - + const targetModel = getTargetModel(host, place); + if (!targetModel) return; return host.doc.addSiblingBlocks(targetModel, props, place); } diff --git a/tests/blocksuite/e2e/image/load.spec.ts b/tests/blocksuite/e2e/image/load.spec.ts index b67ad44599..d19eb0642f 100644 --- a/tests/blocksuite/e2e/image/load.spec.ts +++ b/tests/blocksuite/e2e/image/load.spec.ts @@ -13,7 +13,7 @@ import { test } from '../utils/playwright.js'; const mockImageId = '_e2e_test_image_id_'; async function initMockImage(page: Page) { - await page.evaluate(() => { + await page.evaluate(sourceId => { const { doc } = window; doc.captureSync(); const rootId = doc.addBlock('affine:page'); @@ -21,20 +21,20 @@ async function initMockImage(page: Page) { doc.addBlock( 'affine:image', { - sourceId: '_e2e_test_image_id_', + sourceId, width: 200, height: 180, }, noteId ); doc.captureSync(); - }); + }, mockImageId); } test('image loading but failed', async ({ page }) => { expectConsoleMessage( page, - 'Error: Failed to fetch blob _e2e_test_image_id_', + `Error: Failed to fetch blob ${mockImageId}`, 'warning' ); expectConsoleMessage( @@ -65,26 +65,25 @@ test('image loading but failed', async ({ page }) => { await initMockImage(page); - const loadingContent = await page - .locator( - '.affine-image-fallback-card .affine-image-fallback-card-title-text' - ) - .innerText(); - expect(loadingContent).toBe('Loading image...'); + const title = page.locator( + '.affine-image-fallback-card .affine-image-fallback-card-title-text' + ); + + await expect(title).toHaveText('Image'); await page.waitForTimeout(3 * timeout); - await expect( - page.locator( - '.affine-image-fallback-card .affine-image-fallback-card-title-text' - ) - ).toContainText('Image loading failed.'); + const desc = page.locator( + '.affine-image-fallback-card .affine-image-fallback-card-description' + ); + + await expect(desc).toContainText('Image not found'); }); test('image loading but success', async ({ page }) => { expectConsoleMessage( page, - 'Error: Failed to fetch blob _e2e_test_image_id_', + `Error: Failed to fetch blob ${mockImageId}`, 'warning' ); expectConsoleMessage( @@ -118,21 +117,18 @@ test('image loading but success', async ({ page }) => { body: imageBuffer, }); } - // broken image - return route.fulfill({ - status: 404, - }); + + return route.continue(); } ); await initMockImage(page); - const loadingContent = await page - .locator( - '.affine-image-fallback-card .affine-image-fallback-card-title-text' - ) - .innerText(); - expect(loadingContent).toBe('Loading image...'); + const title = page.locator( + '.affine-image-fallback-card .affine-image-fallback-card-title-text' + ); + + await expect(title).toHaveText('Image'); await page.waitForTimeout(3 * timeout);