Thomas Pedot

mardi 21 avril 2026

Building a Rich Text Editor for Tauri Mobile: TipTap + WebView Implementation

Building a Rich Text Editor for Tauri Mobile: TipTap + WebView Implementation

GitAlchemy's mobile users needed to write formatted issues and merge request descriptions — bold, lists, code blocks, tables — but mobile GitLab clients typically ship plain textareas. This article covers how GitAlchemy implemented a full WYSIWYG editor using TipTap running in a WebView, bridged to native React for a seamless mobile experience.

This builds on the Raycast-inspired design system article, which established the visual foundation.

The Mobile Editor Challenge

TipTap is built on ProseMirror, which relies heavily on browser DOM APIs — selection management, input handling, contenteditable behavior. None of this works natively in React Native or Tauri's mobile WebView without significant adaptation.

The standard approaches:

  1. Native editor libraries — Limited functionality, poor markdown compatibility with web TipTap
  2. TenTap / react-native-webview — React Native WebView wrapper around TipTap, works but adds dependency
  3. Custom WebView bridge — Full control, TipTap-compatible HTML output, works across React Native, Tauri, and Expo

GitAlchemy chose approach 3: a WebView running TipTap, bridged to React via postMessage for two-way communication.

Architecture: WebView as Editor Surface

The editor runs in a WebView component that acts as a self-contained editor iframe:

Plain Text
1┌─────────────────────────────────────────┐
2│           React Native View             │
3│  ┌───────────────────────────────────┐  │
4│  │         WebView (TipTap)          │  │
5│  │  ┌─────────────────────────────┐  │  │
6│  │  │  ProseMirror Editor          │  │  │
7│  │  │  - Formatting toolbar       │  │  │
8│  │  │  - Content editable area    │  │  │
9│  │  │  - Keyboard handling         │  │  │
10│  │  └─────────────────────────────┘  │  │
11│  └───────────────────────────────────┘  │
12│         ↑ postMessage bridge ↑          │
13└─────────────────────────────────────────┘

The WebView receives formatting commands from React and sends content updates back to React.

Implementation

The WebView HTML Template

The editor runs inside an HTML template that loads TipTap and initializes the editor:

HTML
1<!DOCTYPE html>
2<html>
3<head>
4  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
5  <style>
6    body { margin: 0; padding: 16px; background: #1e1f23; color: #fff; font-family: Inter, sans-serif; }
7    .ProseMirror { outline: none; min-height: 150px; }
8    .ProseMirror p.is-editor-empty:first-child::before { content: attr(data-placeholder); float: left; color: #71717a; }
9    .ProseMirror ul, .ProseMirror ol { padding-left: 24px; }
10    .ProseMirror code { background: #2a2b2f; border-radius: 4px; padding: 2px 6px; font-family: monospace; }
11    .ProseMirror pre { background: #2a2b2f; padding: 12px; border-radius: 8px; overflow-x: auto; }
12    .ProseMirror blockquote { border-left: 3px solid #3b82f6; padding-left: 12px; color: #a1a1aa; }
13    .ProseMirror table { border-collapse: collapse; width: 100%; margin: 12px 0; }
14    .ProseMirror th, .ProseMirror td { border: 1px solid #3a3d40; padding: 8px; text-align: left; }
15  </style>
16</head>
17<body>
18  <div id="editor"></div>
19  <script src="https://unpkg.com/@tiptap/core@2.7.0/dist/tiptap.umd.js"></script>
20  <script>
21    const editor = new Tiptap.Editor({
22      element: document.getElementById('editor'),
23      extensions: [
24        Tiptap.StarterKit,
25        Tiptap.Placeholder.configure({ placeholder: 'Write a comment...' }),
26        Tiptap.Link.configure({ openOnClick: false }),
27        Tiptap.Underline,
28      ],
29      content: window.initialContent || '',
30      onUpdate: ({ editor }) => {
31        window.ReactNativeWebView.postMessage(JSON.stringify({
32          type: 'content',
33          html: editor.getHTML()
34        }));
35      },
36    });
37
38    window.format = (command, value) => {
39      if (command === 'insertHTML') {
40        editor.commands.insertContent(value);
41      } else {
42        editor.chain().focus()[command](value).run();
43      }
44    };
45
46    window.setContent = (html) => editor.commands.setContent(html);
47    window.getHTML = () => editor.getHTML();
48  </script>
49</body>
50</html>

The key insight: both mobile (WebView TipTap) and web (React TipTap) editors output identical HTML. No conversion layer needed when syncing between platforms.

React Bridge Component

The React side manages the WebView and toolbar:

TypeScript (TSX)
1function RichTextEditor({ initialContent, onChange }: EditorProps) {
2  const webViewRef = useRef<WebView>(null);
3  const [formats, setFormats] = useState<FormatState>({});
4
5  const handleMessage = (event: WebViewMessageEvent) => {
6    const data = JSON.parse(event.nativeEvent.data);
7    if (data.type === 'content') {
8      onChange?.(data.html);
9    }
10  };
11
12  const execCommand = (command: string, value?: string) => {
13    const js = `window.format('${command}'${value ? `, '${value}'` : ''})`;
14    webViewRef.current?.injectJavaScript(js);
15  };
16
17  return (
18    <>
19      <EditorToolbar onCommand={execCommand} formats={formats} />
20      <WebView
21        ref={webViewRef}
22        source={{ html: generateEditorHTML(initialContent) }}
23        onMessage={handleMessage}
24        style={{ minHeight: 200 }}
25      />
26    </>
27  );
28}

The toolbar buttons call execCommand with commands like toggleBold, toggleItalic, toggleHeading(1), toggleBulletList.

Toolbar Implementation

The formatting toolbar matches the dark theme from the design system:

TypeScript (TSX)
1function EditorToolbar({ onCommand, formats }: ToolbarProps) {
2  const buttons = [
3    { icon: BoldIcon, command: 'toggleBold', label: 'Bold' },
4    { icon: ItalicIcon, command: 'toggleItalic', label: 'Italic' },
5    { icon: HeadingIcon, command: 'toggleHeading', value: '1', label: 'Heading' },
6    { icon: ListIcon, command: 'toggleBulletList', label: 'List' },
7    { icon: CodeIcon, command: 'toggleCode', label: 'Code' },
8    { icon: LinkIcon, command: 'insertHTML', value: '<a href="">', label: 'Link' },
9  ];
10
11  return (
12    <div className="flex gap-2 p-2 bg-surface-secondary border-b border-surface-border">
13      {buttons.map(btn => (
14        <button
15          key={btn.command}
16          onClick={() => onCommand(btn.command, btn.value)}
17          className={`p-2 rounded ${formats[btn.command] ? 'bg-accent-blue' : ''}`}
18        >
19          <btn.icon className="w-5 h-5 text-foreground-primary" />
20        </button>
21      ))}
22    </div>
23  );
24}

Where It's Used

The rich text editor now appears in GitAlchemy wherever users write content:

  • Issue descriptions — Create and edit with formatted text, code blocks, tables
  • Merge request descriptions — Full markdown-like editing for MR context
  • Comments — Threaded discussions with rich formatting
  • Project descriptions — Rich project overviews

See the main GitLab Android article for the full feature set.

SEO Takeaway

This approach — WebView bridge for TipTap — works across Tauri, React Native, and Expo. The key is maintaining HTML output compatibility between platforms so content syncs without transformation.

Related reading: