tsx
import 'prosekit/basic/style.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/react'
import { useMemo } from 'react'
import { defaultContent } from './default-doc'
import { defineExtension } from './extension'
export default function Editor() {
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({ extension, 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 shadow dark:border-zinc-700 flex flex-col bg-white dark:bg-neutral-900'>
<div className='relative w-full flex-1 box-border overflow-y-scroll'>
<div ref={editor.mount} className='ProseMirror box-border min-h-full px-[max(4rem,_calc(50%-20rem))] py-8 outline-none outline-0 [&_span[data-mention="user"]]:text-blue-500 [&_span[data-mention="tag"]]:text-violet-500 [&_pre]:text-white [&_pre]:bg-zinc-800'></div>
</div>
</div>
</ProseKit>
)
}
ts
import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import type { ImageAttrs as BaseImageAttrs } from 'prosekit/extensions/image'
import {
type ReactNodeViewComponent,
defineReactNodeView,
} from 'prosekit/react'
import ImageView from './image-view'
import { defineImageFileHandlers } from './upload-file'
export function defineExtension() {
return union(
defineBasicExtension(),
defineReactNodeView({
name: 'image',
component: ImageView satisfies ReactNodeViewComponent,
}),
defineImageFileHandlers(),
)
}
export type ImageAttrs = BaseImageAttrs & {
width: number | null
height: number | null
}
export type EditorExtension = ReturnType<typeof defineExtension>
ts
import type { NodeJSON } from 'prosekit/core'
export const defaultContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Paste or drop an image to upload it.',
},
],
},
{
type: 'image',
attrs: {
src: 'https://placehold.co/150x150/8bd450/ffffff/png',
width: 150,
height: 150,
},
},
{
type: 'image',
attrs: {
src: 'https://placehold.co/150x75/965fd4/ffffff/png',
width: 150,
height: 75,
},
},
],
}
tsx
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 { setAttrs, node } = props
const attrs = 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 (!url.startsWith('blob:')) {
return
}
const uploadTask = UploadTask.get<string>(url)
if (!uploadTask) {
return
}
const abortController = new AbortController()
void uploadTask.finished
.then((resultUrl) => {
if (resultUrl && typeof resultUrl === 'string') {
if (abortController.signal.aborted) {
return
}
setAttrs({ src: resultUrl })
} else {
if (abortController.signal.aborted) {
return
}
setError('Unexpected upload result')
}
UploadTask.delete(uploadTask.objectURL)
})
.catch((error) => {
if (abortController.signal.aborted) {
return
}
setError(String(error))
UploadTask.delete(uploadTask.objectURL)
})
const unsubscribe = uploadTask.subscribeProgress(({ loaded, total }) => {
if (abortController.signal.aborted) {
return
}
if (total > 0) {
setProgress(loaded / total)
}
})
return () => {
unsubscribe()
abortController.abort()
}
}, [url, setAttrs])
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)) {
setAttrs({ width: naturalWidth, height: naturalHeight })
}
}
return (
<ResizableRoot
width={attrs.width ?? undefined}
height={attrs.height ?? undefined}
aspectRatio={aspectRatio}
onResizeEnd={(event) => setAttrs(event.detail)}
data-selected={props.selected ? '' : undefined}
className='relative flex items-center justify-center box-border overflow-hidden my-2 group max-h-[600px] max-w-full min-h-[64px] min-w-[64px] outline-2 outline-transparent data-[selected]:outline-blue-500 outline-solid'
>
{url && !error && (
<img
src={url}
onLoad={handleImageLoad}
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 bg-gray-800/60 p-1.5 text-xs text-white/80 transition'>
<div className='i-lucide-loader-circle h-4 w-4 animate-spin'></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 h-8 w-8'></div>
<div className='hidden opacity-80 @xs:block'>
Failed to upload image
</div>
</div>
)}
<ResizableHandle
className='absolute bottom-0 right-0 rounded 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 h-4 w-4'></div>
</ResizableHandle>
</ResizableRoot>
)
}
ts
import { union, insertNode } from 'prosekit/core'
import {
defineFileDropHandler,
defineFilePasteHandler,
UploadTask,
type Uploader,
} from 'prosekit/extensions/file'
/**
* Returns an extension that handles image file uploads when pasting or dropping
* images into the editor.
*/
export function defineImageFileHandlers() {
return union(
defineFilePasteHandler(({ view, file }) => {
// Only handle image files
if (!file.type.startsWith('image/')) {
return false
}
// Upload the image to https://tmpfiles.org/
const uploadTask = new UploadTask({
file,
uploader: tmpfilesUploader,
})
// Insert the image node at the current text selection position
const command = insertNode({
type: 'image',
attrs: { src: uploadTask.objectURL },
})
return command(view.state, view.dispatch, view)
}),
defineFileDropHandler(({ view, file, pos }) => {
// Only handle image files
if (!file.type.startsWith('image/')) {
return false
}
// Upload the image to https://tmpfiles.org/
const uploadTask = new UploadTask({
file,
uploader: tmpfilesUploader,
})
// Insert the image node at the drop position
const command = insertNode({
type: 'image',
attrs: { src: uploadTask.objectURL },
pos,
})
return command(view.state, view.dispatch, view)
}),
)
}
/**
* 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 after 1 hour.
*/
const tmpfilesUploader: 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) {
onProgress({
loaded: event.loaded,
total: event.total,
})
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
try {
const json = JSON.parse(xhr.responseText)
const url: string = (json.data.url as string).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)
})
}