mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-16 22:07:09 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user