diff --git a/blocksuite/affine/blocks/attachment/src/attachment-block.ts b/blocksuite/affine/blocks/attachment/src/attachment-block.ts index 3e7fb54d67..d1554c62e9 100644 --- a/blocksuite/affine/blocks/attachment/src/attachment-block.ts +++ b/blocksuite/affine/blocks/attachment/src/attachment-block.ts @@ -22,7 +22,10 @@ import { FileSizeLimitProvider, TelemetryProvider, } from '@blocksuite/affine-shared/services'; -import { formatSize } from '@blocksuite/affine-shared/utils'; +import { + formatSize, + openSingleFileWith, +} from '@blocksuite/affine-shared/utils'; import { AttachmentIcon, ResetIcon, @@ -31,7 +34,7 @@ import { } from '@blocksuite/icons/lit'; import { BlockSelection } from '@blocksuite/std'; 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 { choose } from 'lit/directives/choose.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 { styles } from './styles'; -import { downloadAttachmentBlob, refreshData } from './utils'; +import { downloadAttachmentBlob, getFileType, refreshData } from './utils'; type AttachmentResolvedStateInfo = ResolvedStateInfo & { kind?: TemplateResult; @@ -129,12 +132,50 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent { - if (this.model.props.embed) { - this._refreshKey$.value = nanoid(); - return; - } + batch(() => { + if (this.model.props.embed$.value) { + this._refreshKey$.value = nanoid(); + 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() { @@ -403,7 +444,7 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent { const { model, blobUrl } = this; - if (!model.props.embed || !blobUrl) return null; + if (!model.props.embed$.value || !blobUrl) return null; const { std, _maxFileSize } = this; const provider = std.get(AttachmentEmbedProvider); diff --git a/blocksuite/affine/blocks/attachment/src/configs/toolbar.ts b/blocksuite/affine/blocks/attachment/src/configs/toolbar.ts index fa0a21f4dd..875629ad0d 100644 --- a/blocksuite/affine/blocks/attachment/src/configs/toolbar.ts +++ b/blocksuite/affine/blocks/attachment/src/configs/toolbar.ts @@ -24,6 +24,7 @@ import { DownloadIcon, DuplicateIcon, EditIcon, + ReplaceIcon, ResetIcon, } from '@blocksuite/icons/lit'; import { BlockFlavourIdentifier } from '@blocksuite/std'; @@ -139,27 +140,42 @@ export const attachmentViewDropdownMenu = { }); }; - return html`${keyed( - model, - html`` - )}`; + return html``; }, } as const satisfies ToolbarActionGroup; +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 = { - id: 'c.download', + id: 'd.download', tooltip: 'Download', icon: DownloadIcon(), run(ctx) { const block = ctx.getCurrentBlockByType(AttachmentBlockComponent); block?.download(); }, - when: ctx => { + when(ctx) { const model = ctx.getCurrentModelByType(AttachmentBlockModel); if (!model) return false; // Current citation attachment block does not support download @@ -168,7 +184,7 @@ const downloadAction = { } as const satisfies ToolbarAction; const captionAction = { - id: 'd.caption', + id: 'e.caption', tooltip: 'Caption', icon: CaptionIcon(), run(ctx) { @@ -221,6 +237,7 @@ const builtinToolbarConfig = { }, }, attachmentViewDropdownMenu, + replaceAction, downloadAction, captionAction, { @@ -354,13 +371,17 @@ const builtinSurfaceToolbarConfig = { )}`; }, } satisfies ToolbarActionGroup, + { + ...replaceAction, + id: 'd.replace', + }, { ...downloadAction, - id: 'd.download', + id: 'e.download', }, { ...captionAction, - id: 'e.caption', + id: 'f.caption', }, ], when: ctx => ctx.getSurfaceModelsByType(AttachmentBlockModel).length === 1, diff --git a/blocksuite/affine/shared/src/utils/file/filesys.ts b/blocksuite/affine/shared/src/utils/file/filesys.ts index a650a9ad3a..60715aac0d 100644 --- a/blocksuite/affine/shared/src/utils/file/filesys.ts +++ b/blocksuite/affine/shared/src/utils/file/filesys.ts @@ -176,9 +176,7 @@ export async function openFilesWith( resolve(input.files ? Array.from(input.files) : null); }); // The `cancel` event fires when the user cancels the dialog. - input.addEventListener('cancel', () => { - resolve(null); - }); + input.addEventListener('cancel', () => resolve(null)); // Show the picker. if ('showPicker' in HTMLInputElement.prototype) { input.showPicker(); @@ -188,16 +186,16 @@ export async function openFilesWith( }); } -export function openSingleFileWith( +export async function openSingleFileWith( acceptType?: AcceptTypes ): Promise { - 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() { - const imageFiles = await openFilesWith('Images'); - if (!imageFiles) return []; - return imageFiles; + const files = await openFilesWith('Images'); + return files ?? []; } export function downloadBlob(blob: Blob, name: string) { diff --git a/blocksuite/affine/widgets/toolbar/src/utils.ts b/blocksuite/affine/widgets/toolbar/src/utils.ts index 5c0424325f..e6f624b317 100644 --- a/blocksuite/affine/widgets/toolbar/src/utils.ts +++ b/blocksuite/affine/widgets/toolbar/src/utils.ts @@ -369,14 +369,22 @@ function renderActionItem(action: ToolbarAction, context: ToolbarContext) { const innerToolbar = context.placement$.value === 'inner'; const ids = action.id.split('.'); 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` (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 [isLoading, setIsLoading] = useState(true); const [visibility, setVisibility] = useState(false); @@ -107,8 +112,6 @@ export function PDFViewerEmbedded({ model }: AttachmentViewerProps) { }; }, [cursor, meta, peek]); - useEffect(() => model.props.name$.subscribe(val => setName(val)), [model]); - useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; @@ -163,8 +166,9 @@ export function PDFViewerEmbedded({ model }: AttachmentViewerProps) { useEffect(() => { if (!visibility) return; + if (!blobId) return; - const pdfEntity = pdfService.get(model); + const pdfEntity = pdfService.get(blobId); setPdfEntity(pdfEntity); @@ -172,7 +176,7 @@ export function PDFViewerEmbedded({ model }: AttachmentViewerProps) { pdfEntity.release(); setPdfEntity(null); }; - }, [model, pdfService, visibility]); + }, [blobId, pdfService, visibility]); useEffect(() => { const viewer = viewerRef.current; diff --git a/packages/frontend/core/src/blocksuite/attachment-viewer/pdf/pdf-viewer.tsx b/packages/frontend/core/src/blocksuite/attachment-viewer/pdf/pdf-viewer.tsx index f2283e8797..21697dc993 100644 --- a/packages/frontend/core/src/blocksuite/attachment-viewer/pdf/pdf-viewer.tsx +++ b/packages/frontend/core/src/blocksuite/attachment-viewer/pdf/pdf-viewer.tsx @@ -329,6 +329,9 @@ function PDFViewerContainer({ [pdf] ) ); + const blobId = useLiveData( + useMemo(() => LiveData.fromSignal(model.props.sourceId$), [model]) + ); useEffect(() => { if (state.status !== PDFStatus.Error) return; @@ -337,13 +340,15 @@ function PDFViewerContainer({ }, [state]); useEffect(() => { - const { pdf, release } = pdfService.get(model); + if (!blobId) return; + + const { pdf, release } = pdfService.get(blobId); setPdf(pdf); return () => { release(); }; - }, [model, pdfService, setPdf]); + }, [blobId, pdfService, setPdf]); if (pdf && state.status === PDFStatus.Opened) { return ; diff --git a/packages/frontend/core/src/modules/pdf/entities/pdf.ts b/packages/frontend/core/src/modules/pdf/entities/pdf.ts index 3e2306af46..0b7a93b992 100644 --- a/packages/frontend/core/src/modules/pdf/entities/pdf.ts +++ b/packages/frontend/core/src/modules/pdf/entities/pdf.ts @@ -1,8 +1,7 @@ -import type { AttachmentBlockModel } from '@blocksuite/affine/model'; import { Entity, LiveData, ObjectPool } from '@toeverything/infra'; 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 { PDFRenderer } from '../renderer'; import { PDFPage } from './pdf-page'; @@ -27,8 +26,8 @@ export type PDFRendererState = error: Error; }; -export class PDF extends Entity { - public readonly id: string = this.props.id; +export class PDF extends Entity<{ blobId: string }> { + public readonly id: string = this.props.blobId; readonly renderer = new PDFRenderer(); readonly pages = new ObjectPool({ onDelete: page => page.dispose(), @@ -36,8 +35,26 @@ export class PDF extends Entity { readonly state$ = LiveData.from( // @ts-expect-error type alias - from(downloadBlobToBuffer(this.props)).pipe( - switchMap(data => this.renderer.ob$('open', { data })), + from( + 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 })), // @ts-expect-error type alias startWith({ status: PDFStatus.Opening }), @@ -46,7 +63,7 @@ export class PDF extends Entity { { status: PDFStatus.IDLE } ); - constructor() { + constructor(private readonly workspaceService: WorkspaceService) { super(); this.disposables.push(() => this.pages.clear()); } diff --git a/packages/frontend/core/src/modules/pdf/index.ts b/packages/frontend/core/src/modules/pdf/index.ts index fa64c51bfc..b7c09a1c8a 100644 --- a/packages/frontend/core/src/modules/pdf/index.ts +++ b/packages/frontend/core/src/modules/pdf/index.ts @@ -1,6 +1,6 @@ import type { Framework } from '@toeverything/infra'; -import { WorkspaceScope } from '../workspace'; +import { WorkspaceScope, WorkspaceService } from '../workspace'; import { PDF } from './entities/pdf'; import { PDFPage } from './entities/pdf-page'; import { PDFService } from './services/pdf'; @@ -9,7 +9,7 @@ export function configurePDFModule(framework: Framework) { framework .scope(WorkspaceScope) .service(PDFService) - .entity(PDF) + .entity(PDF, [WorkspaceService]) .entity(PDFPage); } diff --git a/packages/frontend/core/src/modules/pdf/services/pdf.ts b/packages/frontend/core/src/modules/pdf/services/pdf.ts index e4d511a7ea..1f1c545ef2 100644 --- a/packages/frontend/core/src/modules/pdf/services/pdf.ts +++ b/packages/frontend/core/src/modules/pdf/services/pdf.ts @@ -1,4 +1,3 @@ -import type { AttachmentBlockModel } from '@blocksuite/affine/model'; import { ObjectPool, Service } from '@toeverything/infra'; import { PDF } from '../entities/pdf'; @@ -19,11 +18,11 @@ export class PDFService extends Service { }); } - get(model: AttachmentBlockModel) { - let rc = this.PDFs.get(model.id); + get(blobId: string) { + let rc = this.PDFs.get(blobId); 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 }; diff --git a/tests/affine-local/e2e/blocksuite/attachment/toolbar.spec.ts b/tests/affine-local/e2e/blocksuite/attachment/toolbar.spec.ts new file mode 100644 index 0000000000..3b5c4fc574 --- /dev/null +++ b/tests/affine-local/e2e/blocksuite/attachment/toolbar.spec.ts @@ -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'); + }); +});