feat(editor): add replace action in attachment toolbar (#12512)
Closes: [BS-3549](https://linear.app/affine-design/issue/BS-3549/附件-toolbar-上添加-replace-action) [Screen Recording 2025-06-04 at 15.37.40.mov <span class="graphite__hidden">(uploaded via Graphite)</span> <img class="graphite__hidden" src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/8ypiIKZXudF5a0tIgIzf/480c8690-7ec9-4188-92fd-ee3339afb558.mov" />](https://app.graphite.dev/media/video/8ypiIKZXudF5a0tIgIzf/480c8690-7ec9-4188-92fd-ee3339afb558.mov) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added the ability to replace attachments directly from the toolbar, allowing users to select and update files seamlessly. - **Bug Fixes** - Improved handling when replacing embedded attachments with unsupported file types, ensuring the view falls back to a card view as needed. - **Tests** - Introduced end-to-end tests to verify attachment replacement and correct UI behavior in both standard and edgeless editing modes. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -22,7 +22,10 @@ import {
|
|||||||
FileSizeLimitProvider,
|
FileSizeLimitProvider,
|
||||||
TelemetryProvider,
|
TelemetryProvider,
|
||||||
} from '@blocksuite/affine-shared/services';
|
} from '@blocksuite/affine-shared/services';
|
||||||
import { formatSize } from '@blocksuite/affine-shared/utils';
|
import {
|
||||||
|
formatSize,
|
||||||
|
openSingleFileWith,
|
||||||
|
} from '@blocksuite/affine-shared/utils';
|
||||||
import {
|
import {
|
||||||
AttachmentIcon,
|
AttachmentIcon,
|
||||||
ResetIcon,
|
ResetIcon,
|
||||||
@@ -31,7 +34,7 @@ import {
|
|||||||
} from '@blocksuite/icons/lit';
|
} from '@blocksuite/icons/lit';
|
||||||
import { BlockSelection } from '@blocksuite/std';
|
import { BlockSelection } from '@blocksuite/std';
|
||||||
import { nanoid, Slice } from '@blocksuite/store';
|
import { nanoid, Slice } from '@blocksuite/store';
|
||||||
import { computed, signal } from '@preact/signals-core';
|
import { batch, computed, signal } from '@preact/signals-core';
|
||||||
import { html, type TemplateResult } from 'lit';
|
import { html, type TemplateResult } from 'lit';
|
||||||
import { choose } from 'lit/directives/choose.js';
|
import { choose } from 'lit/directives/choose.js';
|
||||||
import { type ClassInfo, classMap } from 'lit/directives/class-map.js';
|
import { type ClassInfo, classMap } from 'lit/directives/class-map.js';
|
||||||
@@ -42,7 +45,7 @@ import { filter } from 'rxjs/operators';
|
|||||||
|
|
||||||
import { AttachmentEmbedProvider } from './embed';
|
import { AttachmentEmbedProvider } from './embed';
|
||||||
import { styles } from './styles';
|
import { styles } from './styles';
|
||||||
import { downloadAttachmentBlob, refreshData } from './utils';
|
import { downloadAttachmentBlob, getFileType, refreshData } from './utils';
|
||||||
|
|
||||||
type AttachmentResolvedStateInfo = ResolvedStateInfo & {
|
type AttachmentResolvedStateInfo = ResolvedStateInfo & {
|
||||||
kind?: TemplateResult;
|
kind?: TemplateResult;
|
||||||
@@ -129,12 +132,50 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
|
|||||||
|
|
||||||
// Refreshes the embed component.
|
// Refreshes the embed component.
|
||||||
reload = () => {
|
reload = () => {
|
||||||
if (this.model.props.embed) {
|
batch(() => {
|
||||||
|
if (this.model.props.embed$.value) {
|
||||||
this._refreshKey$.value = nanoid();
|
this._refreshKey$.value = nanoid();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.refreshData();
|
this.refreshData();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Replaces the current attachment.
|
||||||
|
replace = async () => {
|
||||||
|
const state = this.resourceController.state$.peek();
|
||||||
|
if (state.uploading) return;
|
||||||
|
|
||||||
|
const file = await openSingleFileWith();
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const sourceId = await this.std.store.blobSync.set(file);
|
||||||
|
const type = await getFileType(file);
|
||||||
|
const { name, size } = file;
|
||||||
|
|
||||||
|
let embed = this.model.props.embed$.value ?? false;
|
||||||
|
|
||||||
|
this.std.store.captureSync();
|
||||||
|
this.std.store.transact(() => {
|
||||||
|
this.std.store.updateBlock(this.blockId, {
|
||||||
|
name,
|
||||||
|
size,
|
||||||
|
type,
|
||||||
|
sourceId,
|
||||||
|
embed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const provider = this.std.get(AttachmentEmbedProvider);
|
||||||
|
embed &&= provider.embedded(this.model);
|
||||||
|
|
||||||
|
if (embed) {
|
||||||
|
provider.convertTo(this.model);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reloads
|
||||||
|
this.reload();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
private _selectBlock() {
|
private _selectBlock() {
|
||||||
@@ -403,7 +444,7 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
|
|||||||
|
|
||||||
protected renderEmbedView = () => {
|
protected renderEmbedView = () => {
|
||||||
const { model, blobUrl } = this;
|
const { model, blobUrl } = this;
|
||||||
if (!model.props.embed || !blobUrl) return null;
|
if (!model.props.embed$.value || !blobUrl) return null;
|
||||||
|
|
||||||
const { std, _maxFileSize } = this;
|
const { std, _maxFileSize } = this;
|
||||||
const provider = std.get(AttachmentEmbedProvider);
|
const provider = std.get(AttachmentEmbedProvider);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
DuplicateIcon,
|
DuplicateIcon,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
|
ReplaceIcon,
|
||||||
ResetIcon,
|
ResetIcon,
|
||||||
} from '@blocksuite/icons/lit';
|
} from '@blocksuite/icons/lit';
|
||||||
import { BlockFlavourIdentifier } from '@blocksuite/std';
|
import { BlockFlavourIdentifier } from '@blocksuite/std';
|
||||||
@@ -139,27 +140,42 @@ export const attachmentViewDropdownMenu = {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return html`${keyed(
|
return html`<affine-view-dropdown-menu
|
||||||
model,
|
|
||||||
html`<affine-view-dropdown-menu
|
|
||||||
@toggle=${onToggle}
|
@toggle=${onToggle}
|
||||||
.actions=${actions.value}
|
.actions=${actions.value}
|
||||||
.context=${ctx}
|
.context=${ctx}
|
||||||
.viewType$=${viewType$}
|
.viewType$=${viewType$}
|
||||||
></affine-view-dropdown-menu>`
|
></affine-view-dropdown-menu>`;
|
||||||
)}`;
|
|
||||||
},
|
},
|
||||||
} as const satisfies ToolbarActionGroup<ToolbarAction>;
|
} as const satisfies ToolbarActionGroup<ToolbarAction>;
|
||||||
|
|
||||||
|
const replaceAction = {
|
||||||
|
id: 'c.replace',
|
||||||
|
tooltip: 'Replace attachment',
|
||||||
|
icon: ReplaceIcon(),
|
||||||
|
disabled(ctx) {
|
||||||
|
const block = ctx.getCurrentBlockByType(AttachmentBlockComponent);
|
||||||
|
if (!block) return true;
|
||||||
|
|
||||||
|
const { downloading = false, uploading = false } =
|
||||||
|
block.resourceController.state$.value;
|
||||||
|
return downloading || uploading;
|
||||||
|
},
|
||||||
|
run(ctx) {
|
||||||
|
const block = ctx.getCurrentBlockByType(AttachmentBlockComponent);
|
||||||
|
block?.replace().catch(console.error);
|
||||||
|
},
|
||||||
|
} as const satisfies ToolbarAction;
|
||||||
|
|
||||||
const downloadAction = {
|
const downloadAction = {
|
||||||
id: 'c.download',
|
id: 'd.download',
|
||||||
tooltip: 'Download',
|
tooltip: 'Download',
|
||||||
icon: DownloadIcon(),
|
icon: DownloadIcon(),
|
||||||
run(ctx) {
|
run(ctx) {
|
||||||
const block = ctx.getCurrentBlockByType(AttachmentBlockComponent);
|
const block = ctx.getCurrentBlockByType(AttachmentBlockComponent);
|
||||||
block?.download();
|
block?.download();
|
||||||
},
|
},
|
||||||
when: ctx => {
|
when(ctx) {
|
||||||
const model = ctx.getCurrentModelByType(AttachmentBlockModel);
|
const model = ctx.getCurrentModelByType(AttachmentBlockModel);
|
||||||
if (!model) return false;
|
if (!model) return false;
|
||||||
// Current citation attachment block does not support download
|
// Current citation attachment block does not support download
|
||||||
@@ -168,7 +184,7 @@ const downloadAction = {
|
|||||||
} as const satisfies ToolbarAction;
|
} as const satisfies ToolbarAction;
|
||||||
|
|
||||||
const captionAction = {
|
const captionAction = {
|
||||||
id: 'd.caption',
|
id: 'e.caption',
|
||||||
tooltip: 'Caption',
|
tooltip: 'Caption',
|
||||||
icon: CaptionIcon(),
|
icon: CaptionIcon(),
|
||||||
run(ctx) {
|
run(ctx) {
|
||||||
@@ -221,6 +237,7 @@ const builtinToolbarConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
attachmentViewDropdownMenu,
|
attachmentViewDropdownMenu,
|
||||||
|
replaceAction,
|
||||||
downloadAction,
|
downloadAction,
|
||||||
captionAction,
|
captionAction,
|
||||||
{
|
{
|
||||||
@@ -354,13 +371,17 @@ const builtinSurfaceToolbarConfig = {
|
|||||||
)}`;
|
)}`;
|
||||||
},
|
},
|
||||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||||
|
{
|
||||||
|
...replaceAction,
|
||||||
|
id: 'd.replace',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
...downloadAction,
|
...downloadAction,
|
||||||
id: 'd.download',
|
id: 'e.download',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...captionAction,
|
...captionAction,
|
||||||
id: 'e.caption',
|
id: 'f.caption',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
when: ctx => ctx.getSurfaceModelsByType(AttachmentBlockModel).length === 1,
|
when: ctx => ctx.getSurfaceModelsByType(AttachmentBlockModel).length === 1,
|
||||||
|
|||||||
@@ -176,9 +176,7 @@ export async function openFilesWith(
|
|||||||
resolve(input.files ? Array.from(input.files) : null);
|
resolve(input.files ? Array.from(input.files) : null);
|
||||||
});
|
});
|
||||||
// The `cancel` event fires when the user cancels the dialog.
|
// The `cancel` event fires when the user cancels the dialog.
|
||||||
input.addEventListener('cancel', () => {
|
input.addEventListener('cancel', () => resolve(null));
|
||||||
resolve(null);
|
|
||||||
});
|
|
||||||
// Show the picker.
|
// Show the picker.
|
||||||
if ('showPicker' in HTMLInputElement.prototype) {
|
if ('showPicker' in HTMLInputElement.prototype) {
|
||||||
input.showPicker();
|
input.showPicker();
|
||||||
@@ -188,16 +186,16 @@ export async function openFilesWith(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openSingleFileWith(
|
export async function openSingleFileWith(
|
||||||
acceptType?: AcceptTypes
|
acceptType?: AcceptTypes
|
||||||
): Promise<File | null> {
|
): Promise<File | null> {
|
||||||
return openFilesWith(acceptType, false).then(files => files?.at(0) ?? null);
|
const files = await openFilesWith(acceptType, false);
|
||||||
|
return files?.at(0) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getImageFilesFromLocal() {
|
export async function getImageFilesFromLocal() {
|
||||||
const imageFiles = await openFilesWith('Images');
|
const files = await openFilesWith('Images');
|
||||||
if (!imageFiles) return [];
|
return files ?? [];
|
||||||
return imageFiles;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function downloadBlob(blob: Blob, name: string) {
|
export function downloadBlob(blob: Blob, name: string) {
|
||||||
|
|||||||
@@ -369,14 +369,22 @@ function renderActionItem(action: ToolbarAction, context: ToolbarContext) {
|
|||||||
const innerToolbar = context.placement$.value === 'inner';
|
const innerToolbar = context.placement$.value === 'inner';
|
||||||
const ids = action.id.split('.');
|
const ids = action.id.split('.');
|
||||||
const id = ids[ids.length - 1];
|
const id = ids[ids.length - 1];
|
||||||
|
const label = action.label ?? action.tooltip ?? id;
|
||||||
|
const actived =
|
||||||
|
typeof action.active === 'function'
|
||||||
|
? action.active(context)
|
||||||
|
: action.active;
|
||||||
|
const disabled =
|
||||||
|
typeof action.disabled === 'function'
|
||||||
|
? action.disabled(context)
|
||||||
|
: action.disabled;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<editor-icon-button
|
<editor-icon-button
|
||||||
data-testid=${ifDefined(id)}
|
data-testid=${ifDefined(id)}
|
||||||
aria-label=${ifDefined(action.label ?? action.tooltip ?? id)}
|
aria-label=${ifDefined(label)}
|
||||||
?active=${typeof action.active === 'function'
|
?active=${actived}
|
||||||
? action.active(context)
|
?disabled=${disabled}
|
||||||
: action.active}
|
|
||||||
?disabled=${action.disabled}
|
|
||||||
.tooltip=${action.tooltip}
|
.tooltip=${action.tooltip}
|
||||||
.iconContainerPadding=${innerToolbar ? 4 : 2}
|
.iconContainerPadding=${innerToolbar ? 4 : 2}
|
||||||
.iconSize=${innerToolbar ? '16px' : undefined}
|
.iconSize=${innerToolbar ? '16px' : undefined}
|
||||||
@@ -394,17 +402,24 @@ function renderMenuActionItem(action: ToolbarAction, context: ToolbarContext) {
|
|||||||
const innerToolbar = context.placement$.value === 'inner';
|
const innerToolbar = context.placement$.value === 'inner';
|
||||||
const ids = action.id.split('.');
|
const ids = action.id.split('.');
|
||||||
const id = ids[ids.length - 1];
|
const id = ids[ids.length - 1];
|
||||||
|
const label = action.label ?? action.tooltip ?? id;
|
||||||
|
const actived =
|
||||||
|
typeof action.active === 'function'
|
||||||
|
? action.active(context)
|
||||||
|
: action.active;
|
||||||
|
const disabled =
|
||||||
|
typeof action.disabled === 'function'
|
||||||
|
? action.disabled(context)
|
||||||
|
: action.disabled;
|
||||||
|
const destructive = action.variant === 'destructive' ? 'delete' : undefined;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<editor-menu-action
|
<editor-menu-action
|
||||||
data-testid=${ifDefined(id)}
|
data-testid=${ifDefined(id)}
|
||||||
aria-label=${ifDefined(action.label ?? action.tooltip ?? id)}
|
aria-label=${ifDefined(label)}
|
||||||
class="${ifDefined(
|
class="${ifDefined(destructive)}"
|
||||||
action.variant === 'destructive' ? 'delete' : undefined
|
?active=${actived}
|
||||||
)}"
|
?disabled=${disabled}
|
||||||
?active=${typeof action.active === 'function'
|
|
||||||
? action.active(context)
|
|
||||||
: action.active}
|
|
||||||
?disabled=${action.disabled}
|
|
||||||
.tooltip=${ifDefined(action.tooltip)}
|
.tooltip=${ifDefined(action.tooltip)}
|
||||||
.iconContainerPadding=${innerToolbar ? 4 : 2}
|
.iconContainerPadding=${innerToolbar ? 4 : 2}
|
||||||
.iconSize=${innerToolbar ? '16px' : undefined}
|
.iconSize=${innerToolbar ? '16px' : undefined}
|
||||||
|
|||||||
@@ -66,7 +66,12 @@ export function PDFViewerEmbedded({ model }: AttachmentViewerProps) {
|
|||||||
useMemo(() => (pageEntity ? pageEntity.page.bitmap$ : null), [pageEntity])
|
useMemo(() => (pageEntity ? pageEntity.page.bitmap$ : null), [pageEntity])
|
||||||
);
|
);
|
||||||
|
|
||||||
const [name, setName] = useState(model.props.name);
|
const name = useLiveData(
|
||||||
|
useMemo(() => LiveData.fromSignal(model.props.name$), [model])
|
||||||
|
);
|
||||||
|
const blobId = useLiveData(
|
||||||
|
useMemo(() => LiveData.fromSignal(model.props.sourceId$), [model])
|
||||||
|
);
|
||||||
const [cursor, setCursor] = useState(0);
|
const [cursor, setCursor] = useState(0);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [visibility, setVisibility] = useState(false);
|
const [visibility, setVisibility] = useState(false);
|
||||||
@@ -107,8 +112,6 @@ export function PDFViewerEmbedded({ model }: AttachmentViewerProps) {
|
|||||||
};
|
};
|
||||||
}, [cursor, meta, peek]);
|
}, [cursor, meta, peek]);
|
||||||
|
|
||||||
useEffect(() => model.props.name$.subscribe(val => setName(val)), [model]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
@@ -163,8 +166,9 @@ export function PDFViewerEmbedded({ model }: AttachmentViewerProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visibility) return;
|
if (!visibility) return;
|
||||||
|
if (!blobId) return;
|
||||||
|
|
||||||
const pdfEntity = pdfService.get(model);
|
const pdfEntity = pdfService.get(blobId);
|
||||||
|
|
||||||
setPdfEntity(pdfEntity);
|
setPdfEntity(pdfEntity);
|
||||||
|
|
||||||
@@ -172,7 +176,7 @@ export function PDFViewerEmbedded({ model }: AttachmentViewerProps) {
|
|||||||
pdfEntity.release();
|
pdfEntity.release();
|
||||||
setPdfEntity(null);
|
setPdfEntity(null);
|
||||||
};
|
};
|
||||||
}, [model, pdfService, visibility]);
|
}, [blobId, pdfService, visibility]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const viewer = viewerRef.current;
|
const viewer = viewerRef.current;
|
||||||
|
|||||||
@@ -329,6 +329,9 @@ function PDFViewerContainer({
|
|||||||
[pdf]
|
[pdf]
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const blobId = useLiveData(
|
||||||
|
useMemo(() => LiveData.fromSignal(model.props.sourceId$), [model])
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.status !== PDFStatus.Error) return;
|
if (state.status !== PDFStatus.Error) return;
|
||||||
@@ -337,13 +340,15 @@ function PDFViewerContainer({
|
|||||||
}, [state]);
|
}, [state]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { pdf, release } = pdfService.get(model);
|
if (!blobId) return;
|
||||||
|
|
||||||
|
const { pdf, release } = pdfService.get(blobId);
|
||||||
setPdf(pdf);
|
setPdf(pdf);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
release();
|
release();
|
||||||
};
|
};
|
||||||
}, [model, pdfService, setPdf]);
|
}, [blobId, pdfService, setPdf]);
|
||||||
|
|
||||||
if (pdf && state.status === PDFStatus.Opened) {
|
if (pdf && state.status === PDFStatus.Opened) {
|
||||||
return <PDFViewerInner {...props} pdf={pdf} meta={state.meta} />;
|
return <PDFViewerInner {...props} pdf={pdf} meta={state.meta} />;
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
|
|
||||||
import { Entity, LiveData, ObjectPool } from '@toeverything/infra';
|
import { Entity, LiveData, ObjectPool } from '@toeverything/infra';
|
||||||
import { catchError, from, map, of, startWith, switchMap } from 'rxjs';
|
import { catchError, from, map, of, startWith, switchMap } from 'rxjs';
|
||||||
|
|
||||||
import { downloadBlobToBuffer } from '../../media/utils';
|
import type { WorkspaceService } from '../../workspace';
|
||||||
import type { PDFMeta } from '../renderer';
|
import type { PDFMeta } from '../renderer';
|
||||||
import { PDFRenderer } from '../renderer';
|
import { PDFRenderer } from '../renderer';
|
||||||
import { PDFPage } from './pdf-page';
|
import { PDFPage } from './pdf-page';
|
||||||
@@ -27,8 +26,8 @@ export type PDFRendererState =
|
|||||||
error: Error;
|
error: Error;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class PDF extends Entity<AttachmentBlockModel> {
|
export class PDF extends Entity<{ blobId: string }> {
|
||||||
public readonly id: string = this.props.id;
|
public readonly id: string = this.props.blobId;
|
||||||
readonly renderer = new PDFRenderer();
|
readonly renderer = new PDFRenderer();
|
||||||
readonly pages = new ObjectPool<string, PDFPage>({
|
readonly pages = new ObjectPool<string, PDFPage>({
|
||||||
onDelete: page => page.dispose(),
|
onDelete: page => page.dispose(),
|
||||||
@@ -36,8 +35,26 @@ export class PDF extends Entity<AttachmentBlockModel> {
|
|||||||
|
|
||||||
readonly state$ = LiveData.from<PDFRendererState>(
|
readonly state$ = LiveData.from<PDFRendererState>(
|
||||||
// @ts-expect-error type alias
|
// @ts-expect-error type alias
|
||||||
from(downloadBlobToBuffer(this.props)).pipe(
|
from(
|
||||||
switchMap(data => this.renderer.ob$('open', { data })),
|
this.workspaceService.workspace.engine.blob
|
||||||
|
.get(this.id)
|
||||||
|
.then(blobRecord => {
|
||||||
|
if (blobRecord) {
|
||||||
|
const { data, mime: type } = blobRecord;
|
||||||
|
const blob = new Blob([data], { type });
|
||||||
|
return blob.arrayBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
).pipe(
|
||||||
|
switchMap(data => {
|
||||||
|
if (data) {
|
||||||
|
return this.renderer.ob$('open', { data });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('PDF not found');
|
||||||
|
}),
|
||||||
map(meta => ({ status: PDFStatus.Opened, meta })),
|
map(meta => ({ status: PDFStatus.Opened, meta })),
|
||||||
// @ts-expect-error type alias
|
// @ts-expect-error type alias
|
||||||
startWith({ status: PDFStatus.Opening }),
|
startWith({ status: PDFStatus.Opening }),
|
||||||
@@ -46,7 +63,7 @@ export class PDF extends Entity<AttachmentBlockModel> {
|
|||||||
{ status: PDFStatus.IDLE }
|
{ status: PDFStatus.IDLE }
|
||||||
);
|
);
|
||||||
|
|
||||||
constructor() {
|
constructor(private readonly workspaceService: WorkspaceService) {
|
||||||
super();
|
super();
|
||||||
this.disposables.push(() => this.pages.clear());
|
this.disposables.push(() => this.pages.clear());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Framework } from '@toeverything/infra';
|
import type { Framework } from '@toeverything/infra';
|
||||||
|
|
||||||
import { WorkspaceScope } from '../workspace';
|
import { WorkspaceScope, WorkspaceService } from '../workspace';
|
||||||
import { PDF } from './entities/pdf';
|
import { PDF } from './entities/pdf';
|
||||||
import { PDFPage } from './entities/pdf-page';
|
import { PDFPage } from './entities/pdf-page';
|
||||||
import { PDFService } from './services/pdf';
|
import { PDFService } from './services/pdf';
|
||||||
@@ -9,7 +9,7 @@ export function configurePDFModule(framework: Framework) {
|
|||||||
framework
|
framework
|
||||||
.scope(WorkspaceScope)
|
.scope(WorkspaceScope)
|
||||||
.service(PDFService)
|
.service(PDFService)
|
||||||
.entity(PDF)
|
.entity(PDF, [WorkspaceService])
|
||||||
.entity(PDFPage);
|
.entity(PDFPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
|
|
||||||
import { ObjectPool, Service } from '@toeverything/infra';
|
import { ObjectPool, Service } from '@toeverything/infra';
|
||||||
|
|
||||||
import { PDF } from '../entities/pdf';
|
import { PDF } from '../entities/pdf';
|
||||||
@@ -19,11 +18,11 @@ export class PDFService extends Service {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get(model: AttachmentBlockModel) {
|
get(blobId: string) {
|
||||||
let rc = this.PDFs.get(model.id);
|
let rc = this.PDFs.get(blobId);
|
||||||
|
|
||||||
if (!rc) {
|
if (!rc) {
|
||||||
rc = this.PDFs.put(model.id, this.framework.createEntity(PDF, model));
|
rc = this.PDFs.put(blobId, this.framework.createEntity(PDF, { blobId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { pdf: rc.obj, release: rc.release };
|
return { pdf: rc.obj, release: rc.release };
|
||||||
|
|||||||
118
tests/affine-local/e2e/blocksuite/attachment/toolbar.spec.ts
Normal file
118
tests/affine-local/e2e/blocksuite/attachment/toolbar.spec.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { test } from '@affine-test/kit/playwright';
|
||||||
|
import {
|
||||||
|
importAttachment,
|
||||||
|
importFile,
|
||||||
|
} from '@affine-test/kit/utils/attachment';
|
||||||
|
import {
|
||||||
|
clickEdgelessModeButton,
|
||||||
|
clickView,
|
||||||
|
locateToolbar,
|
||||||
|
toViewCoord,
|
||||||
|
} from '@affine-test/kit/utils/editor';
|
||||||
|
import { openHomePage } from '@affine-test/kit/utils/load-page';
|
||||||
|
import {
|
||||||
|
clickNewPageButton,
|
||||||
|
getBlockSuiteEditorTitle,
|
||||||
|
waitForEmptyEditor,
|
||||||
|
} from '@affine-test/kit/utils/page-logic';
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await openHomePage(page);
|
||||||
|
await clickNewPageButton(page);
|
||||||
|
await waitForEmptyEditor(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Replaces attachment', () => {
|
||||||
|
test('should replace attachment in page', async ({ page }) => {
|
||||||
|
const title = getBlockSuiteEditorTitle(page);
|
||||||
|
await title.click();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
await importAttachment(page, 'lorem-ipsum.pdf');
|
||||||
|
|
||||||
|
const attachment = page.locator('affine-attachment').first();
|
||||||
|
await attachment.click();
|
||||||
|
|
||||||
|
const name = attachment.locator('.affine-attachment-content-title-text');
|
||||||
|
|
||||||
|
await expect(name).toHaveText('lorem-ipsum.pdf');
|
||||||
|
|
||||||
|
const toolbar = locateToolbar(page);
|
||||||
|
const replaceButton = toolbar.getByLabel('Replace attachment');
|
||||||
|
|
||||||
|
await importFile(page, 'v1-color-palettes-snapshot.zip', async () => {
|
||||||
|
await replaceButton.click({ delay: 50 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(attachment).toBeVisible();
|
||||||
|
|
||||||
|
await expect(name).toHaveText('v1-color-palettes-snapshot.zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should replace attachment in edgeless', async ({ page }) => {
|
||||||
|
await clickEdgelessModeButton(page);
|
||||||
|
|
||||||
|
const button = page.locator('edgeless-mindmap-tool-button');
|
||||||
|
await button.click();
|
||||||
|
|
||||||
|
const menu = page.locator('edgeless-mindmap-menu');
|
||||||
|
const mediaItem = menu.locator('.media-item');
|
||||||
|
await mediaItem.click();
|
||||||
|
|
||||||
|
await importFile(page, 'lorem-ipsum.pdf', async () => {
|
||||||
|
await toViewCoord(page, [100, 250]);
|
||||||
|
await clickView(page, [100, 250]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachment = page.locator('affine-edgeless-attachment').first();
|
||||||
|
await attachment.click();
|
||||||
|
|
||||||
|
const name = attachment.locator('.affine-attachment-content-title-text');
|
||||||
|
|
||||||
|
await expect(name).toHaveText('lorem-ipsum.pdf');
|
||||||
|
|
||||||
|
const toolbar = locateToolbar(page);
|
||||||
|
const replaceButton = toolbar.getByLabel('Replace attachment');
|
||||||
|
|
||||||
|
await importFile(page, 'v1-color-palettes-snapshot.zip', async () => {
|
||||||
|
await replaceButton.click({ delay: 50 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(attachment).toBeVisible();
|
||||||
|
|
||||||
|
await expect(name).toHaveText('v1-color-palettes-snapshot.zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fall back to card view when file type does not support embed view', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const title = getBlockSuiteEditorTitle(page);
|
||||||
|
await title.click();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
await importAttachment(page, 'lorem-ipsum.pdf');
|
||||||
|
|
||||||
|
const attachment = page.locator('affine-attachment').first();
|
||||||
|
await attachment.click();
|
||||||
|
|
||||||
|
const toolbar = locateToolbar(page);
|
||||||
|
|
||||||
|
// Switches to embed view
|
||||||
|
await toolbar.getByLabel('Switch view').click();
|
||||||
|
await toolbar.getByLabel('Embed view').click();
|
||||||
|
|
||||||
|
const portal = attachment.locator('lit-react-portal');
|
||||||
|
await expect(portal).toBeVisible();
|
||||||
|
|
||||||
|
const replaceButton = toolbar.getByLabel('Replace attachment');
|
||||||
|
await importFile(page, 'v1-color-palettes-snapshot.zip', async () => {
|
||||||
|
await replaceButton.click({ delay: 50 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(portal).toBeHidden();
|
||||||
|
|
||||||
|
const name = attachment.locator('.affine-attachment-content-title-text');
|
||||||
|
await expect(name).toHaveText('v1-color-palettes-snapshot.zip');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user