Example: image-view
import 'prosekit/basic/style.css' import 'prosekit/basic/typography.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 dark:border-gray-700 shadow flex flex-col bg-white dark:bg-gray-950 color-black dark:color-white'> <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'></div> </div> </div> </ProseKit> ) }
import { defineBasicExtension } from 'prosekit/basic' import { union } from 'prosekit/core' import type { ImageAttrs as BaseImageAttrs } from 'prosekit/extensions/image' import { defineReactNodeView, type ReactNodeViewComponent, } 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>
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, }, }, ], }
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> ) }
import { insertNode, union, } 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) }) }
<script setup lang="ts"> import 'prosekit/basic/style.css' import 'prosekit/basic/typography.css' import { createEditor } from 'prosekit/core' import { ProseKit } from 'prosekit/vue' import { ref, watchPostEffect, } from 'vue' import { defaultContent } from './default-doc' import { defineExtension } from './extension' const editor = createEditor({ extension: defineExtension(), defaultContent }) const editorRef = ref<HTMLDivElement | null>(null) watchPostEffect((onCleanup) => { editor.mount(editorRef.value) onCleanup(() => editor.unmount()) }) </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 flex flex-col bg-white dark:bg-gray-950 color-black dark:color-white'> <Toolbar /> <div class='relative w-full flex-1 box-border overflow-y-scroll'> <div ref="editorRef" class='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' /> </div> </div> </ProseKit> </template>
import { defineBasicExtension } from 'prosekit/basic' import { union } from 'prosekit/core' import { defineVueNodeView, type VueNodeViewComponent, } from 'prosekit/vue' import ImageView from './image-view.vue' import { defineImageFileHandlers } from './upload-file' export function defineExtension() { return union( defineBasicExtension(), defineVueNodeView({ name: 'image', component: ImageView as VueNodeViewComponent, }), defineImageFileHandlers(), ) } export type EditorExtension = ReturnType<typeof defineExtension>
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, }, }, ], }
<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 { setAttrs, node } = props const attrs = computed(() => 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 (!url.value.startsWith('blob:')) { return } const uploadTask = UploadTask.get<string>(url.value) 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 } error.value = 'Unexpected upload result' } UploadTask.delete(uploadTask.objectURL) }) .catch((error) => { if (abortController.signal.aborted) { return } error.value = String(error) UploadTask.delete(uploadTask.objectURL) }) const unsubscribe = uploadTask.subscribeProgress(({ loaded, total }) => { if (abortController.signal.aborted) { return } if (total > 0) { progress.value = loaded / total } }) onCleanup(() => { unsubscribe() abortController.abort() }) }) 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) ) { 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-[600px] max-w-full min-h-[64px] min-w-[64px] outline-2 outline-transparent data-[selected]:outline-blue-500 outline-solid' @resize-end="(event) => setAttrs(event.detail)" > <img v-if="url && !error" :src="url" 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 bg-gray-800/60 p-1.5 text-xs text-white/80 transition'> <div class='i-lucide-loader-circle h-4 w-4 animate-spin'></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 h-8 w-8'></div> <div class='hidden opacity-80 @xs:block'> Failed to upload image </div> </div> <ResizableHandle class='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 class='i-lucide-arrow-down-right h-4 w-4'></div> </ResizableHandle> </ResizableRoot> </template>
import { insertNode, union, } 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) }) }