Skip to main content
Glama
Southclaws

Storyden

by Southclaws
ImagePlugin.tsx7.63 kB
import Image, { ImageOptions } from "@tiptap/extension-image"; import { NodeViewProps, NodeViewWrapper, ReactNodeViewRenderer, mergeAttributes, } from "@tiptap/react"; import { Plugin, PluginKey } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { Asset } from "src/api/openapi-schema"; import { Button } from "@/components/ui/button"; import { ProgressCircle } from "@/components/ui/progress"; import { css } from "@/styled-system/css"; import { styled } from "@/styled-system/jsx"; // NOTE: This is the name of the component that will be used in the HTML. // It cannot be changed. const COMPONENT_NAME = "img"; // NOTE: This plugin key is used to store the upload positions in ProseMirror // state to remove the need to scan the whole document for positions. export const uploadPositionsKey = new PluginKey<Map<string, number>>( "uploadPositions", ); type Options = { handleFiles: (view: EditorView, files: File[]) => Promise<Asset[]>; handleRetry: (view: EditorView, uploadId: string) => void; handleCancel: (view: EditorView, uploadId: string) => void; }; function Component(props: NodeViewProps) { const isUploading = props.node.attrs["data-uploading"] === "true"; const uploadError = props.node.attrs["data-upload-error"]; const uploadId = props.node.attrs["data-upload-id"]; const uploadProgress = props.node.attrs["data-upload-progress"]; const progressPercent = uploadProgress ? parseInt(uploadProgress, 10) : 0; const isEditable = props.editor.isEditable; const isSelected = props.selected && isEditable; const { handleRetry, handleCancel } = props.extension.options as Options; return ( <NodeViewWrapper className={css({ position: "relative", display: "inline-block", cursor: "pointer", outlineWidth: isSelected ? "medium" : "none", outlineStyle: "solid", outlineColor: isSelected ? "blue.a6" : "transparent", borderRadius: "lg", userSelect: isEditable ? "none" : "auto", saturate: isSelected && !isUploading ? "150%" : "100%", filter: "auto", background: isSelected && !isUploading ? "blue.5" : "transparent", mixBlendMode: isSelected && !isUploading ? "screen" : "normal", })} > <styled.img borderRadius="lg" opacity={isUploading ? "5" : "full"} transition="all" {...props.node.attrs} /> {isUploading && ( <styled.div position="absolute" top="0" left="0" width="full" height="full" display="flex" flexDirection="column" alignItems="center" justifyContent="center" pointerEvents="none" gap="3" padding="4" > <ProgressCircle value={progressPercent} size="md" /> </styled.div> )} {uploadError && ( <styled.div position="absolute" inset="0" display="flex" flexDirection="column" alignItems="center" justifyContent="center" backgroundColor="bg.error" opacity="9" borderRadius="md" padding="3" gap="2" userSelect="none" contentEditable={false} > <styled.p fontSize="sm" color="fg.error" fontWeight="medium"> Upload failed </styled.p> <styled.div display="flex" gap="2"> <Button type="button" size="xs" variant="outline" onClick={() => handleRetry(props.view, uploadId)} > Retry </Button> <Button type="button" size="xs" variant="ghost" onClick={() => handleCancel(props.view, uploadId)} > Remove </Button> </styled.div> </styled.div> )} </NodeViewWrapper> ); } export const ImageExtended = Image.extend<ImageOptions & Options>({ content: "inline*", addOptions() { return { ...this.parent?.(), }; }, addAttributes() { return { ...this.parent?.(), "data-upload-id": { default: null, parseHTML: (element) => element.getAttribute("data-upload-id"), renderHTML: (attributes) => { if (!attributes["data-upload-id"]) { return {}; } return { "data-upload-id": attributes["data-upload-id"], }; }, }, "data-uploading": { default: null, parseHTML: (element) => element.getAttribute("data-uploading"), renderHTML: (attributes) => { if (!attributes["data-uploading"]) { return {}; } return { "data-uploading": attributes["data-uploading"], }; }, }, "data-upload-error": { default: null, parseHTML: (element) => element.getAttribute("data-upload-error"), renderHTML: (attributes) => { if (!attributes["data-upload-error"]) { return {}; } return { "data-upload-error": attributes["data-upload-error"], }; }, }, "data-upload-progress": { default: null, parseHTML: (element) => element.getAttribute("data-upload-progress"), renderHTML: (attributes) => { if (!attributes["data-upload-progress"]) { return {}; } return { "data-upload-progress": attributes["data-upload-progress"], }; }, }, }; }, addNodeView() { return ReactNodeViewRenderer(Component); }, parseHTML() { return [ { tag: COMPONENT_NAME, }, ]; }, renderHTML({ HTMLAttributes }) { return [COMPONENT_NAME, mergeAttributes(HTMLAttributes), 0]; }, addProseMirrorPlugins() { const handleFiles = this.options.handleFiles; return [ // Position tracking plugin - maintains a map of uploadId -> position new Plugin({ key: uploadPositionsKey, state: { init() { return new Map<string, number>(); }, apply(_tr, _oldMap, _oldState, newState) { // Rebuild position map by scanning the document const newMap = new Map<string, number>(); newState.doc.descendants((node, pos) => { const uploadId = node.attrs["data-upload-id"]; if (uploadId) { newMap.set(uploadId, pos); } }); return newMap; }, }, }), // Paste handler plugin new Plugin({ props: { handlePaste(view, event) { if (!event.clipboardData) { return false; } const files: File[] = []; // Use "items" if (event.clipboardData.items?.length) { for (const item of event.clipboardData.items) { if (item.kind === "file") { const file = item.getAsFile(); if (file) { files.push(file); } } } } const images = files.filter((file) => /image/i.test(file.type)); if (images.length === 0) { return false; } event.preventDefault(); handleFiles(view, images); return true; }, }, }), ]; }, });

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