Resizable
The resizable component wraps an atomic node (typically an image or embed) with drag handles that update the node's width (and optionally height) attribute as the user drags. Mount it inside a custom node view so the new dimensions persist on the node.
'use client'
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/react'
import { useMemo } from 'react'
import { sampleContent } from '../../sample/sample-doc-image'
import { defineExtension } from './extension'
interface EditorProps {
initialContent?: NodeJSON
}
export default function Editor(props: EditorProps) {
const defaultContent = props.initialContent ?? sampleContent
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({ extension, defaultContent })
}, [defaultContent])
return (
<ProseKit editor={editor}>
<div className="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<div className="relative w-full flex-1 box-border overflow-y-auto">
<div ref={editor.mount} className="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineImageUploadHandler } from 'prosekit/extensions/image'
import { sampleUploader } from '../../sample/sample-uploader'
import { defineImageView } from '../../ui/image-view'
export function defineExtension() {
return union(
defineBasicExtension(),
defineImageView(),
defineImageUploadHandler({
uploader: sampleUploader,
}),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor'import type { NodeJSON } from 'prosekit/core'
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Paste or drop an image to upload it.',
},
],
},
{
type: 'image',
attrs: {
src: 'https://static.photos/white/200x200/1',
width: 160,
height: 160,
},
},
{
type: 'image',
attrs: {
src: 'https://static.photos/yellow/640x360/42',
width: 240,
height: 135,
},
},
],
}import type { Uploader } from 'prosekit/extensions/file'
/**
* Uploads the given file to https://tmpfiles.org/ and returns the URL of the
* uploaded file.
*
* This function is only for demonstration purposes. All uploaded files will be
* deleted by the server after 1 hour.
*/
export const sampleUploader: Uploader<string> = ({ file, onProgress }): Promise<string> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const formData = new FormData()
formData.append('file', file)
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
onProgress({
loaded: event.loaded,
total: event.total,
})
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
try {
const json = JSON.parse(xhr.responseText) as { data: { url: string } }
const url: string = json.data.url.replace('tmpfiles.org/', 'tmpfiles.org/dl/')
// Simulate a larger delay
setTimeout(() => resolve(url), 1000)
} catch (error) {
reject(new Error('Failed to parse response', { cause: error }))
}
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => {
reject(new Error('Upload failed'))
})
xhr.open('POST', 'https://tmpfiles.org/api/v1/upload', true)
xhr.send(formData)
})
}'use client'
import { UploadTask } from 'prosekit/extensions/file'
import type { ImageAttrs } from 'prosekit/extensions/image'
import type { ReactNodeViewProps } from 'prosekit/react'
import { ResizableHandle, ResizableRoot } from 'prosekit/react/resizable'
import { useEffect, useState, type SyntheticEvent } from 'react'
export default function ImageView(props: ReactNodeViewProps) {
const attrs = props.node.attrs as ImageAttrs
const url = attrs.src || ''
const uploading = url.startsWith('blob:')
const [aspectRatio, setAspectRatio] = useState<number | undefined>()
const [error, setError] = useState<string | undefined>()
const [progress, setProgress] = useState(0)
useEffect(() => {
if (!uploading) return
const uploadTask = UploadTask.get<string>(url)
if (!uploadTask) return
let canceled = false
uploadTask.finished.catch((error) => {
if (canceled) return
setError(String(error))
})
const unsubscribeProgress = uploadTask.subscribeProgress(({ loaded, total }) => {
if (canceled) return
setProgress(total ? loaded / total : 0)
})
return () => {
canceled = true
unsubscribeProgress()
}
}, [url, uploading])
const handleImageLoad = (event: SyntheticEvent) => {
const img = event.target as HTMLImageElement
const { naturalWidth, naturalHeight } = img
const ratio = naturalWidth / naturalHeight
if (ratio && Number.isFinite(ratio)) {
setAspectRatio(ratio)
}
if (naturalWidth && naturalHeight && (!attrs.width || !attrs.height)) {
props.setAttrs({ width: naturalWidth, height: naturalHeight })
}
}
return (
<ResizableRoot
width={attrs.width ?? undefined}
height={attrs.height ?? undefined}
aspectRatio={aspectRatio}
onResizeEnd={(event) => props.setAttrs(event.detail)}
data-selected={props.selected ? '' : undefined}
className="relative flex items-center justify-center box-border overflow-hidden my-2 group max-h-150 max-w-full min-h-16 min-w-16 outline-2 outline-transparent data-selected:outline-blue-500 outline-solid"
>
{url && !error && (
<img
src={url}
onLoad={handleImageLoad}
alt="upload preview"
className="h-full w-full max-w-full max-h-full object-contain"
/>
)}
{uploading && !error && (
<div className="absolute bottom-0 left-0 m-1 flex content-center items-center gap-2 rounded-sm bg-gray-800/60 p-1.5 text-xs text-white/80 transition">
<div className="i-lucide-loader-circle size-4 animate-spin block"></div>
<div>{Math.round(progress * 100)}%</div>
</div>
)}
{error && (
<div className="absolute bottom-0 left-0 right-0 top-0 flex flex-col items-center justify-center gap-4 bg-gray-200 p-2 text-sm dark:bg-gray-800 @container">
<div className="i-lucide-image-off size-8 block"></div>
<div className="hidden opacity-80 @xs:block">
Failed to upload image
</div>
</div>
)}
<ResizableHandle
className="absolute bottom-0 right-0 rounded-sm m-1.5 p-1 transition bg-gray-900/30 active:bg-gray-800/60 hover:bg-gray-800/60 text-white/50 active:text-white/80 active:translate-x-0.5 active:translate-y-0.5 opacity-0 hover:opacity-100 group-hover:opacity-100 group-data-resizing:opacity-100"
position="bottom-right"
>
<div className="i-lucide-arrow-down-right size-4 block"></div>
</ResizableHandle>
</ResizableRoot>
)
}import type { Extension } from 'prosekit/core'
import { defineReactNodeView, type ReactNodeViewComponent } from 'prosekit/react'
import ImageView from './image-view'
export function defineImageView(): Extension {
return defineReactNodeView({
name: 'image',
component: ImageView satisfies ReactNodeViewComponent,
})
}import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { useMemo } from 'preact/hooks'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/preact'
import { sampleContent } from '../../sample/sample-doc-image'
import { defineExtension } from './extension'
interface EditorProps {
initialContent?: NodeJSON
}
export default function Editor(props: EditorProps) {
const defaultContent = props.initialContent ?? sampleContent
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({ extension, defaultContent })
}, [defaultContent])
return (
<ProseKit editor={editor}>
<div className="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<div className="relative w-full flex-1 box-border overflow-y-auto">
<div ref={editor.mount} className="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineImageUploadHandler } from 'prosekit/extensions/image'
import { sampleUploader } from '../../sample/sample-uploader'
import { defineImageView } from '../../ui/image-view'
export function defineExtension() {
return union(
defineBasicExtension(),
defineImageView(),
defineImageUploadHandler({
uploader: sampleUploader,
}),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor'import type { NodeJSON } from 'prosekit/core'
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Paste or drop an image to upload it.',
},
],
},
{
type: 'image',
attrs: {
src: 'https://static.photos/white/200x200/1',
width: 160,
height: 160,
},
},
{
type: 'image',
attrs: {
src: 'https://static.photos/yellow/640x360/42',
width: 240,
height: 135,
},
},
],
}import type { Uploader } from 'prosekit/extensions/file'
/**
* Uploads the given file to https://tmpfiles.org/ and returns the URL of the
* uploaded file.
*
* This function is only for demonstration purposes. All uploaded files will be
* deleted by the server after 1 hour.
*/
export const sampleUploader: Uploader<string> = ({ file, onProgress }): Promise<string> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const formData = new FormData()
formData.append('file', file)
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
onProgress({
loaded: event.loaded,
total: event.total,
})
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
try {
const json = JSON.parse(xhr.responseText) as { data: { url: string } }
const url: string = json.data.url.replace('tmpfiles.org/', 'tmpfiles.org/dl/')
// Simulate a larger delay
setTimeout(() => resolve(url), 1000)
} catch (error) {
reject(new Error('Failed to parse response', { cause: error }))
}
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => {
reject(new Error('Upload failed'))
})
xhr.open('POST', 'https://tmpfiles.org/api/v1/upload', true)
xhr.send(formData)
})
}import type { JSX } from 'preact'
import { useEffect, useState } from 'preact/hooks'
import { UploadTask } from 'prosekit/extensions/file'
import type { ImageAttrs } from 'prosekit/extensions/image'
import type { PreactNodeViewProps } from 'prosekit/preact'
import { ResizableHandle, ResizableRoot } from 'prosekit/preact/resizable'
export default function ImageView(props: PreactNodeViewProps) {
const attrs = props.node.attrs as ImageAttrs
const url = attrs.src || ''
const uploading = url.startsWith('blob:')
const [aspectRatio, setAspectRatio] = useState<number | undefined>()
const [error, setError] = useState<string | undefined>()
const [progress, setProgress] = useState(0)
useEffect(() => {
if (!uploading) return
const uploadTask = UploadTask.get<string>(url)
if (!uploadTask) return
let canceled = false
uploadTask.finished.catch((error) => {
if (canceled) return
setError(String(error))
})
const unsubscribeProgress = uploadTask.subscribeProgress(({ loaded, total }) => {
if (canceled) return
setProgress(total ? loaded / total : 0)
})
return () => {
canceled = true
unsubscribeProgress()
}
}, [url, uploading])
const handleImageLoad = (
event: JSX.TargetedEvent<HTMLImageElement, Event>,
) => {
const img = event.currentTarget
const { naturalWidth, naturalHeight } = img
const ratio = naturalWidth / naturalHeight
if (ratio && Number.isFinite(ratio)) {
setAspectRatio(ratio)
}
if (naturalWidth && naturalHeight && (!attrs.width || !attrs.height)) {
props.setAttrs({ width: naturalWidth, height: naturalHeight })
}
}
return (
<ResizableRoot
width={attrs.width ?? undefined}
height={attrs.height ?? undefined}
aspectRatio={aspectRatio}
onResizeEnd={(event) => props.setAttrs(event.detail)}
data-selected={props.selected ? '' : undefined}
className="relative flex items-center justify-center box-border overflow-hidden my-2 group max-h-150 max-w-full min-h-16 min-w-16 outline-2 outline-transparent data-selected:outline-blue-500 outline-solid"
>
{url && !error && (
<img
src={url}
onLoad={handleImageLoad}
alt="upload preview"
className="h-full w-full max-w-full max-h-full object-contain"
/>
)}
{uploading && !error && (
<div className="absolute bottom-0 left-0 m-1 flex content-center items-center gap-2 rounded-sm bg-gray-800/60 p-1.5 text-xs text-white/80 transition">
<div className="i-lucide-loader-circle size-4 animate-spin block"></div>
<div>{Math.round(progress * 100)}%</div>
</div>
)}
{error && (
<div className="absolute bottom-0 left-0 right-0 top-0 flex flex-col items-center justify-center gap-4 bg-gray-200 p-2 text-sm dark:bg-gray-800 @container">
<div className="i-lucide-image-off size-8 block"></div>
<div className="hidden opacity-80 @xs:block">
Failed to upload image
</div>
</div>
)}
<ResizableHandle
className="absolute bottom-0 right-0 rounded-sm m-1.5 p-1 transition bg-gray-900/30 active:bg-gray-800/60 hover:bg-gray-800/60 text-white/50 active:text-white/80 active:translate-x-0.5 active:translate-y-0.5 opacity-0 hover:opacity-100 group-hover:opacity-100 group-data-resizing:opacity-100"
position="bottom-right"
>
<div className="i-lucide-arrow-down-right size-4 block"></div>
</ResizableHandle>
</ResizableRoot>
)
}import type { Extension } from 'prosekit/core'
import { definePreactNodeView, type PreactNodeViewComponent } from 'prosekit/preact'
import ImageView from './image-view'
export function defineImageView(): Extension {
return definePreactNodeView({
name: 'image',
component: ImageView satisfies PreactNodeViewComponent,
})
}import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/solid'
import type { JSX } from 'solid-js'
import { sampleContent } from '../../sample/sample-doc-image'
import { defineExtension } from './extension'
interface EditorProps {
initialContent?: NodeJSON
}
export default function Editor(props: EditorProps): JSX.Element {
const defaultContent = props.initialContent ?? sampleContent
const extension = defineExtension()
const editor = createEditor({ extension, defaultContent })
return (
<ProseKit editor={editor}>
<div class="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<div class="relative w-full flex-1 box-border overflow-y-auto">
<div ref={editor.mount} class="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineImageUploadHandler } from 'prosekit/extensions/image'
import { sampleUploader } from '../../sample/sample-uploader'
import { defineImageView } from '../../ui/image-view'
export function defineExtension() {
return union(
defineBasicExtension(),
defineImageView(),
defineImageUploadHandler({
uploader: sampleUploader,
}),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor'import type { NodeJSON } from 'prosekit/core'
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Paste or drop an image to upload it.',
},
],
},
{
type: 'image',
attrs: {
src: 'https://static.photos/white/200x200/1',
width: 160,
height: 160,
},
},
{
type: 'image',
attrs: {
src: 'https://static.photos/yellow/640x360/42',
width: 240,
height: 135,
},
},
],
}import type { Uploader } from 'prosekit/extensions/file'
/**
* Uploads the given file to https://tmpfiles.org/ and returns the URL of the
* uploaded file.
*
* This function is only for demonstration purposes. All uploaded files will be
* deleted by the server after 1 hour.
*/
export const sampleUploader: Uploader<string> = ({ file, onProgress }): Promise<string> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const formData = new FormData()
formData.append('file', file)
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
onProgress({
loaded: event.loaded,
total: event.total,
})
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
try {
const json = JSON.parse(xhr.responseText) as { data: { url: string } }
const url: string = json.data.url.replace('tmpfiles.org/', 'tmpfiles.org/dl/')
// Simulate a larger delay
setTimeout(() => resolve(url), 1000)
} catch (error) {
reject(new Error('Failed to parse response', { cause: error }))
}
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => {
reject(new Error('Upload failed'))
})
xhr.open('POST', 'https://tmpfiles.org/api/v1/upload', true)
xhr.send(formData)
})
}import { UploadTask } from 'prosekit/extensions/file'
import type { ImageAttrs } from 'prosekit/extensions/image'
import type { SolidNodeViewProps } from 'prosekit/solid'
import { ResizableHandle, ResizableRoot } from 'prosekit/solid/resizable'
import { createEffect, createSignal, onCleanup, Show, type JSX } from 'solid-js'
export default function ImageView(props: SolidNodeViewProps): JSX.Element {
const attrs = () => props.node.attrs as ImageAttrs
const url = () => attrs().src || ''
const uploading = () => url().startsWith('blob:')
const [aspectRatio, setAspectRatio] = createSignal<number | undefined>()
const [error, setError] = createSignal<string | undefined>()
const [progress, setProgress] = createSignal(0)
createEffect(() => {
if (!uploading()) return
const uploadTask = UploadTask.get<string>(url())
if (!uploadTask) return
let canceled = false
uploadTask.finished.catch((err) => {
if (canceled) return
setError(String(err))
})
const unsubscribeProgress = uploadTask.subscribeProgress(({ loaded, total }) => {
if (canceled) return
setProgress(total ? loaded / total : 0)
})
onCleanup(() => {
canceled = true
unsubscribeProgress()
})
})
const handleImageLoad = (event: Event) => {
const img = event.target as HTMLImageElement
const { naturalWidth, naturalHeight } = img
const ratio = naturalWidth / naturalHeight
if (ratio && Number.isFinite(ratio)) {
setAspectRatio(ratio)
}
if (naturalWidth && naturalHeight && (!attrs().width || !attrs().height)) {
props.setAttrs({ width: naturalWidth, height: naturalHeight })
}
}
return (
<ResizableRoot
width={attrs().width ?? undefined}
height={attrs().height ?? undefined}
aspectRatio={aspectRatio()}
onResizeEnd={(event) => props.setAttrs(event.detail)}
attr:data-selected={props.selected ? '' : undefined}
class="relative flex items-center justify-center box-border overflow-hidden my-2 group max-h-150 max-w-full min-h-16 min-w-16 outline-2 outline-transparent data-selected:outline-blue-500 outline-solid"
>
<Show when={url() && !error()}>
<img
src={url()}
onLoad={handleImageLoad}
alt="upload preview"
class="h-full w-full max-w-full max-h-full object-contain"
/>
</Show>
<Show when={uploading() && !error()}>
<div class="absolute bottom-0 left-0 m-1 flex content-center items-center gap-2 rounded-sm bg-gray-800/60 p-1.5 text-xs text-white/80 transition">
<div class="i-lucide-loader-circle size-4 animate-spin block"></div>
<div>{Math.round(progress() * 100)}%</div>
</div>
</Show>
<Show when={error()}>
<div class="absolute bottom-0 left-0 right-0 top-0 flex flex-col items-center justify-center gap-4 bg-gray-200 p-2 text-sm dark:bg-gray-800 @container">
<div class="i-lucide-image-off size-8 block"></div>
<div class="hidden opacity-80 @xs:block">
Failed to upload image
</div>
</div>
</Show>
<ResizableHandle
class="absolute bottom-0 right-0 rounded-sm m-1.5 p-1 transition bg-gray-900/30 active:bg-gray-800/60 hover:bg-gray-800/60 text-white/50 active:text-white/80 active:translate-x-0.5 active:translate-y-0.5 opacity-0 hover:opacity-100 group-hover:opacity-100 group-data-resizing:opacity-100"
position="bottom-right"
>
<div class="i-lucide-arrow-down-right size-4 block"></div>
</ResizableHandle>
</ResizableRoot>
)
}import type { Extension } from 'prosekit/core'
import { defineSolidNodeView, type SolidNodeViewComponent } from 'prosekit/solid'
import ImageView from './image-view'
export function defineImageView(): Extension {
return defineSolidNodeView({
name: 'image',
component: ImageView satisfies SolidNodeViewComponent,
})
}<script lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/svelte'
import { untrack } from 'svelte'
import { sampleContent } from '../../sample/sample-doc-image'
import { defineExtension } from './extension'
const props: {
initialContent?: NodeJSON
} = $props()
const extension = defineExtension()
const defaultContent = untrack(() => props.initialContent ?? sampleContent)
const editor = createEditor({ extension, defaultContent })
</script>
<ProseKit {editor}>
<div class="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<div class="relative w-full flex-1 box-border overflow-y-auto">
<div {@attach editor.mount} class="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
</div>
</div>
</ProseKit>import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineImageUploadHandler } from 'prosekit/extensions/image'
import { sampleUploader } from '../../sample/sample-uploader'
import { defineImageView } from '../../ui/image-view'
export function defineExtension() {
return union(
defineBasicExtension(),
defineImageView(),
defineImageUploadHandler({
uploader: sampleUploader,
}),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.svelte'import type { NodeJSON } from 'prosekit/core'
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Paste or drop an image to upload it.',
},
],
},
{
type: 'image',
attrs: {
src: 'https://static.photos/white/200x200/1',
width: 160,
height: 160,
},
},
{
type: 'image',
attrs: {
src: 'https://static.photos/yellow/640x360/42',
width: 240,
height: 135,
},
},
],
}import type { Uploader } from 'prosekit/extensions/file'
/**
* Uploads the given file to https://tmpfiles.org/ and returns the URL of the
* uploaded file.
*
* This function is only for demonstration purposes. All uploaded files will be
* deleted by the server after 1 hour.
*/
export const sampleUploader: Uploader<string> = ({ file, onProgress }): Promise<string> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const formData = new FormData()
formData.append('file', file)
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
onProgress({
loaded: event.loaded,
total: event.total,
})
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
try {
const json = JSON.parse(xhr.responseText) as { data: { url: string } }
const url: string = json.data.url.replace('tmpfiles.org/', 'tmpfiles.org/dl/')
// Simulate a larger delay
setTimeout(() => resolve(url), 1000)
} catch (error) {
reject(new Error('Failed to parse response', { cause: error }))
}
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => {
reject(new Error('Upload failed'))
})
xhr.open('POST', 'https://tmpfiles.org/api/v1/upload', true)
xhr.send(formData)
})
}<script lang="ts">
import type { ProseMirrorNode } from 'prosekit/pm/model'
import { UploadTask } from 'prosekit/extensions/file'
import type { ImageAttrs } from 'prosekit/extensions/image'
import type { SvelteNodeViewProps } from 'prosekit/svelte'
import { ResizableHandle, ResizableRoot } from 'prosekit/svelte/resizable'
import { fromStore } from 'svelte/store'
interface Props extends SvelteNodeViewProps {}
const props: Props = $props()
const node: ProseMirrorNode = $derived(fromStore(props.node).current)
const selected: boolean = $derived(fromStore(props.selected).current)
const attrs = $derived(node.attrs as ImageAttrs)
const url = $derived(attrs.src || '')
const uploading = $derived(url.startsWith('blob:'))
let aspectRatio = $state<number | undefined>(undefined)
let error = $state<string | undefined>(undefined)
let progress = $state(0)
$effect(() => {
if (!uploading) {
return
}
const uploadTask = UploadTask.get<string>(url)
if (!uploadTask) return
let canceled = false
uploadTask.finished.catch((err) => {
if (canceled) return
error = String(err)
})
const unsubscribeProgress = uploadTask.subscribeProgress(({ loaded, total }) => {
if (canceled) return
progress = total ? loaded / total : 0
})
return () => {
canceled = true
unsubscribeProgress()
}
})
function handleImageLoad(event: Event) {
const img = event.target as HTMLImageElement
const { naturalWidth, naturalHeight } = img
const ratio = naturalWidth / naturalHeight
if (ratio && Number.isFinite(ratio)) {
aspectRatio = ratio
}
if (naturalWidth && naturalHeight && (!attrs.width || !attrs.height)) {
props.setAttrs({ width: naturalWidth, height: naturalHeight })
}
}
</script>
<ResizableRoot
width={attrs.width ?? undefined}
height={attrs.height ?? undefined}
{aspectRatio}
data-selected={selected ? '' : undefined}
class="relative flex items-center justify-center box-border overflow-hidden my-2 group max-h-150 max-w-full min-h-16 min-w-16 outline-2 outline-transparent data-selected:outline-blue-500 outline-solid"
onResizeEnd={(event) => props.setAttrs(event.detail)}
>
{#if url && !error}
<img
src={url}
alt="upload preview"
class="h-full w-full max-w-full max-h-full object-contain"
onload={handleImageLoad}
/>
{/if}
{#if uploading && !error}
<div class="absolute bottom-0 left-0 m-1 flex content-center items-center gap-2 rounded-sm bg-gray-800/60 p-1.5 text-xs text-white/80 transition">
<div class="i-lucide-loader-circle size-4 animate-spin block"></div>
<div>{Math.round(progress * 100)}%</div>
</div>
{/if}
{#if error}
<div class="absolute bottom-0 left-0 right-0 top-0 flex flex-col items-center justify-center gap-4 bg-gray-200 p-2 text-sm dark:bg-gray-800 @container">
<div class="i-lucide-image-off size-8 block"></div>
<div class="hidden opacity-80 @xs:block">
Failed to upload image
</div>
</div>
{/if}
<ResizableHandle
class="absolute bottom-0 right-0 rounded-sm m-1.5 p-1 transition bg-gray-900/30 active:bg-gray-800/60 hover:bg-gray-800/60 text-white/50 active:text-white/80 active:translate-x-0.5 active:translate-y-0.5 opacity-0 hover:opacity-100 group-hover:opacity-100 group-data-resizing:opacity-100"
position="bottom-right"
>
<div class="i-lucide-arrow-down-right size-4 block"></div>
</ResizableHandle>
</ResizableRoot>import type { Extension } from 'prosekit/core'
import { defineSvelteNodeView, type SvelteNodeViewComponent } from 'prosekit/svelte'
import ImageView from './image-view.svelte'
export function defineImageView(): Extension {
return defineSvelteNodeView({
name: 'image',
component: ImageView as SvelteNodeViewComponent,
})
}<script setup lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/vue'
import { sampleContent } from '../../sample/sample-doc-image'
import { defineExtension } from './extension'
const props = defineProps<{
initialContent?: NodeJSON
}>()
const extension = defineExtension()
const defaultContent = props.initialContent ?? sampleContent
const editor = createEditor({ extension, defaultContent })
</script>
<template>
<ProseKit :editor="editor">
<div class="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<div class="relative w-full flex-1 box-border overflow-y-auto">
<div :ref="(el) => editor.mount(el as HTMLElement | null)" class="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500" />
</div>
</div>
</ProseKit>
</template>import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineImageUploadHandler } from 'prosekit/extensions/image'
import { sampleUploader } from '../../sample/sample-uploader'
import { defineImageView } from '../../ui/image-view'
export function defineExtension() {
return union(
defineBasicExtension(),
defineImageView(),
defineImageUploadHandler({
uploader: sampleUploader,
}),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.vue'import type { NodeJSON } from 'prosekit/core'
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Paste or drop an image to upload it.',
},
],
},
{
type: 'image',
attrs: {
src: 'https://static.photos/white/200x200/1',
width: 160,
height: 160,
},
},
{
type: 'image',
attrs: {
src: 'https://static.photos/yellow/640x360/42',
width: 240,
height: 135,
},
},
],
}import type { Uploader } from 'prosekit/extensions/file'
/**
* Uploads the given file to https://tmpfiles.org/ and returns the URL of the
* uploaded file.
*
* This function is only for demonstration purposes. All uploaded files will be
* deleted by the server after 1 hour.
*/
export const sampleUploader: Uploader<string> = ({ file, onProgress }): Promise<string> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const formData = new FormData()
formData.append('file', file)
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
onProgress({
loaded: event.loaded,
total: event.total,
})
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
try {
const json = JSON.parse(xhr.responseText) as { data: { url: string } }
const url: string = json.data.url.replace('tmpfiles.org/', 'tmpfiles.org/dl/')
// Simulate a larger delay
setTimeout(() => resolve(url), 1000)
} catch (error) {
reject(new Error('Failed to parse response', { cause: error }))
}
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => {
reject(new Error('Upload failed'))
})
xhr.open('POST', 'https://tmpfiles.org/api/v1/upload', true)
xhr.send(formData)
})
}<script setup lang="ts">
import { UploadTask } from 'prosekit/extensions/file'
import type { ImageAttrs } from 'prosekit/extensions/image'
import type { VueNodeViewProps } from 'prosekit/vue'
import { ResizableHandle, ResizableRoot } from 'prosekit/vue/resizable'
import { computed, ref, watchEffect } from 'vue'
const props = defineProps<VueNodeViewProps>()
const attrs = computed(() => props.node.value.attrs as ImageAttrs)
const url = computed(() => attrs.value.src || '')
const uploading = computed(() => url.value.startsWith('blob:'))
const aspectRatio = ref<number | undefined>()
const error = ref<string | undefined>()
const progress = ref(0)
watchEffect((onCleanup) => {
if (!uploading.value) return
const uploadTask = UploadTask.get<string>(url.value)
if (!uploadTask) return
let canceled = false
uploadTask.finished.catch((err) => {
if (canceled) return
error.value = String(err)
})
const unsubscribeProgress = uploadTask.subscribeProgress(({ loaded, total }) => {
if (canceled) return
progress.value = total ? loaded / total : 0
})
onCleanup(() => {
canceled = true
unsubscribeProgress()
})
})
function handleImageLoad(event: Event) {
const img = event.target as HTMLImageElement
const { naturalWidth, naturalHeight } = img
const ratio = naturalWidth / naturalHeight
if (ratio && Number.isFinite(ratio)) {
aspectRatio.value = ratio
}
if (naturalWidth && naturalHeight && (!attrs.value.width || !attrs.value.height)) {
props.setAttrs({ width: naturalWidth, height: naturalHeight })
}
}
</script>
<template>
<ResizableRoot
:width="attrs.width ?? undefined"
:height="attrs.height ?? undefined"
:aspect-ratio="aspectRatio"
:data-selected="props.selected.value ? '' : undefined"
class="relative flex items-center justify-center box-border overflow-hidden my-2 group max-h-150 max-w-full min-h-16 min-w-16 outline-2 outline-transparent data-selected:outline-blue-500 outline-solid"
@resize-end="(event) => setAttrs(event.detail)"
>
<img
v-if="url && !error"
:src="url"
alt="upload preview"
class="h-full w-full max-w-full max-h-full object-contain"
@load="handleImageLoad"
/>
<div v-if="uploading && !error" class="absolute bottom-0 left-0 m-1 flex content-center items-center gap-2 rounded-sm bg-gray-800/60 p-1.5 text-xs text-white/80 transition">
<div class="i-lucide-loader-circle size-4 animate-spin block"></div>
<div>{{ Math.round(progress * 100) }}%</div>
</div>
<div v-if="error" class="absolute bottom-0 left-0 right-0 top-0 flex flex-col items-center justify-center gap-4 bg-gray-200 p-2 text-sm dark:bg-gray-800 @container">
<div class="i-lucide-image-off size-8 block"></div>
<div class="hidden opacity-80 @xs:block">
Failed to upload image
</div>
</div>
<ResizableHandle
class="absolute bottom-0 right-0 rounded-sm m-1.5 p-1 transition bg-gray-900/30 active:bg-gray-800/60 hover:bg-gray-800/60 text-white/50 active:text-white/80 active:translate-x-0.5 active:translate-y-0.5 opacity-0 hover:opacity-100 group-hover:opacity-100 group-data-resizing:opacity-100"
position="bottom-right"
>
<div class="i-lucide-arrow-down-right size-4 block"></div>
</ResizableHandle>
</ResizableRoot>
</template>import type { Extension } from 'prosekit/core'
import { defineVueNodeView, type VueNodeViewComponent } from 'prosekit/vue'
import ImageView from './image-view.vue'
export function defineImageView(): Extension {
return defineVueNodeView({
name: 'image',
component: ImageView as VueNodeViewComponent,
})
}Structure
Section titled “Structure”ResizableRoot # holds the current dimensions and forwards drag events
├── <your content> # the node view (e.g. an <img>, <iframe>, …)
└── ResizableHandle # one per resize edge (corners or sides)Mount one ResizableHandle per edge you want draggable (typically four corners for free resize, or just the right edge for width-only). Persist the dimensions back to the node by writing them to the node's attributes from inside the node view.
API reference
Section titled “API reference”- prosekit/react/resizable
- prosekit/vue/resizable
- prosekit/preact/resizable
- prosekit/svelte/resizable
- prosekit/solid/resizable
See also
Section titled “See also”- Custom node views: how to render an atomic node with a framework component so resizing can persist.