Skip to main content
Glama
Southclaws

Storyden

by Southclaws
useContentComposerRich.ts20 kB
"use client"; import { FocusClasses } from "@tiptap/extension-focus"; import { Link } from "@tiptap/extension-link"; import Placeholder from "@tiptap/extension-placeholder"; import { generateHTML, generateJSON } from "@tiptap/html"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { EditorView } from "@tiptap/pm/view"; import { Extension, useEditor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { ChangeEvent, useEffect, useId, useRef, useState } from "react"; import { Asset } from "src/api/openapi-schema"; import { handle } from "@/api/client"; import { css } from "@/styled-system/css"; import { getAssetURL } from "@/utils/asset"; import { ContentComposerProps } from "../composer-props"; import { hasImageFile, isSupportedImage, useImageUpload, } from "../useImageUpload"; import { ImageExtended, uploadPositionsKey } from "./plugins/ImagePlugin"; import { LinkPasteMenuPlugin } from "./plugins/LinkPasteMenuPlugin"; import { LinkPreview } from "./plugins/LinkPreviewPlugin"; const ERROR_UNSUPPORTED_FILE_TYPE = "File type not supported"; export type Block = "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; export function useContentComposer(props: ContentComposerProps) { const { uploadWithProgress } = useImageUpload(); const [uploadingCount, setUploadingCount] = useState(0); const [isDragging, setIsDragging] = useState(false); const [isDragError, setIsDragError] = useState(false); const [dragErrorMessage, setDragErrorMessage] = useState(""); const [dragFileCount, setDragFileCount] = useState(0); const dragCounterRef = useRef(0); const uploadCounterRef = useRef(0); const activeUploadsRef = useRef< Map< string, { abortController: AbortController; blobUrl: string; file: File; status: "uploading" | "failed" | "completed"; } > >(new Map()); // Extension to detect and abort uploads when image nodes are deleted const UploadCleanupExtension = Extension.create({ name: "uploadCleanup", addProseMirrorPlugins() { return [ new Plugin({ key: new PluginKey("uploadCleanup"), appendTransaction(_transactions, oldState, newState) { const oldUploadingIds = new Set<string>(); oldState.doc.descendants((node) => { if (node.type.name === "image" && node.attrs["data-upload-id"]) { oldUploadingIds.add(node.attrs["data-upload-id"]); } }); const newUploadingIds = new Set<string>(); newState.doc.descendants((node) => { if (node.type.name === "image" && node.attrs["data-upload-id"]) { newUploadingIds.add(node.attrs["data-upload-id"]); } }); const deletedUploadIds = Array.from(oldUploadingIds).filter( (id) => !newUploadingIds.has(id), ); deletedUploadIds.forEach((uploadId) => { const upload = activeUploadsRef.current.get(uploadId); if (upload) { upload.abortController.abort("embed deleted"); URL.revokeObjectURL(upload.blobUrl); console.debug( `Upload for ID: ${uploadId} aborted due to node deletion.`, ); activeUploadsRef.current.delete(uploadId); } }); return null; }, }), ]; }, }); const extensions = [ StarterKit, FocusClasses, LinkPreview, LinkPasteMenuPlugin, Link.configure({ // Disable navigation when clicking links in the editor if it's active. openOnClick: props.disabled ? true : false, }).extend({ inclusive: false, parseHTML() { return [ { tag: "a[href]:not([data-display])", getAttrs: (dom) => { const href = dom.getAttribute("href"); return href ? { href } : false; }, }, ]; }, }), ImageExtended.configure({ allowBase64: false, HTMLAttributes: { class: css({ borderRadius: "md" }), }, handleFiles, handleRetry, handleCancel, }), Placeholder.configure({ placeholder: props.placeholder ?? "Write your heart out...", includeChildren: true, showOnlyCurrent: false, }), UploadCleanupExtension, ]; // This is for the initial server render. const initialValueJSON = generateJSON( props.initialValue ?? "<p></p>", extensions, ); const initialValueHTML = generateHTML(initialValueJSON, extensions); // Each editor needs a unique ID for the menu's file upload input ID. const uniqueID = useId(); const editor = useEditor({ immediatelyRender: false, editorProps: { attributes: { "data-editor-id": uniqueID, class: css({ height: "full", width: "full", }), }, }, extensions, content: props.initialValue ?? "<p></p>", onUpdate: ({ editor }) => { let html = editor.getHTML(); // Filter out images that are still uploading or failed const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html"); const uploadingImages = doc.querySelectorAll( 'img[data-uploading="true"]', ); const failedImages = doc.querySelectorAll("img[data-upload-error]"); uploadingImages.forEach((img) => img.remove()); failedImages.forEach((img) => img.remove()); html = doc.body.innerHTML; props.onChange?.(html, editor.isEmpty); }, }); // This is a huge hack but it means the composer doesn't need to be made into // a controlled component. Baiscally, if the resetKey changes, we reset the // content of the editor to the initial value or empty paragraph. Hacky? Yes. useEffect(() => { if (!editor) { return; } if (!props.resetKey) { if (props.value) { editor.commands.setContent(props.value); } return; } editor.commands.setContent(props.initialValue ?? "<p></p>"); }, [editor, props.initialValue, props.value, props.resetKey]); useEffect(() => { if (!editor) { return; } editor.setEditable(!props.disabled, false); }, [editor, props.disabled]); // - // Image uploading logic. // - async function handleProgress( view: EditorView, uploadId: string, percent: number, ) { if (!view) { throw new Error("Unable to access text editor state."); } const positionMap = uploadPositionsKey.getState(view.state); const pos = positionMap?.get(uploadId); if (pos === undefined) { console.warn( `handleProgress: No position found for upload ID: ${uploadId}`, ); return; } const node = view.state.doc.nodeAt(pos); if (node) { const tr = view.state.tr.setNodeMarkup(pos, undefined, { ...node.attrs, "data-upload-progress": Math.round(percent).toString(), }); view.dispatch(tr); } } function markUploadAsFailed(view: EditorView, uploadId: string) { const currentState = view.state; const positionMap = uploadPositionsKey.getState(view.state); const pos = positionMap?.get(uploadId); if (pos === undefined) { console.warn( `markUploadAsFailed: No position found for upload ID: ${uploadId}`, ); return; } const errorTransaction = currentState.tr.setNodeMarkup(pos, undefined, { ...currentState.doc.nodeAt(pos)?.attrs, "data-uploading": null, "data-upload-error": "Upload failed", }); view.dispatch(errorTransaction); const upload = activeUploadsRef.current.get(uploadId); if (upload) { upload.status = "failed"; } } function handleRetry(view: EditorView, uploadId: string) { if (!view) { throw new Error("Unable to access text editor state."); } const upload = activeUploadsRef.current.get(uploadId); if (!upload) { console.warn(`No active upload found for ID: ${uploadId}`); return; } const positionMap = uploadPositionsKey.getState(view.state); const pos = positionMap?.get(uploadId); if (pos === undefined) { console.warn(`handleRetry: No position found for upload ID: ${uploadId}`); return; } const abortController = new AbortController(); activeUploadsRef.current.set(uploadId, { ...upload, abortController, status: "uploading", }); const uploadingTransaction = view.state.tr.setNodeMarkup(pos, undefined, { ...view.state.doc.nodeAt(pos)?.attrs, "data-uploading": "true", "data-upload-error": null, }); view.dispatch(uploadingTransaction); setUploadingCount((prev) => prev + 1); // NOTE: No await here, we allow the handler to not block as we update the // the button inside the image node via the upload progress itself. handle( async () => { const asset = await uploadWithProgress( upload.file, (percent) => handleProgress(view, uploadId, percent), undefined, abortController, ); // we search for the node again because the position might have changed. const positionMap = uploadPositionsKey.getState(view.state); const pos = positionMap?.get(uploadId); if (pos === undefined) { console.warn( `handleRetry finished: No position found for upload ID: ${uploadId}`, ); return; } const updateTransaction = view.state.tr.setNodeMarkup(pos, undefined, { src: getAssetURL(asset.path), alt: upload.file.name, "data-upload-id": null, "data-uploading": null, "data-upload-error": null, "data-upload-progress": null, }); view.dispatch(updateTransaction); URL.revokeObjectURL(upload.blobUrl); const trackedUpload = activeUploadsRef.current.get(uploadId); if (trackedUpload) { trackedUpload.status = "completed"; } props.onAssetUpload?.(asset); }, { onError: async () => { markUploadAsFailed(view, uploadId); }, cleanup: async () => { setUploadingCount((prev) => Math.max(0, prev - 1)); }, }, ); } function handleCancel(view: EditorView, uploadId: string) { if (!view) { throw new Error("Unable to access text editor state."); } const upload = activeUploadsRef.current.get(uploadId); if (!upload) { console.warn(`No active upload found for ID: ${uploadId}`); return; } const positionMap = uploadPositionsKey.getState(view.state); const pos = positionMap?.get(uploadId); if (pos === undefined) { console.warn( `handleCancel: No position found for upload ID: ${uploadId}`, ); return; } console.debug( `Cancelling upload for ID: ${uploadId} and removing node at position ${pos}`, ); const nodeSize = view.state.doc.nodeAt(pos)?.nodeSize ?? 1; const transaction = view.state.tr.delete(pos, pos + nodeSize); view.dispatch(transaction); URL.revokeObjectURL(upload.blobUrl); activeUploadsRef.current.delete(uploadId); } async function handleFiles(view: EditorView, files: File[]) { if (!view) { throw new Error("Unable to access text editor state."); } const { state } = view; const { selection } = state; const { schema } = view.state; const imageNode = schema.nodes?.["image"]; if (!imageNode) { return []; } const assets: Asset[] = []; const insertPos = selection.$head.pos; for (const f of files) { uploadCounterRef.current += 1; const uploadId = `upload-${Date.now()}-${uploadCounterRef.current}`; // Create blob URL for immediate preview const blobUrl = URL.createObjectURL(f); // Create abort controller for this upload const abortController = new AbortController(); // Track this upload activeUploadsRef.current.set(uploadId, { abortController, blobUrl, file: f, status: "uploading", }); // Insert placeholder image immediately const placeholderNode = imageNode.create({ src: blobUrl, alt: f.name, "data-upload-id": uploadId, "data-uploading": "true", }); const insertTransaction = view.state.tr.insert( insertPos, placeholderNode, ); view.dispatch(insertTransaction); setUploadingCount((prev) => prev + 1); handle( async () => { const asset = await uploadWithProgress( f, (percent) => handleProgress(view, uploadId, percent), undefined, abortController, ); // Find the node with this upload-id and update it const currentState = view.state; let nodePos: number | null = null; currentState.doc.descendants((node, pos) => { if ( node.type.name === "image" && node.attrs["data-upload-id"] === uploadId ) { nodePos = pos; return false; // Stop searching } return true; // Continue searching }); if (nodePos !== null) { // Update the node with the real URL and remove upload attrs const updateTransaction = currentState.tr.setNodeMarkup( nodePos, undefined, { src: getAssetURL(asset.path), alt: f.name, "data-upload-id": null, "data-uploading": null, "data-upload-error": null, "data-upload-progress": null, }, ); view.dispatch(updateTransaction); // Clean up blob URL and mark as completed URL.revokeObjectURL(blobUrl); const upload = activeUploadsRef.current.get(uploadId); if (upload) { upload.status = "completed"; } assets.push(asset); props.onAssetUpload?.(asset); } }, { onError: async () => { markUploadAsFailed(view, uploadId); }, cleanup: async () => { setUploadingCount((prev) => prev - 1); }, }, ); } return assets; } async function handleFileUpload(e: ChangeEvent<HTMLInputElement>) { if (!e.currentTarget.files || !editor) { return; } const images = Array.from(e.currentTarget.files).filter((file) => /image/i.test(file.type), ); await handleFiles(editor.view, images); } // - // Text formatting logic. // - function handleBlockType(kind: Block) { switch (kind) { case "p": editor?.chain().focus().setParagraph().run(); break; case "h1": editor?.chain().focus().setHeading({ level: 1 }).run(); break; case "h2": editor?.chain().focus().setHeading({ level: 2 }).run(); break; case "h3": editor?.chain().focus().setHeading({ level: 3 }).run(); break; case "h4": editor?.chain().focus().setHeading({ level: 4 }).run(); break; case "h5": editor?.chain().focus().setHeading({ level: 5 }).run(); break; case "h6": editor?.chain().focus().setHeading({ level: 6 }).run(); break; } } function getBlockType(): Block | null { if (editor?.isActive("paragraph")) return "p"; if (editor?.isActive("heading", { level: 1 })) return "h1"; if (editor?.isActive("heading", { level: 2 })) return "h2"; if (editor?.isActive("heading", { level: 3 })) return "h3"; if (editor?.isActive("heading", { level: 4 })) return "h4"; if (editor?.isActive("heading", { level: 5 })) return "h5"; if (editor?.isActive("heading", { level: 6 })) return "h6"; return "p"; } function getDragOverlayMessage() { if (isDragError) { return dragErrorMessage; } return dragFileCount === 1 ? "Drop 1 file to upload" : `Drop ${dragFileCount} files to upload`; } function handleDragOver(e: React.DragEvent) { e.preventDefault(); } function handleDragEnter(e: React.DragEvent) { const items = Array.from(e.dataTransfer.items); const hasFile = items.some((item) => item.kind === "file"); if (!hasFile) { return; } e.preventDefault(); dragCounterRef.current += 1; setIsDragging(true); const hasImage = hasImageFile(e.dataTransfer.items); const imageCount = items.filter((item) => isSupportedImage(item.type), ).length; if (!hasImage) { setIsDragError(true); setDragErrorMessage(ERROR_UNSUPPORTED_FILE_TYPE); } else { setIsDragError(false); setDragErrorMessage(""); } setDragFileCount(imageCount); } function handleDragLeave() { dragCounterRef.current -= 1; if (dragCounterRef.current === 0) { setIsDragging(false); setIsDragError(false); setDragErrorMessage(""); setDragFileCount(0); } } async function handleDrop(e: React.DragEvent) { e.preventDefault(); dragCounterRef.current = 0; setIsDragging(false); setIsDragError(false); setDragErrorMessage(""); setDragFileCount(0); if (!editor) { throw new Error("Unable to access text editor state."); } const files = Array.from(e.dataTransfer.files); const images = files.filter((file) => /image/i.test(file.type)); if (images.length > 0) { await handleFiles(editor.view, images); } } return { editor, uniqueID, initialValueHTML, uploadingCount, isDragging, isDragError, getDragOverlayMessage, handlers: { handleFileUpload, handleDragOver, handleDragEnter, handleDragLeave, handleDrop, }, format: { text: { active: getBlockType(), set: handleBlockType, }, // Marks bold: { isActive: editor?.isActive("bold") ?? false, isDisabled: editor?.can().toggleBold() === false, toggle: () => editor?.chain().focus().toggleBold().run(), }, italic: { isActive: editor?.isActive("italic") ?? false, isDisabled: editor?.can().toggleItalic() === false, toggle: () => editor?.chain().focus().toggleItalic().run(), }, strike: { isActive: editor?.isActive("strike") ?? false, isDisabled: editor?.can().toggleStrike() === false, toggle: () => editor?.chain().focus().toggleStrike().run(), }, code: { isActive: editor?.isActive("code") ?? false, isDisabled: editor?.can().toggleCode() === false, toggle: () => editor?.chain().focus().toggleCode().run(), }, // Blocks blockquote: { isActive: editor?.isActive("blockquote") ?? false, isDisabled: editor?.can().toggleBlockquote() === false, toggle: () => editor?.chain().focus().toggleBlockquote().run(), }, pre: { isActive: editor?.isActive("codeBlock") ?? false, isDisabled: editor?.can().toggleCodeBlock() === false, toggle: () => editor?.chain().focus().toggleCodeBlock().run(), }, bulletList: { isActive: editor?.isActive("bulletList") ?? false, isDisabled: editor?.can().toggleBulletList() === false, toggle: () => editor?.chain().focus().toggleBulletList().run(), }, orderedList: { isActive: editor?.isActive("orderedList") ?? false, isDisabled: editor?.can().toggleOrderedList() === false, toggle: () => editor?.chain().focus().toggleOrderedList().run(), }, }, }; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Southclaws/storyden'

If you have feedback or need assistance with the MCP directory API, please join our Discord server