mixin_markdown_widget 0.1.0 copy "mixin_markdown_widget: ^0.1.0" to clipboard
mixin_markdown_widget: ^0.1.0 copied to clipboard

A desktop-first Flutter Markdown reader widget with theming and streaming-friendly controller APIs.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:mixin_markdown_widget/mixin_markdown_widget.dart';

import 'ai_chat_demo.dart';

enum _DemoThemePreset {
  ocean,
  warm,
  tight,
}

enum _DemoLayoutMode {
  split,
  previewOnly,
}

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

class DemoApp extends StatelessWidget {
  const DemoApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        useMaterial3: true,
        colorSchemeSeed: const Color(0xFF0B7A75),
      ),
      home: const MarkdownDemoPage(),
    );
  }
}

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

  @override
  State<MarkdownDemoPage> createState() => _MarkdownDemoPageState();
}

class _MarkdownDemoPageState extends State<MarkdownDemoPage> {
  late final MarkdownController _controller;
  late final MarkdownSelectionController _selectionController;
  late final TextEditingController _editorController;
  var _themePreset = _DemoThemePreset.ocean;
  var _layoutMode = _DemoLayoutMode.split;
  var _chunkIndex = 0;
  var _isApplyingProgrammaticEdit = false;

  static const _initialMarkdown = r'''
# Mixin Markdown Widget Showcase

Welcome to the comprehensive demo of **mixin_markdown_widget**. This document is designed to push the parser and renderer to their limits, ensuring robust selection, beautiful geometry, and full feature coverage.

> **Note:** You can edit this text on the left, and it will render instantly on the right. Try selecting text across different blocks to see our advanced path-based smooth selection highlights!

---

## 1. Typography & Inline Styles

This widget supports all standard inline syntax:
* *Italicized text* 
* **Bold emphasis** 
* ***Bold and italic***
* ~~Strikethrough~~
* `inline code snippets`
* Link to [Flutter](https://flutter.dev), and an auto-link: <https://github.com>

Here is a block with mixed inline math: Einstein's famous equation is \( E = mc^2 \), while the quadratic formula is \( x = \frac{-b \pm \sqrt{b^2 -4ac}}{2a} \). 
Notice how selection seamlessly wraps around inline blocks without layout jumps.

And don't forget about footnotes! Here is a reference to a footnote.[^1] And here is another.[^2]

## 2. Lists, Tasks, & Nesting

Markdown isn't complete without lists. And lists inside lists. And quotes inside lists!

### Standard Lists

*   **Fruit**
    *   Apple
    *   Banana
        *   Cavendish
        *   Plantain
*   **Vegetables**
    1.  Carrot
    2.  Broccoli

### Task Lists (Checkboxes)

- [x] Write the core widget logic
- [x] Implement selection handles & gestures
- [ ] Implement robust horizontal scroll boundaries
- [ ] Add real-time streaming parser support
  - [x] Design token chunking
  - [ ] Connect socket layer

### Lists containing advanced blocks

1.  **Code implementation:**
    Here is a quick way to compute a sum in JavaScript:
    
    ```javascript
    function sum(a, b) {
      return a + b;
    }
    console.log(sum(5, 10)); // 15
    ```

2.  **Mathematical definitions:**
    And here is the sum expressed mathematically:
    
    $$
    \sum_{i=1}^{n} i = \frac{n(n+1)}{2}
    $$
    
    > Blockquotes can also live gracefully inside list items. The selection background will adapt to the indentation perfectly.

## 3. Deeply Nested Blockquotes

We tested the nested layout extensively to ensure borders, padding, and text selections don't break even under extreme nesting.

> Level 1: The outer quote.
> > Level 2: The inner quote.
> > > Level 3: Deep quote containing a math block!
> > > 
> > > $$
> > > \int_a^b f(x) dx = F(b) - F(a)
> > > $$
> > > 
> > > And some inline `code` for good measure.
> > 
> > Back to Level 2.
> 
> Back to Level 1.

## 4. Complex Tables

Tables support varying alignments, complex cell contents, and inline styles.

| Feature | Description | Status |
| :--- | :---: | ---: |
| **Parsing** | Fast incremental markdown parsing | ✅ |
| **Selection** | Seamless multi-block text selection | ✅ |
| **Math** | Full LaTeX parsing & rendering (\( \alpha^2 \)) | ✅ |
| **Code** | Syntax highlighting with *re_highlight* | 🚀 Built |

## 5. Media & Links

Images are responsive and support border radiuses based on your specific `MarkdownThemeData`. They can also act as image links!

[![Spectacular mountain landscape](https://picsum.photos/id/1011/960/400)](https://picsum.photos)

## 6. The Edge Cases & Formatting

Horizontal rules separate content cleanly:

***

You can also use HTML tags like `<kbd>` depending on your parser config: Press <kbd>Ctrl</kbd> + <kbd>C</kbd> to copy.

Finally, testing seamless text selection bridging an empty paragraph line to a dense block of text!

Line one.

Line two.

```python
def test_edge_case():
    # Notice the selection corners on the empty lines below:
    
    
    print("Empty lines inside code blocks shouldn't break corner heuristics!")
```

## Footnotes

[^1]: This is the first footnote. It provides additional context about the text above without breaking the flow.
[^2]: This is the second footnote. It can also contain inline formulas like \( a^2 + b^2 = c^2 \).

End of showcase. Feel free to break things!
''';

  static const _streamChunks = <String>[
    '''

## Stream chunk 1

This content was appended through `MarkdownController.appendChunk`.
''',
    '''

## Stream chunk 2

| Phase | Focus |
| --- | --- |
| 1 | Widget and theme |
| 2 | Selection and copy |
| 3 | Streaming optimization |
''',
  ];

  @override
  void initState() {
    super.initState();
    _controller = MarkdownController(data: _initialMarkdown);
    _selectionController = MarkdownSelectionController()
      ..attachDocument(_controller.document);
    _editorController = TextEditingController(text: _initialMarkdown)
      ..addListener(_handleEditorChanged);
  }

  @override
  void dispose() {
    _editorController
      ..removeListener(_handleEditorChanged)
      ..dispose();
    _selectionController.dispose();
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final baseTheme = _themePreset == _DemoThemePreset.tight
        ? MarkdownThemeData.tight(context)
        : MarkdownThemeData.fallback(context);
    final markdownTheme = _themePreset == _DemoThemePreset.warm
        ? baseTheme.copyWith(
            maxContentWidth: 860,
            quoteBackgroundColor: const Color(0xFFFFF5E0),
            quoteBorderColor: const Color(0xFFD79B36),
            codeBlockBackgroundColor: const Color(0xFFF3E7D2),
            tableHeaderBackgroundColor: const Color(0xFFE8D6B3),
            tableRowBackgroundColor: const Color(0xFFFFFBF3),
            selectionColor: const Color(0x66D79B36),
          )
        : baseTheme.copyWith(maxContentWidth: 920);

    return Scaffold(
      appBar: AppBar(
        title: const Text('mixin_markdown_widget'),
        actions: <Widget>[
          IconButton(
            tooltip: 'AI Chat Demo',
            onPressed: () {
              Navigator.of(context).push(
                MaterialPageRoute(
                  builder: (context) => const AIChatDemoPage(),
                ),
              );
            },
            icon: const Icon(Icons.chat_outlined),
          ),
          IconButton(
            key: const Key('toggle-editor-visibility'),
            tooltip: _layoutMode == _DemoLayoutMode.split
                ? 'Hide editor'
                : 'Show editor',
            onPressed: () {
              setState(() {
                _layoutMode = _layoutMode == _DemoLayoutMode.split
                    ? _DemoLayoutMode.previewOnly
                    : _DemoLayoutMode.split;
              });
            },
            icon: Icon(
              _layoutMode == _DemoLayoutMode.split
                  ? Icons.visibility_off_outlined
                  : Icons.visibility_outlined,
            ),
          ),
          PopupMenuButton<_DemoThemePreset>(
            tooltip: 'Switch preview theme',
            initialValue: _themePreset,
            icon: const Icon(Icons.palette_outlined),
            onSelected: (value) {
              setState(() {
                _themePreset = value;
              });
            },
            itemBuilder: (context) => const <PopupMenuEntry<_DemoThemePreset>>[
              PopupMenuItem<_DemoThemePreset>(
                value: _DemoThemePreset.ocean,
                child: Text('Ocean theme (default)'),
              ),
              PopupMenuItem<_DemoThemePreset>(
                value: _DemoThemePreset.warm,
                child: Text('Warm theme (loose)'),
              ),
              PopupMenuItem<_DemoThemePreset>(
                value: _DemoThemePreset.tight,
                child: Text('Tight theme (compact)'),
              ),
            ],
          ),
          TextButton.icon(
            onPressed: _appendChunk,
            icon: const Icon(Icons.bolt_rounded),
            label: const Text('Append chunk'),
          ),
          IconButton(
            tooltip: 'Copy plain text',
            onPressed: _copyPlainText,
            icon: const Icon(Icons.copy_all_outlined),
          ),
          IconButton(
            tooltip: 'Select all model text',
            onPressed: _selectAllModelText,
            icon: const Icon(Icons.select_all_rounded),
          ),
          AnimatedBuilder(
            animation: _selectionController,
            builder: (context, _) => IconButton(
              tooltip: 'Copy selected model text',
              onPressed: _selectionController.hasSelection
                  ? _copySelectedModelText
                  : null,
              icon: const Icon(Icons.content_copy_outlined),
            ),
          ),
          IconButton(
            tooltip: 'Commit stream draft',
            onPressed:
                _controller.streamingState.hasDraft ? _commitStream : null,
            icon: const Icon(Icons.done_all_rounded),
          ),
          IconButton(
            tooltip: 'Reset content',
            onPressed: _resetDocument,
            icon: const Icon(Icons.refresh_rounded),
          ),
        ],
      ),
      body: LayoutBuilder(
        builder: (context, constraints) {
          final isWide = constraints.maxWidth >= 960;
          final editorPanel = _PaneShell(
            title: 'Editor',
            subtitle: 'Write any Markdown you want. The preview updates live.',
            child: TextField(
              key: const Key('markdown-editor'),
              controller: _editorController,
              expands: true,
              maxLines: null,
              minLines: null,
              keyboardType: TextInputType.multiline,
              textAlignVertical: TextAlignVertical.top,
              style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                    fontFamily: 'Menlo',
                  ),
              decoration: const InputDecoration(
                border: InputBorder.none,
                hintText: 'Type Markdown here...',
                contentPadding: EdgeInsets.zero,
              ),
            ),
          );
          final previewPanel = _PaneShell(
            title: 'Preview',
            subtitleBuilder: (context) => AnimatedBuilder(
              animation: _selectionController,
              builder: (context, _) => Text(
                _previewSubtitle(),
                style: Theme.of(context).textTheme.bodyMedium,
              ),
            ),
            child: MarkdownWidget(
              key: const Key('markdown-preview'),
              controller: _controller,
              selectionController: _selectionController,
              theme: markdownTheme,
              onTapLink: (destination, _, __) {
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text('Link tapped: $destination')),
                );
              },
              contextMenuBuilder: (context, controller, buttonItems, anchors) {
                return AdaptiveTextSelectionToolbar.buttonItems(
                  anchors: anchors,
                  buttonItems: [
                    ...buttonItems,
                    ContextMenuButtonItem(
                      onPressed: () {
                        ScaffoldMessenger.of(context).showSnackBar(
                          const SnackBar(content: Text('Custom menu action!')),
                        );
                      },
                      label: '🎉 Custom',
                    ),
                  ],
                );
              },
            ),
          );

          return Padding(
            padding: const EdgeInsets.all(20),
            child: _layoutMode == _DemoLayoutMode.previewOnly
                ? previewPanel
                : isWide
                    ? Row(
                        children: <Widget>[
                          Expanded(child: editorPanel),
                          const SizedBox(width: 20),
                          Expanded(child: previewPanel),
                        ],
                      )
                    : Column(
                        children: <Widget>[
                          Expanded(child: editorPanel),
                          const SizedBox(height: 20),
                          Expanded(child: previewPanel),
                        ],
                      ),
          );
        },
      ),
    );
  }

  void _handleEditorChanged() {
    if (_isApplyingProgrammaticEdit) {
      return;
    }
    _controller.setData(_editorController.text);
    _selectionController.attachDocument(_controller.document);
    _selectionController.clear();
    if (_chunkIndex != 0) {
      setState(() {
        _chunkIndex = 0;
      });
    }
  }

  void _appendChunk() {
    if (_chunkIndex >= _streamChunks.length) {
      return;
    }
    final chunk = _streamChunks[_chunkIndex];
    final nextText = '${_editorController.text}$chunk';
    _controller.appendChunk(chunk);
    _selectionController.attachDocument(_controller.document);
    _selectionController.clear();
    _isApplyingProgrammaticEdit = true;
    _editorController.value = TextEditingValue(
      text: nextText,
      selection: TextSelection.collapsed(offset: nextText.length),
    );
    _isApplyingProgrammaticEdit = false;
    setState(() {
      _chunkIndex += 1;
    });
  }

  void _commitStream() {
    _controller.commitStream();
    _selectionController.attachDocument(_controller.document);
    setState(() {});
  }

  void _copyPlainText() {
    _controller.copyPlainTextToClipboard();
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('Copied plain text to clipboard')),
    );
  }

  void _selectAllModelText() {
    _selectionController.attachDocument(_controller.document);
    _selectionController.selectAll();
    setState(() {});
  }

  void _copySelectedModelText() {
    _selectionController.copySelectionToClipboard();
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('Copied selected model text')),
    );
  }

  void _resetDocument() {
    _controller.setData(_initialMarkdown);
    _selectionController.attachDocument(_controller.document);
    _selectionController.clear();
    _isApplyingProgrammaticEdit = true;
    _editorController.value = TextEditingValue(
      text: _initialMarkdown,
      selection: TextSelection.collapsed(offset: _initialMarkdown.length),
    );
    _isApplyingProgrammaticEdit = false;
    setState(() {
      _chunkIndex = 0;
    });
  }

  String _previewSubtitle() {
    final streamingState = _controller.streamingState;
    final stateLabel = streamingState.hasDraft
        ? 'Streaming: ${streamingState.committedBlocks.length} committed + 1 draft block.'
        : 'Streaming: ${streamingState.committedBlocks.length} committed blocks.';
    final selectionLabel = _selectionController.hasSelection
        ? 'Model selection: ${_selectionController.selectedPlainText.length} chars.'
        : 'Model selection: none.';
    if (_layoutMode == _DemoLayoutMode.previewOnly) {
      return 'Preview-only mode. Current theme: ${_themeLabel(_themePreset)}. $stateLabel $selectionLabel';
    }
    return 'Current theme: ${_themeLabel(_themePreset)}. Links surface through the host app. $stateLabel $selectionLabel';
  }

  String _themeLabel(_DemoThemePreset preset) {
    switch (preset) {
      case _DemoThemePreset.ocean:
        return 'Ocean';
      case _DemoThemePreset.warm:
        return 'Warm';
      case _DemoThemePreset.tight:
        return 'Tight';
    }
  }
}

