Last active
September 5, 2025 09:00
-
-
Save swarajbachu/278f842f70406fac516662e9b3491807 to your computer and use it in GitHub Desktop.
tiptap editor mobile
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** biome-ignore-all lint/suspicious/noExplicitAny: DOM imperative methods require any[] for JSONValue compatibility */ | |
| "use dom"; | |
| import Highlight from "@tiptap/extension-highlight"; | |
| import Placeholder from "@tiptap/extension-placeholder"; | |
| import Typography from "@tiptap/extension-typography"; | |
| import { EditorContent, useEditor } from "@tiptap/react"; | |
| import StarterKit from "@tiptap/starter-kit"; | |
| import type { JSONValue } from "expo/build/dom/dom.types"; | |
| import { type DOMImperativeFactory, useDOMImperativeHandle } from "expo/dom"; | |
| import { forwardRef, useEffect } from "react"; | |
| interface TiptapEditorDOMProps { | |
| content?: string; | |
| onContentChange?: (content: string) => void; | |
| onSelectionChange?: (hasSelection: boolean, selectedText: string) => void; | |
| placeholder?: string; | |
| theme?: "light" | "dark"; | |
| } | |
| export interface TiptapEditorDOMRef extends DOMImperativeFactory { | |
| getHTML: () => string; | |
| setContent: (...args: JSONValue[]) => void; | |
| focus: () => void; | |
| // Formatting methods | |
| toggleBold: () => void; | |
| toggleItalic: () => void; | |
| toggleHeading: (...args: JSONValue[]) => void; | |
| toggleBulletList: () => void; | |
| } | |
| const TiptapEditorDOM = forwardRef<TiptapEditorDOMRef, TiptapEditorDOMProps>( | |
| ( | |
| { | |
| content = "", | |
| onContentChange, | |
| onSelectionChange, | |
| placeholder = "Start writing...", | |
| theme = "light", | |
| }, | |
| ref | |
| ) => { | |
| const editor = useEditor({ | |
| extensions: [ | |
| StarterKit, | |
| Placeholder.configure({ | |
| placeholder, | |
| }), | |
| Typography, | |
| Highlight, | |
| ], | |
| content, | |
| onUpdate: ({ editor: editorInstance }) => { | |
| if (onContentChange) { | |
| onContentChange(editorInstance.getHTML()); | |
| } | |
| }, | |
| onSelectionUpdate: ({ editor: editorInstance }) => { | |
| if (onSelectionChange) { | |
| const { from, to } = editorInstance.state.selection; | |
| const hasSelection = from !== to; | |
| const selectedText = hasSelection | |
| ? editorInstance.state.doc.textBetween(from, to, " ") | |
| : ""; | |
| onSelectionChange(hasSelection, selectedText); | |
| } | |
| }, | |
| }); | |
| // Update content when prop changes | |
| useEffect(() => { | |
| if (editor && content !== editor.getHTML()) { | |
| editor.commands.setContent(content); | |
| } | |
| }, [content, editor]); | |
| // Expose editor methods via useDOMImperativeHandle | |
| useDOMImperativeHandle( | |
| ref, | |
| () => ({ | |
| getHTML: () => editor?.getHTML() || "", | |
| setContent: (...args: JSONValue[]) => { | |
| // biome-ignore lint/nursery/noShadow: <explanation> | |
| const [content] = args; | |
| editor?.commands.setContent(content as string); | |
| }, | |
| focus: () => { | |
| editor?.commands.focus(); | |
| }, | |
| toggleBold: () => { | |
| editor?.chain().focus().toggleBold().run(); | |
| }, | |
| toggleItalic: () => { | |
| editor?.chain().focus().toggleItalic().run(); | |
| }, | |
| toggleHeading: (...args: JSONValue[]) => { | |
| const [level] = args; | |
| editor | |
| ?.chain() | |
| .focus() | |
| .toggleHeading({ level: level as 1 | 2 | 3 }) | |
| .run(); | |
| }, | |
| toggleBulletList: () => { | |
| editor?.chain().focus().toggleBulletList().run(); | |
| }, | |
| }), | |
| [editor] | |
| ); | |
| const isDark = theme === "dark"; | |
| return ( | |
| <div | |
| style={{ | |
| width: "100%", | |
| height: "100%", | |
| // backgroundColor: isDark ? "#1a1a1a" : "#ffffff", | |
| color: isDark ? "#ffffff" : "#000000", | |
| fontFamily: | |
| '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', | |
| }} | |
| > | |
| <style>{` | |
| .ProseMirror { | |
| outline: none; | |
| padding: 16px; | |
| font-size: 16px; | |
| line-height: 1.6; | |
| min-height: calc(100% - 32px); | |
| background: transparent; | |
| color: inherit; | |
| } | |
| .ProseMirror p { | |
| margin-bottom: 16px; | |
| } | |
| .ProseMirror p:last-child { | |
| margin-bottom: 0; | |
| } | |
| .ProseMirror h1 { | |
| font-size: 28px; | |
| font-weight: 700; | |
| margin-bottom: 20px; | |
| margin-top: 24px; | |
| line-height: 1.2; | |
| } | |
| .ProseMirror h2 { | |
| font-size: 24px; | |
| font-weight: 600; | |
| margin-bottom: 16px; | |
| margin-top: 20px; | |
| line-height: 1.3; | |
| } | |
| .ProseMirror h3 { | |
| font-size: 20px; | |
| font-weight: 600; | |
| margin-bottom: 12px; | |
| margin-top: 16px; | |
| line-height: 1.4; | |
| } | |
| .ProseMirror ul, | |
| .ProseMirror ol { | |
| padding-left: 20px; | |
| margin-bottom: 16px; | |
| } | |
| .ProseMirror li { | |
| margin-bottom: 4px; | |
| } | |
| .ProseMirror blockquote { | |
| border-left: 4px solid ${isDark ? "#4a5568" : "#e2e8f0"}; | |
| padding-left: 16px; | |
| margin: 16px 0; | |
| font-style: italic; | |
| color: ${isDark ? "#a0aec0" : "#718096"}; | |
| } | |
| .ProseMirror code { | |
| background-color: ${isDark ? "#2d3748" : "#f7fafc"}; | |
| color: ${isDark ? "#f7fafc" : "#2d3748"}; | |
| padding: 2px 6px; | |
| border-radius: 4px; | |
| font-size: 14px; | |
| font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; | |
| } | |
| .ProseMirror pre { | |
| background-color: ${isDark ? "#2d3748" : "#f7fafc"}; | |
| color: ${isDark ? "#f7fafc" : "#2d3748"}; | |
| padding: 16px; | |
| border-radius: 8px; | |
| margin: 16px 0; | |
| overflow-x: auto; | |
| font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; | |
| font-size: 14px; | |
| line-height: 1.4; | |
| } | |
| .ProseMirror pre code { | |
| background: none; | |
| padding: 0; | |
| } | |
| .ProseMirror strong { | |
| font-weight: 600; | |
| } | |
| .ProseMirror em { | |
| font-style: italic; | |
| } | |
| .ProseMirror mark { | |
| background-color: #fef08a; | |
| color: #000000; | |
| padding: 2px 0; | |
| border-radius: 2px; | |
| } | |
| .ProseMirror hr { | |
| border: none; | |
| border-top: 2px solid ${isDark ? "#4a5568" : "#e2e8f0"}; | |
| margin: 32px 0; | |
| } | |
| .ProseMirror p.is-editor-empty:first-child::before { | |
| content: attr(data-placeholder); | |
| float: left; | |
| color: ${isDark ? "#6b7280" : "#9ca3af"}; | |
| pointer-events: none; | |
| height: 0; | |
| } | |
| .ProseMirror:focus { | |
| outline: none; | |
| } | |
| /* Selection styling */ | |
| .ProseMirror ::selection { | |
| background-color: ${isDark ? "#374151" : "#ddd6fe"}; | |
| } | |
| /* Smooth transitions */ | |
| .ProseMirror * { | |
| transition: color 0.2s ease-in-out; | |
| } | |
| `}</style> | |
| <EditorContent editor={editor} /> | |
| </div> | |
| ); | |
| } | |
| ); | |
| TiptapEditorDOM.displayName = "TiptapEditorDOM"; | |
| export default TiptapEditorDOM; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** biome-ignore-all lint/nursery/noShadow: <explanation> */ | |
| import { useColorScheme } from "nativewind"; | |
| import React, { | |
| forwardRef, | |
| useCallback, | |
| useImperativeHandle, | |
| useRef, | |
| useState, | |
| } from "react"; | |
| import { View } from "react-native"; | |
| import TiptapEditorDOM, { type TiptapEditorDOMRef } from "./tiptap-editor.dom"; | |
| export interface EditorBridge { | |
| getHTML: () => Promise<string>; | |
| setContent: (content: string) => void; | |
| focus: () => void; | |
| // Formatting methods | |
| toggleBold: () => void; | |
| toggleItalic: () => void; | |
| toggleHeading: (level: 1 | 2 | 3) => void; | |
| toggleBulletList: () => void; | |
| // Question text method | |
| } | |
| interface TiptapEditorProps { | |
| content?: string; | |
| onContentChange?: (content: string) => void; | |
| onSelectionChange?: (hasSelection: boolean, selectedText: string) => void; | |
| placeholder?: string; | |
| } | |
| const TiptapEditor = forwardRef<EditorBridge, TiptapEditorProps>( | |
| ( | |
| { | |
| content = "", | |
| onContentChange, | |
| onSelectionChange, | |
| placeholder = "What's on your mind?", | |
| }, | |
| ref | |
| ) => { | |
| const { colorScheme } = useColorScheme(); | |
| const [internalContent, setInternalContent] = useState(content); | |
| const domEditorRef = useRef<TiptapEditorDOMRef>(null); | |
| // Use internal content when parent doesn't provide content, otherwise use parent's content | |
| const currentContent = content || internalContent; | |
| const handleContentChange = useCallback( | |
| (newContent: string) => { | |
| setInternalContent(newContent); | |
| if (onContentChange) { | |
| onContentChange(newContent); | |
| } | |
| }, | |
| [onContentChange] | |
| ); | |
| // Expose ref methods | |
| useImperativeHandle( | |
| ref, | |
| () => ({ | |
| getHTML: async () => { | |
| return domEditorRef.current?.getHTML() || ""; | |
| }, | |
| setContent: (newContent: string) => { | |
| domEditorRef.current?.setContent(newContent); | |
| setInternalContent(newContent); | |
| }, | |
| focus: () => { | |
| domEditorRef.current?.focus(); | |
| }, | |
| toggleBold: () => { | |
| domEditorRef.current?.toggleBold(); | |
| }, | |
| toggleItalic: () => { | |
| domEditorRef.current?.toggleItalic(); | |
| }, | |
| toggleHeading: (level: 1 | 2 | 3) => { | |
| domEditorRef.current?.toggleHeading(level); | |
| }, | |
| toggleBulletList: () => { | |
| domEditorRef.current?.toggleBulletList(); | |
| }, | |
| }), | |
| [] | |
| ); | |
| return ( | |
| <View style={{ flex: 1, width: "100%" }}> | |
| <TiptapEditorDOM | |
| content={currentContent} | |
| onContentChange={handleContentChange} | |
| onSelectionChange={onSelectionChange} | |
| placeholder={placeholder} | |
| ref={domEditorRef} | |
| theme={colorScheme === "dark" ? "dark" : "light"} | |
| /> | |
| </View> | |
| ); | |
| } | |
| ); | |
| TiptapEditor.displayName = "TiptapEditor"; | |
| export default TiptapEditor; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment