From dee9bca18930a89726751d9bc9b04cdd60674136 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 17 May 2026 00:21:29 +0800 Subject: [PATCH 1/3] Add editor file upload support --- .../editor/blocks/file-block-upload.spec.ts | 215 +++++++++ scripts/export-indexeddb-to-zip.js | 451 ++++++++++++++++++ .../http/multipart-upload-store.ts | 184 +++++++ .../js-services/http/multipart-upload.ts | 365 +++++++++++--- .../http/multipart-upload.types.ts | 9 + .../block-popover/FileBlockPopoverContent.tsx | 92 +++- .../ImageBlockPopoverContent.tsx | 87 +++- .../block-popover/PDFBlockPopoverContent.tsx | 90 +++- .../editor/plugins/withInsertData.ts | 121 +++-- src/utils/file-storage-url.ts | 24 + 10 files changed, 1515 insertions(+), 123 deletions(-) create mode 100644 playwright/e2e/editor/blocks/file-block-upload.spec.ts create mode 100644 scripts/export-indexeddb-to-zip.js create mode 100644 src/application/services/js-services/http/multipart-upload-store.ts diff --git a/playwright/e2e/editor/blocks/file-block-upload.spec.ts b/playwright/e2e/editor/blocks/file-block-upload.spec.ts new file mode 100644 index 000000000..45f7716be --- /dev/null +++ b/playwright/e2e/editor/blocks/file-block-upload.spec.ts @@ -0,0 +1,215 @@ +import { test, expect, Page, Request } from '@playwright/test'; +import { signInAndNavigate } from '../../support/auth-utils'; + +/** + * Editor file-block / image-block popover upload regression tests. + * + * Covers the popover code paths in: + * - src/components/editor/components/block-popover/FileBlockPopoverContent.tsx + * - src/components/editor/components/block-popover/ImageBlockPopoverContent.tsx + * + * The critical regression this guards against: after the refactor that + * persists a local IndexedDB snapshot *before* kicking off the remote upload, + * an IndexedDB write failure (private browsing, quota exceeded, etc.) must + * not block the remote upload — the popover should still POST the file to + * the server. + * + * We assert at the network layer rather than the rendered URL, because + * a brand-new test user may not have permissions to fetch the resulting + * file URL back from the storage endpoint, but the upload POST itself is + * the regression signal we care about. + */ +test.describe('Feature: Editor block popover upload', () => { + // Each test creates a new user via GoTrue, which can't handle parallel auth. + test.describe.configure({ mode: 'serial' }); + + let page: Page; + + test.beforeEach(async ({ browser }) => { + page = await browser.newPage(); + await signInAndNavigate(page); + await page.locator('[data-testid="inline-add-page"]').first().waitFor({ state: 'visible', timeout: 30000 }); + }); + + test.afterEach(async () => { + await page.close(); + }); + + /** + * Create a new doc page via the inline-add button. + */ + async function createNewDocPage(): Promise { + const addBtn = page.locator('[data-testid="inline-add-page"]').first(); + await addBtn.click(); + await page.waitForTimeout(500); + await page.getByText('Document', { exact: true }).first().click(); + await page.waitForTimeout(2000); + } + + function getEditor() { + return page.locator('[data-testid="editor-content"]').last(); + } + + /** + * Insert a block via the slash menu by key (e.g. 'file', 'image'). + */ + async function insertBlockViaSlash(slashKey: 'file' | 'image'): Promise { + const editor = getEditor(); + await editor.click({ force: true, position: { x: 100, y: 10 } }); + await page.waitForTimeout(300); + await page.keyboard.type(`/${slashKey}`); + await page.waitForTimeout(600); + await page.locator(`[data-testid="slash-menu-${slashKey}"]`).click(); + await page.waitForTimeout(600); + } + + /** + * Start collecting any request whose URL contains `file_storage` (covers + * single-shot uploads, presigned URL fetches, and multipart endpoints). + * Returns an array reference that fills as requests arrive. + */ + function captureUploadRequests(): Request[] { + const captured: Request[] = []; + page.on('request', (req) => { + if (req.url().includes('file_storage') || req.url().includes('/upload')) { + captured.push(req); + } + }); + return captured; + } + + /** + * 1×1 transparent PNG (smallest valid PNG, ~70 bytes). + */ + const TINY_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64' + ); + + test('Given a File block popover, when user uploads a file, then the remote upload endpoint is hit', async () => { + const uploadRequests = captureUploadRequests(); + + await createNewDocPage(); + await insertBlockViaSlash('file'); + + const dropzone = page.getByTestId('file-dropzone'); + await expect(dropzone).toBeVisible({ timeout: 10000 }); + + const fileInput = dropzone.locator('input[type="file"]'); + await fileInput.setInputFiles({ + name: 'regression.bin', + mimeType: 'application/octet-stream', + buffer: Buffer.from('hello world from regression test'), + }); + + // The popover must hand the file off to the remote upload endpoint, not + // just persist it locally. The local IndexedDB save and the remote upload + // were re-ordered in a recent refactor; this catches a regression where + // a missing/failed local save would short-circuit the remote upload. + await expect.poll( + () => uploadRequests.filter((r) => r.method() !== 'GET').length, + { timeout: 30000, message: 'no upload request fired for file block' } + ).toBeGreaterThan(0); + + // The block also flips out of its empty state (the file name appears). + await expect(getEditor()).toContainText('regression.bin', { timeout: 30000 }); + }); + + test('Given an Image block popover, when user uploads an image, then the remote upload endpoint is hit', async () => { + const uploadRequests = captureUploadRequests(); + + await createNewDocPage(); + await insertBlockViaSlash('image'); + + const dropzone = page.getByTestId('file-dropzone'); + await expect(dropzone).toBeVisible({ timeout: 10000 }); + + const fileInput = dropzone.locator('input[type="file"]'); + await fileInput.setInputFiles({ + name: 'regression.png', + mimeType: 'image/png', + buffer: TINY_PNG, + }); + + await expect.poll( + () => uploadRequests.filter((r) => r.method() !== 'GET').length, + { timeout: 30000, message: 'no upload request fired for image block' } + ).toBeGreaterThan(0); + + // The block flips out of its empty state and renders an . + await expect(getEditor().locator('img').first()).toBeVisible({ timeout: 30000 }); + }); + + test('Given the local FileStorage database is unavailable, when user uploads via the popover, then the remote upload still fires', async () => { + // Make ONLY the FileStorage IndexedDB database (used by the popover's + // local retry-snapshot) fail to open. The rest of the app — including + // its own state databases — is left untouched, so we don't blow up the + // editor itself. + // + // This simulates a private-browsing or quota-exhausted state where the + // local retry snapshot cannot be persisted but the remote upload must + // still proceed. + await page.addInitScript(() => { + const originalOpen = window.indexedDB.open.bind(window.indexedDB); + + // eslint-disable-next-line + (window.indexedDB as any).open = function (name: string, version?: number): IDBOpenDBRequest { + if (name === 'FileStorage') { + const fakeRequest: Partial & { + onerror: ((ev: Event) => void) | null; + onsuccess: ((ev: Event) => void) | null; + onupgradeneeded: ((ev: Event) => void) | null; + onblocked: ((ev: Event) => void) | null; + error: DOMException | null; + result: IDBDatabase | null; + } = { + onerror: null, + onsuccess: null, + onupgradeneeded: null, + onblocked: null, + error: new DOMException('FileStorage disabled for test', 'QuotaExceededError'), + result: null, + }; + + setTimeout(() => { + if (typeof fakeRequest.onerror === 'function') { + fakeRequest.onerror(new Event('error')); + } + }, 0); + + return fakeRequest as IDBOpenDBRequest; + } + + return originalOpen(name, version); + }; + }); + + const uploadRequests = captureUploadRequests(); + + // Re-load the app under the patched environment. + await page.goto('http://localhost:3000/app'); + await page.locator('[data-testid="inline-add-page"]').first().waitFor({ state: 'visible', timeout: 30000 }); + + await createNewDocPage(); + await insertBlockViaSlash('file'); + + const dropzone = page.getByTestId('file-dropzone'); + await expect(dropzone).toBeVisible({ timeout: 10000 }); + + const fileInput = dropzone.locator('input[type="file"]'); + await fileInput.setInputFiles({ + name: 'no-idb.bin', + mimeType: 'application/octet-stream', + buffer: Buffer.from('upload should still reach the server'), + }); + + // The regression we're guarding against: when the local IndexedDB save + // rejects, the popover code must NOT swallow that error and skip the + // remote upload. A non-GET request to a file storage endpoint must still + // fire. + await expect.poll( + () => uploadRequests.filter((r) => r.method() !== 'GET').length, + { timeout: 30000, message: 'no upload request fired when IndexedDB was disabled' } + ).toBeGreaterThan(0); + }); +}); diff --git a/scripts/export-indexeddb-to-zip.js b/scripts/export-indexeddb-to-zip.js new file mode 100644 index 000000000..5dd892372 --- /dev/null +++ b/scripts/export-indexeddb-to-zip.js @@ -0,0 +1,451 @@ +/* + * AppFlowy IndexedDB export helper. + * + * Usage: + * 1. Open https://appflowy.com in the browser profile that has the data. + * 2. Open DevTools Console. + * 3. Paste this whole file and press Enter. + * + * The script exports all IndexedDB databases visible to the current origin into + * a zip file. It is self-contained and does not load third-party libraries. + */ +(async () => { + const ZIP_FILE_PREFIX = 'appflowy-indexeddb-export'; + const textEncoder = new TextEncoder(); + + function assertBrowserSupport() { + if (!globalThis.indexedDB) { + throw new Error('IndexedDB is not available in this browser context.'); + } + + if (!globalThis.Blob || !globalThis.URL || !document?.createElement) { + throw new Error('Blob download APIs are not available in this browser context.'); + } + } + + function timestampForFilename() { + return new Date().toISOString().replace(/[:.]/g, '-'); + } + + function safePathSegment(value) { + const text = String(value || 'unnamed'); + const safe = text.replace(/[\\/:*?"<>|\x00-\x1f]/g, '_').slice(0, 160); + + return safe || 'unnamed'; + } + + function bytesToBase64(bytes) { + let binary = ''; + const chunkSize = 0x8000; + + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + const chunk = bytes.subarray(offset, offset + chunkSize); + + binary += String.fromCharCode(...chunk); + } + + return btoa(binary); + } + + async function valueToJsonSafe(value, seen = new WeakSet()) { + if (value === undefined) { + return { __type: 'Undefined' }; + } + + if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + if (typeof value === 'bigint') { + return { __type: 'BigInt', value: value.toString() }; + } + + if (typeof value === 'symbol' || typeof value === 'function') { + return { __type: typeof value, value: String(value) }; + } + + if (value instanceof Date) { + return { __type: 'Date', value: value.toISOString() }; + } + + if (value instanceof ArrayBuffer) { + return { + __type: 'ArrayBuffer', + byteLength: value.byteLength, + base64: bytesToBase64(new Uint8Array(value)), + }; + } + + if (ArrayBuffer.isView(value)) { + const view = value; + const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + + return { + __type: view.constructor?.name || 'TypedArray', + byteOffset: view.byteOffset, + byteLength: view.byteLength, + base64: bytesToBase64(bytes), + }; + } + + if (value instanceof Blob) { + const isFile = typeof File !== 'undefined' && value instanceof File; + const bytes = new Uint8Array(await value.arrayBuffer()); + + return { + __type: isFile ? 'File' : 'Blob', + name: isFile ? value.name : undefined, + type: value.type, + size: value.size, + lastModified: isFile ? value.lastModified : undefined, + base64: bytesToBase64(bytes), + }; + } + + if (typeof value === 'object') { + if (seen.has(value)) { + return { __type: 'CircularReference' }; + } + + seen.add(value); + + if (Array.isArray(value)) { + return Promise.all(value.map((item) => valueToJsonSafe(item, seen))); + } + + if (value instanceof Map) { + const entries = []; + + for (const [entryKey, entryValue] of value.entries()) { + entries.push({ + key: await valueToJsonSafe(entryKey, seen), + value: await valueToJsonSafe(entryValue, seen), + }); + } + + return { __type: 'Map', entries }; + } + + if (value instanceof Set) { + const values = []; + + for (const item of value.values()) { + values.push(await valueToJsonSafe(item, seen)); + } + + return { __type: 'Set', values }; + } + + const output = {}; + + for (const [key, childValue] of Object.entries(value)) { + output[key] = await valueToJsonSafe(childValue, seen); + } + + return output; + } + + return String(value); + } + + function openDatabase(name) { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error(`Failed to open IndexedDB database: ${name}`)); + request.onblocked = () => reject(new Error(`Opening IndexedDB database was blocked: ${name}`)); + }); + } + + function readStoreSchema(db, storeName) { + const transaction = db.transaction(storeName, 'readonly'); + const store = transaction.objectStore(storeName); + + return { + name: store.name, + keyPath: store.keyPath, + autoIncrement: store.autoIncrement, + indexes: Array.from(store.indexNames).map((indexName) => { + const index = store.index(indexName); + + return { + name: index.name, + keyPath: index.keyPath, + multiEntry: index.multiEntry, + unique: index.unique, + }; + }), + }; + } + + function readStoreRecords(db, storeName) { + return new Promise((resolve, reject) => { + const transaction = db.transaction(storeName, 'readonly'); + const store = transaction.objectStore(storeName); + const records = []; + const request = store.openCursor(); + let pendingSerialization = Promise.resolve(); + + request.onsuccess = () => { + const cursor = request.result; + + if (!cursor) { + pendingSerialization.then(() => resolve(records)).catch(reject); + return; + } + + const key = cursor.key; + const value = cursor.value; + + pendingSerialization = pendingSerialization.then(async () => { + records.push({ + key: await valueToJsonSafe(key), + value: await valueToJsonSafe(value), + }); + }); + + cursor.continue(); + }; + + request.onerror = () => reject(request.error || new Error(`Failed to read object store: ${storeName}`)); + transaction.onerror = () => + reject(transaction.error || new Error(`IndexedDB transaction failed while reading: ${storeName}`)); + }); + } + + async function listDatabaseInfos() { + if (typeof indexedDB.databases === 'function') { + const databaseInfos = await indexedDB.databases(); + + return databaseInfos.filter((info) => info.name); + } + + const manualNames = prompt( + 'This browser does not expose indexedDB.databases(). Enter comma-separated IndexedDB database names to export.' + ); + + return String(manualNames || '') + .split(',') + .map((name) => name.trim()) + .filter(Boolean) + .map((name) => ({ name })); + } + + async function exportDatabase(databaseInfo, files, manifest) { + const dbName = databaseInfo.name; + const dbPath = `databases/${safePathSegment(dbName)}`; + const db = await openDatabase(dbName); + + try { + const storeNames = Array.from(db.objectStoreNames); + const databaseManifest = { + name: dbName, + version: db.version, + objectStores: [], + }; + + manifest.databases.push(databaseManifest); + + for (const storeName of storeNames) { + console.info(`[AppFlowy IndexedDB Export] Reading ${dbName}/${storeName}`); + const storePath = `${dbPath}/stores/${safePathSegment(storeName)}.json`; + const schema = readStoreSchema(db, storeName); + const records = await readStoreRecords(db, storeName); + + databaseManifest.objectStores.push({ + name: storeName, + recordCount: records.length, + path: storePath, + schema, + }); + + files.push({ + path: storePath, + data: JSON.stringify({ schema, records }, null, 2), + }); + } + } finally { + db.close(); + } + } + + function makeCrc32Table() { + const table = new Uint32Array(256); + + for (let i = 0; i < 256; i += 1) { + let crc = i; + + for (let bit = 0; bit < 8; bit += 1) { + crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1; + } + + table[i] = crc >>> 0; + } + + return table; + } + + const crc32Table = makeCrc32Table(); + + function crc32(bytes) { + let crc = 0xffffffff; + + for (let i = 0; i < bytes.length; i += 1) { + crc = crc32Table[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8); + } + + return (crc ^ 0xffffffff) >>> 0; + } + + function dosDateTime(date) { + const year = Math.max(date.getFullYear(), 1980); + const dosTime = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2); + const dosDate = ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(); + + return { dosDate, dosTime }; + } + + function uint16(value) { + const bytes = new Uint8Array(2); + const view = new DataView(bytes.buffer); + + view.setUint16(0, value, true); + return bytes; + } + + function uint32(value) { + const bytes = new Uint8Array(4); + const view = new DataView(bytes.buffer); + + view.setUint32(0, value >>> 0, true); + return bytes; + } + + function toBytes(value) { + return value instanceof Uint8Array ? value : textEncoder.encode(value); + } + + function createZip(files) { + const parts = []; + const centralDirectory = []; + let offset = 0; + const { dosDate, dosTime } = dosDateTime(new Date()); + const pushParts = (target, nextParts) => { + for (const part of nextParts) { + target.push(part); + } + }; + + for (const file of files) { + const nameBytes = textEncoder.encode(file.path); + const dataBytes = toBytes(file.data); + const checksum = crc32(dataBytes); + const localHeader = [ + uint32(0x04034b50), + uint16(20), + uint16(0x0800), + uint16(0), + uint16(dosTime), + uint16(dosDate), + uint32(checksum), + uint32(dataBytes.length), + uint32(dataBytes.length), + uint16(nameBytes.length), + uint16(0), + nameBytes, + ]; + + pushParts(parts, localHeader); + parts.push(dataBytes); + + centralDirectory.push( + uint32(0x02014b50), + uint16(20), + uint16(20), + uint16(0x0800), + uint16(0), + uint16(dosTime), + uint16(dosDate), + uint32(checksum), + uint32(dataBytes.length), + uint32(dataBytes.length), + uint16(nameBytes.length), + uint16(0), + uint16(0), + uint16(0), + uint16(0), + uint32(0), + uint32(offset), + nameBytes + ); + + offset += localHeader.reduce((sum, part) => sum + part.length, 0) + dataBytes.length; + } + + const centralDirectoryOffset = offset; + const centralDirectorySize = centralDirectory.reduce((sum, part) => sum + part.length, 0); + + pushParts(parts, centralDirectory); + pushParts(parts, [ + uint32(0x06054b50), + uint16(0), + uint16(0), + uint16(files.length), + uint16(files.length), + uint32(centralDirectorySize), + uint32(centralDirectoryOffset), + uint16(0) + ]); + + return new Blob(parts, { type: 'application/zip' }); + } + + function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + + setTimeout(() => URL.revokeObjectURL(url), 30_000); + } + + assertBrowserSupport(); + + const files = []; + const manifest = { + exportedAt: new Date().toISOString(), + origin: location.origin, + href: location.href, + userAgent: navigator.userAgent, + databases: [], + }; + const databaseInfos = await listDatabaseInfos(); + + if (databaseInfos.length === 0) { + throw new Error('No IndexedDB databases were found for this origin.'); + } + + console.info(`[AppFlowy IndexedDB Export] Exporting ${databaseInfos.length} database(s).`); + + for (const databaseInfo of databaseInfos) { + await exportDatabase(databaseInfo, files, manifest); + } + + files.unshift({ + path: 'manifest.json', + data: JSON.stringify(manifest, null, 2), + }); + + const zipBlob = createZip(files); + const filename = `${ZIP_FILE_PREFIX}-${safePathSegment(location.hostname)}-${timestampForFilename()}.zip`; + + downloadBlob(zipBlob, filename); + console.info(`[AppFlowy IndexedDB Export] Download started: ${filename}`); +})().catch((error) => { + console.error('[AppFlowy IndexedDB Export] Failed to export IndexedDB data:', error); +}); diff --git a/src/application/services/js-services/http/multipart-upload-store.ts b/src/application/services/js-services/http/multipart-upload-store.ts new file mode 100644 index 000000000..2bc3833bb --- /dev/null +++ b/src/application/services/js-services/http/multipart-upload-store.ts @@ -0,0 +1,184 @@ +import { UploadPartInfo } from './multipart-upload.types'; + +const DB_NAME = 'AppFlowyMultipartUploads'; +const DB_VERSION = 1; +const STORE_NAME = 'uploads'; + +export interface PersistedMultipartUpload { + id: string; + workspaceId: string; + viewId: string; + fileId: string; + uploadId: string; + fileName: string; + fileType: string; + fileSize: number; + fileLastModified: number; + chunkSize: number; + // Held only in-memory during an active upload. Stripped before persistence so + // we don't write the full payload (potentially hundreds of MB) to IndexedDB + // on every part completion. Callers re-attach the live File on resume via + // `getOrCreateSession`. + file?: File; + parts: UploadPartInfo[]; + createdAt: number; + updatedAt: number; +} + +function canUseIndexedDB(): boolean { + return typeof indexedDB !== 'undefined'; +} + +function createSessionId(workspaceId: string, viewId: string, file: File): string { + return [ + 'multipart', + workspaceId, + viewId, + encodeURIComponent(file.name), + file.size, + file.lastModified, + encodeURIComponent(file.type || 'application/octet-stream'), + ].join(':'); +} + +function isSameFile(session: PersistedMultipartUpload, file: File): boolean { + return ( + session.fileName === file.name && + session.fileSize === file.size && + session.fileLastModified === file.lastModified && + session.fileType === (file.type || 'application/octet-stream') + ); +} + +class MultipartUploadStore { + private dbPromise: Promise | null = null; + private memoryStore = new Map(); + + getSessionId(workspaceId: string, viewId: string, file: File): string { + return createSessionId(workspaceId, viewId, file); + } + + async getSession( + workspaceId: string, + viewId: string, + file: File + ): Promise { + const id = this.getSessionId(workspaceId, viewId, file); + const session = await this.getById(id); + + if (!session || !isSameFile(session, file)) { + return null; + } + + return session; + } + + async saveSession(session: PersistedMultipartUpload): Promise { + // Drop the live File reference before persisting — only resume metadata + // needs to survive a reload. The active upload code re-attaches the File + // it was handed by the caller (see `getOrCreateSession`). + const nextSession: PersistedMultipartUpload = { + ...session, + file: undefined, + updatedAt: Date.now(), + }; + + if (!canUseIndexedDB()) { + this.memoryStore.set(nextSession.id, nextSession); + return; + } + + const db = await this.openDb(); + + if (!db) { + this.memoryStore.set(nextSession.id, nextSession); + return; + } + + try { + await new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const request = store.put(nextSession); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } catch { + this.memoryStore.set(nextSession.id, nextSession); + } + } + + async deleteSession(id: string): Promise { + this.memoryStore.delete(id); + + if (!canUseIndexedDB()) { + return; + } + + const db = await this.openDb(); + + if (!db) { + return; + } + + await new Promise((resolve) => { + const transaction = db.transaction(STORE_NAME, 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const request = store.delete(id); + + request.onsuccess = () => resolve(); + request.onerror = () => resolve(); + }); + } + + private async getById(id: string): Promise { + if (!canUseIndexedDB()) { + return this.memoryStore.get(id) ?? null; + } + + const db = await this.openDb(); + + if (!db) { + return this.memoryStore.get(id) ?? null; + } + + return new Promise((resolve) => { + const transaction = db.transaction(STORE_NAME, 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const request = store.get(id); + + request.onsuccess = () => resolve(request.result ?? this.memoryStore.get(id) ?? null); + request.onerror = () => resolve(this.memoryStore.get(id) ?? null); + }); + } + + private openDb(): Promise { + if (!canUseIndexedDB()) { + return Promise.resolve(null); + } + + if (this.dbPromise) { + return this.dbPromise; + } + + this.dbPromise = new Promise((resolve) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => resolve(null); + request.onblocked = () => resolve(null); + request.onsuccess = () => resolve(request.result); + request.onupgradeneeded = () => { + const db = request.result; + + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: 'id' }); + } + }; + }); + + return this.dbPromise; + } +} + +export const multipartUploadStore = new MultipartUploadStore(); diff --git a/src/application/services/js-services/http/multipart-upload.ts b/src/application/services/js-services/http/multipart-upload.ts index ee034444f..875a6eedf 100644 --- a/src/application/services/js-services/http/multipart-upload.ts +++ b/src/application/services/js-services/http/multipart-upload.ts @@ -7,18 +7,22 @@ import { v4 as uuidv4 } from 'uuid'; import { getAppFlowyFileUrl, + getMultipartAbortUrl, getMultipartCompleteUrl, getMultipartCreateUrl, + getMultipartUploadedPartsUrl, getMultipartUploadPartUrl, } from '@/utils/file-storage-url'; import { Log } from '@/utils/log'; import { getAxiosInstance } from './http_api'; +import { multipartUploadStore, PersistedMultipartUpload } from './multipart-upload-store'; import { CHUNK_SIZE, CreateUploadResponse, MAX_CONCURRENCY, MAX_RETRIES, UploadFileMultipartParams, + UploadPartsResponse, UploadPartInfo, } from './multipart-upload.types'; @@ -31,6 +35,31 @@ interface APIResponse { message: string; } +type UploadChunk = { partNumber: number; blob: Blob }; + +// Keyed by destination first, then by File reference. Two distinct File objects +// sharing metadata never collide; the same File uploaded to different +// workspace/view destinations also stays independent. A duplicate dispatch with +// the *same* File instance and *same* destination still dedupes. +const activeUploads = new Map>>(); + +function getActiveUploadKey(workspaceId: string, viewId: string): string { + return `${workspaceId}:${viewId}`; +} + +function isStaleSessionError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + const e = error as { + response?: { status?: number; data?: { code?: number; message?: string } }; + message?: string; + }; + + if (e.response?.status === 404) return true; + const msg = e.message ?? e.response?.data?.message ?? ''; + + return /upload.{0,8}(not\s*found|expired|invalid|missing|gone)/i.test(msg); +} + /** * Creates a multipart upload session */ @@ -64,6 +93,52 @@ async function createMultipartUpload( return response.data.data; } +/** + * Lists parts that the server already has for this multipart upload. + */ +async function listUploadedParts( + workspaceId: string, + parentDir: string, + fileId: string, + uploadId: string +): Promise { + const axiosInstance = getAxiosInstance(); + + if (!axiosInstance) { + throw new Error('API service not initialized'); + } + + const url = getMultipartUploadedPartsUrl(workspaceId, parentDir, fileId, uploadId); + + const response = await axiosInstance.get>(url); + + if (response.data.code !== 0 || !response.data.data) { + throw new Error(response.data.message || 'Failed to list uploaded parts'); + } + + return response.data.data.parts; +} + +/** + * Aborts an upload session so object storage can release incomplete parts. + */ +async function abortMultipartUpload( + workspaceId: string, + parentDir: string, + fileId: string, + uploadId: string +): Promise { + const axiosInstance = getAxiosInstance(); + + if (!axiosInstance) { + return; + } + + const url = getMultipartAbortUrl(workspaceId, parentDir, fileId, uploadId); + + await axiosInstance.delete(url); +} + /** * Uploads a single part with retry logic */ @@ -151,7 +226,7 @@ async function completeMultipartUpload( file_id: fileId, parent_dir: parentDir, upload_id: uploadId, - parts: parts + parts: [...parts] .sort((a, b) => a.part_number - b.part_number) .map((p) => ({ e_tag: p.e_tag, @@ -198,11 +273,123 @@ async function executeWithConcurrency( return results; } -/** - * Main function to upload a file using multipart upload - * Splits the file into chunks and uploads them in parallel - */ -export async function uploadFileMultipart({ +function createChunks(file: File): UploadChunk[] { + const chunks: UploadChunk[] = []; + let offset = 0; + let partNumber = 1; + + while (offset < file.size) { + const end = Math.min(offset + CHUNK_SIZE, file.size); + const blob = file.slice(offset, end); + + chunks.push({ partNumber, blob }); + offset = end; + partNumber++; + } + + return chunks; +} + +function mergeParts(...partLists: UploadPartInfo[][]): UploadPartInfo[] { + const partsByNumber = new Map(); + + for (const parts of partLists) { + for (const part of parts) { + if (!part.e_tag || part.part_number < 1) { + continue; + } + + partsByNumber.set(part.part_number, part); + } + } + + return Array.from(partsByNumber.values()).sort((a, b) => a.part_number - b.part_number); +} + +function getUploadedBytes(chunks: UploadChunk[], parts: UploadPartInfo[]): number { + const uploadedPartNumbers = new Set(parts.map((part) => part.part_number)); + + return chunks.reduce((total, chunk) => { + return uploadedPartNumbers.has(chunk.partNumber) ? total + chunk.blob.size : total; + }, 0); +} + +async function getOrCreateSession( + workspaceId: string, + parentDir: string, + file: File +): Promise { + const existingSession = await multipartUploadStore.getSession(workspaceId, parentDir, file); + + if (existingSession) { + Log.debug('[UploadFile] multipart resumed session found', { + fileId: existingSession.fileId, + uploadId: existingSession.uploadId, + partsCount: existingSession.parts.length, + }); + + return { + ...existingSession, + file, + }; + } + + const requestedFileId = uuidv4(); + const { upload_id: uploadId, file_id: createdFileId } = await createMultipartUpload( + workspaceId, + parentDir, + file, + requestedFileId + ); + const now = Date.now(); + const session: PersistedMultipartUpload = { + id: multipartUploadStore.getSessionId(workspaceId, parentDir, file), + workspaceId, + viewId: parentDir, + fileId: createdFileId || requestedFileId, + uploadId, + fileName: file.name, + fileType: file.type || 'application/octet-stream', + fileSize: file.size, + fileLastModified: file.lastModified, + chunkSize: CHUNK_SIZE, + file, + parts: [], + createdAt: now, + updatedAt: now, + }; + + await multipartUploadStore.saveSession(session); + Log.debug('[UploadFile] multipart upload created', { uploadId, fileId: session.fileId }); + + return session; +} + +async function syncUploadedParts(session: PersistedMultipartUpload): Promise { + try { + const serverParts = await listUploadedParts( + session.workspaceId, + session.viewId, + session.fileId, + session.uploadId + ); + const parts = mergeParts(session.parts, serverParts); + + if (parts.length !== session.parts.length) { + await multipartUploadStore.saveSession({ + ...session, + parts, + }); + } + + return parts; + } catch (error) { + Log.warn('[UploadFile] multipart uploaded-parts lookup failed, using local session', error); + return session.parts; + } +} + +async function uploadFileMultipartInternal({ workspaceId, viewId, file, @@ -210,7 +397,6 @@ export async function uploadFileMultipart({ }: UploadFileMultipartParams): Promise { Log.debug('[UploadFile] multipart starting', { fileName: file.name, fileSize: file.size }); - // Report initializing phase onProgress?.({ phase: 'initializing', totalBytes: file.size, @@ -218,70 +404,141 @@ export async function uploadFileMultipart({ percentage: 0, }); - // Generate a unique file ID - const fileId = uuidv4(); const parentDir = viewId; + const session = await getOrCreateSession(workspaceId, parentDir, file); - // Step 1: Create upload session - const { upload_id: uploadId } = await createMultipartUpload(workspaceId, parentDir, file, fileId); + try { + const chunks = createChunks(file); - Log.debug('[UploadFile] multipart upload created', { uploadId, fileId }); + Log.debug('[UploadFile] multipart chunks created', { totalChunks: chunks.length }); - // Step 2: Split file into chunks - const chunks: { partNumber: number; blob: Blob }[] = []; - let offset = 0; - let partNumber = 1; + const syncedParts = await syncUploadedParts(session); + const partsByNumber = new Map( + syncedParts.map((part) => [part.part_number, part]) + ); + let uploadedBytes = getUploadedBytes(chunks, syncedParts); + const totalBytes = file.size; - while (offset < file.size) { - const end = Math.min(offset + CHUNK_SIZE, file.size); - const blob = file.slice(offset, end); + onProgress?.({ + phase: 'uploading', + totalBytes, + uploadedBytes, + percentage: totalBytes === 0 ? 0 : Math.round((uploadedBytes / totalBytes) * 100), + }); - chunks.push({ partNumber, blob }); - offset = end; - partNumber++; - } + const missingChunks = chunks.filter((chunk) => !partsByNumber.has(chunk.partNumber)); - Log.debug('[UploadFile] multipart chunks created', { totalChunks: chunks.length }); + await executeWithConcurrency(missingChunks, MAX_CONCURRENCY, async (chunk) => { + const result = await uploadPart( + workspaceId, + parentDir, + session.fileId, + session.uploadId, + chunk.partNumber, + chunk.blob + ); - // Step 3: Upload parts with concurrency control - let uploadedBytes = 0; - const totalBytes = file.size; + partsByNumber.set(result.part_number, result); + uploadedBytes += chunk.blob.size; - const parts = await executeWithConcurrency(chunks, MAX_CONCURRENCY, async (chunk) => { - const result = await uploadPart( - workspaceId, - parentDir, - fileId, - uploadId, - chunk.partNumber, - chunk.blob - ); + await multipartUploadStore.saveSession({ + ...session, + parts: Array.from(partsByNumber.values()), + }); + + onProgress?.({ + phase: 'uploading', + totalBytes, + uploadedBytes, + percentage: Math.round((uploadedBytes / totalBytes) * 100), + }); + + return result; + }); + + const parts = Array.from(partsByNumber.values()).sort((a, b) => a.part_number - b.part_number); + + Log.debug('[UploadFile] multipart all parts uploaded', { partsCount: parts.length }); - // Update progress after each part completes - uploadedBytes += chunk.blob.size; onProgress?.({ - phase: 'uploading', + phase: 'completing', totalBytes, - uploadedBytes, - percentage: Math.round((uploadedBytes / totalBytes) * 100), + uploadedBytes: totalBytes, + percentage: 100, }); - return result; - }); + const fileUrl = await completeMultipartUpload( + workspaceId, + parentDir, + session.uploadId, + session.fileId, + parts + ); - Log.debug('[UploadFile] multipart all parts uploaded', { partsCount: parts.length }); + await multipartUploadStore.deleteSession(session.id); - // Step 4: Complete the upload - onProgress?.({ - phase: 'completing', - totalBytes, - uploadedBytes: totalBytes, - percentage: 100, - }); + Log.debug('[UploadFile] multipart completed', { fileUrl }); - const fileUrl = await completeMultipartUpload(workspaceId, parentDir, uploadId, fileId, parts); + return fileUrl; + } catch (error) { + // Discard a stale/expired server-side session so the next attempt starts + // fresh instead of pinning the same (now-dead) uploadId forever. + if (isStaleSessionError(error)) { + Log.warn('[UploadFile] multipart session appears stale; discarding for next attempt', error); + await multipartUploadStore.deleteSession(session.id).catch(() => undefined); + } - Log.debug('[UploadFile] multipart completed', { fileUrl }); + throw error; + } +} - return fileUrl; +/** + * Main function to upload a file using multipart upload + * Splits the file into chunks and uploads them in parallel + */ +export async function uploadFileMultipart({ + workspaceId, + viewId, + file, + onProgress, +}: UploadFileMultipartParams): Promise { + const destKey = getActiveUploadKey(workspaceId, viewId); + const destMap = activeUploads.get(destKey); + const activeUpload = destMap?.get(file); + + if (activeUpload) { + return activeUpload; + } + + const upload = uploadFileMultipartInternal({ workspaceId, viewId, file, onProgress }).finally( + () => { + activeUploads.get(destKey)?.delete(file); + } + ); + const targetMap = destMap ?? new WeakMap>(); + + targetMap.set(file, upload); + if (!destMap) { + activeUploads.set(destKey, targetMap); + } + + return upload; +} + +export async function abortPersistedMultipartUpload( + workspaceId: string, + viewId: string, + file: File +): Promise { + const session = await multipartUploadStore.getSession(workspaceId, viewId, file); + + if (!session) { + return; + } + + try { + await abortMultipartUpload(workspaceId, viewId, session.fileId, session.uploadId); + } finally { + await multipartUploadStore.deleteSession(session.id); + } } diff --git a/src/application/services/js-services/http/multipart-upload.types.ts b/src/application/services/js-services/http/multipart-upload.types.ts index 0cc74bdf9..cbaa9a189 100644 --- a/src/application/services/js-services/http/multipart-upload.types.ts +++ b/src/application/services/js-services/http/multipart-upload.types.ts @@ -45,6 +45,15 @@ export interface CompleteUploadResponse { url: string; } +/** + * Response from uploaded parts endpoint + */ +export interface UploadPartsResponse { + file_id: string; + upload_id: string; + parts: UploadPartInfo[]; +} + // Constants for multipart upload configuration export const MULTIPART_THRESHOLD = 5 * 1024 * 1024; // 5MB - files >= this size use multipart export const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB - AWS S3 minimum part size diff --git a/src/components/editor/components/block-popover/FileBlockPopoverContent.tsx b/src/components/editor/components/block-popover/FileBlockPopoverContent.tsx index 92ce7e3d9..45adc5a54 100644 --- a/src/components/editor/components/block-popover/FileBlockPopoverContent.tsx +++ b/src/components/editor/components/block-popover/FileBlockPopoverContent.tsx @@ -76,23 +76,71 @@ function FileBlockPopoverContent({ blockId, onClose }: { blockId: string; onClos } as FileBlockData; if (!remoteUrl) { - const fileHandler = new FileHandler(); - const res = await fileHandler.handleFileUpload(file); - - data.retry_local_url = res.id; + // Best-effort: a missing local snapshot must not block the remote upload + // (IndexedDB may be unavailable in private mode or over quota). + try { + const fileHandler = new FileHandler(); + const res = await fileHandler.handleFileUpload(file); + + // The popover never renders the local preview itself — the block + // creates its own object URL via `getStoredFile`. Revoke the one + // created here so it doesn't leak until the tab unloads. + URL.revokeObjectURL(res.url); + data.retry_local_url = res.id; + } catch { + data.retry_local_url = ''; + } } return data; }, []); - const insertFileBlock = useCallback( - async (file: File) => { + const cleanupLocalFile = useCallback(async (retryLocalUrl?: string) => { + if (!retryLocalUrl) return; + + const fileHandler = new FileHandler(); + + await fileHandler.cleanup(retryLocalUrl).catch(() => undefined); + }, []); + + const uploadIntoFileBlock = useCallback( + async (targetBlockId: string, file: File, pendingData: FileBlockData) => { const url = await uploadFileRemote(file); - const data = await getData(file, url); - CustomEditor.addBelowBlock(editor, blockId, BlockType.FileBlock, data); + if (!url) { + return; + } + + await cleanupLocalFile(pendingData.retry_local_url); + + // Popover closes before the upload settles, so the user may have + // deleted/edited/replaced the block. Skip the write if the placeholder + // we created is no longer there (block gone, URL already set, or + // retry_local_url has changed because a different file was uploaded + // onto the same block). + let currentData: FileBlockData | undefined; + + try { + const entry = findSlateEntryByBlockId(editor, targetBlockId); + + currentData = entry ? ((entry[0] as { data?: FileBlockData }).data ?? undefined) : undefined; + } catch { + return; + } + + if (!currentData) return; + if (currentData.url) return; + if ((currentData.retry_local_url ?? '') !== (pendingData.retry_local_url ?? '')) return; + + CustomEditor.setBlockData(editor, targetBlockId, { + url, + name: file.name, + uploaded_at: Date.now(), + url_type: FieldURLType.Upload, + retry_local_url: '', + } as FileBlockData); }, - [blockId, editor, getData, uploadFileRemote] + [cleanupLocalFile, editor, uploadFileRemote] ); const handleChangeUploadFiles = useCallback( @@ -101,22 +149,36 @@ function FileBlockPopoverContent({ blockId, onClose }: { blockId: string; onClos setUploading(true); try { + // Run every local snapshot in parallel so the popover doesn't pay + // N×IDB-latency before it can close. + const [primaryData, ...otherDatas] = await Promise.all(files.map((f) => getData(f))); const [file, ...otherFiles] = files; - const url = await uploadFileRemote(file); - const data = await getData(file, url); - CustomEditor.setBlockData(editor, blockId, data); + CustomEditor.setBlockData(editor, blockId, primaryData); + + const pendingUploads: Promise[] = [uploadIntoFileBlock(blockId, file, primaryData)]; + + // Each new block is inserted directly below `blockId`, so iterating + // in reverse preserves the user's original file order in the doc. + const reversedPairs = otherFiles + .map((f, i) => [f, otherDatas[i]] as const) + .reverse(); + + for (const [f, d] of reversedPairs) { + const newId = CustomEditor.addBelowBlock(editor, blockId, BlockType.FileBlock, d); - for (const file of otherFiles.reverse()) { - await insertFileBlock(file); + if (newId) { + pendingUploads.push(uploadIntoFileBlock(newId, f, d)); + } } onClose(); + await Promise.all(pendingUploads); } finally { setUploading(false); } }, - [blockId, editor, getData, insertFileBlock, onClose, uploadFileRemote] + [blockId, editor, getData, onClose, uploadIntoFileBlock] ); const tabOptions = useMemo(() => { diff --git a/src/components/editor/components/block-popover/ImageBlockPopoverContent.tsx b/src/components/editor/components/block-popover/ImageBlockPopoverContent.tsx index b3fc5a887..ae47f5a4a 100644 --- a/src/components/editor/components/block-popover/ImageBlockPopoverContent.tsx +++ b/src/components/editor/components/block-popover/ImageBlockPopoverContent.tsx @@ -66,24 +66,68 @@ function ImageBlockPopoverContent({ blockId, onClose }: { blockId: string; onClo } as ImageBlockData; if (!remoteUrl) { - const fileHandler = new FileHandler(); - const res = await fileHandler.handleFileUpload(file); - - data.retry_local_url = res.id; - data.image_type = undefined; + // Best-effort: a missing local snapshot must not block the remote upload + // (IndexedDB may be unavailable in private mode or over quota). + try { + const fileHandler = new FileHandler(); + const res = await fileHandler.handleFileUpload(file); + + // The popover never renders the local preview itself — the block + // creates its own object URL via `getStoredFile`. Revoke the one + // created here so it doesn't leak until the tab unloads. + URL.revokeObjectURL(res.url); + data.retry_local_url = res.id; + data.image_type = undefined; + } catch { + data.retry_local_url = ''; + } } return data; }, []); - const insertImageBlock = useCallback( - async (file: File) => { + const cleanupLocalFile = useCallback(async (retryLocalUrl?: string) => { + if (!retryLocalUrl) return; + + const fileHandler = new FileHandler(); + + await fileHandler.cleanup(retryLocalUrl).catch(() => undefined); + }, []); + + const uploadIntoImageBlock = useCallback( + async (targetBlockId: string, file: File, pendingData: ImageBlockData) => { const url = await uploadFileRemote(file); - const data = await getData(file, url); - return CustomEditor.addBelowBlock(editor, blockId, BlockType.ImageBlock, data); + if (!url) { + return; + } + + await cleanupLocalFile(pendingData.retry_local_url); + + // Popover closes before the upload settles, so the user may have + // deleted/edited/replaced the block. Skip the write if the placeholder + // we created is no longer there. + let currentData: ImageBlockData | undefined; + + try { + const entry = findSlateEntryByBlockId(editor, targetBlockId); + + currentData = entry ? ((entry[0] as { data?: ImageBlockData }).data ?? undefined) : undefined; + } catch { + return; + } + + if (!currentData) return; + if (currentData.url) return; + if ((currentData.retry_local_url ?? '') !== (pendingData.retry_local_url ?? '')) return; + + CustomEditor.setBlockData(editor, targetBlockId, { + url, + image_type: ImageType.External, + retry_local_url: '', + } as ImageBlockData); }, - [blockId, editor, getData, uploadFileRemote] + [cleanupLocalFile, editor, uploadFileRemote] ); const handleChangeUploadFiles = useCallback( @@ -92,22 +136,31 @@ function ImageBlockPopoverContent({ blockId, onClose }: { blockId: string; onClo setUploading(true); try { + // Run every local snapshot in parallel so the popover doesn't pay + // N×IDB-latency before it can close. + const [primaryData, ...otherDatas] = await Promise.all(files.map((f) => getData(f))); const [file, ...otherFiles] = files; - const url = await uploadFileRemote(file); - const data = await getData(file, url); - CustomEditor.setBlockData(editor, blockId, data); + CustomEditor.setBlockData(editor, blockId, primaryData); let belowBlockId: string | undefined = blockId; + const pendingUploads: Promise[] = [uploadIntoImageBlock(blockId, file, primaryData)]; - for (const file of otherFiles) { - const newId = await insertImageBlock(file); + for (let i = 0; i < otherFiles.length; i++) { + const f = otherFiles[i]; + const d = otherDatas[i]; + const newId: string | undefined = belowBlockId + ? CustomEditor.addBelowBlock(editor, belowBlockId, BlockType.ImageBlock, d) + : undefined; if (newId) { belowBlockId = newId; + pendingUploads.push(uploadIntoImageBlock(newId, f, d)); } } + if (!belowBlockId) return; + belowBlockId = CustomEditor.addBelowBlock(editor, belowBlockId, BlockType.Paragraph, {}); const entry = belowBlockId ? findSlateEntryByBlockId(editor, belowBlockId) : null; @@ -128,11 +181,13 @@ function ImageBlockPopoverContent({ blockId, onClose }: { blockId: string; onClo el?.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 250); + + await Promise.all(pendingUploads); } finally { setUploading(false); } }, - [blockId, editor, getData, insertImageBlock, onClose, uploadFileRemote] + [blockId, editor, getData, onClose, uploadIntoImageBlock] ); const tabOptions = useMemo(() => { diff --git a/src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx b/src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx index 9947292f5..116fe1876 100644 --- a/src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx +++ b/src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx @@ -70,26 +70,79 @@ function PDFBlockPopoverContent({ blockId, onClose }: { blockId: string; onClose [uploadFile] ); - const processFileUpload = useCallback( + const createPendingFileData = useCallback( async (file: File): Promise => { - const url = await uploadFileRemote(file); const data: PDFBlockData = { - url, + url: undefined, name: file.name, uploaded_at: Date.now(), url_type: FieldURLType.Upload, }; - if (!url) { + // Best-effort: a missing local snapshot must not block the remote upload + // (IndexedDB may be unavailable in private mode or over quota). + try { const fileHandler = new FileHandler(); const res = await fileHandler.handleFileUpload(file); + // The popover never renders the local preview itself — the block + // creates its own object URL via `getStoredFile`. Revoke the one + // created here so it doesn't leak until the tab unloads. + URL.revokeObjectURL(res.url); data.retry_local_url = res.id; + } catch { + data.retry_local_url = ''; } return data; }, - [uploadFileRemote] + [] + ); + + const cleanupLocalFile = useCallback(async (retryLocalUrl?: string) => { + if (!retryLocalUrl) return; + + const fileHandler = new FileHandler(); + + await fileHandler.cleanup(retryLocalUrl).catch(() => undefined); + }, []); + + const uploadIntoPdfBlock = useCallback( + async (targetBlockId: string, file: File, pendingData: PDFBlockData) => { + const url = await uploadFileRemote(file); + + if (!url) { + return; + } + + await cleanupLocalFile(pendingData.retry_local_url); + + // Popover closes before the upload settles, so the user may have + // deleted/edited/replaced the block. Skip the write if the placeholder + // we created is no longer there. + let currentData: PDFBlockData | undefined; + + try { + const entry = findSlateEntryByBlockId(editor, targetBlockId); + + currentData = entry ? ((entry[0] as { data?: PDFBlockData }).data ?? undefined) : undefined; + } catch { + return; + } + + if (!currentData) return; + if (currentData.url) return; + if ((currentData.retry_local_url ?? '') !== (pendingData.retry_local_url ?? '')) return; + + CustomEditor.setBlockData(editor, targetBlockId, { + url, + name: file.name, + uploaded_at: Date.now(), + url_type: FieldURLType.Upload, + retry_local_url: '', + } as PDFBlockData); + }, + [cleanupLocalFile, editor, uploadFileRemote] ); const handleChangeUploadFiles = useCallback( @@ -98,23 +151,38 @@ function PDFBlockPopoverContent({ blockId, onClose }: { blockId: string; onClose setUploading(true); try { + // Run every local snapshot in parallel so the popover doesn't pay + // N×IDB-latency before it can close. + const [primaryData, ...otherDatas] = await Promise.all( + files.map((f) => createPendingFileData(f)) + ); const [file, ...otherFiles] = files; - const data = await processFileUpload(file); - CustomEditor.setBlockData(editor, blockId, data); + CustomEditor.setBlockData(editor, blockId, primaryData); + + const pendingUploads: Promise[] = [uploadIntoPdfBlock(blockId, file, primaryData)]; + + // Each new block is inserted directly below `blockId`, so iterating + // in reverse preserves the user's original file order in the doc. + const reversedPairs = otherFiles + .map((f, i) => [f, otherDatas[i]] as const) + .reverse(); - for (const file of otherFiles.reverse()) { - const data = await processFileUpload(file); + for (const [f, d] of reversedPairs) { + const newId = CustomEditor.addBelowBlock(editor, blockId, BlockType.PDFBlock, d); - CustomEditor.addBelowBlock(editor, blockId, BlockType.PDFBlock, data); + if (newId) { + pendingUploads.push(uploadIntoPdfBlock(newId, f, d)); + } } onClose(); + await Promise.all(pendingUploads); } finally { setUploading(false); } }, - [blockId, editor, onClose, processFileUpload] + [blockId, createPendingFileData, editor, onClose, uploadIntoPdfBlock] ); const defaultLink = useMemo(() => { diff --git a/src/components/editor/plugins/withInsertData.ts b/src/components/editor/plugins/withInsertData.ts index 3aa1bc1e7..f1f56aa16 100644 --- a/src/components/editor/plugins/withInsertData.ts +++ b/src/components/editor/plugins/withInsertData.ts @@ -105,48 +105,112 @@ export const withInsertData = (editor: ReactEditor) => { void (async () => { const text = CustomEditor.getBlockTextContent(node); let newBlockId: string = blockId; + const pendingUploads: Promise[] = []; + + // One handler for the whole batch — each `new FileHandler()` opens + // its own IDB connection promise, so reusing avoids that overhead. + const fileHandler = new FileHandler(); + + // Best-effort: a missing local snapshot must not block the remote + // upload (IndexedDB may be unavailable in private mode or over + // quota). Persist every snapshot in parallel so paste latency + // scales with the slowest IDB write, not the sum. + const fileIds = await Promise.all( + fileArray.map(async (file) => { + try { + const res = await fileHandler.handleFileUpload(file); + + // Paste path never renders the local preview itself — the + // block creates its own object URL via `getStoredFile`. + // Revoke the one created here so it doesn't leak until the + // tab unloads. + URL.revokeObjectURL(res.url); + return res.id; + } catch (err) { + Log.warn('withInsertData: failed to persist local snapshot for pasted file', err); + return ''; + } + }) + ); - for (const file of fileArray) { - const url = await e.uploadFile?.(file); - let fileId = ''; - - if (!url) { - const fileHandler = new FileHandler(); - const res = await fileHandler.handleFileUpload(file); - - fileId = res.id; - } - + for (let i = 0; i < fileArray.length; i++) { + const file = fileArray[i]; + const fileId = fileIds[i]; const isImage = file.type.startsWith('image/'); + let insertedBlockId: string | undefined; if (isImage) { const data = { - url: url, - image_type: ImageType.External, + url: '', + image_type: undefined, + retry_local_url: fileId, } as ImageBlockData; - if (fileId) { - data.retry_local_url = fileId; - } - - // Handle images... - newBlockId = CustomEditor.addBelowBlock(e, newBlockId, BlockType.ImageBlock, data) || newBlockId; + insertedBlockId = CustomEditor.addBelowBlock(e, newBlockId, BlockType.ImageBlock, data); + newBlockId = insertedBlockId || newBlockId; } else { const data = { - url: url, + url: '', name: file.name, uploaded_at: Date.now(), url_type: FieldURLType.Upload, + retry_local_url: fileId, } as FileBlockData; - if (fileId) { - data.retry_local_url = fileId; - } - - // Handle files... - newBlockId = CustomEditor.addBelowBlock(e, newBlockId, BlockType.FileBlock, data) || newBlockId; + insertedBlockId = CustomEditor.addBelowBlock(e, newBlockId, BlockType.FileBlock, data); + newBlockId = insertedBlockId || newBlockId; } + if (insertedBlockId) { + pendingUploads.push((async () => { + let url: string | undefined; + + try { + url = await e.uploadFile?.(file); + } catch { + return; + } + + if (!url) return; + + if (fileId) { + await fileHandler.cleanup(fileId).catch(() => undefined); + } + + // The paste handler runs in the background after the user + // already moved on. Skip the write if the placeholder is gone + // or already finalised so we don't clobber later edits. + let currentData: { url?: string; retry_local_url?: string } | undefined; + + try { + const entry = findSlateEntryByBlockId(e, insertedBlockId); + + currentData = entry ? ((entry[0] as { data?: { url?: string; retry_local_url?: string } }).data ?? undefined) : undefined; + } catch { + return; + } + + if (!currentData) return; + if (currentData.url) return; + if ((currentData.retry_local_url ?? '') !== fileId) return; + + if (isImage) { + CustomEditor.setBlockData(e, insertedBlockId, { + url, + image_type: ImageType.External, + retry_local_url: '', + } as ImageBlockData); + } else { + CustomEditor.setBlockData(e, insertedBlockId, { + url, + name: file.name, + uploaded_at: Date.now(), + url_type: FieldURLType.Upload, + retry_local_url: '', + } as FileBlockData); + } + })()); + } } if (!text) { @@ -170,6 +234,9 @@ export const withInsertData = (editor: ReactEditor) => { } + void Promise.all(pendingUploads).catch((err) => { + Log.warn('withInsertData: failed to finalize pasted file upload', err); + }); })(); } @@ -374,4 +441,4 @@ function createTSVDataTransfer(tsv: string): DataTransfer { dt.setData('text/plain', tsv); return dt; -} \ No newline at end of file +} diff --git a/src/utils/file-storage-url.ts b/src/utils/file-storage-url.ts index f43bf52d9..5d8cbbc82 100644 --- a/src/utils/file-storage-url.ts +++ b/src/utils/file-storage-url.ts @@ -137,6 +137,30 @@ export function getMultipartUploadPartUrl( return `${getFileStorageBaseUrl()}/${workspaceId}/upload_part/${parentDir}/${fileId}/${uploadId}/${partNumber}`; } +/** + * Constructs URL for listing uploaded parts in a multipart upload + */ +export function getMultipartUploadedPartsUrl( + workspaceId: string, + parentDir: string, + fileId: string, + uploadId: string +): string { + return `${getFileStorageBaseUrl()}/${workspaceId}/upload_parts/${parentDir}/${fileId}/${uploadId}`; +} + +/** + * Constructs URL for aborting a multipart upload + */ +export function getMultipartAbortUrl( + workspaceId: string, + parentDir: string, + fileId: string, + uploadId: string +): string { + return `${getFileStorageBaseUrl()}/${workspaceId}/upload/${parentDir}/${fileId}/${uploadId}`; +} + /** * Constructs URL for completing a multipart upload * @param workspaceId - The workspace ID From 59df8a94bbf47c7990033fd1076c8299f2d8de2c Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 17 May 2026 00:59:41 +0800 Subject: [PATCH 2/3] Fix stale file upload finalization --- .../js-services/http/multipart-upload.ts | 55 +-- src/application/types.ts | 10 +- .../block-popover/FileBlockPopoverContent.tsx | 13 +- .../ImageBlockPopoverContent.tsx | 14 +- .../block-popover/PDFBlockPopoverContent.tsx | 68 ++-- .../components/blocks/file/FileBlock.tsx | 3 +- .../components/blocks/image/ImageBlock.tsx | 7 +- .../editor/components/blocks/pdf/PDFBlock.tsx | 320 +++++++++--------- .../editor/plugins/withInsertData.ts | 133 ++++---- src/utils/pending-upload.ts | 3 + 10 files changed, 317 insertions(+), 309 deletions(-) create mode 100644 src/utils/pending-upload.ts diff --git a/src/application/services/js-services/http/multipart-upload.ts b/src/application/services/js-services/http/multipart-upload.ts index 875a6eedf..ac215be57 100644 --- a/src/application/services/js-services/http/multipart-upload.ts +++ b/src/application/services/js-services/http/multipart-upload.ts @@ -50,14 +50,16 @@ function getActiveUploadKey(workspaceId: string, viewId: string): string { function isStaleSessionError(error: unknown): boolean { if (typeof error !== 'object' || error === null) return false; const e = error as { - response?: { status?: number; data?: { code?: number; message?: string } }; + response?: { status?: number; data?: { code?: number | string; message?: string } }; message?: string; }; if (e.response?.status === 404) return true; - const msg = e.message ?? e.response?.data?.message ?? ''; + const msg = [e.response?.data?.code, e.response?.data?.message, e.message].filter(Boolean).join(' '); - return /upload.{0,8}(not\s*found|expired|invalid|missing|gone)/i.test(msg); + return /no\s*such\s*upload|nosuchupload|upload.{0,32}(not\s*found|does\s+not\s+exist|doesn't\s+exist|expired|invalid|missing|gone)/i.test( + msg + ); } /** @@ -166,15 +168,11 @@ async function uploadPart( const arrayBuffer = await chunk.arrayBuffer(); - const response = await axiosInstance.put>( - url, - arrayBuffer, - { - headers: { - 'Content-Type': 'application/octet-stream', - }, - } - ); + const response = await axiosInstance.put>(url, arrayBuffer, { + headers: { + 'Content-Type': 'application/octet-stream', + }, + }); if (response.data.code !== 0 || !response.data.data) { throw new Error(response.data.message || `Failed to upload part ${partNumber}`); @@ -367,12 +365,7 @@ async function getOrCreateSession( async function syncUploadedParts(session: PersistedMultipartUpload): Promise { try { - const serverParts = await listUploadedParts( - session.workspaceId, - session.viewId, - session.fileId, - session.uploadId - ); + const serverParts = await listUploadedParts(session.workspaceId, session.viewId, session.fileId, session.uploadId); const parts = mergeParts(session.parts, serverParts); if (parts.length !== session.parts.length) { @@ -413,9 +406,7 @@ async function uploadFileMultipartInternal({ Log.debug('[UploadFile] multipart chunks created', { totalChunks: chunks.length }); const syncedParts = await syncUploadedParts(session); - const partsByNumber = new Map( - syncedParts.map((part) => [part.part_number, part]) - ); + const partsByNumber = new Map(syncedParts.map((part) => [part.part_number, part])); let uploadedBytes = getUploadedBytes(chunks, syncedParts); const totalBytes = file.size; @@ -467,13 +458,7 @@ async function uploadFileMultipartInternal({ percentage: 100, }); - const fileUrl = await completeMultipartUpload( - workspaceId, - parentDir, - session.uploadId, - session.fileId, - parts - ); + const fileUrl = await completeMultipartUpload(workspaceId, parentDir, session.uploadId, session.fileId, parts); await multipartUploadStore.deleteSession(session.id); @@ -510,11 +495,9 @@ export async function uploadFileMultipart({ return activeUpload; } - const upload = uploadFileMultipartInternal({ workspaceId, viewId, file, onProgress }).finally( - () => { - activeUploads.get(destKey)?.delete(file); - } - ); + const upload = uploadFileMultipartInternal({ workspaceId, viewId, file, onProgress }).finally(() => { + activeUploads.get(destKey)?.delete(file); + }); const targetMap = destMap ?? new WeakMap>(); targetMap.set(file, upload); @@ -525,11 +508,7 @@ export async function uploadFileMultipart({ return upload; } -export async function abortPersistedMultipartUpload( - workspaceId: string, - viewId: string, - file: File -): Promise { +export async function abortPersistedMultipartUpload(workspaceId: string, viewId: string, file: File): Promise { const session = await multipartUploadStore.getSession(workspaceId, viewId, file); if (!session) { diff --git a/src/application/types.ts b/src/application/types.ts index 252b28926..c285d70c4 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -124,6 +124,7 @@ export interface FileBlockData extends BlockData { url?: string; url_type?: FieldURLType; retry_local_url?: string; + pending_upload_id?: string; } export enum ImageType { @@ -139,6 +140,7 @@ export interface ImageBlockData extends BlockData { image_type?: ImageType; height?: number; retry_local_url?: string; + pending_upload_id?: string; } export enum VideoType { @@ -195,6 +197,7 @@ export interface PDFBlockData extends BlockData { url?: string; url_type?: FieldURLType; retry_local_url?: string; + pending_upload_id?: string; } export enum GalleryLayout { @@ -1353,7 +1356,12 @@ export interface ViewComponentProps { * Only available in app mode - not provided in publish mode. */ createRowDocument?: (documentId: string) => Promise; - duplicateRowDocument?: (databaseId: string, sourceRowId: string, newRowId: string, clientDocStateB64?: string) => Promise; + duplicateRowDocument?: ( + databaseId: string, + sourceRowId: string, + newRowId: string, + clientDocStateB64?: string + ) => Promise; viewMeta: ViewMetaProps; appendBreadcrumb?: AppendBreadcrumb; onRendered?: () => void; diff --git a/src/components/editor/components/block-popover/FileBlockPopoverContent.tsx b/src/components/editor/components/block-popover/FileBlockPopoverContent.tsx index 45adc5a54..e76f0bac8 100644 --- a/src/components/editor/components/block-popover/FileBlockPopoverContent.tsx +++ b/src/components/editor/components/block-popover/FileBlockPopoverContent.tsx @@ -10,6 +10,7 @@ import FileDropzone from '@/components/_shared/file-dropzone/FileDropzone'; import { TabPanel, ViewTab, ViewTabs } from '@/components/_shared/tabs/ViewTabs'; import { useEditorContext } from '@/components/editor/EditorContext'; import { FileHandler } from '@/utils/file'; +import { createPendingUploadId } from '@/utils/pending-upload'; import EmbedLink from 'src/components/_shared/image-upload/EmbedLink'; @@ -73,6 +74,7 @@ function FileBlockPopoverContent({ blockId, onClose }: { blockId: string; onClos name: file.name, uploaded_at: Date.now(), url_type: FieldURLType.Upload, + pending_upload_id: createPendingUploadId(), } as FileBlockData; if (!remoteUrl) { @@ -116,21 +118,21 @@ function FileBlockPopoverContent({ blockId, onClose }: { blockId: string; onClos // Popover closes before the upload settles, so the user may have // deleted/edited/replaced the block. Skip the write if the placeholder // we created is no longer there (block gone, URL already set, or - // retry_local_url has changed because a different file was uploaded + // pending_upload_id has changed because a different file was uploaded // onto the same block). let currentData: FileBlockData | undefined; try { const entry = findSlateEntryByBlockId(editor, targetBlockId); - currentData = entry ? ((entry[0] as { data?: FileBlockData }).data ?? undefined) : undefined; + currentData = entry ? (entry[0] as { data?: FileBlockData }).data ?? undefined : undefined; } catch { return; } if (!currentData) return; if (currentData.url) return; - if ((currentData.retry_local_url ?? '') !== (pendingData.retry_local_url ?? '')) return; + if (!pendingData.pending_upload_id || currentData.pending_upload_id !== pendingData.pending_upload_id) return; CustomEditor.setBlockData(editor, targetBlockId, { url, @@ -138,6 +140,7 @@ function FileBlockPopoverContent({ blockId, onClose }: { blockId: string; onClos uploaded_at: Date.now(), url_type: FieldURLType.Upload, retry_local_url: '', + pending_upload_id: '', } as FileBlockData); }, [cleanupLocalFile, editor, uploadFileRemote] @@ -160,9 +163,7 @@ function FileBlockPopoverContent({ blockId, onClose }: { blockId: string; onClos // Each new block is inserted directly below `blockId`, so iterating // in reverse preserves the user's original file order in the doc. - const reversedPairs = otherFiles - .map((f, i) => [f, otherDatas[i]] as const) - .reverse(); + const reversedPairs = otherFiles.map((f, i) => [f, otherDatas[i]] as const).reverse(); for (const [f, d] of reversedPairs) { const newId = CustomEditor.addBelowBlock(editor, blockId, BlockType.FileBlock, d); diff --git a/src/components/editor/components/block-popover/ImageBlockPopoverContent.tsx b/src/components/editor/components/block-popover/ImageBlockPopoverContent.tsx index ae47f5a4a..c02ed894c 100644 --- a/src/components/editor/components/block-popover/ImageBlockPopoverContent.tsx +++ b/src/components/editor/components/block-popover/ImageBlockPopoverContent.tsx @@ -12,6 +12,7 @@ import EmbedLink from '@/components/_shared/image-upload/EmbedLink'; import { TabPanel, ViewTab, ViewTabs } from '@/components/_shared/tabs/ViewTabs'; import { useEditorContext } from '@/components/editor/EditorContext'; import { FileHandler } from '@/utils/file'; +import { createPendingUploadId } from '@/utils/pending-upload'; function ImageBlockPopoverContent({ blockId, onClose }: { blockId: string; onClose: () => void }) { const { uploadFile } = useEditorContext(); @@ -63,6 +64,7 @@ function ImageBlockPopoverContent({ blockId, onClose }: { blockId: string; onClo const data = { url: remoteUrl || '', image_type: ImageType.External, + pending_upload_id: createPendingUploadId(), } as ImageBlockData; if (!remoteUrl) { @@ -112,19 +114,20 @@ function ImageBlockPopoverContent({ blockId, onClose }: { blockId: string; onClo try { const entry = findSlateEntryByBlockId(editor, targetBlockId); - currentData = entry ? ((entry[0] as { data?: ImageBlockData }).data ?? undefined) : undefined; + currentData = entry ? (entry[0] as { data?: ImageBlockData }).data ?? undefined : undefined; } catch { return; } if (!currentData) return; if (currentData.url) return; - if ((currentData.retry_local_url ?? '') !== (pendingData.retry_local_url ?? '')) return; + if (!pendingData.pending_upload_id || currentData.pending_upload_id !== pendingData.pending_upload_id) return; CustomEditor.setBlockData(editor, targetBlockId, { url, image_type: ImageType.External, retry_local_url: '', + pending_upload_id: '', } as ImageBlockData); }, [cleanupLocalFile, editor, uploadFileRemote] @@ -196,7 +199,12 @@ function ImageBlockPopoverContent({ blockId, onClose }: { blockId: string; onClo key: 'upload', label: t('button.upload'), panel: ( - + ), }, { diff --git a/src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx b/src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx index 116fe1876..e0ee287bb 100644 --- a/src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx +++ b/src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx @@ -10,6 +10,7 @@ import FileDropzone from '@/components/_shared/file-dropzone/FileDropzone'; import { TabPanel, ViewTab, ViewTabs } from '@/components/_shared/tabs/ViewTabs'; import { useEditorContext } from '@/components/editor/EditorContext'; import { FileHandler } from '@/utils/file'; +import { createPendingUploadId } from '@/utils/pending-upload'; import EmbedLink from 'src/components/_shared/image-upload/EmbedLink'; @@ -70,34 +71,32 @@ function PDFBlockPopoverContent({ blockId, onClose }: { blockId: string; onClose [uploadFile] ); - const createPendingFileData = useCallback( - async (file: File): Promise => { - const data: PDFBlockData = { - url: undefined, - name: file.name, - uploaded_at: Date.now(), - url_type: FieldURLType.Upload, - }; - - // Best-effort: a missing local snapshot must not block the remote upload - // (IndexedDB may be unavailable in private mode or over quota). - try { - const fileHandler = new FileHandler(); - const res = await fileHandler.handleFileUpload(file); - - // The popover never renders the local preview itself — the block - // creates its own object URL via `getStoredFile`. Revoke the one - // created here so it doesn't leak until the tab unloads. - URL.revokeObjectURL(res.url); - data.retry_local_url = res.id; - } catch { - data.retry_local_url = ''; - } + const createPendingFileData = useCallback(async (file: File): Promise => { + const data: PDFBlockData = { + url: undefined, + name: file.name, + uploaded_at: Date.now(), + url_type: FieldURLType.Upload, + pending_upload_id: createPendingUploadId(), + }; + + // Best-effort: a missing local snapshot must not block the remote upload + // (IndexedDB may be unavailable in private mode or over quota). + try { + const fileHandler = new FileHandler(); + const res = await fileHandler.handleFileUpload(file); + + // The popover never renders the local preview itself — the block + // creates its own object URL via `getStoredFile`. Revoke the one + // created here so it doesn't leak until the tab unloads. + URL.revokeObjectURL(res.url); + data.retry_local_url = res.id; + } catch { + data.retry_local_url = ''; + } - return data; - }, - [] - ); + return data; + }, []); const cleanupLocalFile = useCallback(async (retryLocalUrl?: string) => { if (!retryLocalUrl) return; @@ -125,14 +124,14 @@ function PDFBlockPopoverContent({ blockId, onClose }: { blockId: string; onClose try { const entry = findSlateEntryByBlockId(editor, targetBlockId); - currentData = entry ? ((entry[0] as { data?: PDFBlockData }).data ?? undefined) : undefined; + currentData = entry ? (entry[0] as { data?: PDFBlockData }).data ?? undefined : undefined; } catch { return; } if (!currentData) return; if (currentData.url) return; - if ((currentData.retry_local_url ?? '') !== (pendingData.retry_local_url ?? '')) return; + if (!pendingData.pending_upload_id || currentData.pending_upload_id !== pendingData.pending_upload_id) return; CustomEditor.setBlockData(editor, targetBlockId, { url, @@ -140,6 +139,7 @@ function PDFBlockPopoverContent({ blockId, onClose }: { blockId: string; onClose uploaded_at: Date.now(), url_type: FieldURLType.Upload, retry_local_url: '', + pending_upload_id: '', } as PDFBlockData); }, [cleanupLocalFile, editor, uploadFileRemote] @@ -153,9 +153,7 @@ function PDFBlockPopoverContent({ blockId, onClose }: { blockId: string; onClose try { // Run every local snapshot in parallel so the popover doesn't pay // N×IDB-latency before it can close. - const [primaryData, ...otherDatas] = await Promise.all( - files.map((f) => createPendingFileData(f)) - ); + const [primaryData, ...otherDatas] = await Promise.all(files.map((f) => createPendingFileData(f))); const [file, ...otherFiles] = files; CustomEditor.setBlockData(editor, blockId, primaryData); @@ -164,9 +162,7 @@ function PDFBlockPopoverContent({ blockId, onClose }: { blockId: string; onClose // Each new block is inserted directly below `blockId`, so iterating // in reverse preserves the user's original file order in the doc. - const reversedPairs = otherFiles - .map((f, i) => [f, otherDatas[i]] as const) - .reverse(); + const reversedPairs = otherFiles.map((f, i) => [f, otherDatas[i]] as const).reverse(); for (const [f, d] of reversedPairs) { const newId = CustomEditor.addBelowBlock(editor, blockId, BlockType.PDFBlock, d); @@ -206,7 +202,7 @@ function PDFBlockPopoverContent({ blockId, onClose }: { blockId: string; onClose
diff --git a/src/components/editor/components/blocks/file/FileBlock.tsx b/src/components/editor/components/blocks/file/FileBlock.tsx index 7d6ceb6d2..0f3910d2d 100644 --- a/src/components/editor/components/blocks/file/FileBlock.tsx +++ b/src/components/editor/components/blocks/file/FileBlock.tsx @@ -130,6 +130,7 @@ export const FileBlock = memo( uploaded_at: Date.now(), url_type: FieldURLType.Upload, retry_local_url: '', + pending_upload_id: '', } as FileBlockData); } catch (e) { // do nothing @@ -190,7 +191,7 @@ export const FileBlock = memo( /> )}
-
+
{children}
diff --git a/src/components/editor/components/blocks/image/ImageBlock.tsx b/src/components/editor/components/blocks/image/ImageBlock.tsx index 17d41ced0..02a8d2e84 100644 --- a/src/components/editor/components/blocks/image/ImageBlock.tsx +++ b/src/components/editor/components/blocks/image/ImageBlock.tsx @@ -110,7 +110,6 @@ export const ImageBlock = memo( [uploadFile] ); - const handleRetry = useCallback( async (e: React.MouseEvent) => { e.stopPropagation(); @@ -133,6 +132,7 @@ export const ImageBlock = memo( url, image_type: ImageType.External, retry_local_url: '', + pending_upload_id: '', } as ImageBlockData); } catch (e) { // do noting @@ -158,8 +158,9 @@ export const ImageBlock = memo( >
{url || needRetry ? ( >( - ({ node, children, ...attributes }, ref) => { - const { blockId, data } = node; - const { uploadFile } = useEditorContext(); - const editor = useSlateStatic() as YjsEditor; - const [needRetry, setNeedRetry] = useState(false); - const fileHandlerRef = useRef(new FileHandler()); - const [localUrl, setLocalUrl] = useState(undefined); - const [loading, setLoading] = useState(false); - const { url, name, retry_local_url } = data || {}; - const readOnly = useReadOnly() || editor.isElementReadOnly(node as unknown as Element); - const emptyRef = useRef(null); - const [showToolbar, setShowToolbar] = useState(false); - - const hasContent = url || needRetry; - - const className = [ - 'w-full', - url || !readOnly ? 'cursor-pointer' : 'text-text-secondary', - attributes.className, - ] - .filter(Boolean) - .join(' '); - - const { openPopover } = usePopoverContext(); - - const openUploadPopover = useCallback(() => { - if (emptyRef.current && !readOnly) { - openPopover(blockId, BlockType.PDFBlock, emptyRef.current); + forwardRef>(({ node, children, ...attributes }, ref) => { + const { blockId, data } = node; + const { uploadFile } = useEditorContext(); + const editor = useSlateStatic() as YjsEditor; + const [needRetry, setNeedRetry] = useState(false); + const fileHandlerRef = useRef(new FileHandler()); + const [localUrl, setLocalUrl] = useState(undefined); + const [loading, setLoading] = useState(false); + const { url, name, retry_local_url } = data || {}; + const readOnly = useReadOnly() || editor.isElementReadOnly(node as unknown as Element); + const emptyRef = useRef(null); + const [showToolbar, setShowToolbar] = useState(false); + + const hasContent = url || needRetry; + + const className = ['w-full', url || !readOnly ? 'cursor-pointer' : 'text-text-secondary', attributes.className] + .filter(Boolean) + .join(' '); + + const { openPopover } = usePopoverContext(); + + const openUploadPopover = useCallback(() => { + if (emptyRef.current && !readOnly) { + openPopover(blockId, BlockType.PDFBlock, emptyRef.current); + } + }, [blockId, openPopover, readOnly]); + + const openPDFInNewTab = useCallback(() => { + const link = url || localUrl; + + if (link) { + window.open(link, '_blank'); + } + }, [url, localUrl]); + + const handleClick = useCallback(async () => { + try { + if (!url && !needRetry) { + openUploadPopover(); + return; } - }, [blockId, openPopover, readOnly]); - const openPDFInNewTab = useCallback(() => { - const link = url || localUrl; - - if (link) { - window.open(link, '_blank'); + openPDFInNewTab(); + } catch (e: unknown) { + notify.error((e as Error).message); + } + }, [url, needRetry, openUploadPopover, openPDFInNewTab]); + + useEffect(() => { + if (readOnly) return; + void (async () => { + if (retry_local_url) { + const fileData = await fileHandlerRef.current.getStoredFile(retry_local_url); + + setLocalUrl(fileData?.url); + setNeedRetry(!!fileData); + } else { + setNeedRetry(false); } - }, [url, localUrl]); + })(); + }, [readOnly, retry_local_url]); - const handleClick = useCallback(async () => { + const uploadFileRemote = useCallback( + async (file: File) => { try { - if (!url && !needRetry) { - openUploadPopover(); - return; + if (uploadFile) { + return await uploadFile(file); } - - openPDFInNewTab(); } catch (e: unknown) { - notify.error((e as Error).message); + return; } - }, [url, needRetry, openUploadPopover, openPDFInNewTab]); - - useEffect(() => { - if (readOnly) return; - void (async () => { - if (retry_local_url) { - const fileData = await fileHandlerRef.current.getStoredFile(retry_local_url); - - setLocalUrl(fileData?.url); - setNeedRetry(!!fileData); - } else { - setNeedRetry(false); - } - })(); - }, [readOnly, retry_local_url]); - - const uploadFileRemote = useCallback( - async (file: File) => { - try { - if (uploadFile) { - return await uploadFile(file); - } - } catch (e: unknown) { + }, + [uploadFile] + ); + + const handleRetry = useCallback( + async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!retry_local_url) return; + + setLoading(true); + try { + const fileData = await fileHandlerRef.current.getStoredFile(retry_local_url); + const file = fileData?.file; + + if (!file) { + notify.error('File not found. Please upload again.'); return; } - }, - [uploadFile] - ); - - const handleRetry = useCallback( - async (e: React.MouseEvent) => { - e.stopPropagation(); - if (!retry_local_url) return; - - setLoading(true); - try { - const fileData = await fileHandlerRef.current.getStoredFile(retry_local_url); - const file = fileData?.file; - - if (!file) { - notify.error('File not found. Please upload again.'); - return; - } - - const url = await uploadFileRemote(file); - - if (!url) { - notify.error('Upload failed. Please try again.'); - return; - } - - await fileHandlerRef.current.cleanup(retry_local_url); - CustomEditor.setBlockData(editor, blockId, { - url, - name, - uploaded_at: Date.now(), - url_type: FieldURLType.Upload, - retry_local_url: '', - } as PDFBlockData); - } catch (e: unknown) { - notify.error((e as Error).message || 'Failed to retry upload. Please try again.'); - } finally { - setLoading(false); + + const url = await uploadFileRemote(file); + + if (!url) { + notify.error('Upload failed. Please try again.'); + return; } - }, - [blockId, editor, name, retry_local_url, uploadFileRemote] - ); - return ( + await fileHandlerRef.current.cleanup(retry_local_url); + CustomEditor.setBlockData(editor, blockId, { + url, + name, + uploaded_at: Date.now(), + url_type: FieldURLType.Upload, + retry_local_url: '', + pending_upload_id: '', + } as PDFBlockData); + } catch (e: unknown) { + notify.error((e as Error).message || 'Failed to retry upload. Please try again.'); + } finally { + setLoading(false); + } + }, + [blockId, editor, name, retry_local_url, uploadFileRemote] + ); + + return ( +
{ + if (!url) return; + setShowToolbar(true); + }} + onMouseLeave={() => setShowToolbar(false)} + onClick={handleClick} + >
{ - if (!url) return; - setShowToolbar(true); - }} - onMouseLeave={() => setShowToolbar(false)} - onClick={handleClick} + contentEditable={false} + className={`embed-block flex flex-row items-center gap-4 p-4 ${hasContent ? 'text-text-primary' : ''}`} > -
-
- -
- -
- {hasContent ? ( -
-
{name?.trim() || 'PDF Document'}
- {needRetry &&
Upload failed
} -
- ) : ( -
Upload or embed a PDF
- )} -
- - {needRetry && - (loading ? ( - - ) : ( - - - - - - ))} - {showToolbar && url && ( - - )} +
+
-
- {children} + +
+ {hasContent ? ( +
+
{name?.trim() || 'PDF Document'}
+ {needRetry &&
Upload failed
} +
+ ) : ( +
Upload or embed a PDF
+ )}
+ + {needRetry && + (loading ? ( + + ) : ( + + + + + + ))} + {showToolbar && url && ( + + )} +
+
+ {children}
- ); - } - ) +
+ ); + }) ); PDFBlock.displayName = 'PDFBlock'; diff --git a/src/components/editor/plugins/withInsertData.ts b/src/components/editor/plugins/withInsertData.ts index f1f56aa16..6c2e6e843 100644 --- a/src/components/editor/plugins/withInsertData.ts +++ b/src/components/editor/plugins/withInsertData.ts @@ -4,16 +4,21 @@ import { ReactEditor } from 'slate-react'; import { YjsEditor } from '@/application/slate-yjs'; import { CustomEditor } from '@/application/slate-yjs/command'; import { slateContentInsertToYData } from '@/application/slate-yjs/utils/convert'; -import { findSlateEntryByBlockId, getBlockEntry, getSharedRoot, isInsideSimpleTableCell } from '@/application/slate-yjs/utils/editor'; +import { + findSlateEntryByBlockId, + getBlockEntry, + getSharedRoot, + isInsideSimpleTableCell, +} from '@/application/slate-yjs/utils/editor'; import { assertDocExists, getBlock, getChildrenArray } from '@/application/slate-yjs/utils/yjs'; import { BlockType, FieldURLType, FileBlockData, ImageBlockData, ImageType, YjsEditorKey } from '@/application/types'; import { convertSlateFragmentTo } from '@/components/editor/utils/fragment'; import { FileHandler } from '@/utils/file'; import { Log } from '@/utils/log'; +import { createPendingUploadId } from '@/utils/pending-upload'; type BlockElement = Element & { blockId?: string }; - export const withInsertData = (editor: ReactEditor) => { const { insertData } = editor; @@ -45,8 +50,12 @@ export const withInsertData = (editor: ReactEditor) => { const parsed = JSON.parse(decoded) as Node[]; // Check if fragment contains table blocks - const hasTable = parsed.some((n: Node) => - Element.isElement(n) && [BlockType.SimpleTableBlock, BlockType.SimpleTableRowBlock, BlockType.SimpleTableCellBlock].includes(n.type as BlockType) + const hasTable = parsed.some( + (n: Node) => + Element.isElement(n) && + [BlockType.SimpleTableBlock, BlockType.SimpleTableRowBlock, BlockType.SimpleTableCellBlock].includes( + n.type as BlockType + ) ); if (hasTable) { @@ -66,8 +75,7 @@ export const withInsertData = (editor: ReactEditor) => { } const rawFragment = - data.getData('application/x-slate-fragment') || - extractSlateFragmentFromHTML(data.getData('text/html')); + data.getData('application/x-slate-fragment') || extractSlateFragmentFromHTML(data.getData('text/html')); if (rawFragment) { const parsed = decodeSlateFragment(rawFragment); @@ -136,6 +144,7 @@ export const withInsertData = (editor: ReactEditor) => { for (let i = 0; i < fileArray.length; i++) { const file = fileArray[i]; const fileId = fileIds[i]; + const pendingUploadId = createPendingUploadId(); const isImage = file.type.startsWith('image/'); let insertedBlockId: string | undefined; @@ -144,6 +153,7 @@ export const withInsertData = (editor: ReactEditor) => { url: '', image_type: undefined, retry_local_url: fileId, + pending_upload_id: pendingUploadId, } as ImageBlockData; insertedBlockId = CustomEditor.addBelowBlock(e, newBlockId, BlockType.ImageBlock, data); @@ -155,6 +165,7 @@ export const withInsertData = (editor: ReactEditor) => { uploaded_at: Date.now(), url_type: FieldURLType.Upload, retry_local_url: fileId, + pending_upload_id: pendingUploadId, } as FileBlockData; insertedBlockId = CustomEditor.addBelowBlock(e, newBlockId, BlockType.FileBlock, data); @@ -162,54 +173,60 @@ export const withInsertData = (editor: ReactEditor) => { } if (insertedBlockId) { - pendingUploads.push((async () => { - let url: string | undefined; - - try { - url = await e.uploadFile?.(file); - } catch { - return; - } - - if (!url) return; - - if (fileId) { - await fileHandler.cleanup(fileId).catch(() => undefined); - } - - // The paste handler runs in the background after the user - // already moved on. Skip the write if the placeholder is gone - // or already finalised so we don't clobber later edits. - let currentData: { url?: string; retry_local_url?: string } | undefined; - - try { - const entry = findSlateEntryByBlockId(e, insertedBlockId); - - currentData = entry ? ((entry[0] as { data?: { url?: string; retry_local_url?: string } }).data ?? undefined) : undefined; - } catch { - return; - } - - if (!currentData) return; - if (currentData.url) return; - if ((currentData.retry_local_url ?? '') !== fileId) return; - - if (isImage) { - CustomEditor.setBlockData(e, insertedBlockId, { - url, - image_type: ImageType.External, - retry_local_url: '', - } as ImageBlockData); - } else { - CustomEditor.setBlockData(e, insertedBlockId, { - url, - name: file.name, - uploaded_at: Date.now(), - url_type: FieldURLType.Upload, - retry_local_url: '', - } as FileBlockData); - } - })()); + pendingUploads.push( + (async () => { + let url: string | undefined; + + try { + url = await e.uploadFile?.(file); + } catch { + return; + } + + if (!url) return; + + if (fileId) { + await fileHandler.cleanup(fileId).catch(() => undefined); + } + + // The paste handler runs in the background after the user + // already moved on. Skip the write if the placeholder is gone + // or already finalised so we don't clobber later edits. + let currentData: { url?: string; pending_upload_id?: string } | undefined; + + try { + const entry = findSlateEntryByBlockId(e, insertedBlockId); + + currentData = entry + ? (entry[0] as { data?: { url?: string; pending_upload_id?: string } }).data ?? undefined + : undefined; + } catch { + return; + } + + if (!currentData) return; + if (currentData.url) return; + if (currentData.pending_upload_id !== pendingUploadId) return; + + if (isImage) { + CustomEditor.setBlockData(e, insertedBlockId, { + url, + image_type: ImageType.External, + retry_local_url: '', + pending_upload_id: '', + } as ImageBlockData); + } else { + CustomEditor.setBlockData(e, insertedBlockId, { + url, + name: file.name, + uploaded_at: Date.now(), + url_type: FieldURLType.Upload, + retry_local_url: '', + pending_upload_id: '', + } as FileBlockData); + } + })() + ); } } @@ -231,14 +248,12 @@ export const withInsertData = (editor: ReactEditor) => { const [, path] = entry; editor.select(editor.start(path)); - } void Promise.all(pendingUploads).catch((err) => { Log.warn('withInsertData: failed to finalize pasted file upload', err); }); })(); - } }; @@ -342,9 +357,7 @@ function insertFragmentAsSiblings(editor: YjsEditor, fragment: Node[]): boolean // If the current block is empty (no text, no children), the user expects // paste to fill that block — not push it above the pasted content. Insert // at the current index and remove the empty original. - const isEmpty = - CustomEditor.getBlockTextContent(node as Node).length === 0 && - (node.children?.length ?? 0) <= 1; + const isEmpty = CustomEditor.getBlockTextContent(node as Node).length === 0 && (node.children?.length ?? 0) <= 1; const doc = assertDocExists(sharedRoot); let insertedIds: string[] = []; @@ -430,7 +443,7 @@ function extractTextsFromFragment(nodes: Node[]): string | null { if (rows.length === 0) return null; - return rows.map(row => row.join('\t')).join('\n'); + return rows.map((row) => row.join('\t')).join('\n'); } /** diff --git a/src/utils/pending-upload.ts b/src/utils/pending-upload.ts new file mode 100644 index 000000000..d30d6b22e --- /dev/null +++ b/src/utils/pending-upload.ts @@ -0,0 +1,3 @@ +export function createPendingUploadId(): string { + return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} From 3acdfe8d297e9c81f1be7ef5e2e4fc06edcfc822 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 17 May 2026 09:40:12 +0800 Subject: [PATCH 3/3] Fix file upload e2e auth setup --- .../editor/blocks/file-block-upload.spec.ts | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/playwright/e2e/editor/blocks/file-block-upload.spec.ts b/playwright/e2e/editor/blocks/file-block-upload.spec.ts index 45f7716be..ab0960d86 100644 --- a/playwright/e2e/editor/blocks/file-block-upload.spec.ts +++ b/playwright/e2e/editor/blocks/file-block-upload.spec.ts @@ -1,5 +1,6 @@ import { test, expect, Page, Request } from '@playwright/test'; -import { signInAndNavigate } from '../../support/auth-utils'; +import { signInAndWaitForApp } from '../../../support/auth-flow-helpers'; +import { generateRandomEmail } from '../../../support/test-config'; /** * Editor file-block / image-block popover upload regression tests. @@ -25,16 +26,12 @@ test.describe('Feature: Editor block popover upload', () => { let page: Page; - test.beforeEach(async ({ browser }) => { - page = await browser.newPage(); - await signInAndNavigate(page); + test.beforeEach(async ({ page: testPage, request }) => { + page = testPage; + await signInAndWaitForApp(page, request, generateRandomEmail()); await page.locator('[data-testid="inline-add-page"]').first().waitFor({ state: 'visible', timeout: 30000 }); }); - test.afterEach(async () => { - await page.close(); - }); - /** * Create a new doc page via the inline-add button. */ @@ -106,10 +103,12 @@ test.describe('Feature: Editor block popover upload', () => { // just persist it locally. The local IndexedDB save and the remote upload // were re-ordered in a recent refactor; this catches a regression where // a missing/failed local save would short-circuit the remote upload. - await expect.poll( - () => uploadRequests.filter((r) => r.method() !== 'GET').length, - { timeout: 30000, message: 'no upload request fired for file block' } - ).toBeGreaterThan(0); + await expect + .poll(() => uploadRequests.filter((r) => r.method() !== 'GET').length, { + timeout: 30000, + message: 'no upload request fired for file block', + }) + .toBeGreaterThan(0); // The block also flips out of its empty state (the file name appears). await expect(getEditor()).toContainText('regression.bin', { timeout: 30000 }); @@ -131,10 +130,12 @@ test.describe('Feature: Editor block popover upload', () => { buffer: TINY_PNG, }); - await expect.poll( - () => uploadRequests.filter((r) => r.method() !== 'GET').length, - { timeout: 30000, message: 'no upload request fired for image block' } - ).toBeGreaterThan(0); + await expect + .poll(() => uploadRequests.filter((r) => r.method() !== 'GET').length, { + timeout: 30000, + message: 'no upload request fired for image block', + }) + .toBeGreaterThan(0); // The block flips out of its empty state and renders an . await expect(getEditor().locator('img').first()).toBeVisible({ timeout: 30000 }); @@ -207,9 +208,11 @@ test.describe('Feature: Editor block popover upload', () => { // rejects, the popover code must NOT swallow that error and skip the // remote upload. A non-GET request to a file storage endpoint must still // fire. - await expect.poll( - () => uploadRequests.filter((r) => r.method() !== 'GET').length, - { timeout: 30000, message: 'no upload request fired when IndexedDB was disabled' } - ).toBeGreaterThan(0); + await expect + .poll(() => uploadRequests.filter((r) => r.method() !== 'GET').length, { + timeout: 30000, + message: 'no upload request fired when IndexedDB was disabled', + }) + .toBeGreaterThan(0); }); });