Skip to content
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)
  })
}