mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-11 23:26:30 +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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user