File Upload
The template provides file upload capabilities through the rich text editor's image upload system and the server-side API client's upload method. Image uploads integrate with the TipTap editor, while the server client supports general-purpose file uploads with FormData handling.
Architecture Overview
lib/editor/components/
node/image-upload-node/
image-upload-node-extension.ts -- TipTap extension definition
image-upload-node.tsx -- Upload node component
image-upload-node.scss -- Upload node styles
ui/image-upload-button/
image-upload-button.tsx -- Toolbar button component
use-image-upload.ts -- Upload logic hook
lib/api/
server-api-client.ts -- ServerClient.upload() method
Image Upload in the Editor
useImageUpload Hook
The use-image-upload.ts hook at lib/editor/components/ui/image-upload-button/ provides the core upload logic for the TipTap editor:
// lib/editor/components/ui/image-upload-button/use-image-upload.ts
export const IMAGE_UPLOAD_SHORTCUT_KEY = "mod+shift+i";
export interface UseImageUploadConfig {
editor?: Editor | null;
hideWhenUnavailable?: boolean;
onInserted?: () => void;
}
export function useImageUpload(config?: UseImageUploadConfig) {
const { editor: providedEditor, hideWhenUnavailable = false, onInserted } = config || {};
const { editor } = useTiptapEditor(providedEditor);
const isMobile = useIsMobile();
const canInsert = canInsertImage(editor);
const isActive = isImageActive(editor);
const handleImage = useCallback(() => {
if (!editor) return false;
const success = insertImage(editor);
if (success) onInserted?.();
return success;
}, [editor, onInserted]);
// Keyboard shortcut registration
useHotkeys(IMAGE_UPLOAD_SHORTCUT_KEY, (event) => {
event.preventDefault();
handleImage();
}, { enabled: isVisible && canInsert });
return {
isVisible,
isActive,
handleImage,
canInsert,
label: "Add image",
shortcutKeys: IMAGE_UPLOAD_SHORTCUT_KEY,
Icon: ImagePlusIcon,
};
}
Helper Functions
The hook relies on pure helper functions for state checks:
// Check if image can be inserted at the current cursor position
export function canInsertImage(editor: Editor | null): boolean {
if (!editor || !editor.isEditable) return false;
if (!isExtensionAvailable(editor, "imageUpload")) return false;
if (isNodeTypeSelected(editor, ["image"])) return false;
return editor.can().insertContent({ type: "imageUpload" });
}
// Check if the image upload node is currently active
export function isImageActive(editor: Editor | null): boolean {
if (!editor || !editor.isEditable) return false;
return editor.isActive("imageUpload");
}
// Insert an image upload node into the editor
export function insertImage(editor: Editor | null): boolean {
if (!canInsertImage(editor)) return false;
return editor.chain().focus().insertContent({ type: "imageUpload" }).run();
}
// Determine if the upload button should be shown
export function shouldShowButton(props: {
editor: Editor | null;
hideWhenUnavailable: boolean;
}): boolean {
if (!editor || !editor.isEditable) return false;
if (!isExtensionAvailable(editor, "imageUpload")) return false;
if (hideWhenUnavailable && !editor.isActive("code")) {
return canInsertImage(editor);
}
return true;
}
ImageUploadButton Component
The ImageUploadButton component provides a ready-to-use toolbar button:
// lib/editor/components/ui/image-upload-button/image-upload-button.tsx
export const ImageUploadButton = React.forwardRef<
HTMLButtonElement,
ImageUploadButtonProps
>(({ editor: providedEditor, text, hideWhenUnavailable, onInserted, showShortcut, onClick, children, ...buttonProps }, ref) => {
const { editor } = useTiptapEditor(providedEditor);
const { isVisible, canInsert, handleImage, label, isActive, shortcutKeys, Icon } =
useImageUpload({ editor, hideWhenUnavailable, onInserted });
if (!isVisible) return null;
return (
<Button
type="button"
data-style="ghost"
data-active-state={isActive ? "on" : "off"}
disabled={!canInsert}
aria-label={label}
aria-pressed={isActive}
onClick={(e) => { onClick?.(e); handleImage(); }}
ref={ref}
>
{children ?? (
<>
<Icon className="tiptap-button-icon" />
{text && <span className="tiptap-button-text">{text}</span>}
{showShortcut && <ImageShortcutBadge shortcutKeys={shortcutKeys} />}
</>
)}
</Button>
);
});
Usage in the Editor Toolbar
// In the editor toolbar configuration
<ImageUploadButton
editor={editor}
hideWhenUnavailable={true}
text="Image"
showShortcut={true}
onInserted={() => console.log('Image node inserted')}
/>
The keyboard shortcut Ctrl+Shift+I (or Cmd+Shift+I on macOS) triggers image upload when the editor is focused.
Server-Side File Upload
The ServerClient class provides a generic upload method for server-side file uploads:
// lib/api/server-api-client.ts
export class ServerClient {
async upload<T>(
endpoint: string,
file: File | FormData,
options: FetchOptions = {}
): Promise<ApiResponse<T>> {
const formData = file instanceof FormData ? file : new FormData();
if (file instanceof File) {
formData.append('file', file);
}
// Remove Content-Type header to let the browser set the multipart boundary
const filteredHeaders = options.headers
? Object.fromEntries(
Object.entries(options.headers).filter(
([key]) => key.toLowerCase() !== 'content-type'
)
)
: {};
return this.request<T>(endpoint, {
...options,
method: 'POST',
body: formData,
headers: filteredHeaders,
});
}
}
Content-Type Handling
A critical detail: the Content-Type header is intentionally removed when uploading FormData. This allows the browser (or Node.js fetch) to set the correct multipart/form-data boundary automatically. The base request method also handles this:
// Remove Content-Type for FormData (case-insensitive check)
if (fetchOptions.body instanceof FormData) {
const contentTypeKey = Object.keys(normalizedHeaders).find(
(key) => key.toLowerCase() === 'content-type'
);
if (contentTypeKey) {
delete normalizedHeaders[contentTypeKey];
}
}
URL-Encoded Form Data
For non-file form submissions (e.g., reCAPTCHA verification), the postForm method is available:
async postForm<T>(
endpoint: string,
data: Record<string, string>,
options: FetchOptions = {}
): Promise<ApiResponse<T>> {
const formData = new URLSearchParams(data);
return this.request<T>(endpoint, {
...options,
method: 'POST',
body: formData.toString(),
headers: { ...options.headers, 'Content-Type': 'application/x-www-form-urlencoded' },
});
}
Image Upload Node Extension
The TipTap imageUpload extension at lib/editor/components/node/image-upload-node/ defines a custom node type that renders an upload UI within the editor content. When the user selects a file, it is uploaded via the configured endpoint and the node is replaced with a standard image node containing the returned URL.
Upload Flow Summary
- User clicks the Image button in the editor toolbar or presses
Ctrl+Shift+I - The
useImageUploadhook inserts animageUploadnode into the editor - The upload node component renders a file picker or drop zone
- On file selection, the file is uploaded via
serverClient.upload() - On success, the upload node is replaced with a regular image node referencing the uploaded URL
- On failure, an error message is displayed within the node
File Reference
| File | Purpose |
|---|---|
lib/editor/components/ui/image-upload-button/use-image-upload.ts | Upload logic hook |
lib/editor/components/ui/image-upload-button/image-upload-button.tsx | Toolbar button component |
lib/editor/components/node/image-upload-node/image-upload-node-extension.ts | TipTap extension |
lib/editor/components/node/image-upload-node/image-upload-node.tsx | Upload node component |
lib/api/server-api-client.ts | ServerClient.upload() and postForm() methods |