fix(editor): improve image block upload and download states (#12017)

Related to: [BS-3143](https://linear.app/affine-design/issue/BS-3143/更新-loading-和错误样式)

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

- **New Features**
  - Introduced a unified resource controller for managing image and attachment resources, providing improved loading, error, and state handling.
  - Added a visual loading indicator overlay to image blocks for better feedback during image loading.

- **Improvements**
  - Simplified and centralized image and attachment state management, reducing redundant properties and manual state tracking.
  - Updated fallback UI for image blocks with clearer titles, descriptions, and improved layout.
  - Enhanced batch image block creation and download handling for improved efficiency.
  - Refined image block accessibility with improved alt text and streamlined rendering logic.
  - Centralized target model selection for image insertion in AI actions.
  - Reordered CSS declarations without affecting styling.
  - Improved reactive state tracking for blob upload/download operations in mock server.

- **Bug Fixes**
  - Improved cleanup of object URLs to prevent resource leaks.
  - Adjusted toolbar logic to more accurately reflect available actions based on image state.

- **Tests**
  - Updated end-to-end tests to match new UI text and behaviors for image loading and error states.

- **Chores**
  - Refactored internal logic and updated comments for clarity and maintainability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
fundon
2025-05-07 05:15:57 +00:00
parent 8f6e604774
commit 93b1d6c729
17 changed files with 484 additions and 532 deletions
@@ -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<Attachment
computed(() => 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<Attachment
this.contentEditable = 'false';
// This is a tradeoff, initializing `Blob Sync Engine`.
this.resourceController.setEngine(this.std.store.blobSync);
this.disposables.add(this.resourceController.subscribe());
this.disposables.add(this.resourceController);
this.refreshData();
@@ -139,14 +142,6 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
}
}
override disconnectedCallback() {
const blobUrl = this.blobUrl;
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
}
super.disconnectedCallback();
}
override firstUpdated() {
// lazy bindings
this.disposables.addFromEvent(this, 'click', this.onClick);
@@ -207,7 +202,6 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
<div class="affine-attachment-content">
<div class="affine-attachment-content-title">
<div class="affine-attachment-content-title-icon">${icon}</div>
<div class="affine-attachment-content-title-text truncate">
${title}
</div>
@@ -237,7 +231,6 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
<div class="affine-attachment-content">
<div class="affine-attachment-content-title">
<div class="affine-attachment-content-title-icon">${icon}</div>
<div class="affine-attachment-content-title-text truncate">
${title}
</div>
@@ -326,9 +319,6 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
`;
}
@property({ attribute: false })
accessor blobUrl: string | null = null;
override accessor selectedStyle = SelectedStyle.Border;
override accessor useCaptionEditor = true;
@@ -6,9 +6,9 @@ export const styles = css`
border-radius: 8px;
box-sizing: border-box;
user-select: none;
overflow: hidden;
border: 1px solid ${unsafeCSSVarV2('layer/background/tertiary')};
background: ${unsafeCSSVarV2('layer/background/primary')};
overflow: hidden;
&.focused {
border-color: ${unsafeCSSVarV2('layer/insideBorder/primaryBorder')};
@@ -30,6 +30,13 @@ export const styles = css`
min-width: 0;
}
.truncate {
align-self: stretch;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.affine-attachment-content-title {
display: flex;
flex-direction: row;
@@ -47,13 +54,6 @@ export const styles = css`
color: var(--affine-text-primary-color);
}
.truncate {
align-self: stretch;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.affine-attachment-content-title-text {
color: var(--affine-text-primary-color);
font-family: var(--affine-font-family);
@@ -22,15 +22,13 @@ import type { BlockModel } from '@blocksuite/store';
import type { AttachmentBlockComponent } from './attachment-block';
export async function getAttachmentBlob(model: AttachmentBlockModel) {
const {
sourceId$: { value: sourceId },
type$: { value: type },
} = model.props;
const { sourceId$, type$ } = model.props;
const sourceId = sourceId$.peek();
const type = type$.peek();
if (!sourceId) return null;
const doc = model.doc;
let blob = await doc.blobSync.get(sourceId);
const blob = await doc.blobSync.get(sourceId);
if (!blob) return null;
return new Blob([blob], { type });
@@ -72,19 +70,9 @@ export function downloadAttachmentBlob(block: AttachmentBlockComponent) {
export async function refreshData(block: AttachmentBlockComponent) {
const model = block.model;
const sourceId = model.props.sourceId$.peek();
if (!sourceId) return;
const type = model.props.type$.peek();
const url = await block.resourceController.createBlobUrlWith(type);
if (!url) return;
// Releases the previous url.
const prevUrl = block.blobUrl;
if (prevUrl) URL.revokeObjectURL(prevUrl);
block.blobUrl = url;
await block.resourceController.refreshUrlWith(type);
}
export async function getFileType(file: File) {
@@ -94,7 +82,7 @@ export async function getFileType(file: File) {
const buffer = await file.arrayBuffer();
const FileType = await import('file-type');
const fileType = await FileType.fileTypeFromBuffer(buffer);
return fileType ? fileType.mime : '';
return fileType?.mime ?? '';
}
function hasExceeded(
@@ -1,13 +1,10 @@
import { getLoadingIconWith } from '@blocksuite/affine-components/icons';
import type { ColorScheme, ImageBlockModel } from '@blocksuite/affine-model';
import { humanFileSize } from '@blocksuite/affine-shared/utils';
import type { ResolvedStateInfo } from '@blocksuite/affine-components/resource';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { WithDisposable } from '@blocksuite/global/lit';
import { BrokenImageIcon, ImageIcon } from '@blocksuite/icons/lit';
import { modelContext, ShadowlessElement } from '@blocksuite/std';
import { consume } from '@lit/context';
import { ShadowlessElement } from '@blocksuite/std';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { classMap } from 'lit/directives/class-map.js';
export const SURFACE_IMAGE_CARD_WIDTH = 220;
export const SURFACE_IMAGE_CARD_HEIGHT = 122;
@@ -16,120 +13,110 @@ export const NOTE_IMAGE_CARD_HEIGHT = 78;
export class ImageBlockFallbackCard extends WithDisposable(ShadowlessElement) {
static override styles = css`
.affine-image-fallback-card-container {
affine-image-fallback-card {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
.affine-image-fallback-card {
display: flex;
flex: 1;
gap: 8px;
align-self: stretch;
flex-direction: column;
justify-content: space-between;
background-color: var(--affine-background-secondary-color, #f4f4f5);
border-radius: 8px;
border: 1px solid var(--affine-background-tertiary-color, #eee);
border: 1px solid ${unsafeCSSVarV2('layer/background/tertiary')};
background: ${unsafeCSSVarV2('layer/background/secondary')};
padding: 12px;
}
.affine-image-fallback-card-content {
.truncate {
align-self: stretch;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.affine-image-fallback-card-title {
display: flex;
align-items: center;
flex-direction: row;
gap: 8px;
align-items: center;
align-self: stretch;
}
.affine-image-fallback-card-title-icon {
display: flex;
width: 16px;
height: 16px;
align-items: center;
justify-content: center;
color: var(--affine-text-primary-color);
}
.affine-image-fallback-card-title-text {
color: var(--affine-placeholder-color);
text-align: justify;
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 600;
line-height: var(--affine-line-height);
user-select: none;
line-height: 22px;
}
.affine-image-card-size {
overflow: hidden;
padding-top: 12px;
.affine-image-fallback-card-description {
color: var(--affine-text-secondary-color);
text-overflow: ellipsis;
font-size: 10px;
font-family: var(--affine-font-family);
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 400;
line-height: 20px;
user-select: none;
}
.affine-image-fallback-card.loading {
.affine-image-fallback-card-title {
color: var(--affine-placeholder-color);
}
}
.affine-image-fallback-card.error {
.affine-image-fallback-card-title-icon {
color: ${unsafeCSSVarV2('status/error')};
}
}
`;
override render() {
const { theme, mode, loading, error, model } = this;
const { icon, title, description, loading, error } = this.state;
const isEdgeless = mode === 'edgeless';
const width = isEdgeless
? `${SURFACE_IMAGE_CARD_WIDTH}px`
: `${NOTE_IMAGE_CARD_WIDTH}px`;
const height = isEdgeless
? `${SURFACE_IMAGE_CARD_HEIGHT}px`
: `${NOTE_IMAGE_CARD_HEIGHT}px`;
const rotate = isEdgeless ? model.rotate : 0;
const cardStyleMap = styleMap({
transform: `rotate(${rotate}deg)`,
transformOrigin: 'center',
width,
height,
});
const titleIcon = loading
? getLoadingIconWith(theme)
: error
? BrokenImageIcon()
: ImageIcon();
const titleText = loading
? 'Loading image...'
: error
? 'Image loading failed.'
: 'Image';
const size =
!!model.props.size && model.props.size > 0
? humanFileSize(model.props.size, true, 0)
: null;
const classInfo = {
'affine-image-fallback-card': true,
'drag-target': true,
loading,
error,
};
return html`
<div class="affine-image-fallback-card-container">
<div
class="affine-image-fallback-card drag-target"
style=${cardStyleMap}
>
<div class="affine-image-fallback-card-content">
${titleIcon}
<span class="affine-image-fallback-card-title-text"
>${titleText}</span
>
<div class=${classMap(classInfo)}>
<div class="affine-image-fallback-card-title">
<div class="affine-image-fallback-card-title-icon">${icon}</div>
<div class="affine-image-fallback-card-title-text truncate">
${title}
</div>
<div class="affine-image-card-size">${size}</div>
</div>
<div class="affine-image-fallback-card-description truncate">
${description}
</div>
</div>
`;
}
@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 {
@@ -1,4 +1,5 @@
import { html } from 'lit';
import { when } from 'lit/directives/when.js';
const styles = html`<style>
.affine-page-selected-embed-rects-container {
@@ -57,27 +58,26 @@ const styles = html`<style>
</style>`;
export function ImageSelectedRect(readonly: boolean) {
if (readonly) {
return html`${styles}
<div
class="affine-page-selected-embed-rects-container resizable resizes"
></div> `;
}
return html`
${styles}
<div class="affine-page-selected-embed-rects-container resizable resizes">
<div class="resize top-left">
<div class="resize-inner"></div>
</div>
<div class="resize top-right">
<div class="resize-inner"></div>
</div>
<div class="resize bottom-left">
<div class="resize-inner"></div>
</div>
<div class="resize bottom-right">
<div class="resize-inner"></div>
</div>
${when(
!readonly,
() => html`
<div class="resize top-left">
<div class="resize-inner"></div>
</div>
<div class="resize top-right">
<div class="resize-inner"></div>
</div>
<div class="resize bottom-left">
<div class="resize-inner"></div>
</div>
<div class="resize bottom-right">
<div class="resize-inner"></div>
</div>
`
)}
</div>
`;
}
@@ -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`
<div class="resizable-img" style=${styleMap(imageSize)}>
<img
class="drag-target"
src=${this.block.blobUrl ?? ''}
draggable="false"
@error=${this._handleError}
loading="lazy"
src=${this.block.blobUrl}
alt=${this.block.model.props.caption$.value ?? 'Image'}
@error=${this._handleError}
/>
${imageSelectedRect}
</div>
${when(loading, () => html`<div class="loading">${icon}</div>`)}
`;
}
@@ -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;
}
@@ -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);
@@ -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<ImageBlockModel> {
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<ImageBlockModel
};
refreshData = () => {
this.retryCount = 0;
fetchImageBlob(this).catch(console.error);
refreshData(this).catch(console.error);
};
get resizableImg() {
@@ -85,22 +97,14 @@ export class ImageBlockComponent extends CaptionedBlockComponent<ImageBlockModel
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 firstUpdated() {
@@ -110,24 +114,38 @@ export class ImageBlockComponent extends CaptionedBlockComponent<ImageBlockModel
}
override renderBlock() {
const theme = this.std.get(ThemeProvider).theme$.value;
const loadingIcon = getLoadingIconWith(theme);
const blobUrl = this.blobUrl;
const { size = 0 } = this.model.props;
const containerStyleMap = styleMap({
position: 'relative',
width: '100%',
});
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`
<div class="affine-image-container" style=${containerStyleMap}>
${when(
this.loading || this.error,
blobUrl,
() =>
html`<affine-page-image
.block=${this}
.state=${resovledState}
></affine-page-image>`,
() =>
html`<affine-image-fallback-card
.error=${this.error}
.loading=${this.loading}
.mode="${'page'}"
.theme=${theme}
></affine-image-fallback-card>`,
() => html`<affine-page-image .block=${this}></affine-page-image>`
.state=${resovledState}
></affine-image-fallback-card>`
)}
</div>
@@ -135,42 +153,14 @@ export class ImageBlockComponent extends CaptionedBlockComponent<ImageBlockModel
`;
}
override updated() {
this.fallbackCard?.requestUpdate();
}
@property({ attribute: false })
accessor blob: Blob | undefined = undefined;
@property({ attribute: false })
accessor blobUrl: string | undefined = undefined;
override accessor blockContainerStyles = { margin: '18px 0' };
@property({ attribute: false })
accessor downloading = false;
@property({ attribute: false })
accessor error = false;
@query('affine-image-fallback-card')
accessor fallbackCard: ImageBlockFallbackCard | null = null;
@state()
accessor lastSourceId!: string;
@property({ attribute: false })
accessor loading = false;
@query('affine-page-image')
private accessor pageImage: ImageBlockPageComponent | null = null;
@query('.affine-image-container')
accessor hoverableContainer!: HTMLDivElement;
@property({ attribute: false })
accessor retryCount = 0;
override accessor useCaptionEditor = true;
override accessor useZeroWidth = true;
@@ -1,25 +1,47 @@
import type { BlockCaptionEditor } from '@blocksuite/affine-components/caption';
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 } from '@blocksuite/affine-shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { humanFileSize } from '@blocksuite/affine-shared/utils';
import { BrokenImageIcon, ImageIcon } from '@blocksuite/icons/lit';
import { GfxBlockComponent } from '@blocksuite/std';
import { computed } from '@preact/signals-core';
import { css, 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 {
copyImageBlob,
downloadImageBlob,
fetchImageBlob,
resetImageSize,
refreshData,
turnImageIntoCardView,
} from './utils.js';
} from './utils';
@Peekable()
export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockModel> {
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<ImageBlockMod
}
`;
resourceController = new ResourceController(
computed(() => 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<ImageBlockMod
};
refreshData = () => {
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`
<div class="affine-image-container" style=${containerStyleMap}>
${when(
this.loading || this.error || !this.blobUrl,
() =>
html`<affine-image-fallback-card
.error=${this.error}
.loading=${this.loading}
.mode="${'edgeless'}"
.theme=${theme}
></affine-image-fallback-card>`,
() =>
html`<div class="resizable-img">
blobUrl,
() => html`
<div class="resizable-img">
<img
class="drag-target"
src=${this.blobUrl ?? ''}
draggable="false"
@error=${this._handleError}
loading="lazy"
src=${blobUrl}
alt=${caption}
@error=${this._handleError}
/>
</div>`
</div>
${when(
resovledState.loading,
() => html`<div class="loading">${loadingIcon}</div>`
)}
`,
() =>
html`<affine-image-fallback-card
.state=${resovledState}
></affine-image-fallback-card>`
)}
<affine-block-selection .block=${this}></affine-block-selection>
</div>
@@ -118,39 +150,11 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
`;
}
override updated() {
this.fallbackCard?.requestUpdate();
}
@property({ attribute: false })
accessor blob: Blob | undefined = undefined;
@property({ attribute: false })
accessor blobUrl: string | undefined = undefined;
@query('block-caption-editor')
accessor captionEditor!: BlockCaptionEditor | null;
@property({ attribute: false })
accessor downloading = false;
@property({ attribute: false })
accessor error = false;
@query('affine-image-fallback-card')
accessor fallbackCard: ImageBlockFallbackCard | null = null;
@state()
accessor lastSourceId!: string;
@property({ attribute: false })
accessor loading = false;
@query('.resizable-img')
accessor resizableImg!: HTMLDivElement;
@property({ attribute: false })
accessor retryCount = 0;
}
declare global {
+1 -1
View File
@@ -7,5 +7,5 @@ export * from './image-service';
export * from './image-spec';
export * from './turbo/image-layout-handler';
export * from './turbo/image-painter.worker';
export { addImages, downloadImageBlob, uploadBlobForImage } from './utils';
export { addImages, addSiblingImageBlocks, downloadImageBlob } from './utils';
export { ImageSelection } from '@blocksuite/affine-shared/selection';
+55 -179
View File
@@ -11,7 +11,6 @@ import {
NativeClipboardProvider,
} from '@blocksuite/affine-shared/services';
import {
downloadBlob,
getBlockProps,
humanFileSize,
isInsidePageEditor,
@@ -20,219 +19,94 @@ import {
withTempBlobData,
} from '@blocksuite/affine-shared/utils';
import { Bound, type IVec, Vec } from '@blocksuite/global/gfx';
import {
BlockSelection,
type BlockStdScope,
type EditorHost,
} from '@blocksuite/std';
import { BlockSelection, type BlockStdScope } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import type { BlockModel } from '@blocksuite/store';
import {
SURFACE_IMAGE_CARD_HEIGHT,
SURFACE_IMAGE_CARD_WIDTH,
} from './components/image-block-fallback.js';
import type { ImageBlockComponent } from './image-block.js';
import type { ImageEdgelessBlockComponent } from './image-edgeless-block.js';
} from './components/image-block-fallback';
import type { ImageBlockComponent } from './image-block';
import type { ImageEdgelessBlockComponent } from './image-edgeless-block';
const MAX_RETRY_COUNT = 3;
const DEFAULT_ATTACHMENT_NAME = 'affine-attachment';
const imageUploads = new Set<string>();
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<void> {
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<ImageBlockProps> = {
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<ImageBlockProps> = {
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<ImageBlockProps> = { ...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,
@@ -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<string | null>(null);
readonly state$ = signal<Partial<BlobState>>({});
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);
}
}
@@ -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;
};
+1 -1
View File
@@ -14,6 +14,6 @@ export interface BlobSource {
set: (key: string, value: Blob) => Promise<string>;
delete: (key: string) => Promise<void>;
list: () => Promise<string[]>;
// 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<BlobState> | null;
}
@@ -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<string, Blob>();
private readonly _states = new Map<string, BehaviorSubject<BlobState>>();
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<BlobState>(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<BlobState>(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<BlobState>(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<BlobState>,
state?: Partial<BlobState>
) {
state$.next({ ...state$.value, ...state });
}
@@ -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<Partial<BlockProps>>,
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);
}
+22 -26
View File
@@ -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);