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

View File

@@ -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 {

View File

@@ -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>
`;
}

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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 {

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';

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,