Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/api/component-image-block.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ You can configure the component by updating the `imageBlockConfig` ctx in `edito
| `onUpload` | `(file: File) => Promise<string>` | `(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> \| string` | `undefined` | Optional function to proxy the image URL |
| `onImageLoadError` | `(event: Event) => void \| Promise<void>` | `undefined` | Optional callback when an image fails to load (e.g. invalid URL or network error) |
| `maxWidth` | `number \| undefined` | `undefined` | Optional maximum display width in pixels for the image |
| `maxHeight` | `number \| undefined` | `undefined` | Optional maximum display height in pixels for the image |

---

Expand Down Expand Up @@ -137,3 +139,17 @@ ctx.update(imageBlockConfig.key, (defaultConfig) => ({
},
}))
```

## `maxWidth` and `maxHeight`

Optional maximum dimensions (in pixels) for displayed images. Images exceeding these bounds will be scaled down while maintaining their aspect ratio. These constraints also apply during drag-to-resize.

```typescript
import { imageBlockConfig } from '@milkdown/components/image-block'

ctx.update(imageBlockConfig.key, (defaultConfig) => ({
...defaultConfig,
maxWidth: 800,
maxHeight: 600,
}))
```
3 changes: 3 additions & 0 deletions e2e/shim.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ declare global {

var __beforeCrepeCreate__: (crepe: Crepe) => void
var __afterCrepeCreated__: (crepe: Crepe) => void

var __imageBlockMaxWidth__: number | undefined
var __imageBlockMaxHeight__: number | undefined
var __commandsCtx__: typeof commandsCtx

var commands: {
Expand Down
2 changes: 2 additions & 0 deletions e2e/src/data.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { crepe } from './crepe'
import { imageBlock } from './image-block'
import { multiEditor } from './multi-editor'
import { automd } from './plugin-automd'
import { listener } from './plugin-listener'
Expand All @@ -12,4 +13,5 @@ export const cases: { title: string; link: string }[] = [
listener,
automd,
crepe,
imageBlock,
]
11 changes: 11 additions & 0 deletions e2e/src/image-block/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Image Block</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/image-block/main.ts"></script>
</body>
</html>
4 changes: 4 additions & 0 deletions e2e/src/image-block/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const imageBlock = {
title: 'Image Block',
link: '/image-block/',
}
21 changes: 21 additions & 0 deletions e2e/src/image-block/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Crepe, CrepeFeature } from '@milkdown/crepe'
import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'
import { setup } from '../utils'

setup(async () => {
const maxWidth = globalThis.__imageBlockMaxWidth__ as number | undefined
const maxHeight = globalThis.__imageBlockMaxHeight__ as number | undefined

const crepe = new Crepe({
root: '#app',
featureConfigs: {
[CrepeFeature.ImageBlock]: {
maxWidth,
maxHeight,
},
},
})
await crepe.create()
return crepe.editor
}).catch(console.error)
97 changes: 97 additions & 0 deletions e2e/tests/crepe/image-block.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { expect, test, type Page } from '@playwright/test'

// A 1000x800 red PNG generated as a data URL is impractical,
// so we intercept the image request and return a generated PNG.
async function routeMockImage(
page: Page,
url: string,
width: number,
height: number
) {
await page.route(url, async (route) => {
// Generate a minimal valid PNG with the specified dimensions via canvas
const body = await page.evaluate(
({ w, h }) => {
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')!
ctx.fillStyle = '#ff0000'
ctx.fillRect(0, 0, w, h)
return canvas.toDataURL('image/png').split(',')[1]!
},
{ w: width, h: height }
)
await route.fulfill({
contentType: 'image/png',
body: Buffer.from(body, 'base64'),
})
})
}

const MOCK_IMAGE_URL = 'https://mock.test/image.png'
const IMAGE_SELECTOR = 'img[data-type="image-block"]'

test.describe('image block maxWidth', () => {
test('constrains image width to maxWidth', async ({ page }) => {
await page.addInitScript(() => {
window.__imageBlockMaxWidth__ = 400
})

await routeMockImage(page, MOCK_IMAGE_URL, 1000, 800)
await page.goto('/image-block/')

await page.evaluate((url: string) => {
window.__setMarkdown__(`![1.00](${url})`)
}, MOCK_IMAGE_URL)

const img = page.locator(IMAGE_SELECTOR)
await img.waitFor({ state: 'attached' })
await expect(img).toHaveCSS('max-width', '400px')
})
})

test.describe('image block maxHeight', () => {
test('constrains image height to maxHeight', async ({ page }) => {
await page.addInitScript(() => {
window.__imageBlockMaxHeight__ = 300
})

// Use a tall image: 400x800 (natural height > maxHeight)
await routeMockImage(page, MOCK_IMAGE_URL, 400, 800)
await page.goto('/image-block/')

await page.evaluate((url: string) => {
window.__setMarkdown__(`![1.00](${url})`)
}, MOCK_IMAGE_URL)

const img = page.locator(IMAGE_SELECTOR)
await img.waitFor({ state: 'attached' })

// Wait for the onImageLoad handler to set the height
await expect(img).toHaveCSS('height', '300px', { timeout: 5000 })
})
})

test.describe('image block maxWidth and maxHeight combined', () => {
test('constrains both dimensions', async ({ page }) => {
await page.addInitScript(() => {
window.__imageBlockMaxWidth__ = 400
window.__imageBlockMaxHeight__ = 200
})

// 1000x800 image: scaled to 400px width -> height would be 320px, then clamped to 200px
await routeMockImage(page, MOCK_IMAGE_URL, 1000, 800)
await page.goto('/image-block/')

await page.evaluate((url: string) => {
window.__setMarkdown__(`![1.00](${url})`)
}, MOCK_IMAGE_URL)

const img = page.locator(IMAGE_SELECTOR)
await img.waitFor({ state: 'attached' })

await expect(img).toHaveCSS('max-width', '400px')
await expect(img).toHaveCSS('height', '200px', { timeout: 5000 })
})
})
2 changes: 2 additions & 0 deletions packages/components/src/image-block/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface ImageBlockConfig {
onUpload: (file: File) => Promise<string>
proxyDomURL?: (url: string) => Promise<string> | string
onImageLoadError?: (event: Event) => void | Promise<void>
maxWidth?: number
maxHeight?: number
}

export const defaultImageBlockConfig: ImageBlockConfig = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,26 @@ export const ImageViewer = defineComponent<MilkdownImageBlockProps>({
const host = image.closest('.milkdown-image-block')
if (!host) return

const maxWidth = host.getBoundingClientRect().width
let maxWidth = host.getBoundingClientRect().width
if (!maxWidth) return

const height = image.height
const width = image.width
const transformedHeight =
if (config.maxWidth && config.maxWidth < maxWidth)
maxWidth = config.maxWidth

const height = image.naturalHeight
const width = image.naturalWidth
let transformedHeight =
width < maxWidth ? height : maxWidth * (height / width)

if (config.maxHeight && transformedHeight > config.maxHeight)
transformedHeight = config.maxHeight

const h = (transformedHeight * (ratio.value ?? 1)).toFixed(2)
image.dataset.origin = transformedHeight.toFixed(2)
image.dataset.height = h
image.style.height = `${h}px`

if (config.maxWidth) image.style.maxWidth = `${config.maxWidth}px`
}

const onToggleCaption = (e: PointerEvent) => {
Expand Down Expand Up @@ -99,8 +108,11 @@ export const ImageViewer = defineComponent<MilkdownImageBlockProps>({
const image = imageRef.value
if (!image) return
const top = image.getBoundingClientRect().top
const height = e.clientY - top
const h = Number(height < 100 ? 100 : height).toFixed(2)
let height = e.clientY - top
if (height < 100) height = 100
if (config.maxHeight && height > config.maxHeight)
height = config.maxHeight
const h = Number(height).toFixed(2)
image.dataset.height = h
image.style.height = `${h}px`
}
Expand Down
4 changes: 4 additions & 0 deletions packages/crepe/src/feature/image-block/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ interface ImageBlockConfig {
blockUploadPlaceholderText: string
blockOnUpload: (file: File) => Promise<string>
onImageLoadError: (event: Event) => void | Promise<void>
maxWidth: number
maxHeight: number
}

export type ImageBlockFeatureConfig = Partial<ImageBlockConfig>
Expand Down Expand Up @@ -63,6 +65,8 @@ export const imageBlock: DefineFeature<ImageBlockFeatureConfig> = (
onUpload: config?.blockOnUpload ?? config?.onUpload ?? value.onUpload,
proxyDomURL: config?.proxyDomURL,
onImageLoadError: config?.onImageLoadError ?? value.onImageLoadError,
maxWidth: config?.maxWidth,
maxHeight: config?.maxHeight,
}))
})
.use(imageBlockComponent)
Expand Down
Loading