Skip to content

Instantly share code, notes, and snippets.

@swarajbachu
Last active September 5, 2025 09:00
Show Gist options
  • Select an option

  • Save swarajbachu/278f842f70406fac516662e9b3491807 to your computer and use it in GitHub Desktop.

Select an option

Save swarajbachu/278f842f70406fac516662e9b3491807 to your computer and use it in GitHub Desktop.
tiptap editor mobile
/** 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;
/** 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