{ "name": "unified-registry", "type": "registry:block", "registryDependencies": [ "button", "dropdown-menu", "input", "label", "popover", "separator", "switch", "toggle", "tooltip", "dialog", "toggle-group", "sonner" ], "dependencies": [ "lowlight", "react-medium-image-zoom", "@radix-ui/react-icons", "@tiptap/extension-code-block-lowlight", "@tiptap/extension-color", "@tiptap/extension-horizontal-rule", "@tiptap/extension-image", "@tiptap/extension-text-style", "@tiptap/extension-typography", "@tiptap/extensions", "@tiptap/pm", "@tiptap/react", "@tiptap/starter-kit" ], "devDependencies": [ "@tailwindcss/typography", "tailwindcss-animate" ], "tailwind": { "config": { "plugins": [ "require(\"tailwindcss-animate\")", "require(\"@tailwindcss/typography\")" ], "theme": { "extend": { "typography": { "DEFAULT": { "css": { "code::before": { "content": "''" }, "code::after": { "content": "''" }, "code": { "background": "#f3f3f3", "wordWrap": "break-word", "padding": ".1rem .2rem", "borderRadius": ".2rem" } } } } } } } }, "cssVars": {}, "files": [ { "path": "src/components/minimal-tiptap/utils.ts", "content": "import type { Editor } from \"@tiptap/react\"\nimport type { MinimalTiptapProps } from \"./minimal-tiptap\"\n\ntype ShortcutKeyResult = {\n symbol: string\n readable: string\n}\n\nexport type FileError = {\n file: File | string\n reason: \"type\" | \"size\" | \"invalidBase64\" | \"base64NotAllowed\"\n}\n\nexport type FileValidationOptions = {\n allowedMimeTypes: string[]\n maxFileSize?: number\n allowBase64: boolean\n}\n\ntype FileInput = File | { src: string | File; alt?: string; title?: string }\n\nexport const isClient = (): boolean => typeof window !== \"undefined\"\nexport const isServer = (): boolean => !isClient()\nexport const isMacOS = (): boolean =>\n isClient() && window.navigator.platform === \"MacIntel\"\n\nconst shortcutKeyMap: Record = {\n mod: isMacOS()\n ? { symbol: \"⌘\", readable: \"Command\" }\n : { symbol: \"Ctrl\", readable: \"Control\" },\n alt: isMacOS()\n ? { symbol: \"⌥\", readable: \"Option\" }\n : { symbol: \"Alt\", readable: \"Alt\" },\n shift: { symbol: \"⇧\", readable: \"Shift\" },\n}\n\nexport const getShortcutKey = (key: string): ShortcutKeyResult =>\n shortcutKeyMap[key.toLowerCase()] || { symbol: key, readable: key }\n\nexport const getShortcutKeys = (keys: string[]): ShortcutKeyResult[] =>\n keys.map(getShortcutKey)\n\nexport const getOutput = (\n editor: Editor,\n format: MinimalTiptapProps[\"output\"]\n): object | string => {\n switch (format) {\n case \"json\":\n return editor.getJSON()\n case \"html\":\n return editor.isEmpty ? \"\" : editor.getHTML()\n default:\n return editor.getText()\n }\n}\n\nexport const isUrl = (\n text: string,\n options: { requireHostname: boolean; allowBase64?: boolean } = {\n requireHostname: false,\n }\n): boolean => {\n if (text.includes(\"\\n\")) return false\n\n try {\n const url = new URL(text)\n const blockedProtocols = [\n \"javascript:\",\n \"file:\",\n \"vbscript:\",\n ...(options.allowBase64 ? [] : [\"data:\"]),\n ]\n\n if (blockedProtocols.includes(url.protocol)) return false\n if (options.allowBase64 && url.protocol === \"data:\")\n return /^data:image\\/[a-z]+;base64,/.test(text)\n if (url.hostname) return true\n\n return (\n url.protocol !== \"\" &&\n (url.pathname.startsWith(\"//\") || url.pathname.startsWith(\"http\")) &&\n !options.requireHostname\n )\n } catch {\n return false\n }\n}\n\nexport const sanitizeUrl = (\n url: string | null | undefined,\n options: { allowBase64?: boolean } = {}\n): string | undefined => {\n if (!url) return undefined\n\n if (options.allowBase64 && url.startsWith(\"data:image\")) {\n return isUrl(url, { requireHostname: false, allowBase64: true })\n ? url\n : undefined\n }\n\n return isUrl(url, {\n requireHostname: false,\n allowBase64: options.allowBase64,\n }) || /^(\\/|#|mailto:|sms:|fax:|tel:)/.test(url)\n ? url\n : `https://${url}`\n}\n\nexport const blobUrlToBase64 = async (blobUrl: string): Promise => {\n const response = await fetch(blobUrl)\n const blob = await response.blob()\n\n return new Promise((resolve, reject) => {\n const reader = new FileReader()\n reader.onloadend = () => {\n if (typeof reader.result === \"string\") {\n resolve(reader.result)\n } else {\n reject(new Error(\"Failed to convert Blob to base64\"))\n }\n }\n reader.onerror = reject\n reader.readAsDataURL(blob)\n })\n}\n\nexport const randomId = (): string => Math.random().toString(36).slice(2, 11)\n\nexport const fileToBase64 = (file: File | Blob): Promise => {\n return new Promise((resolve, reject) => {\n const reader = new FileReader()\n reader.onloadend = () => {\n if (typeof reader.result === \"string\") {\n resolve(reader.result)\n } else {\n reject(new Error(\"Failed to convert File to base64\"))\n }\n }\n reader.onerror = reject\n reader.readAsDataURL(file)\n })\n}\n\nconst validateFileOrBase64 = (\n input: File | string,\n options: FileValidationOptions,\n originalFile: T,\n validFiles: T[],\n errors: FileError[]\n): void => {\n const { isValidType, isValidSize } = checkTypeAndSize(input, options)\n\n if (isValidType && isValidSize) {\n validFiles.push(originalFile)\n } else {\n if (!isValidType) errors.push({ file: input, reason: \"type\" })\n if (!isValidSize) errors.push({ file: input, reason: \"size\" })\n }\n}\n\nconst checkTypeAndSize = (\n input: File | string,\n { allowedMimeTypes, maxFileSize }: FileValidationOptions\n): { isValidType: boolean; isValidSize: boolean } => {\n const mimeType = input instanceof File ? input.type : base64MimeType(input)\n const size =\n input instanceof File ? input.size : atob(input.split(\",\")[1]).length\n\n const isValidType =\n allowedMimeTypes.length === 0 ||\n allowedMimeTypes.includes(mimeType) ||\n allowedMimeTypes.includes(`${mimeType.split(\"/\")[0]}/*`)\n\n const isValidSize = !maxFileSize || size <= maxFileSize\n\n return { isValidType, isValidSize }\n}\n\nconst base64MimeType = (encoded: string): string => {\n const result = encoded.match(/data:([a-zA-Z0-9]+\\/[a-zA-Z0-9-.+]+).*,.*/)\n return result && result.length > 1 ? result[1] : \"unknown\"\n}\n\nconst isBase64 = (str: string): boolean => {\n if (str.startsWith(\"data:\")) {\n const matches = str.match(/^data:[^;]+;base64,(.+)$/)\n if (matches && matches[1]) {\n str = matches[1]\n } else {\n return false\n }\n }\n\n try {\n return btoa(atob(str)) === str\n } catch {\n return false\n }\n}\n\nexport const filterFiles = (\n files: T[],\n options: FileValidationOptions\n): [T[], FileError[]] => {\n const validFiles: T[] = []\n const errors: FileError[] = []\n\n files.forEach((file) => {\n const actualFile = \"src\" in file ? file.src : file\n\n if (actualFile instanceof File) {\n validateFileOrBase64(actualFile, options, file, validFiles, errors)\n } else if (typeof actualFile === \"string\") {\n if (isBase64(actualFile)) {\n if (options.allowBase64) {\n validateFileOrBase64(actualFile, options, file, validFiles, errors)\n } else {\n errors.push({ file: actualFile, reason: \"base64NotAllowed\" })\n }\n } else {\n if (!sanitizeUrl(actualFile, { allowBase64: options.allowBase64 })) {\n errors.push({ file: actualFile, reason: \"invalidBase64\" })\n } else {\n validFiles.push(file)\n }\n }\n }\n })\n\n return [validFiles, errors]\n}\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/utils.ts" }, { "path": "src/components/minimal-tiptap/types.ts", "content": "import type { Editor } from \"@tiptap/react\"\nimport type { EditorView } from \"@tiptap/pm/view\"\nimport type { EditorState } from \"@tiptap/pm/state\"\n\nexport interface LinkProps {\n url: string\n text?: string\n openInNewTab?: boolean\n}\n\nexport interface ShouldShowProps {\n editor: Editor\n view: EditorView\n state: EditorState\n oldState?: EditorState\n from: number\n to: number\n}\n\nexport interface FormatAction {\n label: string\n icon?: React.ReactNode\n action: (editor: Editor) => void\n isActive: (editor: Editor) => boolean\n canExecute: (editor: Editor) => boolean\n shortcuts: string[]\n value: string\n}\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/types.ts" }, { "path": "src/components/minimal-tiptap/minimal-tiptap.tsx", "content": "import \"./styles/index.css\"\n\nimport type { Content, Editor } from \"@tiptap/react\"\nimport type { UseMinimalTiptapEditorProps } from \"./hooks/use-minimal-tiptap\"\nimport { EditorContent, EditorContext } from \"@tiptap/react\"\nimport { Separator } from \"@/components/ui/separator\"\nimport { cn } from \"@/lib/utils\"\nimport { SectionOne } from \"./components/section/one\"\nimport { SectionTwo } from \"./components/section/two\"\nimport { SectionThree } from \"./components/section/three\"\nimport { SectionFour } from \"./components/section/four\"\nimport { SectionFive } from \"./components/section/five\"\nimport { LinkBubbleMenu } from \"./components/bubble-menu/link-bubble-menu\"\nimport { useMinimalTiptapEditor } from \"./hooks/use-minimal-tiptap\"\nimport { MeasuredContainer } from \"./components/measured-container\"\nimport { useTiptapEditor } from \"./hooks/use-tiptap-editor\"\n\nexport interface MinimalTiptapProps extends Omit<\n UseMinimalTiptapEditorProps,\n \"onUpdate\"\n> {\n value?: Content\n onChange?: (value: Content) => void\n className?: string\n editorContentClassName?: string\n}\n\nconst Toolbar = ({ editor }: { editor: Editor }) => (\n
\n
\n \n\n \n\n \n\n \n\n \n\n \n\n \n\n \n\n \n
\n
\n)\n\nexport const MinimalTiptapEditor = ({\n value,\n onChange,\n className,\n editorContentClassName,\n ...props\n}: MinimalTiptapProps) => {\n const editor = useMinimalTiptapEditor({\n value,\n onUpdate: onChange,\n ...props,\n })\n\n if (!editor) {\n return null\n }\n\n return (\n \n \n \n )\n}\n\nMinimalTiptapEditor.displayName = \"MinimalTiptapEditor\"\n\nexport default MinimalTiptapEditor\n\nexport const MainMinimalTiptapEditor = ({\n editor: providedEditor,\n className,\n editorContentClassName,\n}: MinimalTiptapProps & { editor: Editor }) => {\n const { editor } = useTiptapEditor(providedEditor)\n\n if (!editor) {\n return null\n }\n\n return (\n \n \n \n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/minimal-tiptap.tsx" }, { "path": "src/components/minimal-tiptap/index.ts", "content": "export * from \"./minimal-tiptap\"\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/index.ts" }, { "path": "src/components/minimal-tiptap/styles/index.css", "content": "@import \"./partials/code.css\";\n@import \"./partials/placeholder.css\";\n@import \"./partials/lists.css\";\n@import \"./partials/typography.css\";\n@import \"./partials/zoom.css\";\n\n@reference \"../../../global.css\";\n\n:root {\n --mt-overlay: rgba(251, 251, 251, 0.75);\n --mt-transparent-foreground: rgba(0, 0, 0, 0.4);\n --mt-bg-secondary: rgba(251, 251, 251, 0.8);\n --mt-code-background: #082b781f;\n --mt-code-color: #d4d4d4;\n --mt-secondary: #9d9d9f;\n --mt-pre-background: #ececec;\n --mt-pre-border: #e0e0e0;\n --mt-pre-color: #2f2f31;\n --mt-hr: #dcdcdc;\n --mt-drag-handle-hover: #5c5c5e;\n\n --mt-accent-bold-blue: #05c;\n --mt-accent-bold-teal: #206a83;\n --mt-accent-bold-green: #216e4e;\n --mt-accent-bold-orange: #a54800;\n --mt-accent-bold-red: #ae2e24;\n --mt-accent-bold-purple: #5e4db2;\n\n --mt-accent-gray: #758195;\n --mt-accent-blue: #1d7afc;\n --mt-accent-teal: #2898bd;\n --mt-accent-green: #22a06b;\n --mt-accent-orange: #fea362;\n --mt-accent-red: #c9372c;\n --mt-accent-purple: #8270db;\n\n --mt-accent-blue-subtler: #cce0ff;\n --mt-accent-teal-subtler: #c6edfb;\n --mt-accent-green-subtler: #baf3db;\n --mt-accent-yellow-subtler: #f8e6a0;\n --mt-accent-red-subtler: #ffd5d2;\n --mt-accent-purple-subtler: #dfd8fd;\n\n --hljs-string: #aa430f;\n --hljs-title: #b08836;\n --hljs-comment: #999999;\n --hljs-keyword: #0c5eb1;\n --hljs-attr: #3a92bc;\n --hljs-literal: #c82b0f;\n --hljs-name: #259792;\n --hljs-selector-tag: #c8500f;\n --hljs-number: #3da067;\n}\n\n.dark {\n --mt-overlay: rgba(31, 32, 35, 0.75);\n --mt-transparent-foreground: rgba(255, 255, 255, 0.4);\n --mt-bg-secondary: rgba(31, 32, 35, 0.8);\n --mt-code-background: #ffffff13;\n --mt-code-color: #2c2e33;\n --mt-secondary: #595a5c;\n --mt-pre-background: #080808;\n --mt-pre-border: #23252a;\n --mt-pre-color: #e3e4e6;\n --mt-hr: #26282d;\n --mt-drag-handle-hover: #969799;\n\n --mt-accent-bold-blue: #85b8ff;\n --mt-accent-bold-teal: #9dd9ee;\n --mt-accent-bold-green: #7ee2b8;\n --mt-accent-bold-orange: #fec195;\n --mt-accent-bold-red: #fd9891;\n --mt-accent-bold-purple: #b8acf6;\n\n --mt-accent-gray: #738496;\n --mt-accent-blue: #388bff;\n --mt-accent-teal: #42b2d7;\n --mt-accent-green: #2abb7f;\n --mt-accent-orange: #a54800;\n --mt-accent-red: #e2483d;\n --mt-accent-purple: #8f7ee7;\n\n --mt-accent-blue-subtler: #09326c;\n --mt-accent-teal-subtler: #164555;\n --mt-accent-green-subtler: #164b35;\n --mt-accent-yellow-subtler: #533f04;\n --mt-accent-red-subtler: #5d1f1a;\n --mt-accent-purple-subtler: #352c63;\n\n --hljs-string: #da936b;\n --hljs-title: #f1d59d;\n --hljs-comment: #aaaaaa;\n --hljs-keyword: #6699cc;\n --hljs-attr: #90cae8;\n --hljs-literal: #f2777a;\n --hljs-name: #5fc0a0;\n --hljs-selector-tag: #e8c785;\n --hljs-number: #b6e7b6;\n}\n\n.minimal-tiptap-editor .ProseMirror {\n @apply flex max-w-full cursor-text flex-col;\n @apply z-0 outline-0;\n}\n\n.minimal-tiptap-editor .ProseMirror > div.editor {\n @apply block flex-1 whitespace-pre-wrap;\n}\n\n.minimal-tiptap-editor .ProseMirror .block-node:not(:last-child),\n.minimal-tiptap-editor .ProseMirror .list-node:not(:last-child),\n.minimal-tiptap-editor .ProseMirror .text-node:not(:last-child) {\n @apply mb-2.5;\n}\n\n.minimal-tiptap-editor .ProseMirror ol,\n.minimal-tiptap-editor .ProseMirror ul {\n @apply pl-6;\n}\n\n.minimal-tiptap-editor .ProseMirror blockquote,\n.minimal-tiptap-editor .ProseMirror dl,\n.minimal-tiptap-editor .ProseMirror ol,\n.minimal-tiptap-editor .ProseMirror p,\n.minimal-tiptap-editor .ProseMirror pre,\n.minimal-tiptap-editor .ProseMirror ul {\n @apply m-0;\n}\n\n.minimal-tiptap-editor .ProseMirror li {\n @apply leading-7;\n}\n\n.minimal-tiptap-editor .ProseMirror p {\n @apply break-words;\n}\n\n.minimal-tiptap-editor .ProseMirror li .text-node:has(+ .list-node),\n.minimal-tiptap-editor .ProseMirror li > .list-node,\n.minimal-tiptap-editor .ProseMirror li > .text-node,\n.minimal-tiptap-editor .ProseMirror li p {\n @apply mb-0;\n}\n\n.minimal-tiptap-editor .ProseMirror blockquote {\n @apply relative pl-3.5;\n}\n\n.minimal-tiptap-editor .ProseMirror blockquote::before,\n.minimal-tiptap-editor .ProseMirror blockquote.is-empty::before {\n @apply bg-accent-foreground/15 absolute top-0 bottom-0 left-0 h-full w-1 rounded-sm content-[''];\n}\n\n.minimal-tiptap-editor .ProseMirror hr {\n @apply my-3 h-0.5 w-full border-none bg-[var(--mt-hr)];\n}\n\n.minimal-tiptap-editor .ProseMirror-focused hr.ProseMirror-selectednode {\n @apply outline-muted-foreground rounded-full outline-2 outline-offset-1;\n}\n\n.minimal-tiptap-editor .ProseMirror .ProseMirror-gapcursor {\n @apply pointer-events-none absolute hidden;\n}\n\n.minimal-tiptap-editor .ProseMirror .ProseMirror-hideselection {\n @apply caret-transparent;\n}\n\n.minimal-tiptap-editor .ProseMirror.resize-cursor {\n @apply cursor-col-resize;\n}\n\n.minimal-tiptap-editor .ProseMirror .selection {\n @apply inline-block;\n}\n\n.minimal-tiptap-editor .ProseMirror s span {\n @apply line-through;\n}\n\n.minimal-tiptap-editor .ProseMirror .selection,\n.minimal-tiptap-editor .ProseMirror *::selection {\n @apply bg-primary/25;\n}\n\n/* Override native selection when custom selection is present */\n.minimal-tiptap-editor .ProseMirror .selection::selection {\n background: transparent;\n}\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/styles/index.css" }, { "path": "src/components/minimal-tiptap/hooks/use-tiptap-editor.ts", "content": "\"use client\"\n\nimport type { Editor } from \"@tiptap/react\"\nimport { useCurrentEditor, useEditorState } from \"@tiptap/react\"\nimport { useMemo } from \"react\"\n\n/**\n * Hook that provides access to a Tiptap editor instance.\n *\n * Accepts an optional editor instance directly, or falls back to retrieving\n * the editor from the Tiptap context if available. This allows components\n * to work both when given an editor directly and when used within a Tiptap\n * editor context.\n *\n * @param providedEditor - Optional editor instance to use instead of the context editor\n * @returns The provided editor or the editor from context, whichever is available\n */\nexport function useTiptapEditor(providedEditor?: Editor | null): {\n editor: Editor | null\n editorState?: Editor[\"state\"]\n canCommand?: Editor[\"can\"]\n} {\n const { editor: coreEditor } = useCurrentEditor()\n const mainEditor = useMemo(\n () => providedEditor || coreEditor,\n [providedEditor, coreEditor]\n )\n\n const editorState = useEditorState({\n editor: mainEditor,\n selector(context) {\n if (!context.editor) {\n return {\n editor: null,\n editorState: undefined,\n canCommand: undefined,\n }\n }\n\n return {\n editor: context.editor,\n editorState: context.editor.state,\n canCommand: context.editor.can,\n }\n },\n })\n\n return editorState || { editor: null }\n}\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/hooks/use-tiptap-editor.ts" }, { "path": "src/components/minimal-tiptap/hooks/use-throttle.ts", "content": "import { useRef, useCallback } from \"react\"\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function useThrottle void>(\n callback: T,\n delay: number\n): (...args: Parameters) => void {\n const lastRan = useRef(Date.now())\n const timeoutRef = useRef(null)\n\n return useCallback(\n (...args: Parameters) => {\n const handler = () => {\n if (Date.now() - lastRan.current >= delay) {\n callback(...args)\n lastRan.current = Date.now()\n } else {\n if (timeoutRef.current) {\n clearTimeout(timeoutRef.current)\n }\n timeoutRef.current = setTimeout(\n () => {\n callback(...args)\n lastRan.current = Date.now()\n },\n delay - (Date.now() - lastRan.current)\n )\n }\n }\n\n handler()\n },\n [callback, delay]\n )\n}\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/hooks/use-throttle.ts" }, { "path": "src/components/minimal-tiptap/hooks/use-theme.ts", "content": "import * as React from \"react\"\n\nexport const useTheme = () => {\n const [isDarkMode, setIsDarkMode] = React.useState(false)\n\n React.useEffect(() => {\n const darkModeMediaQuery = window.matchMedia(\"(prefers-color-scheme: dark)\")\n setIsDarkMode(darkModeMediaQuery.matches)\n\n const handleChange = (e: MediaQueryListEvent) => {\n const newDarkMode = e.matches\n setIsDarkMode(newDarkMode)\n }\n\n darkModeMediaQuery.addEventListener(\"change\", handleChange)\n\n return () => {\n darkModeMediaQuery.removeEventListener(\"change\", handleChange)\n }\n }, [])\n\n return isDarkMode\n}\n\nexport default useTheme\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/hooks/use-theme.ts" }, { "path": "src/components/minimal-tiptap/hooks/use-minimal-tiptap.ts", "content": "import * as React from \"react\"\nimport type { Editor } from \"@tiptap/react\"\nimport type { Content, UseEditorOptions } from \"@tiptap/react\"\nimport { StarterKit } from \"@tiptap/starter-kit\"\nimport { useEditor } from \"@tiptap/react\"\nimport { Typography } from \"@tiptap/extension-typography\"\nimport { TextStyle } from \"@tiptap/extension-text-style\"\nimport { Placeholder, Selection } from \"@tiptap/extensions\"\nimport {\n Image,\n HorizontalRule,\n CodeBlockLowlight,\n Color,\n UnsetAllMarks,\n ResetMarksOnEnter,\n FileHandler,\n} from \"../extensions\"\nimport { cn } from \"@/lib/utils\"\nimport { fileToBase64, getOutput, randomId } from \"../utils\"\nimport { useThrottle } from \"../hooks/use-throttle\"\nimport { toast } from \"sonner\"\n\nexport interface UseMinimalTiptapEditorProps extends UseEditorOptions {\n value?: Content\n output?: \"html\" | \"json\" | \"text\"\n placeholder?: string\n editorClassName?: string\n throttleDelay?: number\n onUpdate?: (content: Content) => void\n onBlur?: (content: Content) => void\n uploader?: (file: File) => Promise\n}\n\nasync function fakeuploader(file: File): Promise {\n // NOTE: This is a fake upload function. Replace this with your own upload logic.\n // This function should return the uploaded image URL.\n\n // wait 3s to simulate upload\n await new Promise((resolve) => setTimeout(resolve, 3000))\n\n const src = await fileToBase64(file)\n\n return src\n}\n\nconst createExtensions = ({\n placeholder,\n uploader,\n}: {\n placeholder: string\n uploader?: (file: File) => Promise\n}) => [\n StarterKit.configure({\n blockquote: { HTMLAttributes: { class: \"block-node\" } },\n // bold\n bulletList: { HTMLAttributes: { class: \"list-node\" } },\n code: { HTMLAttributes: { class: \"inline\", spellcheck: \"false\" } },\n codeBlock: false,\n // document\n dropcursor: { width: 2, class: \"ProseMirror-dropcursor border\" },\n // gapcursor\n // hardBreak\n heading: { HTMLAttributes: { class: \"heading-node\" } },\n // undoRedo\n horizontalRule: false,\n // italic\n // listItem\n // listKeymap\n link: {\n enableClickSelection: true,\n openOnClick: false,\n HTMLAttributes: {\n class: \"link\",\n },\n },\n orderedList: { HTMLAttributes: { class: \"list-node\" } },\n paragraph: { HTMLAttributes: { class: \"text-node\" } },\n // strike\n // text\n // underline\n // trailingNode\n }),\n Image.configure({\n allowedMimeTypes: [\"image/*\"],\n maxFileSize: 5 * 1024 * 1024,\n allowBase64: true,\n uploadFn: async (file) => {\n return uploader ? await uploader(file) : await fakeuploader(file)\n },\n onToggle(editor, files, pos) {\n editor.commands.insertContentAt(\n pos,\n files.map((image) => {\n const blobUrl = URL.createObjectURL(image)\n const id = randomId()\n\n return {\n type: \"image\",\n attrs: {\n id,\n src: blobUrl,\n alt: image.name,\n title: image.name,\n fileName: image.name,\n },\n }\n })\n )\n },\n onImageRemoved({ id, src }) {\n console.log(\"Image removed\", { id, src })\n },\n onValidationError(errors) {\n errors.forEach((error) => {\n toast.error(\"Image validation error\", {\n position: \"bottom-right\",\n description: error.reason,\n })\n })\n },\n onActionSuccess({ action }) {\n const mapping = {\n copyImage: \"Copy Image\",\n copyLink: \"Copy Link\",\n download: \"Download\",\n }\n toast.success(mapping[action], {\n position: \"bottom-right\",\n description: \"Image action success\",\n })\n },\n onActionError(error, { action }) {\n const mapping = {\n copyImage: \"Copy Image\",\n copyLink: \"Copy Link\",\n download: \"Download\",\n }\n toast.error(`Failed to ${mapping[action]}`, {\n position: \"bottom-right\",\n description: error.message,\n })\n },\n }),\n FileHandler.configure({\n allowBase64: true,\n allowedMimeTypes: [\"image/*\"],\n maxFileSize: 5 * 1024 * 1024,\n onDrop: (editor, files, pos) => {\n files.forEach(async (file) => {\n const src = await fileToBase64(file)\n editor.commands.insertContentAt(pos, {\n type: \"image\",\n attrs: { src },\n })\n })\n },\n onPaste: (editor, files) => {\n files.forEach(async (file) => {\n const src = await fileToBase64(file)\n editor.commands.insertContent({\n type: \"image\",\n attrs: { src },\n })\n })\n },\n onValidationError: (errors) => {\n errors.forEach((error) => {\n toast.error(\"Image validation error\", {\n position: \"bottom-right\",\n description: error.reason,\n })\n })\n },\n }),\n Color,\n TextStyle,\n Selection,\n Typography,\n UnsetAllMarks,\n HorizontalRule,\n ResetMarksOnEnter,\n CodeBlockLowlight,\n Placeholder.configure({ placeholder: () => placeholder }),\n]\n\nexport const useMinimalTiptapEditor = ({\n value,\n output = \"html\",\n placeholder = \"\",\n editorClassName,\n throttleDelay = 0,\n onUpdate,\n onBlur,\n uploader,\n ...props\n}: UseMinimalTiptapEditorProps) => {\n const throttledSetValue = useThrottle(\n (value: Content) => onUpdate?.(value),\n throttleDelay\n )\n\n const handleUpdate = React.useCallback(\n (editor: Editor) => throttledSetValue(getOutput(editor, output)),\n [output, throttledSetValue]\n )\n\n const handleCreate = React.useCallback(\n (editor: Editor) => {\n if (value && editor.isEmpty) {\n editor.commands.setContent(value)\n }\n },\n [value]\n )\n\n const handleBlur = React.useCallback(\n (editor: Editor) => onBlur?.(getOutput(editor, output)),\n [output, onBlur]\n )\n\n const editor = useEditor({\n immediatelyRender: false,\n extensions: createExtensions({ placeholder, uploader }),\n editorProps: {\n attributes: {\n autocomplete: \"off\",\n autocorrect: \"off\",\n autocapitalize: \"off\",\n class: cn(\"focus:outline-hidden\", editorClassName),\n },\n },\n onUpdate: ({ editor }) => handleUpdate(editor),\n onCreate: ({ editor }) => handleCreate(editor),\n onBlur: ({ editor }) => handleBlur(editor),\n ...props,\n })\n\n return editor\n}\n\nexport default useMinimalTiptapEditor\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/hooks/use-minimal-tiptap.ts" }, { "path": "src/components/minimal-tiptap/hooks/use-container-size.ts", "content": "import { useState, useEffect, useCallback } from \"react\"\n\nconst DEFAULT_RECT: DOMRect = {\n top: 0,\n left: 0,\n bottom: 0,\n right: 0,\n x: 0,\n y: 0,\n width: 0,\n height: 0,\n toJSON: () => \"{}\",\n}\n\nexport function useContainerSize(element: HTMLElement | null): DOMRect {\n const [size, setSize] = useState(\n () => element?.getBoundingClientRect() ?? DEFAULT_RECT\n )\n\n const handleResize = useCallback(() => {\n if (!element) return\n\n const newRect = element.getBoundingClientRect()\n\n setSize((prevRect) => {\n if (\n Math.round(prevRect.width) === Math.round(newRect.width) &&\n Math.round(prevRect.height) === Math.round(newRect.height) &&\n Math.round(prevRect.x) === Math.round(newRect.x) &&\n Math.round(prevRect.y) === Math.round(newRect.y)\n ) {\n return prevRect\n }\n return newRect\n })\n }, [element])\n\n useEffect(() => {\n if (!element) return\n\n const resizeObserver = new ResizeObserver(handleResize)\n resizeObserver.observe(element)\n\n window.addEventListener(\"click\", handleResize)\n window.addEventListener(\"resize\", handleResize)\n\n return () => {\n resizeObserver.disconnect()\n window.removeEventListener(\"click\", handleResize)\n window.removeEventListener(\"resize\", handleResize)\n }\n }, [element, handleResize])\n\n return size\n}\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/hooks/use-container-size.ts" }, { "path": "src/components/minimal-tiptap/extensions/index.ts", "content": "export * from \"./code-block-lowlight\"\nexport * from \"./color\"\nexport * from \"./horizontal-rule\"\nexport * from \"./image\"\nexport * from \"./unset-all-marks\"\nexport * from \"./reset-marks-on-enter\"\nexport * from \"./file-handler\"\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/extensions/index.ts" }, { "path": "src/components/minimal-tiptap/components/toolbar-section.tsx", "content": "import * as React from \"react\"\nimport type { Editor } from \"@tiptap/react\"\nimport type { FormatAction } from \"../types\"\nimport type { VariantProps } from \"class-variance-authority\"\nimport type { toggleVariants } from \"@/components/ui/toggle\"\nimport { cn } from \"@/lib/utils\"\nimport { CaretDownIcon } from \"@radix-ui/react-icons\"\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\"\nimport { ToolbarButton } from \"./toolbar-button\"\nimport { ShortcutKey } from \"./shortcut-key\"\nimport { getShortcutKey } from \"../utils\"\n\ninterface ToolbarSectionProps extends VariantProps {\n editor: Editor\n actions: FormatAction[]\n activeActions?: string[]\n mainActionCount?: number\n dropdownIcon?: React.ReactNode\n dropdownTooltip?: string\n dropdownClassName?: string\n}\n\nexport const ToolbarSection: React.FC = ({\n editor,\n actions,\n activeActions = actions.map((action) => action.value),\n mainActionCount = 0,\n dropdownIcon,\n dropdownTooltip = \"More options\",\n dropdownClassName = \"w-12\",\n size,\n variant,\n}) => {\n const { mainActions, dropdownActions } = React.useMemo(() => {\n const sortedActions = actions\n .filter((action) => activeActions.includes(action.value))\n .sort(\n (a, b) =>\n activeActions.indexOf(a.value) - activeActions.indexOf(b.value)\n )\n\n return {\n mainActions: sortedActions.slice(0, mainActionCount),\n dropdownActions: sortedActions.slice(mainActionCount),\n }\n }, [actions, activeActions, mainActionCount])\n\n const renderToolbarButton = React.useCallback(\n (action: FormatAction) => (\n action.action(editor)}\n disabled={!action.canExecute(editor)}\n isActive={action.isActive(editor)}\n tooltip={`${action.label} ${action.shortcuts.map((s) => getShortcutKey(s).symbol).join(\" \")}`}\n aria-label={action.label}\n size={size}\n variant={variant}\n >\n {action.icon}\n \n ),\n [editor, size, variant]\n )\n\n const renderDropdownMenuItem = React.useCallback(\n (action: FormatAction) => (\n action.action(editor)}\n disabled={!action.canExecute(editor)}\n className={cn(\"flex flex-row items-center justify-between gap-4\", {\n \"bg-accent\": action.isActive(editor),\n })}\n aria-label={action.label}\n >\n {action.label}\n \n \n ),\n [editor]\n )\n\n const isDropdownActive = dropdownActions.some((action) =>\n action.isActive(editor)\n )\n\n return (\n <>\n {mainActions.map(renderToolbarButton)}\n {dropdownActions.length > 0 && (\n \n \n \n {dropdownIcon || }\n \n \n \n {dropdownActions.map(renderDropdownMenuItem)}\n \n \n )}\n \n )\n}\n\nexport default ToolbarSection\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/components/toolbar-section.tsx" }, { "path": "src/components/minimal-tiptap/components/toolbar-button.tsx", "content": "import * as React from \"react\"\nimport type { TooltipContentProps } from \"@radix-ui/react-tooltip\"\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\"\nimport { Toggle } from \"@/components/ui/toggle\"\nimport { cn } from \"@/lib/utils\"\n\ninterface ToolbarButtonProps extends React.ComponentProps {\n isActive?: boolean\n tooltip?: string\n tooltipOptions?: TooltipContentProps\n}\n\nexport const ToolbarButton = ({\n isActive,\n children,\n tooltip,\n className,\n tooltipOptions,\n ...props\n}: ToolbarButtonProps) => {\n const toggleButton = (\n \n {children}\n \n )\n\n if (!tooltip) {\n return toggleButton\n }\n\n return (\n \n {toggleButton}\n \n
{tooltip}
\n
\n
\n )\n}\n\nToolbarButton.displayName = \"ToolbarButton\"\n\nexport default ToolbarButton\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/components/toolbar-button.tsx" }, { "path": "src/components/minimal-tiptap/components/spinner.tsx", "content": "import * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\ntype SpinnerProps = React.ComponentProps<\"svg\">\n\nconst SpinnerComponent = function Spinner({\n className,\n ...props\n}: SpinnerProps) {\n return (\n \n \n \n \n )\n}\n\nSpinnerComponent.displayName = \"Spinner\"\n\nexport const Spinner = React.memo(SpinnerComponent)\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/components/spinner.tsx" }, { "path": "src/components/minimal-tiptap/components/shortcut-key.tsx", "content": "import * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport { getShortcutKey } from \"../utils\"\n\nexport interface ShortcutKeyProps extends React.ComponentProps<\"span\"> {\n keys: string[]\n}\n\nexport const ShortcutKey = ({\n ref,\n className,\n keys,\n ...props\n}: ShortcutKeyProps) => {\n const modifiedKeys = keys.map((key) => getShortcutKey(key))\n const ariaLabel = modifiedKeys\n .map((shortcut) => shortcut.readable)\n .join(\" + \")\n\n return (\n \n {modifiedKeys.map((shortcut) => (\n \n {shortcut.symbol}\n \n ))}\n \n )\n}\n\nShortcutKey.displayName = \"ShortcutKey\"\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/components/shortcut-key.tsx" }, { "path": "src/components/minimal-tiptap/components/measured-container.tsx", "content": "import * as React from \"react\"\nimport { useContainerSize } from \"../hooks/use-container-size\"\n\ninterface MeasuredContainerProps {\n as: T\n name: string\n children?: React.ReactNode\n}\n\nexport const MeasuredContainer = ({\n as: Component,\n name,\n children,\n style = {},\n ...props\n}: MeasuredContainerProps & React.ComponentProps) => {\n const innerRef = React.useRef(null)\n const rect = useContainerSize(innerRef.current)\n\n const customStyle = {\n [`--${name}-width`]: `${rect.width}px`,\n [`--${name}-height`]: `${rect.height}px`,\n }\n\n return (\n \n {children}\n \n )\n}\n\nMeasuredContainer.displayName = \"MeasuredContainer\"\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/components/measured-container.tsx" }, { "path": "src/components/minimal-tiptap/styles/partials/zoom.css", "content": "[data-rmiz-ghost] {\n position: absolute;\n pointer-events: none;\n}\n[data-rmiz-btn-zoom],\n[data-rmiz-btn-unzoom] {\n background-color: rgba(0, 0, 0, 0.7);\n border-radius: 50%;\n border: none;\n box-shadow: 0 0 1px rgba(255, 255, 255, 0.5);\n color: #fff;\n height: 40px;\n margin: 0;\n outline-offset: 2px;\n padding: 9px;\n touch-action: manipulation;\n width: 40px;\n -webkit-appearance: none;\n -moz-appearance: none;\n appearance: none;\n}\n[data-rmiz-btn-zoom]:not(:focus):not(:active) {\n position: absolute;\n clip: rect(0 0 0 0);\n clip-path: inset(50%);\n height: 1px;\n overflow: hidden;\n pointer-events: none;\n white-space: nowrap;\n width: 1px;\n}\n[data-rmiz-btn-zoom] {\n position: absolute;\n inset: 10px 10px auto auto;\n cursor: zoom-in;\n}\n[data-rmiz-btn-unzoom] {\n position: absolute;\n inset: 20px 20px auto auto;\n cursor: zoom-out;\n z-index: 1;\n}\n[data-rmiz-content=\"found\"] img,\n[data-rmiz-content=\"found\"] svg,\n[data-rmiz-content=\"found\"] [role=\"img\"],\n[data-rmiz-content=\"found\"] [data-zoom] {\n cursor: inherit;\n}\n[data-rmiz-modal]::backdrop {\n display: none;\n}\n[data-rmiz-modal][open] {\n position: fixed;\n width: 100vw;\n width: 100dvw;\n height: 100vh;\n height: 100dvh;\n max-width: none;\n max-height: none;\n margin: 0;\n padding: 0;\n border: 0;\n background: transparent;\n overflow: hidden;\n}\n[data-rmiz-modal-overlay] {\n position: absolute;\n inset: 0;\n transition: background-color 0.3s;\n}\n[data-rmiz-modal-overlay=\"hidden\"] {\n background-color: rgba(255, 255, 255, 0);\n}\n[data-rmiz-modal-overlay=\"visible\"] {\n background-color: rgba(255, 255, 255, 1);\n}\n[data-rmiz-modal-content] {\n position: relative;\n width: 100%;\n height: 100%;\n}\n[data-rmiz-modal-img] {\n position: absolute;\n cursor: zoom-out;\n image-rendering: high-quality;\n transform-origin: top left;\n transition: transform 0.3s;\n}\n@media (prefers-reduced-motion: reduce) {\n [data-rmiz-modal-overlay],\n [data-rmiz-modal-img] {\n transition-duration: 0.01ms !important;\n }\n}\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/styles/partials/zoom.css" }, { "path": "src/components/minimal-tiptap/styles/partials/typography.css", "content": ".minimal-tiptap-editor .ProseMirror .heading-node {\n @apply relative font-semibold;\n}\n\n.minimal-tiptap-editor .ProseMirror .heading-node:first-child {\n @apply mt-0;\n}\n\n.minimal-tiptap-editor .ProseMirror h1 {\n @apply mt-[46px] mb-4 text-[1.375rem] leading-7 tracking-[-0.004375rem];\n}\n\n.minimal-tiptap-editor .ProseMirror h2 {\n @apply mt-8 mb-3.5 text-[1.1875rem] leading-7 tracking-[0.003125rem];\n}\n\n.minimal-tiptap-editor .ProseMirror h3 {\n @apply mt-6 mb-3 text-[1.0625rem] leading-6 tracking-[0.00625rem];\n}\n\n.minimal-tiptap-editor .ProseMirror h4 {\n @apply mt-4 mb-2 text-[0.9375rem] leading-6;\n}\n\n.minimal-tiptap-editor .ProseMirror h5 {\n @apply mt-4 mb-2 text-sm;\n}\n\n.minimal-tiptap-editor .ProseMirror h5 {\n @apply mt-4 mb-2 text-sm;\n}\n\n.minimal-tiptap-editor .ProseMirror a.link {\n @apply text-primary underline;\n}\n\n.minimal-tiptap-editor .ProseMirror a.link:hover {\n @apply underline;\n}\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/styles/partials/typography.css" }, { "path": "src/components/minimal-tiptap/styles/partials/placeholder.css", "content": ".minimal-tiptap-editor .ProseMirror > p.is-editor-empty::before {\n content: attr(data-placeholder);\n @apply pointer-events-none float-left h-0 text-[var(--mt-secondary)];\n}\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/styles/partials/placeholder.css" }, { "path": "src/components/minimal-tiptap/styles/partials/lists.css", "content": ".minimal-tiptap-editor .ProseMirror ol {\n @apply list-decimal;\n}\n\n.minimal-tiptap-editor .ProseMirror ol ol {\n list-style: lower-alpha;\n}\n\n.minimal-tiptap-editor .ProseMirror ol ol ol {\n list-style: lower-roman;\n}\n\n.minimal-tiptap-editor .ProseMirror ul {\n list-style: disc;\n}\n\n.minimal-tiptap-editor .ProseMirror ul ul {\n list-style: circle;\n}\n\n.minimal-tiptap-editor .ProseMirror ul ul ul {\n list-style: square;\n}\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/styles/partials/lists.css" }, { "path": "src/components/minimal-tiptap/styles/partials/code.css", "content": ".minimal-tiptap-editor .ProseMirror code.inline {\n @apply rounded border border-[var(--mt-code-color)] bg-[var(--mt-code-background)] px-1 py-0.5 text-sm;\n}\n\n.minimal-tiptap-editor .ProseMirror pre {\n @apply relative overflow-auto rounded border font-mono text-sm;\n @apply border-[var(--mt-pre-border)] bg-[var(--mt-pre-background)] text-[var(--mt-pre-color)];\n @apply text-left hyphens-none whitespace-pre;\n}\n\n.minimal-tiptap-editor .ProseMirror code {\n @apply leading-[1.7em] break-words;\n}\n\n.minimal-tiptap-editor .ProseMirror pre code {\n @apply block overflow-x-auto p-3.5;\n}\n\n.minimal-tiptap-editor .ProseMirror pre {\n .hljs-keyword,\n .hljs-operator,\n .hljs-function,\n .hljs-built_in,\n .hljs-builtin-name {\n color: var(--hljs-keyword);\n }\n\n .hljs-attr,\n .hljs-symbol,\n .hljs-property,\n .hljs-attribute,\n .hljs-variable,\n .hljs-template-variable,\n .hljs-params {\n color: var(--hljs-attr);\n }\n\n .hljs-name,\n .hljs-regexp,\n .hljs-link,\n .hljs-type,\n .hljs-addition {\n color: var(--hljs-name);\n }\n\n .hljs-string,\n .hljs-bullet {\n color: var(--hljs-string);\n }\n\n .hljs-title,\n .hljs-subst,\n .hljs-section {\n color: var(--hljs-title);\n }\n\n .hljs-literal,\n .hljs-type,\n .hljs-deletion {\n color: var(--hljs-literal);\n }\n\n .hljs-selector-tag,\n .hljs-selector-id,\n .hljs-selector-class {\n color: var(--hljs-selector-tag);\n }\n\n .hljs-number {\n color: var(--hljs-number);\n }\n\n .hljs-comment,\n .hljs-meta,\n .hljs-quote {\n color: var(--hljs-comment);\n }\n\n .hljs-emphasis {\n @apply italic;\n }\n\n .hljs-strong {\n @apply font-bold;\n }\n}\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/styles/partials/code.css" }, { "path": "src/components/minimal-tiptap/extensions/unset-all-marks/unset-all-marks.ts", "content": "import { Extension } from \"@tiptap/react\"\n\nexport const UnsetAllMarks = Extension.create({\n addKeyboardShortcuts() {\n return {\n \"Mod-\\\\\": () => this.editor.commands.unsetAllMarks(),\n }\n },\n})\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/extensions/unset-all-marks/unset-all-marks.ts" }, { "path": "src/components/minimal-tiptap/extensions/unset-all-marks/index.ts", "content": "export * from \"./unset-all-marks\"\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/extensions/unset-all-marks/index.ts" }, { "path": "src/components/minimal-tiptap/extensions/reset-marks-on-enter/reset-marks-on-enter.ts", "content": "import { Extension } from \"@tiptap/react\"\n\nexport const ResetMarksOnEnter = Extension.create({\n name: \"resetMarksOnEnter\",\n\n addKeyboardShortcuts() {\n return {\n Enter: ({ editor }) => {\n if (\n editor.isActive(\"bold\") ||\n editor.isActive(\"italic\") ||\n editor.isActive(\"strike\") ||\n editor.isActive(\"underline\") ||\n editor.isActive(\"code\")\n ) {\n editor.commands.splitBlock({ keepMarks: false })\n\n return true\n }\n\n return false\n },\n }\n },\n})\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/extensions/reset-marks-on-enter/reset-marks-on-enter.ts" }, { "path": "src/components/minimal-tiptap/extensions/reset-marks-on-enter/index.ts", "content": "export * from \"./reset-marks-on-enter\"\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/extensions/reset-marks-on-enter/index.ts" }, { "path": "src/components/minimal-tiptap/extensions/image/index.ts", "content": "export * from \"./image\"\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/extensions/image/index.ts" }, { "path": "src/components/minimal-tiptap/extensions/image/image.ts", "content": "import type { ImageOptions } from \"@tiptap/extension-image\"\nimport { Image as TiptapImage } from \"@tiptap/extension-image\"\nimport type { Editor } from \"@tiptap/react\"\nimport { ReactNodeViewRenderer } from \"@tiptap/react\"\nimport { ImageViewBlock } from \"./components/image-view-block\"\nimport {\n filterFiles,\n randomId,\n type FileError,\n type FileValidationOptions,\n} from \"../../utils\"\nimport { ReplaceStep } from \"@tiptap/pm/transform\"\nimport type { Attrs } from \"@tiptap/pm/model\"\n\ntype ImageAction = \"download\" | \"copyImage\" | \"copyLink\"\n\ninterface DownloadImageCommandProps {\n src: string\n alt?: string\n}\n\ninterface ImageActionProps extends DownloadImageCommandProps {\n action: ImageAction\n}\n\nexport type UploadReturnType =\n | string\n | {\n id: string | number\n src: string\n }\n\ninterface CustomImageOptions\n extends ImageOptions, Omit {\n uploadFn?: (file: File, editor: Editor) => Promise\n onImageRemoved?: (props: Attrs) => void\n onActionSuccess?: (props: ImageActionProps) => void\n onActionError?: (error: Error, props: ImageActionProps) => void\n downloadImage?: (\n props: ImageActionProps,\n options: CustomImageOptions\n ) => Promise\n copyImage?: (\n props: ImageActionProps,\n options: CustomImageOptions\n ) => Promise\n copyLink?: (\n props: ImageActionProps,\n options: CustomImageOptions\n ) => Promise\n onValidationError?: (errors: FileError[]) => void\n onToggle?: (editor: Editor, files: File[], pos: number) => void\n}\n\ndeclare module \"@tiptap/react\" {\n interface Commands {\n setImages: {\n setImages: (\n attrs: { src: string | File; alt?: string; title?: string }[]\n ) => ReturnType\n }\n downloadImage: {\n downloadImage: (attrs: DownloadImageCommandProps) => ReturnType\n }\n copyImage: {\n copyImage: (attrs: DownloadImageCommandProps) => ReturnType\n }\n copyLink: {\n copyLink: (attrs: DownloadImageCommandProps) => ReturnType\n }\n toggleImage: {\n toggleImage: () => ReturnType\n }\n }\n}\n\nconst handleError = (\n error: unknown,\n props: ImageActionProps,\n errorHandler?: (error: Error, props: ImageActionProps) => void\n): void => {\n const typedError = error instanceof Error ? error : new Error(\"Unknown error\")\n errorHandler?.(typedError, props)\n}\n\nconst handleDataUrl = (src: string): { blob: Blob; extension: string } => {\n const [header, base64Data] = src.split(\",\")\n const mimeType = header.split(\":\")[1].split(\";\")[0]\n const extension = mimeType.split(\"/\")[1]\n const byteCharacters = atob(base64Data)\n const byteArray = new Uint8Array(byteCharacters.length)\n for (let i = 0; i < byteCharacters.length; i++) {\n byteArray[i] = byteCharacters.charCodeAt(i)\n }\n const blob = new Blob([byteArray], { type: mimeType })\n return { blob, extension }\n}\n\nconst handleImageUrl = async (\n src: string\n): Promise<{ blob: Blob; extension: string }> => {\n const response = await fetch(src)\n if (!response.ok) throw new Error(\"Failed to fetch image\")\n const blob = await response.blob()\n const extension = blob.type.split(/\\/|\\+/)[1]\n return { blob, extension }\n}\n\nconst fetchImageBlob = async (\n src: string\n): Promise<{ blob: Blob; extension: string }> => {\n return src.startsWith(\"data:\") ? handleDataUrl(src) : handleImageUrl(src)\n}\n\nconst saveImage = async (\n blob: Blob,\n name: string,\n extension: string\n): Promise => {\n const imageURL = URL.createObjectURL(blob)\n const link = document.createElement(\"a\")\n link.href = imageURL\n link.download = `${name}.${extension}`\n document.body.appendChild(link)\n link.click()\n document.body.removeChild(link)\n URL.revokeObjectURL(imageURL)\n}\n\nconst downloadImage = async (\n props: ImageActionProps,\n options: CustomImageOptions\n): Promise => {\n const { src, alt } = props\n const potentialName = alt || \"image\"\n\n try {\n const { blob, extension } = await fetchImageBlob(src)\n await saveImage(blob, potentialName, extension)\n options.onActionSuccess?.({ ...props, action: \"download\" })\n } catch (error) {\n handleError(error, { ...props, action: \"download\" }, options.onActionError)\n }\n}\n\nconst copyImage = async (\n props: ImageActionProps,\n options: CustomImageOptions\n): Promise => {\n const { src } = props\n try {\n const res = await fetch(src)\n const blob = await res.blob()\n await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })])\n options.onActionSuccess?.({ ...props, action: \"copyImage\" })\n } catch (error) {\n handleError(error, { ...props, action: \"copyImage\" }, options.onActionError)\n }\n}\n\nconst copyLink = async (\n props: ImageActionProps,\n options: CustomImageOptions\n): Promise => {\n const { src } = props\n try {\n await navigator.clipboard.writeText(src)\n options.onActionSuccess?.({ ...props, action: \"copyLink\" })\n } catch (error) {\n handleError(error, { ...props, action: \"copyLink\" }, options.onActionError)\n }\n}\n\nexport const Image = TiptapImage.extend({\n atom: true,\n\n addOptions() {\n const parentOptions = this.parent?.() || {}\n return {\n ...parentOptions,\n inline: false,\n allowBase64: false,\n HTMLAttributes: {},\n resize: false,\n\n allowedMimeTypes: [],\n maxFileSize: 0,\n uploadFn: undefined,\n onToggle: undefined,\n downloadImage: undefined,\n copyImage: undefined,\n copyLink: undefined,\n }\n },\n\n addAttributes() {\n return {\n src: {\n default: null,\n },\n alt: {\n default: null,\n },\n title: {\n default: null,\n },\n id: {\n default: null,\n },\n width: {\n default: null,\n },\n height: {\n default: null,\n },\n fileName: {\n default: null,\n },\n }\n },\n\n addCommands() {\n return {\n setImages:\n (attrs) =>\n ({ commands }) => {\n const [validImages, errors] = filterFiles(attrs, {\n allowedMimeTypes: this.options.allowedMimeTypes,\n maxFileSize: this.options.maxFileSize,\n allowBase64: this.options.allowBase64,\n })\n\n if (errors.length > 0 && this.options.onValidationError) {\n this.options.onValidationError(errors)\n }\n\n if (validImages.length > 0) {\n return commands.insertContent(\n validImages.map((image) => {\n if (image.src instanceof File) {\n const blobUrl = URL.createObjectURL(image.src)\n const id = randomId()\n\n return {\n type: this.type.name,\n attrs: {\n id,\n src: blobUrl,\n alt: image.alt,\n title: image.title,\n fileName: image.src.name,\n },\n }\n } else {\n return {\n type: this.type.name,\n attrs: {\n id: randomId(),\n src: image.src,\n alt: image.alt,\n title: image.title,\n fileName: null,\n },\n }\n }\n })\n )\n }\n\n return false\n },\n\n downloadImage: (attrs) => () => {\n const downloadFunc = this.options.downloadImage || downloadImage\n void downloadFunc({ ...attrs, action: \"download\" }, this.options)\n return true\n },\n\n copyImage: (attrs) => () => {\n const copyImageFunc = this.options.copyImage || copyImage\n void copyImageFunc({ ...attrs, action: \"copyImage\" }, this.options)\n return true\n },\n\n copyLink: (attrs) => () => {\n const copyLinkFunc = this.options.copyLink || copyLink\n void copyLinkFunc({ ...attrs, action: \"copyLink\" }, this.options)\n return true\n },\n\n toggleImage:\n () =>\n ({ editor }) => {\n const input = document.createElement(\"input\")\n input.type = \"file\"\n input.accept = this.options.allowedMimeTypes.join(\",\")\n input.onchange = () => {\n const files = input.files\n if (!files) return\n\n const [validImages, errors] = filterFiles(Array.from(files), {\n allowedMimeTypes: this.options.allowedMimeTypes,\n maxFileSize: this.options.maxFileSize,\n allowBase64: this.options.allowBase64,\n })\n\n if (errors.length > 0 && this.options.onValidationError) {\n this.options.onValidationError(errors)\n return false\n }\n\n if (validImages.length === 0) return false\n\n if (this.options.onToggle) {\n this.options.onToggle(\n editor,\n validImages,\n editor.state.selection.from\n )\n }\n\n return false\n }\n\n input.click()\n return true\n },\n }\n },\n\n onTransaction({ transaction }) {\n transaction.steps.forEach((step) => {\n if (step instanceof ReplaceStep && step.slice.size === 0) {\n const deletedPages = transaction.before.content.cut(step.from, step.to)\n\n deletedPages.forEach((node) => {\n if (node.type.name === \"image\") {\n const attrs = node.attrs\n\n if (attrs.src.startsWith(\"blob:\")) {\n URL.revokeObjectURL(attrs.src)\n }\n\n this.options.onImageRemoved?.(attrs)\n }\n })\n }\n })\n },\n\n addNodeView() {\n return ReactNodeViewRenderer(ImageViewBlock, {\n className: \"block-node\",\n })\n },\n})\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/extensions/image/image.ts" }, { "path": "src/components/minimal-tiptap/extensions/horizontal-rule/index.ts", "content": "export * from \"./horizontal-rule\"\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/extensions/horizontal-rule/index.ts" }, { "path": "src/components/minimal-tiptap/extensions/horizontal-rule/horizontal-rule.ts", "content": "/*\n * Wrap the horizontal rule in a div element.\n * Also add a keyboard shortcut to insert a horizontal rule.\n */\nimport { HorizontalRule as TiptapHorizontalRule } from \"@tiptap/extension-horizontal-rule\"\n\nexport const HorizontalRule = TiptapHorizontalRule.extend({\n addKeyboardShortcuts() {\n return {\n \"Mod-Alt--\": () =>\n this.editor.commands.insertContent({\n type: this.name,\n }),\n }\n },\n})\n\nexport default HorizontalRule\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/extensions/horizontal-rule/horizontal-rule.ts" }, { "path": "src/components/minimal-tiptap/extensions/file-handler/index.ts", "content": "import { type Editor, Extension } from \"@tiptap/react\"\nimport { Plugin, PluginKey } from \"@tiptap/pm/state\"\nimport type { FileError, FileValidationOptions } from \"../../utils\"\nimport { filterFiles } from \"../../utils\"\n\ntype FileHandlePluginOptions = {\n key?: PluginKey\n editor: Editor\n onPaste?: (editor: Editor, files: File[], pasteContent?: string) => void\n onDrop?: (editor: Editor, files: File[], pos: number) => void\n onValidationError?: (errors: FileError[]) => void\n} & FileValidationOptions\n\nconst FileHandlePlugin = (options: FileHandlePluginOptions) => {\n const {\n key,\n editor,\n onPaste,\n onDrop,\n onValidationError,\n allowedMimeTypes,\n maxFileSize,\n } = options\n\n return new Plugin({\n key: key || new PluginKey(\"fileHandler\"),\n\n props: {\n handleDrop(view, event) {\n const { dataTransfer } = event\n\n if (!dataTransfer?.files.length) {\n return false\n }\n\n event.preventDefault()\n event.stopPropagation()\n\n const pos = view.posAtCoords({\n left: event.clientX,\n top: event.clientY,\n })\n\n const [validFiles, errors] = filterFiles(\n Array.from(dataTransfer.files),\n {\n allowedMimeTypes,\n maxFileSize,\n allowBase64: options.allowBase64,\n }\n )\n\n if (errors.length > 0 && onValidationError) {\n onValidationError(errors)\n }\n\n if (validFiles.length > 0 && onDrop) {\n onDrop(editor, validFiles, pos?.pos ?? 0)\n }\n\n return true\n },\n\n handlePaste(_, event) {\n const { clipboardData } = event\n\n if (!clipboardData?.files.length) {\n return false\n }\n\n event.preventDefault()\n event.stopPropagation()\n\n const [validFiles, errors] = filterFiles(\n Array.from(clipboardData.files),\n {\n allowedMimeTypes,\n maxFileSize,\n allowBase64: options.allowBase64,\n }\n )\n const html = clipboardData.getData(\"text/html\")\n\n if (errors.length > 0 && onValidationError) {\n onValidationError(errors)\n }\n\n if (validFiles.length > 0 && onPaste) {\n onPaste(editor, validFiles, html)\n }\n\n return true\n },\n },\n })\n}\n\nexport const FileHandler = Extension.create<\n Omit\n>({\n name: \"fileHandler\",\n\n addOptions() {\n return {\n allowBase64: false,\n allowedMimeTypes: [],\n maxFileSize: 0,\n }\n },\n\n addProseMirrorPlugins() {\n return [\n FileHandlePlugin({\n key: new PluginKey(this.name),\n editor: this.editor,\n ...this.options,\n }),\n ]\n },\n})\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/extensions/file-handler/index.ts" }, { "path": "src/components/minimal-tiptap/extensions/color/index.ts", "content": "export * from \"./color\"\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/extensions/color/index.ts" }, { "path": "src/components/minimal-tiptap/extensions/color/color.ts", "content": "import { Color as TiptapColor } from \"@tiptap/extension-color\"\nimport { Plugin } from \"@tiptap/pm/state\"\n\nexport const Color = TiptapColor.extend({\n addProseMirrorPlugins() {\n return [\n ...(this.parent?.() || []),\n new Plugin({\n props: {\n handleKeyDown: (_, event) => {\n if (event.key === \"Enter\") {\n this.editor.commands.unsetColor()\n }\n return false\n },\n },\n }),\n ]\n },\n})\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/extensions/color/color.ts" }, { "path": "src/components/minimal-tiptap/extensions/code-block-lowlight/index.ts", "content": "export * from \"./code-block-lowlight\"\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/extensions/code-block-lowlight/index.ts" }, { "path": "src/components/minimal-tiptap/extensions/code-block-lowlight/code-block-lowlight.ts", "content": "import { CodeBlockLowlight as TiptapCodeBlockLowlight } from \"@tiptap/extension-code-block-lowlight\"\nimport { common, createLowlight } from \"lowlight\"\n\nexport const CodeBlockLowlight = TiptapCodeBlockLowlight.extend({\n addOptions() {\n return {\n ...this.parent?.(),\n lowlight: createLowlight(common),\n defaultLanguage: null,\n HTMLAttributes: {\n class: \"block-node\",\n },\n languageClassPrefix: 'language-',\n exitOnTripleEnter: true,\n exitOnArrowDown: true,\n enableTabIndentation: false,\n tabSize: 4,\n }\n },\n})\n\nexport default CodeBlockLowlight\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/extensions/code-block-lowlight/code-block-lowlight.ts" }, { "path": "src/components/minimal-tiptap/components/section/two.tsx", "content": "import * as React from \"react\"\nimport type { Editor } from \"@tiptap/react\"\nimport type { FormatAction } from \"../../types\"\nimport type { toggleVariants } from \"@/components/ui/toggle\"\nimport type { VariantProps } from \"class-variance-authority\"\nimport {\n CodeIcon,\n DotsHorizontalIcon,\n FontBoldIcon,\n FontItalicIcon,\n StrikethroughIcon,\n TextNoneIcon,\n UnderlineIcon,\n} from \"@radix-ui/react-icons\"\nimport { ToolbarSection } from \"../toolbar-section\"\n\ntype TextStyleAction =\n | \"bold\"\n | \"italic\"\n | \"underline\"\n | \"strikethrough\"\n | \"code\"\n | \"clearFormatting\"\n\ninterface TextStyle extends FormatAction {\n value: TextStyleAction\n}\n\nconst formatActions: TextStyle[] = [\n {\n value: \"bold\",\n label: \"Bold\",\n icon: ,\n action: (editor) => editor.chain().focus().toggleBold().run(),\n isActive: (editor) => editor.isActive(\"bold\"),\n canExecute: (editor) =>\n editor.can().chain().focus().toggleBold().run() &&\n !editor.isActive(\"codeBlock\"),\n shortcuts: [\"mod\", \"B\"],\n },\n {\n value: \"italic\",\n label: \"Italic\",\n icon: ,\n action: (editor) => editor.chain().focus().toggleItalic().run(),\n isActive: (editor) => editor.isActive(\"italic\"),\n canExecute: (editor) =>\n editor.can().chain().focus().toggleItalic().run() &&\n !editor.isActive(\"codeBlock\"),\n shortcuts: [\"mod\", \"I\"],\n },\n {\n value: \"underline\",\n label: \"Underline\",\n icon: ,\n action: (editor) => editor.chain().focus().toggleUnderline().run(),\n isActive: (editor) => editor.isActive(\"underline\"),\n canExecute: (editor) =>\n editor.can().chain().focus().toggleUnderline().run() &&\n !editor.isActive(\"codeBlock\"),\n shortcuts: [\"mod\", \"U\"],\n },\n {\n value: \"strikethrough\",\n label: \"Strikethrough\",\n icon: ,\n action: (editor) => editor.chain().focus().toggleStrike().run(),\n isActive: (editor) => editor.isActive(\"strike\"),\n canExecute: (editor) =>\n editor.can().chain().focus().toggleStrike().run() &&\n !editor.isActive(\"codeBlock\"),\n shortcuts: [\"mod\", \"shift\", \"S\"],\n },\n {\n value: \"code\",\n label: \"Code\",\n icon: ,\n action: (editor) => editor.chain().focus().toggleCode().run(),\n isActive: (editor) => editor.isActive(\"code\"),\n canExecute: (editor) =>\n editor.can().chain().focus().toggleCode().run() &&\n !editor.isActive(\"codeBlock\"),\n shortcuts: [\"mod\", \"E\"],\n },\n {\n value: \"clearFormatting\",\n label: \"Clear formatting\",\n icon: ,\n action: (editor) => editor.chain().focus().unsetAllMarks().run(),\n isActive: () => false,\n canExecute: (editor) =>\n editor.can().chain().focus().unsetAllMarks().run() &&\n !editor.isActive(\"codeBlock\"),\n shortcuts: [\"mod\", \"\\\\\"],\n },\n]\n\ninterface SectionTwoProps extends VariantProps {\n editor: Editor\n activeActions?: TextStyleAction[]\n mainActionCount?: number\n}\n\nexport const SectionTwo: React.FC = ({\n editor,\n activeActions = formatActions.map((action) => action.value),\n mainActionCount = 2,\n size,\n variant,\n}) => {\n return (\n }\n dropdownTooltip=\"More formatting\"\n dropdownClassName=\"w-8\"\n size={size}\n variant={variant}\n />\n )\n}\n\nSectionTwo.displayName = \"SectionTwo\"\n\nexport default SectionTwo\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/components/section/two.tsx" }, { "path": "src/components/minimal-tiptap/components/section/three.tsx", "content": "import * as React from \"react\"\nimport type { Editor } from \"@tiptap/react\"\nimport type { toggleVariants } from \"@/components/ui/toggle\"\nimport type { VariantProps } from \"class-variance-authority\"\nimport { CaretDownIcon, CheckIcon } from \"@radix-ui/react-icons\"\nimport { ToolbarButton } from \"../toolbar-button\"\nimport {\n Popover,\n PopoverTrigger,\n PopoverContent,\n} from \"@/components/ui/popover\"\nimport { ToggleGroup, ToggleGroupItem } from \"@/components/ui/toggle-group\"\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\"\nimport { useTheme } from \"../../hooks/use-theme\"\n\ninterface ColorItem {\n cssVar: string\n label: string\n darkLabel?: string\n}\n\ninterface ColorPalette {\n label: string\n colors: ColorItem[]\n inverse: string\n}\n\nconst COLORS: ColorPalette[] = [\n {\n label: \"Palette 1\",\n inverse: \"hsl(var(--background))\",\n colors: [\n { cssVar: \"hsl(var(--foreground))\", label: \"Default\" },\n { cssVar: \"var(--mt-accent-bold-blue)\", label: \"Bold blue\" },\n { cssVar: \"var(--mt-accent-bold-teal)\", label: \"Bold teal\" },\n { cssVar: \"var(--mt-accent-bold-green)\", label: \"Bold green\" },\n { cssVar: \"var(--mt-accent-bold-orange)\", label: \"Bold orange\" },\n { cssVar: \"var(--mt-accent-bold-red)\", label: \"Bold red\" },\n { cssVar: \"var(--mt-accent-bold-purple)\", label: \"Bold purple\" },\n ],\n },\n {\n label: \"Palette 2\",\n inverse: \"hsl(var(--background))\",\n colors: [\n { cssVar: \"var(--mt-accent-gray)\", label: \"Gray\" },\n { cssVar: \"var(--mt-accent-blue)\", label: \"Blue\" },\n { cssVar: \"var(--mt-accent-teal)\", label: \"Teal\" },\n { cssVar: \"var(--mt-accent-green)\", label: \"Green\" },\n { cssVar: \"var(--mt-accent-orange)\", label: \"Orange\" },\n { cssVar: \"var(--mt-accent-red)\", label: \"Red\" },\n { cssVar: \"var(--mt-accent-purple)\", label: \"Purple\" },\n ],\n },\n {\n label: \"Palette 3\",\n inverse: \"hsl(var(--foreground))\",\n colors: [\n { cssVar: \"hsl(var(--background))\", label: \"White\", darkLabel: \"Black\" },\n { cssVar: \"var(--mt-accent-blue-subtler)\", label: \"Blue subtle\" },\n { cssVar: \"var(--mt-accent-teal-subtler)\", label: \"Teal subtle\" },\n { cssVar: \"var(--mt-accent-green-subtler)\", label: \"Green subtle\" },\n { cssVar: \"var(--mt-accent-yellow-subtler)\", label: \"Yellow subtle\" },\n { cssVar: \"var(--mt-accent-red-subtler)\", label: \"Red subtle\" },\n { cssVar: \"var(--mt-accent-purple-subtler)\", label: \"Purple subtle\" },\n ],\n },\n]\n\nconst MemoizedColorButton = React.memo<{\n color: ColorItem\n isSelected: boolean\n inverse: string\n onClick: (value: string) => void\n}>(({ color, isSelected, inverse, onClick }) => {\n const isDarkMode = useTheme()\n const label = isDarkMode && color.darkLabel ? color.darkLabel : color.label\n\n return (\n \n \n ) => {\n e.preventDefault()\n onClick(color.cssVar)\n }}\n >\n {isSelected && (\n \n )}\n \n \n \n

{label}

\n
\n
\n )\n})\n\nMemoizedColorButton.displayName = \"MemoizedColorButton\"\n\nconst MemoizedColorPicker = React.memo<{\n palette: ColorPalette\n selectedColor: string\n inverse: string\n onColorChange: (value: string) => void\n}>(({ palette, selectedColor, inverse, onColorChange }) => (\n {\n if (value) onColorChange(value)\n }}\n className=\"gap-1.5\"\n >\n {palette.colors.map((color, index) => (\n \n ))}\n \n))\n\nMemoizedColorPicker.displayName = \"MemoizedColorPicker\"\n\ninterface SectionThreeProps extends VariantProps {\n editor: Editor\n}\n\nexport const SectionThree: React.FC = ({\n editor,\n size,\n variant,\n}) => {\n const color =\n editor.getAttributes(\"textStyle\")?.color || \"hsl(var(--foreground))\"\n const [selectedColor, setSelectedColor] = React.useState(color)\n\n const handleColorChange = React.useCallback(\n (value: string) => {\n setSelectedColor(value)\n if (editor.state.storedMarks) {\n const textStyleMarkType = editor.schema.marks.textStyle\n if (textStyleMarkType) {\n editor.view.dispatch(\n editor.state.tr.removeStoredMark(textStyleMarkType)\n )\n }\n }\n\n setTimeout(() => {\n editor.chain().setColor(value).run()\n }, 0)\n },\n [editor]\n )\n\n React.useEffect(() => {\n setSelectedColor(color)\n }, [color])\n\n return (\n \n \n \n \n \n \n \n \n \n \n \n \n
\n {COLORS.map((palette, index) => (\n \n ))}\n
\n
\n
\n )\n}\n\nSectionThree.displayName = \"SectionThree\"\n\nexport default SectionThree\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/components/section/three.tsx" }, { "path": "src/components/minimal-tiptap/components/section/one.tsx", "content": "import * as React from \"react\"\nimport type { Editor } from \"@tiptap/react\"\nimport type { FormatAction } from \"../../types\"\nimport type { VariantProps } from \"class-variance-authority\"\nimport type { toggleVariants } from \"@/components/ui/toggle\"\nimport { cn } from \"@/lib/utils\"\nimport { CaretDownIcon, LetterCaseCapitalizeIcon } from \"@radix-ui/react-icons\"\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\"\nimport { ToolbarButton } from \"../toolbar-button\"\nimport { ShortcutKey } from \"../shortcut-key\"\n\ntype Level = 1 | 2 | 3 | 4 | 5 | 6\ninterface TextStyle\n extends Omit<\n FormatAction,\n \"value\" | \"icon\" | \"action\" | \"isActive\" | \"canExecute\"\n > {\n element: keyof React.JSX.IntrinsicElements\n level?: Level\n className: string\n}\n\nconst formatActions: TextStyle[] = [\n {\n label: \"Normal Text\",\n element: \"span\",\n className: \"grow\",\n shortcuts: [\"mod\", \"alt\", \"0\"],\n },\n {\n label: \"Heading 1\",\n element: \"h1\",\n level: 1,\n className: \"m-0 grow text-3xl font-extrabold\",\n shortcuts: [\"mod\", \"alt\", \"1\"],\n },\n {\n label: \"Heading 2\",\n element: \"h2\",\n level: 2,\n className: \"m-0 grow text-xl font-bold\",\n shortcuts: [\"mod\", \"alt\", \"2\"],\n },\n {\n label: \"Heading 3\",\n element: \"h3\",\n level: 3,\n className: \"m-0 grow text-lg font-semibold\",\n shortcuts: [\"mod\", \"alt\", \"3\"],\n },\n {\n label: \"Heading 4\",\n element: \"h4\",\n level: 4,\n className: \"m-0 grow text-base font-semibold\",\n shortcuts: [\"mod\", \"alt\", \"4\"],\n },\n {\n label: \"Heading 5\",\n element: \"h5\",\n level: 5,\n className: \"m-0 grow text-sm font-normal\",\n shortcuts: [\"mod\", \"alt\", \"5\"],\n },\n {\n label: \"Heading 6\",\n element: \"h6\",\n level: 6,\n className: \"m-0 grow text-sm font-normal\",\n shortcuts: [\"mod\", \"alt\", \"6\"],\n },\n]\n\ninterface SectionOneProps extends VariantProps {\n editor: Editor\n activeLevels?: Level[]\n}\n\nexport const SectionOne: React.FC = ({\n editor,\n activeLevels = [1, 2, 3, 4, 5, 6],\n size,\n variant,\n}) => {\n const filteredActions = React.useMemo(\n () =>\n formatActions.filter(\n (action) => !action.level || activeLevels.includes(action.level)\n ),\n [activeLevels]\n )\n\n const handleStyleChange = React.useCallback(\n (level?: Level) => {\n if (level) {\n editor.chain().focus().toggleHeading({ level }).run()\n } else {\n editor.chain().focus().setParagraph().run()\n }\n },\n [editor]\n )\n\n const renderMenuItem = React.useCallback(\n ({ label, element: Element, level, className, shortcuts }: TextStyle) => (\n handleStyleChange(level)}\n className={cn(\"flex flex-row items-center justify-between gap-4\", {\n \"bg-accent\": level\n ? editor.isActive(\"heading\", { level })\n : editor.isActive(\"paragraph\"),\n })}\n aria-label={label}\n >\n {label}\n \n \n ),\n [editor, handleStyleChange]\n )\n\n return (\n \n \n \n \n \n \n \n \n {filteredActions.map(renderMenuItem)}\n \n \n )\n}\n\nSectionOne.displayName = \"SectionOne\"\n\nexport default SectionOne\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/components/section/one.tsx" }, { "path": "src/components/minimal-tiptap/components/section/four.tsx", "content": "import * as React from \"react\"\nimport type { Editor } from \"@tiptap/react\"\nimport type { FormatAction } from \"../../types\"\nimport type { toggleVariants } from \"@/components/ui/toggle\"\nimport type { VariantProps } from \"class-variance-authority\"\nimport { CaretDownIcon, ListBulletIcon } from \"@radix-ui/react-icons\"\nimport { ToolbarSection } from \"../toolbar-section\"\n\ntype ListItemAction = \"orderedList\" | \"bulletList\"\ninterface ListItem extends FormatAction {\n value: ListItemAction\n}\n\nconst formatActions: ListItem[] = [\n {\n value: \"orderedList\",\n label: \"Numbered list\",\n icon: (\n \n \n \n ),\n isActive: (editor) => editor.isActive(\"orderedList\"),\n action: (editor) => editor.chain().focus().toggleOrderedList().run(),\n canExecute: (editor) =>\n editor.can().chain().focus().toggleOrderedList().run(),\n shortcuts: [\"mod\", \"shift\", \"7\"],\n },\n {\n value: \"bulletList\",\n label: \"Bullet list\",\n icon: ,\n isActive: (editor) => editor.isActive(\"bulletList\"),\n action: (editor) => editor.chain().focus().toggleBulletList().run(),\n canExecute: (editor) =>\n editor.can().chain().focus().toggleBulletList().run(),\n shortcuts: [\"mod\", \"shift\", \"8\"],\n },\n]\n\ninterface SectionFourProps extends VariantProps {\n editor: Editor\n activeActions?: ListItemAction[]\n mainActionCount?: number\n}\n\nexport const SectionFour: React.FC = ({\n editor,\n activeActions = formatActions.map((action) => action.value),\n mainActionCount = 0,\n size,\n variant,\n}) => {\n return (\n \n \n \n \n }\n dropdownTooltip=\"Lists\"\n size={size}\n variant={variant}\n />\n )\n}\n\nSectionFour.displayName = \"SectionFour\"\n\nexport default SectionFour\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/components/section/four.tsx" }, { "path": "src/components/minimal-tiptap/components/section/five.tsx", "content": "import * as React from \"react\"\nimport type { Editor } from \"@tiptap/react\"\nimport type { FormatAction } from \"../../types\"\nimport type { toggleVariants } from \"@/components/ui/toggle\"\nimport type { VariantProps } from \"class-variance-authority\"\nimport {\n CaretDownIcon,\n CodeIcon,\n DividerHorizontalIcon,\n PlusIcon,\n QuoteIcon,\n} from \"@radix-ui/react-icons\"\nimport { LinkEditPopover } from \"../link/link-edit-popover\"\nimport { ImageEditDialog } from \"../image/image-edit-dialog\"\nimport { ToolbarSection } from \"../toolbar-section\"\n\ntype InsertElementAction = \"codeBlock\" | \"blockquote\" | \"horizontalRule\"\ninterface InsertElement extends FormatAction {\n value: InsertElementAction\n}\n\nconst formatActions: InsertElement[] = [\n {\n value: \"codeBlock\",\n label: \"Code block\",\n icon: ,\n action: (editor) => editor.chain().focus().toggleCodeBlock().run(),\n isActive: (editor) => editor.isActive(\"codeBlock\"),\n canExecute: (editor) =>\n editor.can().chain().focus().toggleCodeBlock().run(),\n shortcuts: [\"mod\", \"alt\", \"C\"],\n },\n {\n value: \"blockquote\",\n label: \"Blockquote\",\n icon: ,\n action: (editor) => editor.chain().focus().toggleBlockquote().run(),\n isActive: (editor) => editor.isActive(\"blockquote\"),\n canExecute: (editor) =>\n editor.can().chain().focus().toggleBlockquote().run(),\n shortcuts: [\"mod\", \"shift\", \"B\"],\n },\n {\n value: \"horizontalRule\",\n label: \"Divider\",\n icon: ,\n action: (editor) => editor.chain().focus().setHorizontalRule().run(),\n isActive: () => false,\n canExecute: (editor) =>\n editor.can().chain().focus().setHorizontalRule().run(),\n shortcuts: [\"mod\", \"alt\", \"-\"],\n },\n]\n\ninterface SectionFiveProps extends VariantProps {\n editor: Editor\n activeActions?: InsertElementAction[]\n mainActionCount?: number\n}\n\nexport const SectionFive: React.FC = ({\n editor,\n activeActions = formatActions.map((action) => action.value),\n mainActionCount = 0,\n size,\n variant,\n}) => {\n return (\n <>\n \n \n \n \n \n \n }\n dropdownTooltip=\"Insert elements\"\n size={size}\n variant={variant}\n />\n \n )\n}\n\nSectionFive.displayName = \"SectionFive\"\n\nexport default SectionFive\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/components/section/five.tsx" }, { "path": "src/components/minimal-tiptap/components/link/link-popover-block.tsx", "content": "import * as React from \"react\"\nimport { Separator } from \"@/components/ui/separator\"\nimport { ToolbarButton } from \"../toolbar-button\"\nimport {\n CopyIcon,\n ExternalLinkIcon,\n LinkBreak2Icon,\n} from \"@radix-ui/react-icons\"\n\ninterface LinkPopoverBlockProps {\n url: string\n onClear: () => void\n onEdit: (e: React.MouseEvent) => void\n}\n\nexport const LinkPopoverBlock: React.FC = ({\n url,\n onClear,\n onEdit,\n}) => {\n const [copyTitle, setCopyTitle] = React.useState(\"Copy\")\n\n const handleCopy = React.useCallback(\n (e: React.MouseEvent) => {\n e.preventDefault()\n navigator.clipboard\n .writeText(url)\n .then(() => {\n setCopyTitle(\"Copied!\")\n setTimeout(() => setCopyTitle(\"Copy\"), 1000)\n })\n .catch(console.error)\n },\n [url]\n )\n\n const handleOpenLink = React.useCallback(() => {\n window.open(url, \"_blank\", \"noopener,noreferrer\")\n }, [url])\n\n return (\n
\n
\n \n Edit link\n \n \n \n \n \n \n \n \n \n \n {\n if (e.target === e.currentTarget) e.preventDefault()\n },\n }}\n >\n \n \n
\n
\n )\n}\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/components/link/link-popover-block.tsx" }, { "path": "src/components/minimal-tiptap/components/link/link-edit-popover.tsx", "content": "import * as React from \"react\"\nimport type { Editor } from \"@tiptap/react\"\nimport type { VariantProps } from \"class-variance-authority\"\nimport type { toggleVariants } from \"@/components/ui/toggle\"\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\"\nimport { Link2Icon } from \"@radix-ui/react-icons\"\nimport { ToolbarButton } from \"../toolbar-button\"\nimport { LinkEditBlock } from \"./link-edit-block\"\n\ninterface LinkEditPopoverProps extends VariantProps {\n editor: Editor\n}\n\nconst LinkEditPopover = ({ editor, size, variant }: LinkEditPopoverProps) => {\n const [open, setOpen] = React.useState(false)\n\n const { from, to } = editor.state.selection\n const text = editor.state.doc.textBetween(from, to, \" \")\n\n const onSetLink = React.useCallback(\n (url: string, text?: string, openInNewTab?: boolean) => {\n editor\n .chain()\n .focus()\n .extendMarkRange(\"link\")\n .insertContent({\n type: \"text\",\n text: text || url,\n marks: [\n {\n type: \"link\",\n attrs: {\n href: url,\n target: openInNewTab ? \"_blank\" : \"\",\n },\n },\n ],\n })\n .setLink({ href: url })\n .run()\n\n editor.commands.enter()\n },\n [editor]\n )\n\n return (\n \n \n \n \n \n \n \n \n \n \n )\n}\n\nexport { LinkEditPopover }\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/components/link/link-edit-popover.tsx" }, { "path": "src/components/minimal-tiptap/components/link/link-edit-block.tsx", "content": "import * as React from \"react\"\nimport { Button } from \"@/components/ui/button\"\nimport { Label } from \"@/components/ui/label\"\nimport { Switch } from \"@/components/ui/switch\"\nimport { Input } from \"@/components/ui/input\"\nimport { cn } from \"@/lib/utils\"\n\nexport interface LinkEditorProps extends React.ComponentProps<\"div\"> {\n defaultUrl?: string\n defaultText?: string\n defaultIsNewTab?: boolean\n onSave: (url: string, text?: string, isNewTab?: boolean) => void\n}\n\nexport const LinkEditBlock = ({\n onSave,\n defaultIsNewTab,\n defaultUrl,\n defaultText,\n className,\n}: LinkEditorProps) => {\n const formRef = React.useRef(null)\n const [url, setUrl] = React.useState(defaultUrl || \"\")\n const [text, setText] = React.useState(defaultText || \"\")\n const [isNewTab, setIsNewTab] = React.useState(defaultIsNewTab || false)\n\n const handleSave = React.useCallback(\n (e: React.FormEvent) => {\n e.preventDefault()\n if (formRef.current) {\n const isValid = Array.from(\n formRef.current.querySelectorAll(\"input\")\n ).every((input) => input.checkValidity())\n\n if (isValid) {\n onSave(url, text, isNewTab)\n } else {\n formRef.current.querySelectorAll(\"input\").forEach((input) => {\n if (!input.checkValidity()) {\n input.reportValidity()\n }\n })\n }\n }\n },\n [onSave, url, text, isNewTab]\n )\n\n return (\n
\n
\n
\n \n setUrl(e.target.value)}\n />\n
\n\n
\n \n setText(e.target.value)}\n />\n
\n\n
\n \n \n
\n\n
\n \n
\n
\n
\n )\n}\n\nLinkEditBlock.displayName = \"LinkEditBlock\"\n\nexport default LinkEditBlock\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/components/link/link-edit-block.tsx" }, { "path": "src/components/minimal-tiptap/components/image/image-edit-dialog.tsx", "content": "import type { Editor } from \"@tiptap/react\"\nimport type { VariantProps } from \"class-variance-authority\"\nimport type { toggleVariants } from \"@/components/ui/toggle\"\nimport { useState } from \"react\"\nimport { ImageIcon } from \"@radix-ui/react-icons\"\nimport { ToolbarButton } from \"../toolbar-button\"\nimport {\n Dialog,\n DialogContent,\n DialogHeader,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/components/ui/dialog\"\nimport { ImageEditBlock } from \"./image-edit-block\"\n\ninterface ImageEditDialogProps extends VariantProps {\n editor: Editor\n}\n\nconst ImageEditDialog = ({ editor, size, variant }: ImageEditDialogProps) => {\n const [open, setOpen] = useState(false)\n\n return (\n \n \n \n \n \n \n \n \n Select image\n \n Upload an image from your computer\n \n \n setOpen(false)} />\n \n \n )\n}\n\nexport { ImageEditDialog }\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/components/image/image-edit-dialog.tsx" }, { "path": "src/components/minimal-tiptap/components/image/image-edit-block.tsx", "content": "import * as React from \"react\"\nimport type { Editor } from \"@tiptap/react\"\nimport { Button } from \"@/components/ui/button\"\nimport { Label } from \"@/components/ui/label\"\nimport { Input } from \"@/components/ui/input\"\n\ninterface ImageEditBlockProps {\n editor: Editor\n close: () => void\n}\n\nexport const ImageEditBlock: React.FC = ({\n editor,\n close,\n}) => {\n const fileInputRef = React.useRef(null)\n const [link, setLink] = React.useState(\"\")\n\n const handleClick = React.useCallback(() => {\n fileInputRef.current?.click()\n }, [])\n\n const handleFile = React.useCallback(\n async (e: React.ChangeEvent) => {\n const files = e.target.files\n if (!files?.length) return\n\n const insertImages = async () => {\n const contentBucket = []\n const filesArray = Array.from(files)\n\n for (const file of filesArray) {\n contentBucket.push({ src: file })\n }\n\n editor.commands.setImages(contentBucket)\n }\n\n await insertImages()\n close()\n },\n [editor, close]\n )\n\n const handleSubmit = React.useCallback(\n (e: React.FormEvent) => {\n e.preventDefault()\n e.stopPropagation()\n\n if (link) {\n editor.commands.setImages([{ src: link }])\n close()\n }\n },\n [editor, link, close]\n )\n\n return (\n
\n
\n \n
\n ) =>\n setLink(e.target.value)\n }\n />\n \n
\n
\n \n \n \n )\n}\n\nexport default ImageEditBlock\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/components/image/image-edit-block.tsx" }, { "path": "src/components/minimal-tiptap/components/bubble-menu/link-bubble-menu.tsx", "content": "import * as React from \"react\"\nimport type { ShouldShowProps } from \"../../types\"\nimport type { Editor } from \"@tiptap/react\"\nimport { BubbleMenu } from \"@tiptap/react/menus\"\nimport { LinkEditBlock } from \"../link/link-edit-block\"\nimport { LinkPopoverBlock } from \"../link/link-popover-block\"\n\ninterface LinkBubbleMenuProps {\n editor: Editor\n}\n\ninterface LinkAttributes {\n href: string\n target: string\n}\n\nexport const LinkBubbleMenu: React.FC = ({ editor }) => {\n const [showEdit, setShowEdit] = React.useState(false)\n const [linkAttrs, setLinkAttrs] = React.useState({\n href: \"\",\n target: \"\",\n })\n const [selectedText, setSelectedText] = React.useState(\"\")\n\n const updateLinkState = React.useCallback(() => {\n const { from, to } = editor.state.selection\n const { href, target } = editor.getAttributes(\"link\")\n const text = editor.state.doc.textBetween(from, to, \" \")\n\n setLinkAttrs({ href, target })\n setSelectedText(text)\n }, [editor])\n\n const shouldShow = React.useCallback(\n ({ editor, from, to }: ShouldShowProps) => {\n if (from === to) {\n return false\n }\n const { href } = editor.getAttributes(\"link\")\n\n if (!editor.isActive(\"link\") || !editor.isEditable) {\n return false\n }\n\n if (href) {\n updateLinkState()\n return true\n }\n return false\n },\n [updateLinkState]\n )\n\n const handleEdit = React.useCallback(() => {\n setShowEdit(true)\n }, [])\n\n const onSetLink = React.useCallback(\n (url: string, text?: string, openInNewTab?: boolean) => {\n editor\n .chain()\n .focus()\n .extendMarkRange(\"link\")\n .insertContent({\n type: \"text\",\n text: text || url,\n marks: [\n {\n type: \"link\",\n attrs: {\n href: url,\n target: openInNewTab ? \"_blank\" : \"\",\n },\n },\n ],\n })\n .setLink({ href: url, target: openInNewTab ? \"_blank\" : \"\" })\n .run()\n setShowEdit(false)\n updateLinkState()\n },\n [editor, updateLinkState]\n )\n\n const onUnsetLink = React.useCallback(() => {\n editor.chain().focus().extendMarkRange(\"link\").unsetLink().run()\n setShowEdit(false)\n updateLinkState()\n }, [editor, updateLinkState])\n\n return (\n setShowEdit(false),\n }}\n >\n {showEdit ? (\n \n ) : (\n \n )}\n \n )\n}\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/components/bubble-menu/link-bubble-menu.tsx" }, { "path": "src/components/minimal-tiptap/extensions/image/hooks/use-image-actions.ts", "content": "import * as React from \"react\"\nimport type { Editor } from \"@tiptap/react\"\nimport type { Node } from \"@tiptap/pm/model\"\nimport { isUrl } from \"../../../utils\"\n\ninterface UseImageActionsProps {\n editor: Editor\n node: Node\n src: string\n onViewClick: (value: boolean) => void\n}\n\nexport type ImageActionHandlers = {\n onView?: () => void\n onDownload?: () => void\n onCopy?: () => void\n onCopyLink?: () => void\n onRemoveImg?: () => void\n}\n\nexport const useImageActions = ({\n editor,\n node,\n src,\n onViewClick,\n}: UseImageActionsProps) => {\n const isLink = isUrl(src)\n\n const onView = React.useCallback(() => {\n onViewClick(true)\n }, [onViewClick])\n\n const onDownload = React.useCallback(() => {\n editor.commands.downloadImage({ src: node.attrs.src, alt: node.attrs.alt })\n }, [editor.commands, node.attrs.alt, node.attrs.src])\n\n const onCopy = React.useCallback(() => {\n editor.commands.copyImage({ src: node.attrs.src })\n }, [editor.commands, node.attrs.src])\n\n const onCopyLink = React.useCallback(() => {\n editor.commands.copyLink({ src: node.attrs.src })\n }, [editor.commands, node.attrs.src])\n\n const onRemoveImg = React.useCallback(() => {\n editor.commands.command(({ tr, dispatch }) => {\n const { selection } = tr\n const nodeAtSelection = tr.doc.nodeAt(selection.from)\n\n if (nodeAtSelection && nodeAtSelection.type.name === \"image\") {\n if (dispatch) {\n tr.deleteSelection()\n return true\n }\n }\n return false\n })\n }, [editor.commands])\n\n return { isLink, onView, onDownload, onCopy, onCopyLink, onRemoveImg }\n}\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/extensions/image/hooks/use-image-actions.ts" }, { "path": "src/components/minimal-tiptap/extensions/image/hooks/use-drag-resize.ts", "content": "import { useState, useCallback, useEffect } from \"react\"\n\ntype ResizeDirection = \"left\" | \"right\"\nexport type ElementDimensions = { width: number; height: number }\n\ntype HookParams = {\n initialWidth?: number\n initialHeight?: number\n contentWidth?: number\n contentHeight?: number\n gridInterval: number\n minWidth: number\n minHeight: number\n maxWidth: number\n onDimensionsChange?: (dimensions: ElementDimensions) => void\n}\n\nexport function useDragResize({\n initialWidth,\n initialHeight,\n contentWidth,\n contentHeight,\n gridInterval,\n minWidth,\n minHeight,\n maxWidth,\n onDimensionsChange,\n}: HookParams) {\n const [dimensions, updateDimensions] = useState({\n width: Math.max(initialWidth ?? minWidth, minWidth),\n height: Math.max(initialHeight ?? minHeight, minHeight),\n })\n const [boundaryWidth, setBoundaryWidth] = useState(Infinity)\n const [resizeOrigin, setResizeOrigin] = useState(0)\n const [initialDimensions, setInitialDimensions] = useState(dimensions)\n const [resizeDirection, setResizeDirection] = useState<\n ResizeDirection | undefined\n >()\n\n const widthConstraint = useCallback(\n (proposedWidth: number, maxAllowedWidth: number) => {\n const effectiveMinWidth = Math.max(\n minWidth,\n Math.min(\n contentWidth ?? minWidth,\n (gridInterval / 100) * maxAllowedWidth\n )\n )\n return Math.min(\n maxAllowedWidth,\n Math.max(proposedWidth, effectiveMinWidth)\n )\n },\n [gridInterval, contentWidth, minWidth]\n )\n\n const handlePointerMove = useCallback(\n (event: PointerEvent) => {\n event.preventDefault()\n const movementDelta =\n (resizeDirection === \"left\"\n ? resizeOrigin - event.pageX\n : event.pageX - resizeOrigin) * 2\n const gridUnitWidth = (gridInterval / 100) * boundaryWidth\n const proposedWidth = initialDimensions.width + movementDelta\n const alignedWidth =\n Math.round(proposedWidth / gridUnitWidth) * gridUnitWidth\n const finalWidth = widthConstraint(alignedWidth, boundaryWidth)\n const aspectRatio =\n contentHeight && contentWidth ? contentHeight / contentWidth : 1\n\n updateDimensions({\n width: Math.max(finalWidth, minWidth),\n height: Math.max(\n contentWidth\n ? finalWidth * aspectRatio\n : (contentHeight ?? minHeight),\n minHeight\n ),\n })\n },\n [\n widthConstraint,\n resizeDirection,\n boundaryWidth,\n resizeOrigin,\n gridInterval,\n contentHeight,\n contentWidth,\n initialDimensions.width,\n minWidth,\n minHeight,\n ]\n )\n\n const handlePointerUp = useCallback(\n (event: PointerEvent) => {\n event.preventDefault()\n event.stopPropagation()\n\n setResizeOrigin(0)\n setResizeDirection(undefined)\n onDimensionsChange?.(dimensions)\n },\n [onDimensionsChange, dimensions]\n )\n\n const handleKeydown = useCallback(\n (event: KeyboardEvent) => {\n if (event.key === \"Escape\") {\n event.preventDefault()\n event.stopPropagation()\n updateDimensions({\n width: Math.max(initialDimensions.width, minWidth),\n height: Math.max(initialDimensions.height, minHeight),\n })\n setResizeDirection(undefined)\n }\n },\n [initialDimensions, minWidth, minHeight]\n )\n\n const initiateResize = useCallback(\n (direction: ResizeDirection) =>\n (event: React.PointerEvent) => {\n event.preventDefault()\n event.stopPropagation()\n\n setBoundaryWidth(maxWidth)\n setInitialDimensions({\n width: Math.max(\n widthConstraint(dimensions.width, maxWidth),\n minWidth\n ),\n height: Math.max(dimensions.height, minHeight),\n })\n setResizeOrigin(event.pageX)\n setResizeDirection(direction)\n },\n [\n maxWidth,\n widthConstraint,\n dimensions.width,\n dimensions.height,\n minWidth,\n minHeight,\n ]\n )\n\n useEffect(() => {\n if (resizeDirection) {\n document.addEventListener(\"keydown\", handleKeydown)\n document.addEventListener(\"pointermove\", handlePointerMove)\n document.addEventListener(\"pointerup\", handlePointerUp)\n\n return () => {\n document.removeEventListener(\"keydown\", handleKeydown)\n document.removeEventListener(\"pointermove\", handlePointerMove)\n document.removeEventListener(\"pointerup\", handlePointerUp)\n }\n }\n }, [resizeDirection, handleKeydown, handlePointerMove, handlePointerUp])\n\n return {\n initiateResize,\n isResizing: !!resizeDirection,\n updateDimensions,\n currentWidth: Math.max(dimensions.width, minWidth),\n currentHeight: Math.max(dimensions.height, minHeight),\n }\n}\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/extensions/image/hooks/use-drag-resize.ts" }, { "path": "src/components/minimal-tiptap/extensions/image/components/resize-handle.tsx", "content": "import * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\ninterface ResizeProps extends React.ComponentProps<\"div\"> {\n isResizing?: boolean\n}\n\nexport const ResizeHandle = ({\n ref,\n className,\n isResizing = false,\n ...props\n}: ResizeProps) => {\n return (\n \n )\n}\n\nResizeHandle.displayName = \"ResizeHandle\"\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/extensions/image/components/resize-handle.tsx" }, { "path": "src/components/minimal-tiptap/extensions/image/components/image-view-block.tsx", "content": "import * as React from \"react\"\nimport { NodeViewWrapper, type NodeViewProps } from \"@tiptap/react\"\nimport type { ElementDimensions } from \"../hooks/use-drag-resize\"\nimport { useDragResize } from \"../hooks/use-drag-resize\"\nimport { ResizeHandle } from \"./resize-handle\"\nimport { cn } from \"@/lib/utils\"\nimport { Controlled as ControlledZoom } from \"react-medium-image-zoom\"\nimport { ActionButton, ActionWrapper, ImageActions } from \"./image-actions\"\nimport { useImageActions } from \"../hooks/use-image-actions\"\nimport { blobUrlToBase64, randomId } from \"../../../utils\"\nimport { InfoCircledIcon, TrashIcon } from \"@radix-ui/react-icons\"\nimport { ImageOverlay } from \"./image-overlay\"\nimport { Spinner } from \"../../../components/spinner\"\nimport type { UploadReturnType } from \"../image\"\n\nconst MAX_HEIGHT = 600\nconst MIN_HEIGHT = 120\nconst MIN_WIDTH = 120\n\ninterface ImageState {\n src: string\n isServerUploading: boolean\n imageLoaded: boolean\n isZoomed: boolean\n error: boolean\n naturalSize: ElementDimensions\n}\n\nconst normalizeUploadResponse = (res: UploadReturnType) => ({\n src: typeof res === \"string\" ? res : res.src,\n id: typeof res === \"string\" ? randomId() : res.id,\n})\n\nexport const ImageViewBlock: React.FC = ({\n editor,\n node,\n selected,\n updateAttributes,\n}) => {\n const {\n src: initialSrc,\n width: initialWidth,\n height: initialHeight,\n fileName,\n } = node.attrs\n const uploadAttemptedRef = React.useRef(false)\n\n const initSrc = React.useMemo(() => {\n if (typeof initialSrc === \"string\") {\n return initialSrc\n }\n return initialSrc.src\n }, [initialSrc])\n\n const [imageState, setImageState] = React.useState({\n src: initSrc,\n isServerUploading: false,\n imageLoaded: false,\n isZoomed: false,\n error: false,\n naturalSize: { width: initialWidth, height: initialHeight },\n })\n\n const containerRef = React.useRef(null)\n const [activeResizeHandle, setActiveResizeHandle] = React.useState<\n \"left\" | \"right\" | null\n >(null)\n\n const onDimensionsChange = React.useCallback(\n ({ width, height }: ElementDimensions) => {\n updateAttributes({ width, height })\n },\n [updateAttributes]\n )\n\n const aspectRatio =\n imageState.naturalSize.width / imageState.naturalSize.height\n const maxWidth = MAX_HEIGHT * aspectRatio\n const containerMaxWidth = containerRef.current\n ? parseFloat(\n getComputedStyle(containerRef.current).getPropertyValue(\n \"--editor-width\"\n )\n )\n : Infinity\n\n const { isLink, onView, onDownload, onCopy, onCopyLink, onRemoveImg } =\n useImageActions({\n editor,\n node,\n src: imageState.src,\n onViewClick: (isZoomed) =>\n setImageState((prev) => ({ ...prev, isZoomed })),\n })\n\n const {\n currentWidth,\n currentHeight,\n updateDimensions,\n initiateResize,\n isResizing,\n } = useDragResize({\n initialWidth: initialWidth ?? imageState.naturalSize.width,\n initialHeight: initialHeight ?? imageState.naturalSize.height,\n contentWidth: imageState.naturalSize.width,\n contentHeight: imageState.naturalSize.height,\n gridInterval: 0.1,\n onDimensionsChange,\n minWidth: MIN_WIDTH,\n minHeight: MIN_HEIGHT,\n maxWidth: containerMaxWidth > 0 ? containerMaxWidth : maxWidth,\n })\n\n const shouldMerge = React.useMemo(() => currentWidth <= 180, [currentWidth])\n\n const handleImageLoad = React.useCallback(\n (ev: React.SyntheticEvent) => {\n const img = ev.target as HTMLImageElement\n const newNaturalSize = {\n width: img.naturalWidth,\n height: img.naturalHeight,\n }\n setImageState((prev) => ({\n ...prev,\n naturalSize: newNaturalSize,\n imageLoaded: true,\n }))\n updateAttributes({\n width: img.width || newNaturalSize.width,\n height: img.height || newNaturalSize.height,\n alt: img.alt,\n title: img.title,\n })\n\n if (!initialWidth) {\n updateDimensions((state) => ({ ...state, width: newNaturalSize.width }))\n }\n },\n [initialWidth, updateAttributes, updateDimensions]\n )\n\n const handleImageError = React.useCallback(() => {\n setImageState((prev) => ({ ...prev, error: true, imageLoaded: true }))\n }, [])\n\n const handleResizeStart = React.useCallback(\n (direction: \"left\" | \"right\") =>\n (event: React.PointerEvent) => {\n setActiveResizeHandle(direction)\n initiateResize(direction)(event)\n },\n [initiateResize]\n )\n\n const handleResizeEnd = React.useCallback(() => {\n setActiveResizeHandle(null)\n }, [])\n\n React.useEffect(() => {\n if (!isResizing) {\n handleResizeEnd()\n }\n }, [isResizing, handleResizeEnd])\n\n React.useEffect(() => {\n const handleImage = async () => {\n if (!initSrc.startsWith(\"blob:\") || uploadAttemptedRef.current) {\n return\n }\n\n uploadAttemptedRef.current = true\n const imageExtension = editor.options.extensions.find(\n (ext) => ext.name === \"image\"\n )\n const { uploadFn } = imageExtension?.options ?? {}\n\n if (!uploadFn) {\n try {\n const base64 = await blobUrlToBase64(initSrc)\n setImageState((prev) => ({ ...prev, src: base64 }))\n updateAttributes({ src: base64 })\n } catch {\n setImageState((prev) => ({ ...prev, error: true }))\n }\n return\n }\n\n try {\n setImageState((prev) => ({ ...prev, isServerUploading: true }))\n const response = await fetch(initSrc)\n const blob = await response.blob()\n const file = new File([blob], fileName, { type: blob.type })\n\n const url = await uploadFn(file, editor)\n const normalizedData = normalizeUploadResponse(url)\n\n setImageState((prev) => ({\n ...prev,\n ...normalizedData,\n isServerUploading: false,\n }))\n\n updateAttributes(normalizedData)\n } catch {\n setImageState((prev) => ({\n ...prev,\n error: true,\n isServerUploading: false,\n }))\n }\n }\n\n handleImage()\n }, [editor, fileName, initSrc, updateAttributes])\n\n return (\n \n \n \n
\n
\n {imageState.isServerUploading && !imageState.error && (\n
\n \n
\n )}\n\n {imageState.error && (\n
\n \n

\n Failed to load image\n

\n
\n )}\n\n \n setImageState((prev) => ({ ...prev, isZoomed: false }))\n }\n >\n \n \n
\n\n {imageState.isServerUploading && }\n\n {editor.isEditable &&\n imageState.imageLoaded &&\n !imageState.error &&\n !imageState.isServerUploading && (\n <>\n \n \n \n )}\n
\n\n {imageState.error && (\n \n }\n tooltip=\"Remove image\"\n onClick={onRemoveImg}\n />\n \n )}\n\n {!isResizing &&\n !imageState.error &&\n !imageState.isServerUploading && (\n \n )}\n \n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/extensions/image/components/image-view-block.tsx" }, { "path": "src/components/minimal-tiptap/extensions/image/components/image-overlay.tsx", "content": "import * as React from \"react\"\nimport { Spinner } from \"../../../components/spinner\"\nimport { cn } from \"@/lib/utils\"\n\nexport const ImageOverlay = React.memo(() => {\n return (\n \n \n \n )\n})\n\nImageOverlay.displayName = \"ImageOverlay\"\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/extensions/image/components/image-overlay.tsx" }, { "path": "src/components/minimal-tiptap/extensions/image/components/image-actions.tsx", "content": "import * as React from \"react\"\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\"\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\"\nimport {\n ClipboardCopyIcon,\n DotsHorizontalIcon,\n DownloadIcon,\n Link2Icon,\n SizeIcon,\n} from \"@radix-ui/react-icons\"\n\ninterface ImageActionsProps {\n shouldMerge?: boolean\n isLink?: boolean\n onView?: () => void\n onDownload?: () => void\n onCopy?: () => void\n onCopyLink?: () => void\n}\n\ninterface ActionButtonProps extends React.ComponentProps<\"button\"> {\n icon: React.ReactNode\n tooltip: string\n}\n\nexport const ActionWrapper = ({\n children,\n className,\n ...props\n}: React.ComponentProps<\"div\">) => (\n \n {children}\n \n)\n\nActionWrapper.displayName = \"ActionWrapper\"\n\nexport const ActionButton = ({\n icon,\n tooltip,\n className,\n ...props\n}: ActionButtonProps) => (\n \n \n \n {icon}\n \n \n {tooltip}\n \n)\n\nActionButton.displayName = \"ActionButton\"\n\ntype ActionKey = \"onView\" | \"onDownload\" | \"onCopy\" | \"onCopyLink\"\n\nconst ActionItems: Array<{\n key: ActionKey\n icon: React.ReactNode\n tooltip: string\n isLink?: boolean\n}> = [\n {\n key: \"onView\",\n icon: ,\n tooltip: \"View image\",\n },\n {\n key: \"onDownload\",\n icon: ,\n tooltip: \"Download image\",\n },\n {\n key: \"onCopy\",\n icon: ,\n tooltip: \"Copy image to clipboard\",\n },\n {\n key: \"onCopyLink\",\n icon: ,\n tooltip: \"Copy image link\",\n isLink: true,\n },\n]\n\nexport const ImageActions: React.FC = ({\n shouldMerge = false,\n isLink = false,\n ...actions\n}) => {\n const [isOpen, setIsOpen] = React.useState(false)\n\n const handleAction = React.useCallback(\n (e: React.MouseEvent, action: (() => void) | undefined) => {\n e.preventDefault()\n e.stopPropagation()\n action?.()\n },\n []\n )\n\n const filteredActions = React.useMemo(\n () => ActionItems.filter((item) => isLink || !item.isLink),\n [isLink]\n )\n\n return (\n \n {shouldMerge ? (\n \n \n }\n tooltip=\"Open menu\"\n onClick={(e) => e.preventDefault()}\n />\n \n \n {filteredActions.map(({ key, icon, tooltip }) => (\n handleAction(e, actions[key])}\n >\n
\n {icon}\n {tooltip}\n
\n \n ))}\n
\n
\n ) : (\n filteredActions.map(({ key, icon, tooltip }) => (\n handleAction(e, actions[key])}\n />\n ))\n )}\n
\n )\n}\n\nImageActions.displayName = \"ImageActions\"\n", "type": "registry:ui", "target": "components/ui/minimal-tiptap/extensions/image/components/image-actions.tsx" } ] }