bullet_editor 0.2.0 copy "bullet_editor: ^0.2.0" to clipboard
bullet_editor: ^0.2.0 copied to clipboard

A structured rich text editor for Flutter built on a single TextField. Supports headings, lists, tasks, code blocks, block quotes, and full markdown round-trip serialization.

example/lib/main.dart

import 'package:bullet_editor/bullet_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() {
  runApp(const BulletEditorExample());
}

class BulletEditorExample extends StatefulWidget {
  const BulletEditorExample({super.key});

  @override
  State<BulletEditorExample> createState() => _BulletEditorExampleState();
}

class _BulletEditorExampleState extends State<BulletEditorExample> {
  ThemeMode _themeMode = ThemeMode.light;

  void _toggleTheme() {
    setState(() {
      _themeMode = _themeMode == ThemeMode.light
          ? ThemeMode.dark
          : ThemeMode.light;
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Bullet Editor POC',
      theme: ThemeData(useMaterial3: true, brightness: Brightness.light),
      darkTheme: ThemeData(useMaterial3: true, brightness: Brightness.dark),
      themeMode: _themeMode,
      home: EditorScreen(onToggleTheme: _toggleTheme),
    );
  }
}

class EditorScreen extends StatefulWidget {
  const EditorScreen({super.key, this.onToggleTheme});

  final VoidCallback? onToggleTheme;

  @override
  State<EditorScreen> createState() => _EditorScreenState();
}

class _EditorScreenState extends State<EditorScreen> {
  late final EditorController<BlockType, InlineStyle, InlineEntityType>
  _controller;
  late final FocusNode _focusNode;

  @override
  void initState() {
    super.initState();

    final doc = Document([
      TextBlock(
        id: 'b1',
        blockType: BlockType.h1,
        segments: [const StyledSegment('Welcome to Bullet Editor')],
      ),
      TextBlock(
        id: 'b2',
        blockType: BlockType.paragraph,
        segments: [
          const StyledSegment('This is a '),
          const StyledSegment('bold', {InlineStyle.bold}),
          const StyledSegment(' paragraph with a '),
          const StyledSegment(
            'link',
            {InlineEntityType.link},
            {'url': 'https://flutter.dev'},
          ),
          const StyledSegment('.'),
        ],
      ),
      TextBlock(
        id: 'b2a',
        blockType: BlockType.paragraph,
        segments: [
          const StyledSegment('Adjacent links: '),
          const StyledSegment(
            'alpha',
            {InlineEntityType.link},
            {'url': 'https://example.com/alpha'},
          ),
          const StyledSegment(
            'beta',
            {InlineEntityType.link},
            {'url': 'https://example.com/beta'},
          ),
          const StyledSegment('.'),
        ],
      ),
      TextBlock(
        id: 'bh2',
        blockType: BlockType.h2,
        segments: [const StyledSegment('Heading 2 example')],
      ),
      TextBlock(
        id: 'bh3',
        blockType: BlockType.h3,
        segments: [const StyledSegment('Heading 3 example')],
      ),
      TextBlock(
        id: 'bh4',
        blockType: BlockType.h4,
        segments: [const StyledSegment('Heading 4 example')],
      ),
      TextBlock(
        id: 'bh5',
        blockType: BlockType.h5,
        segments: [const StyledSegment('Heading 5 example')],
      ),
      TextBlock(
        id: 'bh6',
        blockType: BlockType.h6,
        segments: [const StyledSegment('Heading 6 example')],
      ),
      TextBlock(
        id: 'bq1',
        blockType: BlockType.blockQuote,
        segments: [const StyledSegment('This is a block quote')],
      ),
      TextBlock(
        id: 'binline',
        blockType: BlockType.paragraph,
        segments: [
          const StyledSegment('Here is some '),
          const StyledSegment('inline code', {InlineStyle.code}),
          const StyledSegment(' in a paragraph.'),
        ],
      ),
      TextBlock(
        id: 'bcode',
        blockType: BlockType.codeBlock,
        segments: [const StyledSegment('void main() {\n  print("Hello!");\n}')],
        metadata: {'language': 'dart'},
      ),
      // Image disabled — needs multi-widget architecture for proper rendering.
      TextBlock(
        id: 'b3',
        blockType: BlockType.listItem,
        segments: [const StyledSegment('Parent item')],
        children: [
          TextBlock(
            id: 'b3a',
            blockType: BlockType.listItem,
            segments: [const StyledSegment('Nested child')],
          ),
        ],
      ),
      TextBlock(
        id: 'b4',
        blockType: BlockType.listItem,
        segments: [const StyledSegment('Tab to indent, Shift+Tab to outdent')],
      ),
      TextBlock(id: 'bdiv', blockType: BlockType.divider),
      TextBlock(
        id: 'b5',
        blockType: BlockType.numberedList,
        segments: [const StyledSegment('First numbered item')],
      ),
      TextBlock(
        id: 'b6',
        blockType: BlockType.numberedList,
        segments: [const StyledSegment('Second numbered item')],
      ),
      TextBlock(
        id: 'b7',
        blockType: BlockType.taskItem,
        segments: [const StyledSegment('Unchecked task')],
        metadata: {'checked': false},
      ),
      TextBlock(
        id: 'b8',
        blockType: BlockType.taskItem,
        segments: [const StyledSegment('Completed task')],
        metadata: {'checked': true},
      ),
    ]);

    _controller = EditorController(
      schema: EditorSchema.standard(),
      document: doc,
      onInlineEntityTap: (entity) =>
          debugPrint('Inline entity tapped: ${entity.data}'),
      // Input rules come from the schema — no manual list needed.
    );
    _controller.addListener(() => setState(() {}));

    _focusNode = FocusNode();
  }

