Building a Custom Rich Text Editor in Flutter: Why I Skipped Flutter Quill

Nash9Nash9
4 min read

TL;DR

  • Flutter Quill caused 4 hours of dependency conflicts

  • Built custom solution in 2 hours with ~200 lines of code

  • Markdown-first approach works across all platforms

  • Zero external dependencies, complete control

The Problem Statement

When building ShabDify (a multi-platform content publisher), I needed a rich text editor with these requirements:

  • Functionality: Bold, italic, headers, lists, links

  • Output: Clean text for multiple platforms (Medium, LinkedIn, Reddit)

  • Maintainability: No dependency hell

  • Performance: Lightweight, fast rendering

Flutter Quill seemed like the obvious choice. Here's why it wasn't.

The Dependency Nightmare

Initial Setup Attempt

dependencies:
  flutter_quill: ^11.0.0
  flutter_quill_extensions: ^11.0.0
  flutter_quill_delta_from_html: ^1.5.0

The Cascade of Conflicts

Conflict #1:

Because flutter_quill >=9.3.12 depends on intl ^0.19.0 
and shabdify depends on intl ^0.20.2, version solving failed.

Solution: Downgrade intl to ^0.19.0

Conflict #2:

Because webview_flutter 4.13.0 depends on webview_flutter_android ^4.7.0 
and quill_html_editor depends on webview_flutter_android ^3.13.2, 
version solving failed.

Solution: Remove quill_html_editor

Conflict #3:

flutter_quill_delta_from_html >=1.4.1 is incompatible with flutter_quill ^9.4.4

At this point, I was spending more time managing dependencies than building features.

The Custom Solution Architecture

Core Components

// 1. Toolbar Widget
class FormattingToolbar extends StatelessWidget {
  final TextEditingController controller;
  final VoidCallback onFormatApplied;

  const FormattingToolbar({
    required this.controller,
    required this.onFormatApplied,
  });
}

// 2. Format Application Logic
class TextFormatter {
  static void applyFormat(
    TextEditingController controller,
    String startTag,
    String endTag,
  ) {
    final selection = controller.selection;
    final text = controller.text;
    final selectedText = selection.textInside(text);

    final newText = text.replaceRange(
      selection.start,
      selection.end,
      '$startTag$selectedText$endTag',
    );

    controller.value = TextEditingValue(
      text: newText,
      selection: TextSelection.collapsed(
        offset: selection.start + startTag.length + selectedText.length + endTag.length,
      ),
    );
  }
}

// 3. Preview Renderer
class MarkdownPreview extends StatelessWidget {
  final String markdown;

  Widget _buildFormattedContent(String text) {
    // Convert markdown to styled widgets
    return _parseMarkdownToWidgets(text);
  }
}

Implementation Details

Bold/Italic Formatting

void _applyBold() {
  TextFormatter.applyFormat(controller, '**', '**');
}

void _applyItalic() {
  TextFormatter.applyFormat(controller, '*', '*');
}

Heading Support

void _applyHeading() {
  final selection = controller.selection;
  final text = controller.text;
  final lines = text.split('\n');

  // Find current line and toggle heading
  final currentLineIndex = _getCurrentLineIndex(selection.start, lines);
  final currentLine = lines[currentLineIndex];

  if (currentLine.startsWith('# ')) {
    lines[currentLineIndex] = currentLine.substring(2);
  } else {
    lines[currentLineIndex] = '# $currentLine';
  }

  controller.text = lines.join('\n');
}

HTML Paste Detection

void _handlePaste(String pastedText) {
  if (_isHtmlContent(pastedText)) {
    final markdown = _convertHtmlToMarkdown(pastedText);
    _insertText(markdown);
  } else {
    _insertText(pastedText);
  }
}

bool _isHtmlContent(String text) {
  return text.contains('<p>') || 
         text.contains('<strong>') || 
         text.contains('<em>') ||
         text.contains('<h1>');
}

String _convertHtmlToMarkdown(String html) {
  return html
    .replaceAll(RegExp(r'<strong[^>]*>(.*?)</strong>'), '**\$1**')
    .replaceAll(RegExp(r'<em[^>]*>(.*?)</em>'), '*\$1*')
    .replaceAll(RegExp(r'<h1[^>]*>(.*?)</h1>'), '# \$1')
    .replaceAll(RegExp(r'<p[^>]*>(.*?)</p>'), '\$1\n')
    .replaceAll(RegExp(r'<[^>]+>'), ''); // Remove remaining tags
}

Platform-Specific Output

class PlatformFormatter {
  static String formatForPlatform(String markdown, String platform) {
    switch (platform.toLowerCase()) {
      case 'medium':
        return _convertToHtml(markdown);
      case 'linkedin':
        return _stripFormatting(markdown);
      case 'reddit':
        return markdown; // Reddit supports markdown
      default:
        return markdown;
    }
  }

  static String _convertToHtml(String markdown) {
    return markdown
      .replaceAllMapped(RegExp(r'\*\*(.*?)\*\*'), 
        (match) => '<strong>${match.group(1)}</strong>')
      .replaceAllMapped(RegExp(r'\*(.*?)\*'), 
        (match) => '<em>${match.group(1)}</em>')
      .replaceAllMapped(RegExp(r'^# (.*)$', multiLine: true), 
        (match) => '<h1>${match.group(1)}</h1>');
  }
}

Performance Comparison

MetricFlutter QuillCustom SolutionBundle size impact+2.3MB+0KBSetup time4+ hours2 hoursDependencies470Control levelLimitedCompleteMaintenanceExternalInternal

Advantages of the Custom Approach

1. Zero Dependencies

  • No version conflicts

  • No external maintenance burden

  • Smaller bundle size

2. Markdown-First Philosophy

  • Universal format support

  • Platform-agnostic content

  • Easy to parse and convert

3. Complete Control

  • Custom behaviors

  • Platform-specific optimizations

  • Easy debugging

4. Maintainability

  • ~200 lines of understandable code

  • No black box behaviors

  • Easy to extend

Trade-offs and Considerations

What You Lose

  • Advanced features (tables, images, complex formatting)

  • Collaborative editing capabilities

  • Extensive plugin ecosystem

What You Gain

  • Predictable behavior

  • Easy customization

  • No dependency management

  • Complete understanding of the codebase

Conclusion

For ShabDify's use case, the custom solution was clearly superior. The key insight was realizing that I didn't need a full-featured editor—I needed a specific set of formatting tools with reliable output.

When to Build Custom:

  • Simple formatting requirements

  • Platform-specific output needs

  • Dependency conflicts with existing libraries

  • Need for complete control

When to Use Libraries:

  • Complex features (collaborative editing, plugins)

  • Rapid prototyping

  • Standard use cases with no customization needs

What's Next

In the next article, I'll cover:

  • Advanced markdown parsing techniques

  • Platform-specific content optimization

  • Building a plugin system for the custom editor


Have you faced similar dependency challenges? Share your experiences in the comments below.

0
Subscribe to my newsletter

Read articles from Nash9 directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Nash9
Nash9