class _PaneShell extends StatelessWidget {
  const _PaneShell({
    required this.title,
    required this.child,
    this.subtitle,
    this.subtitleBuilder,
  });

  final String title;
  final Widget child;
  final String? subtitle;
  final WidgetBuilder? subtitleBuilder;

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;
    return DecoratedBox(
      decoration: BoxDecoration(
        color: colorScheme.surface,
        borderRadius: BorderRadius.circular(24),
        border: Border.all(color: colorScheme.outlineVariant),
        boxShadow: <BoxShadow>[
          BoxShadow(
            color: colorScheme.shadow.withValues(alpha: 0.05),
            blurRadius: 18,
            offset: const Offset(0, 8),
          ),
        ],
      ),
      child: Padding(
        padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text(title, style: Theme.of(context).textTheme.titleLarge),
            const SizedBox(height: 6),
            if (subtitleBuilder != null)
              subtitleBuilder!(context)
            else if (subtitle != null)
              Text(
                subtitle!,
                style: Theme.of(context).textTheme.bodyMedium,
              ),
            const SizedBox(height: 16),
            Expanded(child: child),
          ],
        ),
      ),
    );
  }
}
0
likes
0
points
358
downloads

Publisher

verified publishermixin.dev

Weekly Downloads

A desktop-first Flutter Markdown reader widget with theming and streaming-friendly controller APIs.

Homepage
Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

flutter, flutter_math_fork, markdown, pretext, re_highlight

More

Packages that depend on mixin_markdown_widget