From 61fcae7c6781bb579c9d5c30e0f5fac935a07e3b Mon Sep 17 00:00:00 2001 From: John Fu Date: Fri, 20 Feb 2026 12:55:20 +1100 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20add=20optional=20onImage?= =?UTF-8?q?LoadError=20callback=20for=20image-block=20(#2251)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 🤖 set up vitest for packages/components Enables unit testing of components * feat: 🎸 add optional onImageLoadError callback for image-block Let's you to configure image-block with an optional callback when an image fails to load (e.g. invalid URL or network error) ✅ Closes: #2245 fix: 🐛 blockOnImageLoadError should be onImageLoadError * test: 💍 unit test onImageLoadError * Add onImageLoadError callback to image-block Add an optional callback for handling image load errors in the image-block component. * chore: remove changeset file --- docs/api/component-image-block.md | 25 +++++++++++ packages/components/package.json | 8 +++- packages/components/rollup.config.js | 7 +++- .../__internal__/components/image-input.tsx | 23 ++++++++-- packages/components/src/__tests__/setup.ts | 6 +++ packages/components/src/image-block/config.ts | 1 + .../image-viewer.onImageLoadError.spec.tsx | 42 +++++++++++++++++++ .../view/components/image-block.tsx | 1 + .../view/components/image-viewer.tsx | 3 ++ packages/components/vitest.config.ts | 9 ++++ .../crepe/src/feature/image-block/index.ts | 2 + pnpm-lock.yaml | 42 ++++--------------- 12 files changed, 130 insertions(+), 39 deletions(-) create mode 100644 packages/components/src/__tests__/setup.ts create mode 100644 packages/components/src/image-block/view/components/__tests__/image-viewer.onImageLoadError.spec.tsx create mode 100644 packages/components/vitest.config.ts diff --git a/docs/api/component-image-block.md b/docs/api/component-image-block.md index 1955fda43a1..2e63b1a0bc5 100644 --- a/docs/api/component-image-block.md +++ b/docs/api/component-image-block.md @@ -47,6 +47,7 @@ You can configure the component by updating the `imageBlockConfig` ctx in `edito | `captionPlaceholderText` | `string` | `'Image caption'` | Placeholder text for the caption input | | `onUpload` | `(file: File) => Promise` | `(file) => Promise.resolve(URL.createObjectURL(file))` | Function called when an image is uploaded; must return a Promise with the image URL | | `proxyDomURL` | `(url: string) => Promise \| string` | `undefined` | Optional function to proxy the image URL | +| `onImageLoadError` | `(event: Event) => void \| Promise` | `undefined` | Optional callback when an image fails to load (e.g. invalid URL or network error) | --- @@ -112,3 +113,27 @@ ctx.update(imageBlockConfig.key, (defaultConfig) => ({ }, })) ``` + +## `onImageLoadError` + +Optional callback invoked when an image fails to load (invalid URL, CORS, 404, etc.). Use it to show a message, fallback UI, or report errors. May be sync or async (`Promise`). + +```typescript +import { imageBlockConfig } from '@milkdown/components/image-block' + +ctx.update(imageBlockConfig.key, (defaultConfig) => ({ + ...defaultConfig, + onImageLoadError: (event: Event) => { + console.error('Image failed to load', event) + // e.g. show toast or replace with placeholder + }, +})) + +// Async is also supported +ctx.update(imageBlockConfig.key, (defaultConfig) => ({ + ...defaultConfig, + onImageLoadError: async (event: Event) => { + await reportToAnalytics('image_load_error', event) + }, +})) +``` diff --git a/packages/components/package.json b/packages/components/package.json index 7fea600f736..01d9888f147 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -98,7 +98,8 @@ "src" ], "scripts": { - "build": "rollup -c" + "build": "rollup -c", + "test": "vitest run" }, "peerDependencies": { "@codemirror/language": "^6", @@ -127,6 +128,9 @@ "devDependencies": { "@codemirror/language": "^6.10.1", "@codemirror/state": "^6.4.1", - "@codemirror/view": "^6.26.0" + "@codemirror/view": "^6.26.0", + "@testing-library/vue": "^8.1.0", + "jsdom": "^28.0.0", + "vitest": "^4.0.0" } } diff --git a/packages/components/rollup.config.js b/packages/components/rollup.config.js index a0106c4f0f6..a992584b34d 100644 --- a/packages/components/rollup.config.js +++ b/packages/components/rollup.config.js @@ -63,6 +63,11 @@ const dirs = fs.readdirSync(path.resolve(dirname, './src')) export default () => dirs - .filter((x) => x !== '__internal__' && !x.includes('index')) + .filter( + (x) => + x !== '__internal__' && + x !== '__tests__' && + !x.includes('index') + ) .flatMap(componentModule) .concat(main) diff --git a/packages/components/src/__internal__/components/image-input.tsx b/packages/components/src/__internal__/components/image-input.tsx index 13e5bf91cfa..eb0e9c99ecc 100644 --- a/packages/components/src/__internal__/components/image-input.tsx +++ b/packages/components/src/__internal__/components/image-input.tsx @@ -22,6 +22,7 @@ type ImageInputProps = { className?: string onUpload: (file: File) => Promise + onImageLoadError?: (event: Event) => void | Promise } export const ImageInput = defineComponent({ @@ -62,6 +63,10 @@ export const ImageInput = defineComponent({ type: Function, required: true, }, + onImageLoadError: { + type: Function, + required: false, + }, }, setup({ readonly, @@ -73,6 +78,7 @@ export const ImageInput = defineComponent({ confirmButton, uploadPlaceholderText, className, + onImageLoadError, }) { const focusLinkInput = ref(false) const linkInputRef = ref() @@ -153,9 +159,20 @@ export const ImageInput = defineComponent({ )} {currentLink.value && ( -
onConfirmLinkInput()}> - -
+ <> +
+ + Promise.resolve(onImageLoadError?.(e)).catch(() => {}) + } + /> +
+
onConfirmLinkInput()}> + +
+ )} ) diff --git a/packages/components/src/__tests__/setup.ts b/packages/components/src/__tests__/setup.ts new file mode 100644 index 00000000000..fab6f953962 --- /dev/null +++ b/packages/components/src/__tests__/setup.ts @@ -0,0 +1,6 @@ +import { cleanup } from '@testing-library/vue' +import { afterEach } from 'vitest' + +afterEach(() => { + cleanup() +}) diff --git a/packages/components/src/image-block/config.ts b/packages/components/src/image-block/config.ts index f783957c762..823c85fda2b 100644 --- a/packages/components/src/image-block/config.ts +++ b/packages/components/src/image-block/config.ts @@ -11,6 +11,7 @@ export interface ImageBlockConfig { captionPlaceholderText: string onUpload: (file: File) => Promise proxyDomURL?: (url: string) => Promise | string + onImageLoadError?: (event: Event) => void | Promise } export const defaultImageBlockConfig: ImageBlockConfig = { diff --git a/packages/components/src/image-block/view/components/__tests__/image-viewer.onImageLoadError.spec.tsx b/packages/components/src/image-block/view/components/__tests__/image-viewer.onImageLoadError.spec.tsx new file mode 100644 index 00000000000..fc40afb3c95 --- /dev/null +++ b/packages/components/src/image-block/view/components/__tests__/image-viewer.onImageLoadError.spec.tsx @@ -0,0 +1,42 @@ +import { render } from '@testing-library/vue' +import { expect, test, vi } from 'vitest' +import { ref } from 'vue' + +import { ImageViewer } from '../image-viewer' + +test('calls onImageLoadError when img fires error', async () => { + const onImageLoadError = vi.fn() + const config = { + onImageLoadError, + captionIcon: '💬', + captionPlaceholderText: 'Image caption', + imageIcon: '🖼️', + uploadButton: 'Upload', + confirmButton: 'Confirm', + uploadPlaceholderText: 'or paste an image URL', + onUpload: async () => 'https://example.com/photo.png', + } + + // render the ImageViewer component + const { container } = render(ImageViewer, { + props: { + src: ref('https://example.com/photo.png'), + caption: ref(''), + ratio: ref(1), + selected: ref(false), + readonly: ref(false), + setAttr: () => {}, + config, + }, + }) + + const img = container.querySelector('img[data-type="image-block"]') + expect(img).toBeTruthy() + + // simulate the image load error + img!.dispatchEvent(new Event('error')) + + // expect the onImageLoadError function to have been called + expect(onImageLoadError).toHaveBeenCalledTimes(1) + expect(onImageLoadError).toHaveBeenCalledWith(expect.any(Event)) +}) diff --git a/packages/components/src/image-block/view/components/image-block.tsx b/packages/components/src/image-block/view/components/image-block.tsx index cd3721f36ba..20f53b3d9a8 100644 --- a/packages/components/src/image-block/view/components/image-block.tsx +++ b/packages/components/src/image-block/view/components/image-block.tsx @@ -70,6 +70,7 @@ export const MilkdownImageBlock = defineComponent({ confirmButton={props.config.confirmButton} uploadPlaceholderText={props.config.uploadPlaceholderText} onUpload={props.config.onUpload} + onImageLoadError={props.config.onImageLoadError} /> ) } diff --git a/packages/components/src/image-block/view/components/image-viewer.tsx b/packages/components/src/image-block/view/components/image-viewer.tsx index 802ff106e45..2c5d8bbed8c 100644 --- a/packages/components/src/image-block/view/components/image-viewer.tsx +++ b/packages/components/src/image-block/view/components/image-viewer.tsx @@ -143,6 +143,9 @@ export const ImageViewer = defineComponent({ onLoad={onImageLoad} src={src.value} alt={caption.value} + onError={(e) => + Promise.resolve(config.onImageLoadError?.(e)).catch(() => {}) + } />
Promise + onImageLoadError: (event: Event) => void | Promise } export type ImageBlockFeatureConfig = Partial @@ -61,6 +62,7 @@ export const imageBlock: DefineFeature = ( config?.blockUploadPlaceholderText ?? 'or paste link', onUpload: config?.blockOnUpload ?? config?.onUpload ?? value.onUpload, proxyDomURL: config?.proxyDomURL, + onImageLoadError: config?.onImageLoadError ?? value.onImageLoadError, })) }) .use(imageBlockComponent) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94afedb2233..db66274bc83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -305,6 +305,15 @@ importers: '@codemirror/view': specifier: ^6.26.0 version: 6.39.12 + '@testing-library/vue': + specifier: ^8.1.0 + version: 8.1.0(@vue/compiler-sfc@3.5.27)(vue@3.5.27(typescript@5.9.3)) + jsdom: + specifier: ^28.0.0 + version: 28.0.0 + vitest: + specifier: ^4.0.0 + version: 4.0.18(@types/node@24.10.9)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) packages/core: dependencies: @@ -1853,49 +1862,41 @@ packages: resolution: {integrity: sha512-wdcQ7Niad9JpjZIGEeqKJnTvczVunqlZ/C06QzR5zOQNeLVRScQ9S5IesKWUAPsJQDizV+teQX53nTK+Z5Iy+g==} cpu: [arm64] os: [linux] - libc: [glibc] '@oxc-resolver/binding-linux-arm64-musl@11.17.0': resolution: {integrity: sha512-65B2/t39HQN5AEhkLsC+9yBD1iRUkKOIhfmJEJ7g6wQ9kylra7JRmNmALFjbsj0VJsoSQkpM8K07kUZuNJ9Kxw==} cpu: [arm64] os: [linux] - libc: [musl] '@oxc-resolver/binding-linux-ppc64-gnu@11.17.0': resolution: {integrity: sha512-kExgm3TLK21dNMmcH+xiYGbc6BUWvT03PUZ2aYn8mUzGPeeORklBhg3iYcaBI3ZQHB25412X1Z6LLYNjt4aIaA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxc-resolver/binding-linux-riscv64-gnu@11.17.0': resolution: {integrity: sha512-1utUJC714/ydykZQE8c7QhpEyM4SaslMfRXxN9G61KYazr6ndt85LaubK3EZCSD50vVEfF4PVwFysCSO7LN9uA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxc-resolver/binding-linux-riscv64-musl@11.17.0': resolution: {integrity: sha512-mayiYOl3LMmtO2CLn4I5lhanfxEo0LAqlT/EQyFbu1ZN3RS+Xa7Q3JEM0wBpVIyfO/pqFrjvC5LXw/mHNDEL7A==} cpu: [riscv64] os: [linux] - libc: [musl] '@oxc-resolver/binding-linux-s390x-gnu@11.17.0': resolution: {integrity: sha512-Ow/yI+CrUHxIIhn/Y1sP/xoRKbCC3x9O1giKr3G/pjMe+TCJ5ZmfqVWU61JWwh1naC8X5Xa7uyLnbzyYqPsHfg==} cpu: [s390x] os: [linux] - libc: [glibc] '@oxc-resolver/binding-linux-x64-gnu@11.17.0': resolution: {integrity: sha512-Z4J7XlPMQOLPANyu6y3B3V417Md4LKH5bV6bhqgaG99qLHmU5LV2k9ErV14fSqoRc/GU/qOpqMdotxiJqN/YWg==} cpu: [x64] os: [linux] - libc: [glibc] '@oxc-resolver/binding-linux-x64-musl@11.17.0': resolution: {integrity: sha512-0effK+8lhzXsgsh0Ny2ngdnTPF30v6QQzVFApJ1Ctk315YgpGkghkelvrLYYgtgeFJFrzwmOJ2nDvCrUFKsS2Q==} cpu: [x64] os: [linux] - libc: [musl] '@oxc-resolver/binding-openharmony-arm64@11.17.0': resolution: {integrity: sha512-kFB48dRUW6RovAICZaxHKdtZe+e94fSTNA2OedXokzMctoU54NPZcv0vUX5PMqyikLIKJBIlW7laQidnAzNrDA==} @@ -1936,25 +1937,21 @@ packages: resolution: {integrity: sha512-75tf1HvwdZ3ebk83yMbSB+moAEWK98mYqpXiaFAi6Zshie7r+Cx5PLXZFUEqkscenoZ+fcNXakHxfn94V6nf1g==} cpu: [arm64] os: [linux] - libc: [glibc] '@oxlint/linux-arm64-musl@1.43.0': resolution: {integrity: sha512-BHV4fb36T2p/7bpA9fiJ5ayt7oJbiYX10nklW5arYp4l9/9yG/FQC5J4G1evzbJ/YbipF9UH0vYBAm5xbqGrvw==} cpu: [arm64] os: [linux] - libc: [musl] '@oxlint/linux-x64-gnu@1.43.0': resolution: {integrity: sha512-1l3nvnzWWse1YHibzZ4HQXdF/ibfbKZhp9IguElni3bBqEyPEyurzZ0ikWynDxKGXqZa+UNXTFuU1NRVX1RJ3g==} cpu: [x64] os: [linux] - libc: [glibc] '@oxlint/linux-x64-musl@1.43.0': resolution: {integrity: sha512-+jNYgLGRFTJxJuaSOZJBwlYo5M0TWRw0+3y5MHOL4ArrIdHyCthg6r4RbVWrsR1qUfUE1VSSHQ2bfbC99RXqMg==} cpu: [x64] os: [linux] - libc: [musl] '@oxlint/win32-arm64@1.43.0': resolution: {integrity: sha512-dvs1C/HCjCyGTURMagiHprsOvVTT3omDiSzi5Qw0D4QFJ1pEaNlfBhVnOUYgUfS6O7Mcmj4+G+sidRsQcWQ/kA==} @@ -2048,79 +2045,66 @@ packages: resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} @@ -2261,28 +2245,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -3755,28 +3735,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}