feat(editor): adjust attachment block UI (#11763)
Closes: [BS-3143](https://linear.app/affine-design/issue/BS-3143/更新-attachment-错误样式) Closes: [BS-3341](https://linear.app/affine-design/issue/BS-3341/attachment-select状态ui调整) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Improved attachment block with unified reactive state handling for loading, errors, and downloads. - Enhanced card layouts with modular rendering and dynamic buttons based on state. - **Bug Fixes** - Fixed UI state for "Embed view" action, now correctly disabled when not embedded. - Resolved attachment card styling issues and text overflow with new utility classes. - **Refactor** - Streamlined attachment block rendering and state management for better performance and maintainability. - Updated toolbar configuration for consistent parameter naming. - Simplified embedded and open methods for improved clarity. - Made internal functions private to reduce exported API surface. - **Chores** - Removed unused icon export and legacy upload tracking functions. - Updated attachment utilities to use reactive state, added data refresh, and improved error handling. - Refined image dimension handling for consistent resizing behavior. - Improved test selectors to target focused attachment containers for better reliability. - Refactored CSS to separate container and card styling and adopt theme-based colors consistently. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
import { getEmbedCardIcons } from '@blocksuite/affine-block-embed';
|
import { getEmbedCardIcons } from '@blocksuite/affine-block-embed';
|
||||||
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
|
|
||||||
import {
|
import {
|
||||||
AttachmentIcon16,
|
CaptionedBlockComponent,
|
||||||
getAttachmentFileIcon,
|
SelectedStyle,
|
||||||
} from '@blocksuite/affine-components/icons';
|
} from '@blocksuite/affine-components/caption';
|
||||||
|
import { getAttachmentFileIcon } from '@blocksuite/affine-components/icons';
|
||||||
import { Peekable } from '@blocksuite/affine-components/peek';
|
import { Peekable } from '@blocksuite/affine-components/peek';
|
||||||
import { toast } from '@blocksuite/affine-components/toast';
|
import { toast } from '@blocksuite/affine-components/toast';
|
||||||
import {
|
import {
|
||||||
@@ -15,17 +15,22 @@ import {
|
|||||||
ThemeProvider,
|
ThemeProvider,
|
||||||
} from '@blocksuite/affine-shared/services';
|
} from '@blocksuite/affine-shared/services';
|
||||||
import { humanFileSize } from '@blocksuite/affine-shared/utils';
|
import { humanFileSize } from '@blocksuite/affine-shared/utils';
|
||||||
|
import { AttachmentIcon, ResetIcon, WarningIcon } from '@blocksuite/icons/lit';
|
||||||
import { BlockSelection } from '@blocksuite/std';
|
import { BlockSelection } from '@blocksuite/std';
|
||||||
import { Slice } from '@blocksuite/store';
|
import { Slice } from '@blocksuite/store';
|
||||||
import { html } from 'lit';
|
import { type BlobState } from '@blocksuite/sync';
|
||||||
|
import { effect, signal } from '@preact/signals-core';
|
||||||
|
import { html, type TemplateResult } from 'lit';
|
||||||
import { property } from 'lit/decorators.js';
|
import { property } from 'lit/decorators.js';
|
||||||
import { classMap } from 'lit/directives/class-map.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';
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
|
import { when } from 'lit/directives/when.js';
|
||||||
|
|
||||||
import { AttachmentEmbedProvider } from './embed';
|
import { AttachmentEmbedProvider } from './embed';
|
||||||
import { styles } from './styles';
|
import { styles } from './styles';
|
||||||
import { checkAttachmentBlob, downloadAttachmentBlob } from './utils';
|
import { downloadAttachmentBlob, refreshData } from './utils';
|
||||||
|
type State = 'loading' | 'uploading' | 'warning' | 'oversize' | 'none';
|
||||||
@Peekable({
|
@Peekable({
|
||||||
enableOn: ({ model }: AttachmentBlockComponent) => {
|
enableOn: ({ model }: AttachmentBlockComponent) => {
|
||||||
return !model.doc.readonly && model.props.type.endsWith('pdf');
|
return !model.doc.readonly && model.props.type.endsWith('pdf');
|
||||||
@@ -36,6 +41,8 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
|
|||||||
|
|
||||||
blockDraggable = true;
|
blockDraggable = true;
|
||||||
|
|
||||||
|
blobState$ = signal<Partial<BlobState>>({});
|
||||||
|
|
||||||
protected containerStyleMap = styleMap({
|
protected containerStyleMap = styleMap({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -63,26 +70,45 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
|
|||||||
};
|
};
|
||||||
|
|
||||||
embedded = () => {
|
embedded = () => {
|
||||||
return this.std
|
return (
|
||||||
.get(AttachmentEmbedProvider)
|
Boolean(this.blobUrl) &&
|
||||||
.embedded(this.model, this._maxFileSize);
|
this.std
|
||||||
|
.get(AttachmentEmbedProvider)
|
||||||
|
.embedded(this.model, this._maxFileSize)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
open = () => {
|
open = () => {
|
||||||
if (!this.blobUrl) {
|
const blobUrl = this.blobUrl;
|
||||||
return;
|
if (!blobUrl) return;
|
||||||
}
|
window.open(blobUrl, '_blank');
|
||||||
window.open(this.blobUrl, '_blank');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
refreshData = () => {
|
refreshData = () => {
|
||||||
checkAttachmentBlob(this).catch(console.error);
|
refreshData(this.std, this).catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateBlobState(state: Partial<BlobState>) {
|
||||||
|
this.blobState$.value = { ...this.blobState$.value, ...state };
|
||||||
|
}
|
||||||
|
|
||||||
|
determineState = (
|
||||||
|
loading: boolean,
|
||||||
|
uploading: boolean,
|
||||||
|
overSize: boolean,
|
||||||
|
error: boolean
|
||||||
|
): State => {
|
||||||
|
if (overSize) return 'oversize';
|
||||||
|
if (error) return 'warning';
|
||||||
|
if (uploading) return 'uploading';
|
||||||
|
if (loading) return 'loading';
|
||||||
|
return 'none';
|
||||||
};
|
};
|
||||||
|
|
||||||
protected get embedView() {
|
protected get embedView() {
|
||||||
return this.std
|
return this.std
|
||||||
.get(AttachmentEmbedProvider)
|
.get(AttachmentEmbedProvider)
|
||||||
.render(this.model, this.blobUrl, this._maxFileSize);
|
.render(this.model, this.blobUrl ?? undefined, this._maxFileSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _selectBlock() {
|
private _selectBlock() {
|
||||||
@@ -96,9 +122,30 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
|
|||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
|
||||||
|
this.contentEditable = 'false';
|
||||||
|
|
||||||
this.refreshData();
|
this.refreshData();
|
||||||
|
|
||||||
this.contentEditable = 'false';
|
this.disposables.add(
|
||||||
|
effect(() => {
|
||||||
|
const blobId = this.model.props.sourceId$.value;
|
||||||
|
if (!blobId) return;
|
||||||
|
|
||||||
|
const blobState$ = this.std.store.blobSync.blobState$(blobId);
|
||||||
|
if (!blobState$) return;
|
||||||
|
|
||||||
|
const subscription = blobState$.subscribe(state => {
|
||||||
|
if (state.overSize || state.errorMessage) {
|
||||||
|
state.uploading = false;
|
||||||
|
state.downloading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateBlobState(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
if (!this.model.props.style) {
|
if (!this.model.props.style) {
|
||||||
this.doc.withoutTransact(() => {
|
this.doc.withoutTransact(() => {
|
||||||
@@ -107,27 +154,12 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.model.propsUpdated.subscribe(({ key }) => {
|
|
||||||
if (key === 'sourceId') {
|
|
||||||
// Reset the blob url when the sourceId is changed
|
|
||||||
if (this.blobUrl) {
|
|
||||||
URL.revokeObjectURL(this.blobUrl);
|
|
||||||
this.blobUrl = undefined;
|
|
||||||
}
|
|
||||||
this.refreshData();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Workaround for https://github.com/toeverything/blocksuite/issues/4724
|
|
||||||
this.disposables.add(
|
|
||||||
this.std.get(ThemeProvider).theme$.subscribe(() => this.requestUpdate())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override disconnectedCallback() {
|
override disconnectedCallback() {
|
||||||
if (this.blobUrl) {
|
const blobUrl = this.blobUrl;
|
||||||
URL.revokeObjectURL(this.blobUrl);
|
if (blobUrl) {
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
}
|
}
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
@@ -148,71 +180,173 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override renderBlock() {
|
protected renderUpgradeButton = () => {
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
protected renderReloadButton = () => {
|
||||||
|
return html`
|
||||||
|
<button
|
||||||
|
class="affine-attachment-content-button"
|
||||||
|
@click=${(event: MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.refreshData();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${ResetIcon()} Reload
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
protected renderWithHorizontal(
|
||||||
|
classInfo: ClassInfo,
|
||||||
|
icon: TemplateResult,
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
kind: TemplateResult,
|
||||||
|
state: State
|
||||||
|
) {
|
||||||
|
return html`<div class=${classMap(classInfo)}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="affine-attachment-content-description">
|
||||||
|
<div class="affine-attachment-content-info truncate">
|
||||||
|
${description}
|
||||||
|
</div>
|
||||||
|
${choose(state, [
|
||||||
|
['oversize', this.renderUpgradeButton],
|
||||||
|
['warning', this.renderReloadButton],
|
||||||
|
])}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="affine-attachment-banner">${kind}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected renderWithVertical(
|
||||||
|
classInfo: ClassInfo,
|
||||||
|
icon: TemplateResult,
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
kind: TemplateResult,
|
||||||
|
state?: State
|
||||||
|
) {
|
||||||
|
return html`<div class=${classMap(classInfo)}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="affine-attachment-content-info truncate">
|
||||||
|
${description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="affine-attachment-banner">
|
||||||
|
${kind}
|
||||||
|
${choose(state, [
|
||||||
|
['oversize', this.renderUpgradeButton],
|
||||||
|
['warning', this.renderReloadButton],
|
||||||
|
])}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected renderCard = () => {
|
||||||
const { name, size, style } = this.model.props;
|
const { name, size, style } = this.model.props;
|
||||||
const cardStyle = style ?? AttachmentBlockStyles[1];
|
const cardStyle = style ?? AttachmentBlockStyles[1];
|
||||||
|
|
||||||
const theme = this.std.get(ThemeProvider).theme;
|
const theme = this.std.get(ThemeProvider).theme$.value;
|
||||||
const { LoadingIcon } = getEmbedCardIcons(theme);
|
const { LoadingIcon } = getEmbedCardIcons(theme);
|
||||||
|
|
||||||
const titleIcon = this.loading ? LoadingIcon : AttachmentIcon16;
|
const blobState = this.blobState$.value;
|
||||||
const titleText = this.loading ? 'Loading...' : name;
|
const {
|
||||||
const infoText = this.error ? 'File loading failed.' : humanFileSize(size);
|
uploading = false,
|
||||||
|
downloading = false,
|
||||||
|
overSize = false,
|
||||||
|
errorMessage,
|
||||||
|
} = blobState;
|
||||||
|
const warning = !overSize && Boolean(errorMessage);
|
||||||
|
const error = overSize || warning;
|
||||||
|
const loading = !error && downloading;
|
||||||
|
const state = this.determineState(loading, uploading, overSize, error);
|
||||||
|
|
||||||
const fileType = name.split('.').pop() ?? '';
|
const classInfo = {
|
||||||
const FileTypeIcon = getAttachmentFileIcon(fileType);
|
'affine-attachment-card': true,
|
||||||
|
[cardStyle]: true,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
};
|
||||||
|
|
||||||
const embedView = this.embedView;
|
const icon = loading
|
||||||
|
? LoadingIcon
|
||||||
|
: error
|
||||||
|
? WarningIcon()
|
||||||
|
: AttachmentIcon();
|
||||||
|
const title = uploading ? 'Uploading...' : loading ? 'Loading...' : name;
|
||||||
|
const description = errorMessage || humanFileSize(size);
|
||||||
|
const kind = getAttachmentFileIcon(name.split('.').pop() ?? '');
|
||||||
|
|
||||||
|
return when(
|
||||||
|
cardStyle === 'cubeThick',
|
||||||
|
() =>
|
||||||
|
this.renderWithVertical(
|
||||||
|
classInfo,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
kind,
|
||||||
|
state
|
||||||
|
),
|
||||||
|
() =>
|
||||||
|
this.renderWithHorizontal(
|
||||||
|
classInfo,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
kind,
|
||||||
|
state
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
override renderBlock() {
|
||||||
return html`
|
return html`
|
||||||
<div class="affine-attachment-container" style=${this.containerStyleMap}>
|
<div
|
||||||
${embedView
|
class=${classMap({
|
||||||
? html`<div class="affine-attachment-embed-container">
|
'affine-attachment-container': true,
|
||||||
${embedView}
|
focused: this.selected$.value,
|
||||||
</div>`
|
})}
|
||||||
: html`<div
|
style=${this.containerStyleMap}
|
||||||
class=${classMap({
|
>
|
||||||
'affine-attachment-card': true,
|
${when(
|
||||||
[cardStyle]: true,
|
this.embedView,
|
||||||
loading: this.loading,
|
() =>
|
||||||
error: this.error,
|
html`<div class="affine-attachment-embed-container">
|
||||||
unsynced: false,
|
${this.embedView}
|
||||||
})}
|
</div>`,
|
||||||
>
|
this.renderCard
|
||||||
<div class="affine-attachment-content">
|
)}
|
||||||
<div class="affine-attachment-content-title">
|
|
||||||
<div class="affine-attachment-content-title-icon">
|
|
||||||
${titleIcon}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="affine-attachment-content-title-text">
|
|
||||||
${titleText}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="affine-attachment-content-info">${infoText}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="affine-attachment-banner">${FileTypeIcon}</div>
|
|
||||||
</div>`}
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor allowEmbed = false;
|
accessor blobUrl: string | null = null;
|
||||||
|
|
||||||
@property({ attribute: false })
|
override accessor selectedStyle = SelectedStyle.Border;
|
||||||
accessor blobUrl: string | undefined = undefined;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
accessor downloading = false;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
accessor error = false;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
accessor loading = false;
|
|
||||||
|
|
||||||
override accessor useCaptionEditor = true;
|
override accessor useCaptionEditor = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ export const attachmentViewDropdownMenu = {
|
|||||||
{
|
{
|
||||||
id: 'embed',
|
id: 'embed',
|
||||||
label: 'Embed view',
|
label: 'Embed view',
|
||||||
|
disabled: ctx => {
|
||||||
|
const block = ctx.getCurrentBlockByType(AttachmentBlockComponent);
|
||||||
|
return block ? !block.embedded() : true;
|
||||||
|
},
|
||||||
run(ctx) {
|
run(ctx) {
|
||||||
const model = ctx.getCurrentModelByType(AttachmentBlockModel);
|
const model = ctx.getCurrentModelByType(AttachmentBlockModel);
|
||||||
if (!model) return;
|
if (!model) return;
|
||||||
@@ -156,24 +160,24 @@ const builtinToolbarConfig = {
|
|||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
id: 'a.rename',
|
id: 'a.rename',
|
||||||
content(cx) {
|
content(ctx) {
|
||||||
const block = cx.getCurrentBlockByType(AttachmentBlockComponent);
|
const block = ctx.getCurrentBlockByType(AttachmentBlockComponent);
|
||||||
if (!block) return null;
|
if (!block) return null;
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
abortController.signal.onabort = () => cx.show();
|
abortController.signal.onabort = () => ctx.show();
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<editor-icon-button
|
<editor-icon-button
|
||||||
aria-label="Rename"
|
aria-label="Rename"
|
||||||
.tooltip="${'Rename'}"
|
.tooltip="${'Rename'}"
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
cx.hide();
|
ctx.hide();
|
||||||
|
|
||||||
createLitPortal({
|
createLitPortal({
|
||||||
template: RenameModal({
|
template: RenameModal({
|
||||||
model: block.model,
|
model: block.model,
|
||||||
editorHost: cx.host,
|
editorHost: ctx.host,
|
||||||
abortController,
|
abortController,
|
||||||
}),
|
}),
|
||||||
computePosition: {
|
computePosition: {
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ const embedConfig: AttachmentEmbedConfig[] = [
|
|||||||
/**
|
/**
|
||||||
* Turn the attachment block into an image block.
|
* Turn the attachment block into an image block.
|
||||||
*/
|
*/
|
||||||
export async function turnIntoImageBlock(model: AttachmentBlockModel) {
|
async function turnIntoImageBlock(model: AttachmentBlockModel) {
|
||||||
if (!model.doc.schema.flavourSchemaMap.has('affine:image')) {
|
if (!model.doc.schema.flavourSchemaMap.has('affine:image')) {
|
||||||
console.error('The image flavour is not supported!');
|
console.error('The image flavour is not supported!');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,31 +1,33 @@
|
|||||||
|
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||||
import { css } from 'lit';
|
import { css } from 'lit';
|
||||||
|
|
||||||
export const styles = css`
|
export const styles = css`
|
||||||
.affine-attachment-card {
|
.affine-attachment-container {
|
||||||
margin: 0 auto;
|
border-radius: 8px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
user-select: none;
|
||||||
|
border: 1px solid ${unsafeCSSVarV2('layer/background/tertiary')};
|
||||||
|
background: ${unsafeCSSVarV2('layer/background/primary')};
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.focused {
|
||||||
|
border-color: ${unsafeCSSVarV2('layer/insideBorder/primaryBorder')};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.affine-attachment-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid var(--affine-background-tertiary-color);
|
|
||||||
|
|
||||||
opacity: var(--add, 1);
|
|
||||||
background: var(--affine-background-primary-color);
|
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.affine-attachment-content {
|
.affine-attachment-content {
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex: 1 0 0;
|
flex: 1 0 0;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.affine-attachment-content-title {
|
.affine-attachment-content-title {
|
||||||
@@ -33,7 +35,6 @@ export const styles = css`
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,24 +44,18 @@ export const styles = css`
|
|||||||
height: 16px;
|
height: 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
color: var(--affine-text-primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.affine-attachment-content-title-icon svg {
|
.truncate {
|
||||||
width: 16px;
|
align-self: stretch;
|
||||||
height: 16px;
|
text-overflow: ellipsis;
|
||||||
fill: var(--affine-background-primary-color);
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.affine-attachment-content-title-text {
|
.affine-attachment-content-title-text {
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 1;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
|
|
||||||
word-break: break-all;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
color: var(--affine-text-primary-color);
|
color: var(--affine-text-primary-color);
|
||||||
|
|
||||||
font-family: var(--affine-font-family);
|
font-family: var(--affine-font-family);
|
||||||
font-size: var(--affine-font-sm);
|
font-size: var(--affine-font-sm);
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@@ -68,17 +63,15 @@ export const styles = css`
|
|||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.affine-attachment-content-description {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
align-self: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.affine-attachment-content-info {
|
.affine-attachment-content-info {
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
-webkit-line-clamp: 1;
|
|
||||||
flex: 1 0 0;
|
|
||||||
|
|
||||||
word-break: break-all;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--affine-text-secondary-color);
|
color: var(--affine-text-secondary-color);
|
||||||
text-overflow: ellipsis;
|
|
||||||
|
|
||||||
font-family: var(--affine-font-family);
|
font-family: var(--affine-font-family);
|
||||||
font-size: var(--affine-font-xs);
|
font-size: var(--affine-font-xs);
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@@ -86,6 +79,26 @@ export const styles = css`
|
|||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.affine-attachment-content-button {
|
||||||
|
display: flex;
|
||||||
|
height: 20px;
|
||||||
|
align-items: center;
|
||||||
|
align-self: stretch;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0 4px;
|
||||||
|
color: ${unsafeCSSVarV2('button/primary')};
|
||||||
|
font-family: var(--affine-font-family);
|
||||||
|
font-size: var(--affine-font-xs);
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 20px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.affine-attachment-banner {
|
.affine-attachment-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -93,16 +106,15 @@ export const styles = css`
|
|||||||
}
|
}
|
||||||
|
|
||||||
.affine-attachment-card.loading {
|
.affine-attachment-card.loading {
|
||||||
background: var(--affine-background-secondary-color);
|
|
||||||
|
|
||||||
.affine-attachment-content-title-text {
|
.affine-attachment-content-title-text {
|
||||||
color: var(--affine-placeholder-color);
|
color: var(--affine-placeholder-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.affine-attachment-card.error,
|
.affine-attachment-card.error {
|
||||||
.affine-attachment-card.unsynced {
|
.affine-attachment-content-title-icon {
|
||||||
background: var(--affine-background-secondary-color);
|
color: ${unsafeCSSVarV2('status/error')};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.affine-attachment-card.cubeThick {
|
.affine-attachment-card.cubeThick {
|
||||||
@@ -116,7 +128,7 @@ export const styles = css`
|
|||||||
}
|
}
|
||||||
|
|
||||||
.affine-attachment-banner {
|
.affine-attachment-banner {
|
||||||
justify-content: flex-start;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,128 +21,19 @@ import type { BlockModel } from '@blocksuite/store';
|
|||||||
|
|
||||||
import type { AttachmentBlockComponent } from './attachment-block';
|
import type { AttachmentBlockComponent } from './attachment-block';
|
||||||
|
|
||||||
const attachmentUploads = new Set<string>();
|
|
||||||
export function setAttachmentUploading(blockId: string) {
|
|
||||||
attachmentUploads.add(blockId);
|
|
||||||
}
|
|
||||||
export function setAttachmentUploaded(blockId: string) {
|
|
||||||
attachmentUploads.delete(blockId);
|
|
||||||
}
|
|
||||||
function isAttachmentUploading(blockId: string) {
|
|
||||||
return attachmentUploads.has(blockId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function will not verify the size of the file.
|
|
||||||
*/
|
|
||||||
// TODO(@fundon): should remove
|
|
||||||
export async function uploadAttachmentBlob(
|
|
||||||
std: BlockStdScope,
|
|
||||||
blockId: string,
|
|
||||||
blob: Blob,
|
|
||||||
filetype: string,
|
|
||||||
isEdgeless?: boolean
|
|
||||||
): Promise<void> {
|
|
||||||
if (isAttachmentUploading(blockId)) return;
|
|
||||||
|
|
||||||
let sourceId: string | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setAttachmentUploading(blockId);
|
|
||||||
sourceId = await std.store.blobSync.set(blob);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
if (error instanceof Error) {
|
|
||||||
toast(
|
|
||||||
std.host,
|
|
||||||
`Failed to upload attachment! ${error.message || error.toString()}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setAttachmentUploaded(blockId);
|
|
||||||
|
|
||||||
const block = std.store.getBlock(blockId);
|
|
||||||
|
|
||||||
std.store.withoutTransact(() => {
|
|
||||||
if (!block) return;
|
|
||||||
|
|
||||||
std.store.updateBlock(block.model, {
|
|
||||||
sourceId,
|
|
||||||
} satisfies Partial<AttachmentBlockProps>);
|
|
||||||
});
|
|
||||||
|
|
||||||
std.getOptional(TelemetryProvider)?.track('AttachmentUploadedEvent', {
|
|
||||||
page: `${isEdgeless ? 'whiteboard' : 'doc'} editor`,
|
|
||||||
module: 'attachment',
|
|
||||||
segment: 'attachment',
|
|
||||||
control: 'uploader',
|
|
||||||
type: filetype,
|
|
||||||
category: block && sourceId ? 'success' : 'failure',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAttachmentBlob(model: AttachmentBlockModel) {
|
export async function getAttachmentBlob(model: AttachmentBlockModel) {
|
||||||
const sourceId = model.props.sourceId;
|
const {
|
||||||
if (!sourceId) {
|
sourceId$: { value: sourceId },
|
||||||
return null;
|
type$: { value: type },
|
||||||
}
|
} = model.props;
|
||||||
|
if (!sourceId) return null;
|
||||||
|
|
||||||
const doc = model.doc;
|
const doc = model.doc;
|
||||||
let blob = await doc.blobSync.get(sourceId);
|
let blob = await doc.blobSync.get(sourceId);
|
||||||
|
|
||||||
if (blob) {
|
if (!blob) return null;
|
||||||
blob = new Blob([blob], { type: model.props.type });
|
|
||||||
}
|
|
||||||
|
|
||||||
return blob;
|
return new Blob([blob], { type });
|
||||||
}
|
|
||||||
|
|
||||||
// TODO(@fundon): should remove
|
|
||||||
export async function checkAttachmentBlob(block: AttachmentBlockComponent) {
|
|
||||||
const model = block.model;
|
|
||||||
const { id } = model;
|
|
||||||
const { sourceId } = model.props;
|
|
||||||
|
|
||||||
if (isAttachmentUploading(id)) {
|
|
||||||
block.loading = true;
|
|
||||||
block.error = false;
|
|
||||||
block.allowEmbed = false;
|
|
||||||
if (block.blobUrl) {
|
|
||||||
URL.revokeObjectURL(block.blobUrl);
|
|
||||||
block.blobUrl = undefined;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!sourceId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await getAttachmentBlob(model);
|
|
||||||
if (!blob) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
block.loading = false;
|
|
||||||
block.error = false;
|
|
||||||
block.allowEmbed = block.embedded();
|
|
||||||
if (block.blobUrl) {
|
|
||||||
URL.revokeObjectURL(block.blobUrl);
|
|
||||||
}
|
|
||||||
block.blobUrl = URL.createObjectURL(blob);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(error, model, sourceId);
|
|
||||||
|
|
||||||
block.loading = false;
|
|
||||||
block.error = true;
|
|
||||||
block.allowEmbed = false;
|
|
||||||
if (block.blobUrl) {
|
|
||||||
URL.revokeObjectURL(block.blobUrl);
|
|
||||||
block.blobUrl = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,26 +41,22 @@ export async function checkAttachmentBlob(block: AttachmentBlockComponent) {
|
|||||||
* the download process may take a long time!
|
* the download process may take a long time!
|
||||||
*/
|
*/
|
||||||
export function downloadAttachmentBlob(block: AttachmentBlockComponent) {
|
export function downloadAttachmentBlob(block: AttachmentBlockComponent) {
|
||||||
const { host, model, loading, error, downloading, blobUrl } = block;
|
const { host, model, blobUrl, blobState$ } = block;
|
||||||
if (downloading) {
|
|
||||||
toast(host, 'Download in progress...');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (blobState$.peek().downloading) {
|
||||||
toast(host, 'Please wait, file is loading...');
|
toast(host, 'Download in progress...');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = model.props.name;
|
const name = model.props.name;
|
||||||
const shortName = name.length < 20 ? name : name.slice(0, 20) + '...';
|
const shortName = name.length < 20 ? name : name.slice(0, 20) + '...';
|
||||||
|
|
||||||
if (error || !blobUrl) {
|
if (!blobUrl) {
|
||||||
toast(host, `Failed to download ${shortName}!`);
|
toast(host, `Failed to download ${shortName}!`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
block.downloading = true;
|
block.updateBlobState({ downloading: true });
|
||||||
|
|
||||||
toast(host, `Downloading ${shortName}`);
|
toast(host, `Downloading ${shortName}`);
|
||||||
|
|
||||||
@@ -180,7 +67,34 @@ export function downloadAttachmentBlob(block: AttachmentBlockComponent) {
|
|||||||
tmpLink.dispatchEvent(event);
|
tmpLink.dispatchEvent(event);
|
||||||
tmpLink.remove();
|
tmpLink.remove();
|
||||||
|
|
||||||
block.downloading = false;
|
block.updateBlobState({ downloading: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshData(
|
||||||
|
std: BlockStdScope,
|
||||||
|
block: AttachmentBlockComponent
|
||||||
|
) {
|
||||||
|
const model = block.model;
|
||||||
|
const sourceId = model.props.sourceId$.peek();
|
||||||
|
if (!sourceId) return;
|
||||||
|
|
||||||
|
const blobUrl = block.blobUrl;
|
||||||
|
if (blobUrl) {
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
block.blobUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let blob = await std.store.blobSync.get(sourceId);
|
||||||
|
if (!blob) {
|
||||||
|
block.updateBlobState({ errorMessage: 'File not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = model.props.type$.peek();
|
||||||
|
|
||||||
|
blob = new Blob([blob], { type });
|
||||||
|
|
||||||
|
block.blobUrl = URL.createObjectURL(blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFileType(file: File) {
|
export async function getFileType(file: File) {
|
||||||
@@ -219,6 +133,7 @@ async function buildPropsWith(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { name, size } = file;
|
const { name, size } = file;
|
||||||
|
// TODO(@fundon): should re-upload when upload timeout
|
||||||
const sourceId = await std.store.blobSync.set(file);
|
const sourceId = await std.store.blobSync.set(file);
|
||||||
type = await getFileType(file);
|
type = await getFileType(file);
|
||||||
|
|
||||||
@@ -233,6 +148,7 @@ async function buildPropsWith(
|
|||||||
category = 'failure';
|
category = 'failure';
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
|
// TODO(@fundon): should change event name because this is just a local operation.
|
||||||
std.getOptional(TelemetryProvider)?.track('AttachmentUploadedEvent', {
|
std.getOptional(TelemetryProvider)?.track('AttachmentUploadedEvent', {
|
||||||
page: `${mode} editor`,
|
page: `${mode} editor`,
|
||||||
module: 'attachment',
|
module: 'attachment',
|
||||||
@@ -303,7 +219,6 @@ export async function addAttachments(
|
|||||||
const gap = 32;
|
const gap = 32;
|
||||||
const width = EMBED_CARD_WIDTH.cubeThick;
|
const width = EMBED_CARD_WIDTH.cubeThick;
|
||||||
const height = EMBED_CARD_HEIGHT.cubeThick;
|
const height = EMBED_CARD_HEIGHT.cubeThick;
|
||||||
|
|
||||||
const flavour = AttachmentBlockSchema.model.flavour;
|
const flavour = AttachmentBlockSchema.model.flavour;
|
||||||
|
|
||||||
const blocks = propsArray.map((props, index) => {
|
const blocks = propsArray.map((props, index) => {
|
||||||
@@ -312,7 +227,7 @@ export async function addAttachments(
|
|||||||
return { flavour, blockProps: { ...props, style, xywh } };
|
return { flavour, blockProps: { ...props, style, xywh } };
|
||||||
});
|
});
|
||||||
|
|
||||||
const blockIds = std.store.addBlocks(blocks);
|
const blockIds = std.store.addBlocks(blocks, gfx.surface);
|
||||||
|
|
||||||
gfx.selection.set({
|
gfx.selection.set({
|
||||||
elements: blockIds,
|
elements: blockIds,
|
||||||
|
|||||||
@@ -481,10 +481,12 @@ export async function addImages(
|
|||||||
|
|
||||||
// If maxWidth is provided, limit the width of the image to maxWidth
|
// If maxWidth is provided, limit the width of the image to maxWidth
|
||||||
// Otherwise, use the original width
|
// Otherwise, use the original width
|
||||||
const width = maxWidth ? Math.min(props.width, maxWidth) : props.width;
|
if (maxWidth) {
|
||||||
const height = maxWidth
|
const p = props.height / props.width;
|
||||||
? (props.height / props.width) * width
|
props.width = Math.min(props.width, maxWidth);
|
||||||
: props.height;
|
props.height = props.width * p;
|
||||||
|
}
|
||||||
|
const { width, height } = props;
|
||||||
|
|
||||||
const xywh = calcBoundByOrigin(
|
const xywh = calcBoundByOrigin(
|
||||||
center,
|
center,
|
||||||
|
|||||||
@@ -340,11 +340,6 @@ export const FontFamilyIcon = icons.FontIcon({
|
|||||||
height: '20',
|
height: '20',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AttachmentIcon16 = icons.AttachmentIcon({
|
|
||||||
width: '16',
|
|
||||||
height: '16',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const TextBackgroundDuotoneIcon = html` <svg
|
export const TextBackgroundDuotoneIcon = html` <svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="20"
|
width="20"
|
||||||
|
|||||||
@@ -306,7 +306,9 @@ test('should enable pointer event in pdf viewer', async ({ page }) => {
|
|||||||
const attachment = page.locator('affine-attachment');
|
const attachment = page.locator('affine-attachment');
|
||||||
await attachment.click();
|
await attachment.click();
|
||||||
|
|
||||||
const attachmentSelection = attachment.locator('affine-block-selection');
|
const attachmentSelection = attachment.locator(
|
||||||
|
'.affine-attachment-container.focused'
|
||||||
|
);
|
||||||
|
|
||||||
const toolbar = locateToolbar(page);
|
const toolbar = locateToolbar(page);
|
||||||
|
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ test(`support dragging attachment block directly`, async ({
|
|||||||
await page.mouse.move(rect.x + 40, rect.y + rect.height + 80, { steps: 20 });
|
await page.mouse.move(rect.x + 40, rect.y + rect.height + 80, { steps: 20 });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
|
|
||||||
const rects = page.locator('affine-block-selection').locator('visible=true');
|
const rects = page.locator('.affine-attachment-container.focused');
|
||||||
await expect(rects).toHaveCount(1);
|
await expect(rects).toHaveCount(1);
|
||||||
expect(await getPageSnapshot(page, true)).toMatchSnapshot(
|
expect(await getPageSnapshot(page, true)).toMatchSnapshot(
|
||||||
`${testInfo.title}_3.json`
|
`${testInfo.title}_3.json`
|
||||||
|
|||||||
Reference in New Issue
Block a user