Skip to main content
Glama

Storyden

by Southclaws
Mozilla Public License 2.0
229
useContentComposer.ts7.42 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 { useEditor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { ChangeEvent, useEffect, useId, useMemo } from "react"; import { Xid } from "xid-ts"; import { Asset } from "src/api/openapi-schema"; import { css } from "@/styled-system/css"; import { getAssetURL } from "@/utils/asset"; import { useImageUpload } from "../useImageUpload"; import { ImageExtended } from "./plugins/ImagePlugin"; export type Block = "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; export type ContentComposerProps = { className?: string; disabled?: boolean; resetKey?: string; initialValue?: string; // NOTE: This is not for making the editor controllable but for optimistic // mutation/revalidation of disabled editors. Use with care! value?: string; placeholder?: string; onChange?: (value: string, isEmpty: boolean) => void; onAssetUpload?: (asset: Asset) => void; }; export function useContentComposer(props: ContentComposerProps) { const { upload } = useImageUpload(); const extensions = [ StarterKit, FocusClasses, Link.configure({ // Disable navigation when clicking links in the editor if it's active. openOnClick: props.disabled ? true : false, }).extend({ inclusive: false, }), ImageExtended.configure({ allowBase64: false, HTMLAttributes: { class: css({ borderRadius: "md" }), }, handleFiles, }), Placeholder.configure({ placeholder: props.placeholder ?? "Write your heart out...", includeChildren: true, showOnlyCurrent: false, }), ]; // 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 }) => { const html = editor.getHTML(); 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]); async function handleFiles(files: File[]) { if (!editor) { return []; } const { view } = editor; const { state } = view; const { selection } = state; const { schema } = view.state; const imageNode = schema.nodes?.["image"]; if (!imageNode) { return []; } const assets: Asset[] = []; for (const f of files) { const asset = await upload(f); const node = imageNode.create({ src: getAssetURL(asset.path) }); const transaction = view.state.tr.insert(selection.$head.pos, node); view.dispatch(transaction); assets.push(asset); props.onAssetUpload?.(asset); } return assets; } async function handleFileUpload(e: ChangeEvent<HTMLInputElement>) { if (!e.currentTarget.files) { return; } const images = Array.from(e.currentTarget.files).filter((file) => /image/i.test(file.type), ); await handleFiles(images); } 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"; } return { editor, uniqueID, initialValueHTML, handlers: { handleFileUpload, }, 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(), }, }, }; }

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