mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-24 22:09:08 +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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user