fix: use blob URLs for image uploads instead of data URLs

The uploadFile callback was converting images to data URLs via FileReader,
which caused images to get stuck on "Loading..." in the editor. Switched
to URL.createObjectURL() which returns a lightweight blob URL immediately.
The Tauri vault-save path is now fully fire-and-forget so it never blocks
the URL return.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-21 11:00:41 +01:00
parent 5e738f1e2b
commit e02db50533
2 changed files with 50 additions and 77 deletions

View File

@@ -11,81 +11,56 @@ function createTestPng(filepath: string) {
fs.writeFileSync(filepath, Buffer.from(TEST_PNG_BASE64, 'base64'))
}
test('image upload via slash menu inserts image block', async ({ page }) => {
test('image upload via file picker displays image with blob URL', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(1000)
// Click first note item (each note card has a type-icon data-testid)
const noteItem = page.locator('[data-testid="type-icon"]').first()
await noteItem.click({ timeout: 10000 })
// Open a note
await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 })
await page.waitForTimeout(500)
// Screenshot before image upload
await page.screenshot({ path: 'test-results/image-upload-before.png', fullPage: true })
// Click into the editor to focus it
const editor = page.locator('.bn-editor')
await expect(editor).toBeVisible({ timeout: 10000 })
await editor.click()
await page.waitForTimeout(200)
// Press Enter to create a new line, then type /image to open slash menu
// Insert image block via slash command
await page.keyboard.press('Enter')
await page.waitForTimeout(100)
await page.keyboard.type('/image', { delay: 50 })
await page.keyboard.type('/image', { delay: 80 })
await page.waitForTimeout(500)
// Screenshot showing slash menu with image option
await page.screenshot({ path: 'test-results/image-slash-menu.png', fullPage: true })
// Select Image from slash menu (press Enter to pick first match)
await page.keyboard.press('Enter')
await page.waitForTimeout(500)
// Look for Image item in the slash menu
const imageMenuItem = page.locator('[class*="suggestionMenu"] [class*="item"]', { hasText: 'Image' }).first()
const menuVisible = await imageMenuItem.isVisible({ timeout: 3000 }).catch(() => false)
// Verify Upload tab is available (uploadFile is configured)
const fileInput = page.locator('input[type="file"]')
expect(await fileInput.count()).toBeGreaterThan(0)
if (menuVisible) {
await imageMenuItem.click()
await page.waitForTimeout(500)
// Upload a test image
const testImagePath = path.join(process.cwd(), 'test-results', 'test-image.png')
createTestPng(testImagePath)
// After inserting image block, look for upload tab/button
const uploadTab = page.getByText(/upload/i).first()
const uploadVisible = await uploadTab.isVisible({ timeout: 3000 }).catch(() => false)
await fileInput.first().setInputFiles(testImagePath)
await page.waitForTimeout(2000)
if (uploadVisible) {
await uploadTab.click()
await page.waitForTimeout(200)
// Verify: image element exists in the editor
const images = page.locator('.bn-editor img')
const imageCount = await images.count()
expect(imageCount).toBeGreaterThan(0)
// Create a test image file
const testImagePath = path.join(__dirname, '..', 'test-results', 'test-image.png')
createTestPng(testImagePath)
// Verify: image uses blob URL (not stuck on empty or data URL)
const src = await images.first().getAttribute('src')
expect(src).toMatch(/^blob:/)
// Try to upload via input[type=file] or file chooser
const uploadInput = page.locator('input[type="file"]').first()
if (await uploadInput.count() > 0) {
await uploadInput.setInputFiles(testImagePath)
} else {
const fileChooserPromise = page.waitForEvent('filechooser', { timeout: 5000 })
const uploadBtn = page.locator('button', { hasText: /upload|choose|browse/i }).first()
if (await uploadBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
await uploadBtn.click()
const fileChooser = await fileChooserPromise
await fileChooser.setFiles(testImagePath)
}
}
// Verify: no "Loading..." elements remain
const loadingEls = page.locator('.bn-file-loading-preview')
expect(await loadingEls.count()).toBe(0)
await page.waitForTimeout(1000)
// Verify an image is now displayed in the editor
const imageInEditor = page.locator('.bn-editor img')
const imageCount = await imageInEditor.count()
console.log(`Images found in editor after upload: ${imageCount}`)
// Clean up test image
if (fs.existsSync(testImagePath)) fs.unlinkSync(testImagePath)
}
}
// Screenshot after image upload attempt
await page.screenshot({ path: 'test-results/image-upload-after.png', fullPage: true })
if (fs.existsSync(testImagePath)) fs.unlinkSync(testImagePath)
})
test('editor has uploadFile configured (no error on image block insert)', async ({ page }) => {
@@ -93,8 +68,7 @@ test('editor has uploadFile configured (no error on image block insert)', async
await page.waitForTimeout(1000)
// Click first note
const noteItem = page.locator('[data-testid="type-icon"]').first()
await noteItem.click({ timeout: 10000 })
await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 })
await page.waitForTimeout(500)
const editor = page.locator('.bn-editor')
@@ -110,12 +84,9 @@ test('editor has uploadFile configured (no error on image block insert)', async
await page.keyboard.type('/image', { delay: 30 })
await page.waitForTimeout(500)
// Click Image in the slash menu if visible
const imageMenuItem = page.locator('[class*="suggestionMenu"] [class*="item"]', { hasText: 'Image' }).first()
if (await imageMenuItem.isVisible({ timeout: 3000 }).catch(() => false)) {
await imageMenuItem.click()
await page.waitForTimeout(500)
}
// Press Enter to select Image
await page.keyboard.press('Enter')
await page.waitForTimeout(500)
await page.screenshot({ path: 'test-results/image-block-inserted.png', fullPage: true })

View File

@@ -164,26 +164,28 @@ export const Editor = memo(function Editor({
const editor = useCreateBlockNote({
schema,
uploadFile: async (file: File) => {
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(file)
})
// Use blob URL for immediate display — lightweight and avoids encoding large files as data URLs
const blobUrl = URL.createObjectURL(file)
// In Tauri mode, also persist the file to the vault's attachments directory
// In Tauri mode, persist the file to the vault's attachments directory (fire-and-forget)
if (isTauri() && vaultPathRef.current) {
const base64 = dataUrl.split(',')[1]
if (base64) {
invoke('save_image', {
vaultPath: vaultPathRef.current,
filename: file.name,
data: base64,
}).catch(err => console.warn('Failed to save image to vault:', err))
}
const vaultPath = vaultPathRef.current
file.arrayBuffer().then(buf => {
const bytes = new Uint8Array(buf)
let binary = ''
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i])
const base64 = btoa(binary)
if (base64) {
invoke('save_image', {
vaultPath,
filename: file.name,
data: base64,
}).catch(err => console.warn('Failed to save image to vault:', err))
}
}).catch(err => console.warn('Failed to read image for vault save:', err))
}
return dataUrl
return blobUrl
},
})
// Cache parsed blocks per tab path for instant switching