  @override
  void dispose() {
    _controller.dispose();
    _focusNode.dispose();
    super.dispose();
  }

  Future<void> _showLinkDialog() async {
    if (!_controller.value.selection.isValid) return;

    final touchedLinks = _controller.inlineEntitiesInSelection(
      type: InlineEntityType.link,
    );
    if (touchedLinks.length > 1) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text(
            'Selection touches multiple links. Edit one link at a time.',
          ),
        ),
      );
      _focusNode.requestFocus();
      return;
    }

    final touchedLink = touchedLinks.isEmpty ? null : touchedLinks.single;
    final editInfo = touchedLink != null
        ? InlineEntityEditInfo(
            text: touchedLink.text,
            type: InlineEntityType.link,
            data: touchedLink.data,
          )
        : _controller.inlineEntityEditInfo;
    final linkData = editInfo?.type == InlineEntityType.link
        ? editInfo!.data as LinkData
        : null;
    final result = await showDialog<_LinkDialogResult>(
      context: context,
      builder: (ctx) => _LinkDialog(
        initialText: editInfo?.text ?? '',
        initialUrl: linkData?.url ?? '',
        isEditing: editInfo?.type == InlineEntityType.link,
      ),
    );

    if (!mounted || result == null) {
      _focusNode.requestFocus();
      return;
    }

    if (result.remove) {
      _selectInlineEntity(touchedLink);
      _controller.removeInlineEntity(InlineEntityType.link);
      _focusNode.requestFocus();
      return;
    }

    final url = result.url.trim();
    if (url.isEmpty) {
      _focusNode.requestFocus();
      return;
    }

    final text = result.text.trim();
    _selectInlineEntity(touchedLink);
    _controller.setInlineEntity(
      InlineEntityType.link,
      LinkData(url: url),
      text: text.isEmpty ? null : text,
    );
    _focusNode.requestFocus();
  }

  void _selectInlineEntity(InlineEntityInfo<InlineEntityType>? entity) {
    if (entity != null) {
      _controller.value = _controller.value.copyWith(
        selection: TextSelection(
          baseOffset: entity.displayStart,
          extentOffset: entity.displayEnd,
        ),
      );
    }
  }

  String _buildDebugText() {
    final buf = StringBuffer();
    buf.writeln(
      'Document (${_controller.document.allBlocks.length} blocks, '
      '${_controller.document.blocks.length} roots)',
    );
    for (final entry in _controller.document.allBlocks.asMap().entries) {
      final i = entry.key;
      final block = entry.value;
      final depth = _controller.document.depthOf(i);
      final indent = '  ' * depth;
      final meta = block.metadata.isNotEmpty ? ' ${block.metadata}' : '';
      buf.writeln('$indent[$i] ${block.blockType}$meta: "${block.plainText}"');
      for (final segmentEntry in block.segments.asMap().entries) {
        final segment = segmentEntry.value;
        final styles = segment.styles.isEmpty ? '[]' : '${segment.styles}';
        final attributes = segment.attributes.isEmpty
            ? '{}'
            : '${segment.attributes}';
        buf.writeln(
          '$indent  - seg ${segmentEntry.key}: '
          '"${segment.text}" styles=$styles attrs=$attributes',
        );
      }
    }
    return buf.toString();
  }

  // App-level shortcuts are added via Shortcuts widget in build().

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Bullet Editor POC'),
        actions: [
          IconButton(
            icon: Icon(
              Theme.of(context).brightness == Brightness.dark
                  ? Icons.light_mode
                  : Icons.dark_mode,
            ),
            tooltip: 'Toggle dark mode',
            onPressed: widget.onToggleTheme,
          ),
        ],
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Row(
              children: [
                Expanded(
                  child: EditorToolbar(
                    controller: _controller,
                    blockTypeSelector: BlockTypeSelector(
                      controller: _controller,
                      items: const [
                        BlockTypeSelectorItem(
                          type: BlockType.paragraph,
                          label: 'Paragraph',
                        ),
                        BlockTypeSelectorItem(type: BlockType.h1, label: 'H1'),
                        BlockTypeSelectorItem(type: BlockType.h2, label: 'H2'),
                        BlockTypeSelectorItem(
                          type: BlockType.listItem,
                          label: 'Bullet',
                        ),
                        BlockTypeSelectorItem(
                          type: BlockType.numberedList,
                          label: 'Numbered',
                        ),
                        BlockTypeSelectorItem(
                          type: BlockType.taskItem,
                          label: 'Task',
                        ),
                        BlockTypeSelectorItem(
                          type: BlockType.blockQuote,
                          label: 'Quote',
                        ),
                      ],
                    ),
                    styleButtons: [
                      StyleToggleButton(
                        controller: _controller,
                        style: InlineStyle.bold,
                        icon: Icons.format_bold,
                        tooltip: 'Bold',
                      ),
                      StyleToggleButton(
                        controller: _controller,
                        style: InlineStyle.italic,
                        icon: Icons.format_italic,
                        tooltip: 'Italic',
                      ),
                      StyleToggleButton(
                        controller: _controller,
                        style: InlineStyle.strikethrough,
                        icon: Icons.format_strikethrough,
                        tooltip: 'Strikethrough',
                      ),
                      StyleToggleButton(
                        controller: _controller,
                        style: InlineStyle.code,
                        icon: Icons.code,
                        tooltip: 'Code',
                      ),
                    ],
                  ),
                ),
                const SizedBox(width: 8),
                IconButton(
                  icon: Icon(
                    Icons.link,
                    color:
                        _controller.inlineEntityEditInfo?.type ==
                            InlineEntityType.link
                        ? Theme.of(context).colorScheme.primary
                        : null,
                  ),
                  tooltip: 'Link (Cmd+K)',
                  onPressed: _showLinkDialog,
                ),
              ],
            ),
            const SizedBox(height: 8),
            Expanded(
              flex: 3,
              // Cmd+K for link dialog is app-specific, so it lives here
              // rather than in BulletEditor.
              child: Shortcuts(
                shortcuts: const {
                  SingleActivator(LogicalKeyboardKey.keyK, meta: true):
                      _LinkDialogIntent(),
                },
                child: Actions(
                  actions: {
                    _LinkDialogIntent: CallbackAction<_LinkDialogIntent>(
                      onInvoke: (_) {
                        _showLinkDialog();
                        return null;
                      },
                    ),
                  },
                  child: BulletEditor(
                    controller: _controller,
                    focusNode: _focusNode,
                  ),
                ),
              ),
            ),
            const SizedBox(height: 16),
            // Debug panel — shows allBlocks with depth.
            Expanded(
              flex: 2,
              child: Container(
                padding: const EdgeInsets.all(12),
                decoration: BoxDecoration(
                  color: Theme.of(context).colorScheme.surfaceContainerLow,
                  borderRadius: BorderRadius.circular(8),
                  border: Border.all(
                    color: Theme.of(context).colorScheme.outlineVariant,
                  ),
                ),
                child: SingleChildScrollView(
                  child: SelectableText(
                    _buildDebugText(),
                    style: TextStyle(
                      fontSize: 12,
                      fontFamily: 'monospace',
                      color: Theme.of(context).colorScheme.onSurfaceVariant,
                    ),
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _LinkDialogIntent extends Intent {
  const _LinkDialogIntent();
}

class _LinkDialogResult {
  const _LinkDialogResult({
    required this.text,
    required this.url,
    this.remove = false,
  });

  final String text;
  final String url;
  final bool remove;
}

class _LinkDialog extends StatefulWidget {
  const _LinkDialog({
    required this.initialText,
    required this.initialUrl,
    required this.isEditing,
  });

  final String initialText;
  final String initialUrl;
  final bool isEditing;

  @override
  State<_LinkDialog> createState() => _LinkDialogState();
}

class _LinkDialogState extends State<_LinkDialog> {
  late final TextEditingController _textController;
  late final TextEditingController _urlController;

  @override
  void initState() {
    super.initState();
    _textController = TextEditingController(text: widget.initialText);
    _urlController = TextEditingController(text: widget.initialUrl);
  }

  @override
  void dispose() {
    _textController.dispose();
    _urlController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AlertDialog(
      title: Text(widget.isEditing ? 'Edit Link' : 'Add Link'),
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          TextField(
            controller: _textController,
            autofocus: true,
            decoration: const InputDecoration(labelText: 'Text'),
            textInputAction: TextInputAction.next,
          ),
          const SizedBox(height: 16),
          TextField(
            controller: _urlController,
            decoration: const InputDecoration(
              labelText: 'URL',
              hintText: 'https://example.com',
            ),
            keyboardType: TextInputType.url,
            textInputAction: TextInputAction.done,
            onSubmitted: (_) => Navigator.of(context).pop(
              _LinkDialogResult(
                text: _textController.text,
                url: _urlController.text,
              ),
            ),
          ),
        ],
      ),
      actions: [
        if (widget.isEditing)
          TextButton(
            onPressed: () => Navigator.of(
              context,
            ).pop(const _LinkDialogResult(text: '', url: '', remove: true)),
            child: const Text('Remove'),
          ),
        TextButton(
          onPressed: () => Navigator.of(context).pop(),
          child: const Text('Cancel'),
        ),
        TextButton(
          onPressed: () => Navigator.of(context).pop(
            _LinkDialogResult(
              text: _textController.text,
              url: _urlController.text,
            ),
          ),
          child: Text(widget.isEditing ? 'Update' : 'Add'),
        ),
      ],
    );
  }
}
0
likes
150
points
546
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A structured rich text editor for Flutter built on a single TextField. Supports headings, lists, tasks, code blocks, block quotes, and full markdown round-trip serialization.

Repository (GitHub)
View/report issues
Contributing

Topics

#editor #markdown #rich-text #wysiwyg #flutter

License

MIT (license)

Dependencies

flutter

More

Packages that depend on bullet_editor