From 26e32414929da13e09ae8593ded46838636b879a Mon Sep 17 00:00:00 2001 From: Denis Chuvasov Date: Wed, 3 Sep 2025 08:50:34 +0200 Subject: [PATCH 1/5] feat: support theming --- README.md | 3 + example/lib/main.dart | 69 ++++++++++++++++++----- lib/src/render.dart | 22 ++++---- lib/src/theme.dart | 125 +++++++++++++++++++++++++++++++++++++----- 4 files changed, 181 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index f30b157..3a45cf4 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,9 @@ MarkdownTheme( ) ``` +Or you can use the `MarkdownThemeData.mergeTheme(Theme.of(context))` factory to create a theme that inherits from the application's theme. +This approach allows you to easily support both light and dark themes, and keeps your markdown styling consistent with the rest of your application. + ### Custom Block Painters For advanced customization, you can provide custom block painters: diff --git a/example/lib/main.dart b/example/lib/main.dart index d72b0d0..e6a7210 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -6,7 +6,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_md/flutter_md.dart'; void main() => runZonedGuarded( - () => runApp(const App()), + () => runApp(ThemeModel( + notifier: ValueNotifier(ThemeMode.dark), + child: const App())), (e, s) => print(e), ); @@ -17,21 +19,16 @@ class App extends StatelessWidget { /// {@macro app} const App({super.key}); - /// Light theme for the app. - static final ThemeData theme = ThemeData.light(); - @override Widget build(BuildContext context) => MaterialApp( title: 'Markdown', - themeMode: ThemeMode.light, - theme: theme, - darkTheme: theme, + themeMode: ThemeModel.of(context).value, + theme: ThemeData.light(), + darkTheme: ThemeData.dark(), home: const HomeScreen(), builder: (context, child) => MarkdownTheme( - data: MarkdownThemeData( - textStyle: const TextStyle(fontSize: 14.0, color: Colors.black), - textDirection: TextDirection.ltr, - textScaler: TextScaler.noScaling, + data: MarkdownThemeData.mergeTheme( + Theme.of(context), // Exclude images from the markdown rendering, // so they are not rendered in the output. // Because image spans are not supported yet. @@ -52,6 +49,42 @@ class App extends StatelessWidget { ); } +/// {@template theme_model} +/// A widget that provides the [ThemeMode] to its descendants. +/// {@endtemplate} +class ThemeModel extends InheritedNotifier> { + /// {@macro theme_model} + const ThemeModel({super.key, super.notifier, required super.child}); + + /// The state from the closest instance of this class + /// that encloses the given context, if any. + /// e.g. `Theme.maybeOf(context)`. + static ValueNotifier? maybeOf(BuildContext context, + {bool listen = true}) => + listen + ? context.dependOnInheritedWidgetOfExactType()?.notifier + : context.getInheritedWidgetOfExactType()?.notifier; + + static Never _notFoundInheritedWidgetOfExactType() => throw ArgumentError( + 'Out of scope, not found inherited widget ' + 'a ThemeModel of the exact type', + 'out_of_scope', + ); + + /// The state from the closest instance of this class + /// that encloses the given context. + /// e.g. `Theme.of(context)` + static ValueNotifier of(BuildContext context, + {bool listen = true}) => + maybeOf(context, listen: listen) ?? _notFoundInheritedWidgetOfExactType(); + + @override + bool updateShouldNotify( + covariant InheritedNotifier> oldWidget) { + return !identical(notifier, oldWidget.notifier); + } +} + /// {@template home_screen} /// HomeScreen widget. /// {@endtemplate} @@ -104,9 +137,17 @@ class _HomeScreenState extends State { @override Widget build(BuildContext context) => Scaffold( appBar: AppBar( - centerTitle: true, - title: const Text('Markdown'), - ), + centerTitle: true, + title: const Text('Markdown'), + actions: [ + // theme switch widget + Switch.adaptive( + value: ThemeModel.of(context).value == ThemeMode.dark, + onChanged: (value) { + ThemeModel.of(context).value = + value ? ThemeMode.dark : ThemeMode.light; + }), + ]), body: SafeArea( child: CustomMultiChildLayout( delegate: _layoutDelegate, diff --git a/lib/src/render.dart b/lib/src/render.dart index 7cebde9..8ba60be 100644 --- a/lib/src/render.dart +++ b/lib/src/render.dart @@ -836,7 +836,7 @@ class BlockPainter$Quote with ParagraphGestureHandler implements BlockPainter { const Radius.circular(4.0), // Rounded corners for the quote block. ), Paint() - ..color = const Color.fromARGB(255, 235, 235, 235) + ..color = theme.surfaceColor ?? const Color.fromARGB(255, 235, 235, 235) ..isAntiAlias = false ..style = PaintingStyle.fill, ); @@ -848,8 +848,9 @@ class BlockPainter$Quote with ParagraphGestureHandler implements BlockPainter { const quoteFamily = 'MaterialIcons'; final textStyle = TextStyle( fontFamily: quoteFamily, - fontSize: - theme.quoteStyle?.fontSize ?? theme.textStyle.fontSize ?? 14.0, + fontSize: theme.quoteStyle?.fontSize ?? + theme.textStyle.fontSize ?? + kDefaultFontSize, color: const Color(0xFF7F7F7F), // Gray color for the quote icon. ); final quotePainter = TextPainter( @@ -1096,7 +1097,7 @@ class BlockPainter$Spacer implements BlockPainter { @override Size layout(double width) { - final height = theme.textStyle.fontSize ?? 14.0; + final height = theme.textStyle.fontSize ?? kDefaultFontSize; return _size = Size(0, height * count); } @@ -1141,7 +1142,7 @@ class BlockPainter$Divider implements BlockPainter { @override Size layout(double width) { - final height = theme.textStyle.fontSize ?? 14.0; + final height = theme.textStyle.fontSize ?? kDefaultFontSize; return _size = Size(0, height); } @@ -1174,7 +1175,7 @@ class BlockPainter$Code implements BlockPainter { text: text, style: theme.textStyle.copyWith( fontFamily: 'monospace', - fontSize: theme.textStyle.fontSize ?? 14.0, + fontSize: theme.textStyle.fontSize ?? kDefaultFontSize, ), ), textAlign: TextAlign.start, @@ -1225,7 +1226,7 @@ class BlockPainter$Code implements BlockPainter { const Radius.circular(padding), ), Paint() - ..color = const Color.fromARGB(255, 235, 235, 235) + ..color = theme.surfaceColor ?? const Color.fromARGB(255, 235, 235, 235) ..isAntiAlias = false ..style = PaintingStyle.fill, ); @@ -1289,7 +1290,7 @@ class BlockPainter$Table implements BlockPainter { return _size = Size( width, // The width of the table is the same as the available width. (header.cells.length + rows.length) * - ((theme.textStyle.fontSize ?? 14.0) + padding * 2), + ((theme.textStyle.fontSize ?? kDefaultFontSize) + padding * 2), ); } @@ -1301,7 +1302,8 @@ class BlockPainter$Table implements BlockPainter { // Draw the header row. final columnWidth = size.width / columns; final cellMaxWidth = columnWidth - padding * 2; - final rowHeight = (theme.textStyle.fontSize ?? 14.0) + padding * 2; + final rowHeight = + (theme.textStyle.fontSize ?? kDefaultFontSize) + padding * 2; canvas.drawRRect( RRect.fromLTRBR( 0, // Left @@ -1311,7 +1313,7 @@ class BlockPainter$Table implements BlockPainter { const Radius.circular(padding), // Radius for rounded corners ), Paint() - ..color = const Color.fromARGB(255, 235, 235, 235) + ..color = theme.surfaceColor ?? const Color.fromARGB(255, 235, 235, 235) ..style = PaintingStyle.fill ..isAntiAlias = false, ); diff --git a/lib/src/theme.dart b/lib/src/theme.dart index f692960..63e9c55 100644 --- a/lib/src/theme.dart +++ b/lib/src/theme.dart @@ -21,6 +21,10 @@ class MarkdownThemeData implements ThemeExtension { this.h5Style, this.h6Style, this.quoteStyle, + this.linkColor = Colors.indigo, + this.surfaceColor = const Color.fromARGB(255, 235, 235, 235), + this.highlightBackgroundColor = const Color(0x40FF5722), + this.monospaceBackgroundColor = const Color(0x409E9E9E), this.blockFilter, this.spanFilter, this.builder, @@ -28,6 +32,53 @@ class MarkdownThemeData implements ThemeExtension { }) : _headingStyles = List.filled(8, null), _textStyles = HashMap(); + /// Creates a [MarkdownThemeData] from the given [ThemeData]. + factory MarkdownThemeData.mergeTheme( + ThemeData theme, { + TextStyle? textStyle, + TextDirection? textDirection, + TextScaler? textScaler, + TextStyle? h1Style, + TextStyle? h2Style, + TextStyle? h3Style, + TextStyle? h4Style, + TextStyle? h5Style, + TextStyle? h6Style, + TextStyle? quoteStyle, + Color? linkColor, + Color? surfaceColor, + Color? highlightBackgroundColor, + Color? monospaceBackgroundColor, + bool Function(MD$Block block)? blockFilter, + bool Function(MD$Span span)? spanFilter, + BlockPainter? Function(MD$Block block, MarkdownThemeData theme)? builder, + void Function(String title, String url)? onLinkTap, + }) { + return MarkdownThemeData( + textStyle: textStyle ?? + theme.textTheme.bodyMedium ?? + const TextStyle(color: Colors.black, fontSize: kDefaultFontSize), + textDirection: textDirection ?? TextDirection.ltr, + textScaler: textScaler ?? TextScaler.noScaling, + h1Style: h1Style ?? theme.textTheme.headlineLarge, + h2Style: h2Style ?? theme.textTheme.headlineMedium, + h3Style: h3Style ?? theme.textTheme.headlineSmall, + h4Style: h4Style ?? theme.textTheme.titleLarge, + h5Style: h5Style ?? theme.textTheme.titleMedium, + h6Style: h6Style ?? theme.textTheme.titleSmall, + linkColor: linkColor ?? theme.colorScheme.primary, + surfaceColor: surfaceColor ?? theme.colorScheme.surfaceContainerHigh, + highlightBackgroundColor: + highlightBackgroundColor ?? theme.colorScheme.errorContainer, + monospaceBackgroundColor: + monospaceBackgroundColor ?? theme.colorScheme.surfaceContainerHigh, + blockFilter: blockFilter, + spanFilter: spanFilter, + builder: builder, + onLinkTap: onLinkTap, + ); + } + @override Object get type => MarkdownThemeData; @@ -61,6 +112,18 @@ class MarkdownThemeData implements ThemeExtension { /// Default text style for quote blocks. final TextStyle? quoteStyle; + /// The color to use for link text. + final Color? linkColor; + + /// The color to use for the background of the quote, block, table and etc. + final Color? surfaceColor; + + /// The color to use for the background of highlighted text. + final Color? highlightBackgroundColor; + + /// The color to use for the background of monospace text. + final Color? monospaceBackgroundColor; + /// A filter function to determine whether a block should be rendered. /// If the function returns `true`, the block will be rendered. /// @@ -98,34 +161,34 @@ class MarkdownThemeData implements ThemeExtension { _headingStyles[level] ??= switch (level.clamp(1, 7)) { 1 => h1Style ?? textStyle.copyWith( - fontSize: (textStyle.fontSize ?? 14.0) + 10.0, + fontSize: (textStyle.fontSize ?? kDefaultFontSize) + 10.0, fontWeight: FontWeight.bold, decoration: TextDecoration.underline, decorationStyle: TextDecorationStyle.solid, ), 2 => h2Style ?? textStyle.copyWith( - fontSize: (textStyle.fontSize ?? 14.0) + 8.0, + fontSize: (textStyle.fontSize ?? kDefaultFontSize) + 8.0, fontWeight: FontWeight.bold, ), 3 => h3Style ?? textStyle.copyWith( - fontSize: (textStyle.fontSize ?? 14.0) + 6.0, + fontSize: (textStyle.fontSize ?? kDefaultFontSize) + 6.0, fontWeight: FontWeight.bold, ), 4 => h4Style ?? textStyle.copyWith( - fontSize: (textStyle.fontSize ?? 14.0) + 4.0, + fontSize: (textStyle.fontSize ?? kDefaultFontSize) + 4.0, fontWeight: FontWeight.bold, ), 5 => h5Style ?? textStyle.copyWith( - fontSize: (textStyle.fontSize ?? 14.0) + 2.0, + fontSize: (textStyle.fontSize ?? kDefaultFontSize) + 2.0, fontWeight: FontWeight.bold, ), 6 => h6Style ?? textStyle.copyWith( - fontSize: (textStyle.fontSize ?? 14.0) + 0.0, + fontSize: (textStyle.fontSize ?? kDefaultFontSize) + 0.0, fontWeight: FontWeight.bold, ), _ => textStyle, @@ -153,16 +216,14 @@ class MarkdownThemeData implements ThemeExtension { }, fontFamily: style.contains(MD$Style.monospace) ? 'monospace' : null, color: switch (style) { - var s when s.contains(MD$Style.link) => Colors.indigo, - var s when s.contains(MD$Style.highlight) => Colors.black, - var s when s.contains(MD$Style.monospace) => Colors.black, + var s when s.contains(MD$Style.link) => linkColor, _ => null, }, backgroundColor: switch (style) { var s when s.contains(MD$Style.highlight) => - Colors.deepOrange.withValues(alpha: 0.25), + highlightBackgroundColor, var s when s.contains(MD$Style.monospace) => - Colors.grey.withValues(alpha: 0.25), + monospaceBackgroundColor, _ => null, }, ), @@ -180,6 +241,10 @@ class MarkdownThemeData implements ThemeExtension { TextStyle? h5Style, TextStyle? h6Style, TextStyle? quoteStyle, + Color? linkColor, + Color? surfaceColor, + Color? highlightBackgroundColor, + Color? monospaceBackgroundColor, bool Function(MD$Block block)? blockFilter, bool Function(MD$Span span)? spanFilter, }) => @@ -194,16 +259,48 @@ class MarkdownThemeData implements ThemeExtension { h5Style: h5Style ?? this.h5Style, h6Style: h6Style ?? this.h6Style, quoteStyle: quoteStyle ?? this.quoteStyle, + linkColor: linkColor ?? this.linkColor, + surfaceColor: surfaceColor ?? this.surfaceColor, + highlightBackgroundColor: + highlightBackgroundColor ?? this.highlightBackgroundColor, + monospaceBackgroundColor: + monospaceBackgroundColor ?? this.monospaceBackgroundColor, blockFilter: blockFilter ?? this.blockFilter, spanFilter: spanFilter ?? this.spanFilter, ); @override ThemeExtension lerp( - covariant ThemeExtension? other, + covariant MarkdownThemeData? other, double t, - ) => - other ?? this; + ) { + if (identical(this, other)) return this; + + return MarkdownThemeData( + textDirection: + t < 0.5 ? textDirection : other?.textDirection ?? TextDirection.ltr, + textScaler: + t < 0.5 ? textScaler : other?.textScaler ?? TextScaler.noScaling, + textStyle: TextStyle.lerp(textStyle, other?.textStyle, t)!, + h1Style: TextStyle.lerp(h1Style, other?.h1Style, t), + h2Style: TextStyle.lerp(h2Style, other?.h2Style, t), + h3Style: TextStyle.lerp(h3Style, other?.h3Style, t), + h4Style: TextStyle.lerp(h4Style, other?.h4Style, t), + h5Style: TextStyle.lerp(h5Style, other?.h5Style, t), + h6Style: TextStyle.lerp(h6Style, other?.h6Style, t), + quoteStyle: TextStyle.lerp(quoteStyle, other?.quoteStyle, t), + linkColor: Color.lerp(linkColor, other?.linkColor, t), + surfaceColor: Color.lerp(surfaceColor, other?.surfaceColor, t), + highlightBackgroundColor: Color.lerp( + highlightBackgroundColor, other?.highlightBackgroundColor, t), + monospaceBackgroundColor: Color.lerp( + monospaceBackgroundColor, other?.monospaceBackgroundColor, t), + blockFilter: t < 0.5 ? blockFilter : other?.blockFilter, + spanFilter: t < 0.5 ? spanFilter : other?.spanFilter, + builder: t < 0.5 ? builder : other?.builder, + onLinkTap: t < 0.5 ? onLinkTap : other?.onLinkTap, + ); + } @override String toString() => 'MarkdownThemeData{}'; From db5659c9990810c6cc227294b9908bdcf420fa29 Mon Sep 17 00:00:00 2001 From: Denis Chuvasov Date: Wed, 3 Sep 2025 14:00:27 +0200 Subject: [PATCH 2/5] feat: add divider color to MarkdownThemeData and update BlockPainter$Quote --- lib/src/render.dart | 98 ++++++++++----------------------------------- lib/src/theme.dart | 13 ++++++ 2 files changed, 35 insertions(+), 76 deletions(-) diff --git a/lib/src/render.dart b/lib/src/render.dart index 8ba60be..72d93e1 100644 --- a/lib/src/render.dart +++ b/lib/src/render.dart @@ -760,7 +760,7 @@ class BlockPainter$Quote with ParagraphGestureHandler implements BlockPainter { required List spans, required this.indent, required this.theme, - }) : painter = TextPainter( + }) : painter = TextPainter( text: _paragraphFromMarkdownSpans( spans: spans, theme: theme, @@ -769,7 +769,13 @@ class BlockPainter$Quote with ParagraphGestureHandler implements BlockPainter { textAlign: TextAlign.start, textDirection: theme.textDirection, textScaler: theme.textScaler, - ); + ), + linePaint = Paint() + ..color = theme.dividerColor ?? + const Color(0x7F7F7F7F) // Gray color for the line. + ..isAntiAlias = false + ..strokeWidth = 4.0 + ..style = PaintingStyle.fill; final MarkdownThemeData theme; @@ -779,11 +785,7 @@ class BlockPainter$Quote with ParagraphGestureHandler implements BlockPainter { static const double lineIndent = 10.0; // Indentation for quote blocks. - static final Paint linePaint = Paint() - ..color = const Color(0x7F7F7F7F) // Gray color for the line. - ..isAntiAlias = false - ..strokeWidth = 4.0 - ..style = PaintingStyle.fill; + final Paint linePaint; @override Size get size => _size; @@ -830,75 +832,19 @@ class BlockPainter$Quote with ParagraphGestureHandler implements BlockPainter { // If the width is less than required do not paint anything. if (size.width < _size.width) return; - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromLTWH(0, offset, size.width, _size.height), - const Radius.circular(4.0), // Rounded corners for the quote block. - ), - Paint() - ..color = theme.surfaceColor ?? const Color.fromARGB(255, 235, 235, 235) - ..isAntiAlias = false - ..style = PaintingStyle.fill, - ); - - { - // --- Icons.format_quote_outlined --- // - try { - const quoteCodePoint = 0xf0a9; - const quoteFamily = 'MaterialIcons'; - final textStyle = TextStyle( - fontFamily: quoteFamily, - fontSize: theme.quoteStyle?.fontSize ?? - theme.textStyle.fontSize ?? - kDefaultFontSize, - color: const Color(0xFF7F7F7F), // Gray color for the quote icon. - ); - final quotePainter = TextPainter( - text: TextSpan( - text: String.fromCharCode(quoteCodePoint), - style: textStyle, - ), - textAlign: TextAlign.start, - textDirection: theme.textDirection, - textScaler: theme.textScaler, - )..layout(); - canvas - ..save() - ..translate( - _size.width + quotePainter.width, - offset + _size.height, - ) - ..rotate(math.pi); - quotePainter.paint( - canvas, - Offset( - _size.width, - _size.height - quotePainter.height, - ), - ); - canvas.restore(); - quotePainter.paint( - canvas, - Offset( - size.width - quotePainter.width - 2.0, - offset + _size.height - quotePainter.height, - ), - ); - } on Object { - for (var i = 1; i <= indent; i++) - canvas.drawLine( - Offset( - i * lineIndent - lineIndent / 2, - offset + 12, - ), - Offset( - i * lineIndent - lineIndent / 2, - offset + _size.height - 12, - ), - linePaint, - ); - } - } + // --- Draw vertical lines --- // + for (var i = 1; i <= indent; i++) + canvas.drawLine( + Offset( + i * lineIndent, + offset, + ), + Offset( + i * lineIndent, + offset + _size.height, + ), + linePaint, + ); painter.paint( canvas, diff --git a/lib/src/theme.dart b/lib/src/theme.dart index 63e9c55..e749c2c 100644 --- a/lib/src/theme.dart +++ b/lib/src/theme.dart @@ -25,6 +25,7 @@ class MarkdownThemeData implements ThemeExtension { this.surfaceColor = const Color.fromARGB(255, 235, 235, 235), this.highlightBackgroundColor = const Color(0x40FF5722), this.monospaceBackgroundColor = const Color(0x409E9E9E), + this.dividerColor, this.blockFilter, this.spanFilter, this.builder, @@ -49,6 +50,7 @@ class MarkdownThemeData implements ThemeExtension { Color? surfaceColor, Color? highlightBackgroundColor, Color? monospaceBackgroundColor, + Color? dividerColor, bool Function(MD$Block block)? blockFilter, bool Function(MD$Span span)? spanFilter, BlockPainter? Function(MD$Block block, MarkdownThemeData theme)? builder, @@ -66,12 +68,17 @@ class MarkdownThemeData implements ThemeExtension { h4Style: h4Style ?? theme.textTheme.titleLarge, h5Style: h5Style ?? theme.textTheme.titleMedium, h6Style: h6Style ?? theme.textTheme.titleSmall, + quoteStyle: quoteStyle ?? + theme.textTheme.bodyMedium?.copyWith( + color: + theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.75)), linkColor: linkColor ?? theme.colorScheme.primary, surfaceColor: surfaceColor ?? theme.colorScheme.surfaceContainerHigh, highlightBackgroundColor: highlightBackgroundColor ?? theme.colorScheme.errorContainer, monospaceBackgroundColor: monospaceBackgroundColor ?? theme.colorScheme.surfaceContainerHigh, + dividerColor: dividerColor ?? theme.dividerColor.withValues(alpha: 0.12), blockFilter: blockFilter, spanFilter: spanFilter, builder: builder, @@ -124,6 +131,9 @@ class MarkdownThemeData implements ThemeExtension { /// The color to use for the background of monospace text. final Color? monospaceBackgroundColor; + /// The color to use for the divider. + final Color? dividerColor; + /// A filter function to determine whether a block should be rendered. /// If the function returns `true`, the block will be rendered. /// @@ -245,6 +255,7 @@ class MarkdownThemeData implements ThemeExtension { Color? surfaceColor, Color? highlightBackgroundColor, Color? monospaceBackgroundColor, + Color? dividerColor, bool Function(MD$Block block)? blockFilter, bool Function(MD$Span span)? spanFilter, }) => @@ -265,6 +276,7 @@ class MarkdownThemeData implements ThemeExtension { highlightBackgroundColor ?? this.highlightBackgroundColor, monospaceBackgroundColor: monospaceBackgroundColor ?? this.monospaceBackgroundColor, + dividerColor: dividerColor ?? this.dividerColor, blockFilter: blockFilter ?? this.blockFilter, spanFilter: spanFilter ?? this.spanFilter, ); @@ -295,6 +307,7 @@ class MarkdownThemeData implements ThemeExtension { highlightBackgroundColor, other?.highlightBackgroundColor, t), monospaceBackgroundColor: Color.lerp( monospaceBackgroundColor, other?.monospaceBackgroundColor, t), + dividerColor: Color.lerp(dividerColor, other?.dividerColor, t), blockFilter: t < 0.5 ? blockFilter : other?.blockFilter, spanFilter: t < 0.5 ? spanFilter : other?.spanFilter, builder: t < 0.5 ? builder : other?.builder, From 5d4199533fdb4b7d5696765fa45b353fbfd2b06c Mon Sep 17 00:00:00 2001 From: Denis Chuvasov Date: Tue, 9 Sep 2025 11:39:58 +0200 Subject: [PATCH 3/5] fix: Inline code incorrectly parses inner markdown syntax (#10) --- .vscode/tasks.json | 2 +- lib/src/parser.dart | 33 +++++++++++++++++++++-------- test/parser/parser_test.dart | 41 ++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 5fd5001..e986402 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -39,7 +39,7 @@ }, "type": "shell", "command": [ - "dart format --fix -l 80 lib test" + "dart format -l 80 lib test" ], "dependsOn": [], "args": [], diff --git a/lib/src/parser.dart b/lib/src/parser.dart index ad97fa3..00bb8a3 100644 --- a/lib/src/parser.dart +++ b/lib/src/parser.dart @@ -453,17 +453,19 @@ List _parseInlineSpans(String text) { for (var i = 0; i < length; i++) { final ch = codes[i]; - // Check for escaped characters - if (ch == esc /* \ */ && i != length - 1) { - final nextChar = codes[i + 1]; - // Check if the next character is an escaped character - if (_escapedChars.length > nextChar && _escapedChars[nextChar] == 1) { - hasExcluded = true; // We have an escaped character - excluded.add(i); // Exclude this character as it is escaped - i++; // skip next char - continue; + // If we are inside a monospace block, we should only look + // for the closing backtick. + if (mask.contains(MD$Style.monospace)) { + if (ch == 96 /* ` */ && i > 0 && codes[i - 1] != esc /* ignore \` */) { + // Found closing backtick + maybePushSpan(i); + mask ^= MD$Style.monospace; + start = i + 1; } + // We continue to the next character, ignoring any other + // special markers. + continue; } // If this character is part of a link or image, skip it @@ -478,6 +480,19 @@ List _parseInlineSpans(String text) { continue; } + // Check for escaped characters + if (ch == esc /* \ */ && i != length - 1) { + final nextChar = codes[i + 1]; + // Check if the next character is an escaped character + if (_escapedChars.length > nextChar && _escapedChars[nextChar] == 1) { + hasExcluded = true; // We have an escaped character + excluded.add(i); // Exclude this character as it is escaped + i++; // skip next char + + continue; + } + } + // If the character is not a special inline marker, continue if (_kind.length > ch && _kind[ch] == 0) continue; diff --git a/test/parser/parser_test.dart b/test/parser/parser_test.dart index 2938687..c6a6ee1 100644 --- a/test/parser/parser_test.dart +++ b/test/parser/parser_test.dart @@ -370,6 +370,47 @@ void main() => group('Parse', () { ); }); + test('Inline code should treat inline code as literal', () { + // A map of markdown syntax to its expected literal representation + // inside a code block. + const List syntaxes = [ + '*italic*', + '_italic_', + '**bold**', + '__underline__', + '~~strike~~', + '==highlight==', + '||spoiler||', + r'\* \_ \`', + '!alt', + '* Item 1', + '# Header', + '---', + '1. Item **1**', + '[Markdown Live Preview](https://markdownlivepreview.com/)', + 'package:flutter_md/flutter_md.dart' + ]; + + final text = syntaxes.map((syntax) => '`$syntax`').join(); + final markdown = markdownDecoder.convert(text); + + expect(markdown.blocks, + allOf(isNotEmpty, hasLength(1), everyElement(isA()))); + + final paragraph = markdown.blocks.first as MD$Paragraph; + final spans = paragraph.spans; + + for (var i = 0; i < syntaxes.length; i++) { + final expectedText = syntaxes.elementAt(i); + + final codeSpan = spans[i]; + expect(codeSpan.text, expectedText); + expect(codeSpan.style, MD$Style.monospace, + reason: + 'Span for "$expectedText" should only have monospace style'); + } + }); + group('Parse escaped characters', () { const escapedCharacterTests = { r'\\': r'\', // Backslash From b1389c5faa044ccb445d82882bb8b60feecf25fd Mon Sep 17 00:00:00 2001 From: Dennis Tschuwasow Date: Wed, 10 Sep 2025 12:59:28 +0200 Subject: [PATCH 4/5] V 0.0.7 (#13) * feat: support theming * feat: add divider color to MarkdownThemeData and update BlockPainter$Quote * fix: Inline code incorrectly parses inner markdown syntax (#10) * fix: No indentation after line breaks within list item (#4) * chore: update version to 0.0.7 and enhance changelog with recent fixes and features --- CHANGELOG.md | 7 +++++++ pubspec.yaml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14a160f..64e4d9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.0.7 + +- **FIXED**: Preserved indentation on line breaks within list items [#4]. +- **FIXED**: Inline code no longer processes inner Markdown syntax [#10]. +- **CHANGED**: Improved theme support. +- **ADDED**: Dark mode support in the example app. + ## 0.0.6 - **FIXED**: Fixed escaping of special characters. [#6] diff --git a/pubspec.yaml b/pubspec.yaml index 0aaf0b8..b7abed8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,7 +8,7 @@ description: > Markdown library written in Dart. It can parse and display Markdown. -version: 0.0.6 +version: 0.0.7 homepage: https://github.com/DoctorinaAI/md repository: https://github.com/DoctorinaAI/md From 94eba4a7dcb28bbdcaab066f69ffbdc2848b08fb Mon Sep 17 00:00:00 2001 From: Denis Chuvasov Date: Wed, 10 Sep 2025 22:55:57 +0200 Subject: [PATCH 5/5] feat: new table render --- lib/src/render.dart | 359 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 285 insertions(+), 74 deletions(-) diff --git a/lib/src/render.dart b/lib/src/render.dart index 72d93e1..b6aaf04 100644 --- a/lib/src/render.dart +++ b/lib/src/render.dart @@ -1190,30 +1190,41 @@ class BlockPainter$Code implements BlockPainter { /// A class for painting a table block in markdown. @meta.internal -class BlockPainter$Table implements BlockPainter { +class BlockPainter$Table with ParagraphGestureHandler implements BlockPainter { BlockPainter$Table({ required this.header, required this.rows, required this.theme, }) : columns = header.cells.length, - painter = TextPainter( - textAlign: TextAlign.start, - textDirection: theme.textDirection, - textScaler: theme.textScaler, - ); + _columnWidths = List.filled(header.cells.length, 0.0), + _rowHeights = List.filled(rows.length + 1, 0.0), + _borderPaint = Paint() + ..color = theme.dividerColor ?? const Color(0x1F000000) + ..style = PaintingStyle.stroke + ..isAntiAlias = false + ..strokeWidth = 1.0, + _rowBackgroundPaint = Paint() + ..style = PaintingStyle.fill + ..isAntiAlias = false + ..color = + theme.surfaceColor ?? const Color.fromARGB(255, 235, 235, 235); - /// Padding for the table cells. - static const double padding = 4.0; + /// Padding for table cells. + static const double padding = 8.0; /// The theme for the markdown table. final MarkdownThemeData theme; - /// Text painter for rendering the table content. - final TextPainter painter; - /// The number of columns in the table. final int columns; + final List _columnWidths; + final List _rowHeights; + final Paint _borderPaint; + final Paint _rowBackgroundPaint; + + Float32List? _borderPoints; + /// The header row of the table. final MD$TableRow header; @@ -1224,95 +1235,295 @@ class BlockPainter$Table implements BlockPainter { Size get size => _size; Size _size = Size.zero; + List> _cellPainters = const []; + + /// Last span hit by the tap down event. + TextSpan? _lastSpan; + @override - void handleTapDown(PointerDownEvent _) {/* Do nothing */} + void handleTapDown(PointerDownEvent event) { + _lastSpan = null; // Reset the span on tap down. + final span = _getSpanForOffset(event.localPosition); + if (span != null) { + _lastSpan = span; + } + } @override - void handleTapUp(PointerUpEvent _) {/* Do nothing */} + void handleTapUp(PointerUpEvent event) { + if (_lastSpan == null) return; // No span was hit on tap down. + final span = _getSpanForOffset(event.localPosition); + if (span != null && _lastSpan == span) { + // If the span is the same as the one hit on tap down, + // call the tap recognizer. + if (span case TextSpan(recognizer: TapGestureRecognizer(:var onTap))) + onTap?.call(); + } + _lastSpan = null; // Clear the span after handling the tap. + } + + TextSpan? _getSpanForOffset(Offset position) { + final rowHeights = + List.generate(_cellPainters.length, (r) => _rowHeights[r]); + + double currentY = 0.0; + + for (int r = 0; r < _cellPainters.length; r++) { + final rowHeight = rowHeights[r]; + double currentX = 0.0; + + if (position.dy >= currentY && position.dy < currentY + rowHeight) { + // In this row. + for (int c = 0; c < _cellPainters[r].length; c++) { + final painter = _cellPainters[r][c]; + if (painter.text == null) { + currentX += _columnWidths[c]; + continue; + } + final columnWidth = _columnWidths[c]; + + if (position.dx >= currentX && position.dx < currentX + columnWidth) { + // In this cell. + final verticalPadding = (rowHeight - painter.height) / 2; + final horizontalPadding = + (r == 0) ? (columnWidth - painter.width) / 2 : padding; + + final painterOffset = Offset( + currentX + horizontalPadding, currentY + verticalPadding); + final localPosition = position - painterOffset; + + // Check if inside the actual painted text area. + if (localPosition.dx < 0 || + localPosition.dx > painter.width || + localPosition.dy < 0 || + localPosition.dy > painter.height) { + currentX += columnWidth; + continue; + } + + final textPosition = painter.getPositionForOffset(localPosition); + final span = painter.text!.getSpanForPosition(textPosition); + if (span is TextSpan) { + return span; + } + return null; // Found cell, but no span. + } + currentX += columnWidth; + } + } + currentY += rowHeight; + } + return null; + } @override Size layout(double width) { if (columns < 1) return _size = Size.zero; - return _size = Size( - width, // The width of the table is the same as the available width. - (header.cells.length + rows.length) * - ((theme.textStyle.fontSize ?? kDefaultFontSize) + padding * 2), - ); + + // Dispose old painters + for (final row in _cellPainters) { + for (final painter in row) { + painter.dispose(); + } + } + + final allRows = [header, ...rows]; + final naturalWidths = List.filled(columns, 0.0); + final minWidths = List.filled(columns, 0.0); + + // Create painters for each row and column and calculate natural widths + _cellPainters = List.generate(allRows.length, (r) { + final row = allRows[r]; + return List.generate(columns, (c) { + if (c >= row.cells.length) { + return TextPainter(textDirection: theme.textDirection); + } + final cell = row.cells[c]; + final style = (r == 0) + ? theme.textStyle.copyWith(fontWeight: FontWeight.bold) + : null; + final textPainter = TextPainter( + text: _paragraphFromMarkdownSpans( + spans: cell, theme: theme, textStyle: style), + textAlign: (r == 0) ? TextAlign.center : TextAlign.start, + textDirection: theme.textDirection, + textScaler: theme.textScaler, + ); + + // Calculate natural width + textPainter.layout(maxWidth: double.infinity); + naturalWidths[c] = + math.max(naturalWidths[c], textPainter.width + padding * 2); + + // Calculate min width (longest word) + final cellText = cell.map((s) => s.text).join(); + final words = cellText.split(RegExp(r'\s+')); + if (words.isNotEmpty) { + final longestWord = + words.reduce((a, b) => a.length > b.length ? a : b); + final wordPainter = TextPainter( + text: TextSpan(text: longestWord, style: style), + textDirection: theme.textDirection, + )..layout(); + minWidths[c] = + math.max(minWidths[c], wordPainter.width + padding * 2); + wordPainter.dispose(); + } + + return textPainter; + }); + }); + + _columnWidths.setAll(0, _distributeWidths(naturalWidths, minWidths, width)); + + final totalWidth = _columnWidths.reduce((a, b) => a + b); + + // Layout painters with final widths and calculate row heights + + double totalHeight = 0.0; + for (int r = 0; r < allRows.length; r++) { + double rowHeight = 0.0; + for (int c = 0; c < columns; c++) { + final painter = _cellPainters[r][c]; + if (painter.text == null) continue; + painter.layout(maxWidth: math.max(0.0, _columnWidths[c] - padding * 2)); + rowHeight = math.max( + rowHeight, + painter.height, + ); + } + + _rowHeights[r] = rowHeight + padding * 2; + totalHeight += _rowHeights[r]; + } + + // Cache border points + final points = Float32List(((allRows.length - 1) + (columns - 1)) * 4); + var pointIndex = 0; + // Horizontal lines + double lineY = 0; + for (int r = 0; r < allRows.length - 1; r++) { + lineY += _rowHeights[r]; + points[pointIndex++] = 0; + points[pointIndex++] = lineY; + points[pointIndex++] = totalWidth; + points[pointIndex++] = lineY; + } + // Vertical lines + double lineX = 0; + for (int c = 0; c < columns - 1; c++) { + lineX += _columnWidths[c]; + points[pointIndex++] = lineX; + points[pointIndex++] = 0; + points[pointIndex++] = lineX; + points[pointIndex++] = totalHeight; + } + _borderPoints = points; + + return _size = Size(totalWidth, totalHeight); } @override void paint(Canvas canvas, Size size, double offset) { // If the width is less than required do not paint anything. - if (size.width < _size.width || columns < 1) return; + if (columns < 1) return; - // Draw the header row. - final columnWidth = size.width / columns; - final cellMaxWidth = columnWidth - padding * 2; - final rowHeight = - (theme.textStyle.fontSize ?? kDefaultFontSize) + padding * 2; - canvas.drawRRect( - RRect.fromLTRBR( - 0, // Left - offset, // Top - size.width, // Right - offset + _size.height, // Bottom - const Radius.circular(padding), // Radius for rounded corners - ), - Paint() - ..color = theme.surfaceColor ?? const Color.fromARGB(255, 235, 235, 235) - ..style = PaintingStyle.fill - ..isAntiAlias = false, - ); + double currentY = offset; + final rowHeights = + List.generate(_cellPainters.length, (r) => _rowHeights[r]); - for (var i = 0; i < columns; i++) { - final cell = header.cells[i]; - painter - ..text = TextSpan( - text: cell.map((span) => span.text).join(), - style: theme.textStyle.copyWith( - fontWeight: FontWeight.bold, - ), - ) - ..layout( - minWidth: 0, - maxWidth: cellMaxWidth, + for (int r = 0; r < _cellPainters.length; r++) { + double currentX = 0; + + // Draw background for even data rows. + if (r % 2 == 0 && r != 0) { + canvas.drawRect( + Rect.fromLTWH(0, currentY, _size.width, rowHeights[r]), + _rowBackgroundPaint, ); - painter.paint( - canvas, - Offset( - i * columnWidth + padding, - offset + rowHeight - padding - painter.height / 2, - ), - ); - } + } + + for (int c = 0; c < columns; c++) { + final painter = _cellPainters[r][c]; + if (painter.text == null) { + currentX += _cellPainters[r].length > c ? _columnWidths[c] : 0; + continue; + } + + final verticalPadding = (rowHeights[r] - painter.height) / 2; + final horizontalPadding = (r == 0) + ? (_columnWidths[c] - painter.width) / 2 // Center for header rows + : padding; // Left align for data rows - for (var i = 0; i < rows.length; i++) { - final row = rows[i]; - for (var j = 0; j < columns; j++) { - if (j >= row.cells.length) continue; // Skip if the cell is missing. - final cell = row.cells[j]; - painter - ..text = TextSpan( - text: cell.map((span) => span.text).join(), - style: theme.textStyle, - ) - ..layout( - minWidth: 0, - maxWidth: cellMaxWidth, - ); painter.paint( canvas, Offset( - j * columnWidth + padding, - offset + rowHeight * (i + 2) - painter.height / 2, + currentX + horizontalPadding, + currentY + verticalPadding, ), ); + currentX += _columnWidths[c]; } + currentY += rowHeights[r]; + } + + // Draw inner borders + if (_borderPoints != null) { + canvas.save(); + canvas.translate(0, offset); + canvas.drawRawPoints(PointMode.lines, _borderPoints!, _borderPaint); + canvas.restore(); } + + // Draw outer borders + canvas.drawRect( + Rect.fromLTRB( + 0, + offset, + _size.width, + offset + _size.height, + ), + _borderPaint, + ); } @override void dispose() { - painter.dispose(); + for (final row in _cellPainters) { + for (final painter in row) { + painter.dispose(); + } + } + _cellPainters = const []; + } + + /// Helper function to distribute widths among columns, respecting minimums. + /// If total minimum width exceeds availableWidth, + /// it returns the minimum widths as-is, + /// implying that the content will overflow and require scrolling. + List _distributeWidths( + List natural, List min, double availableWidth) { + final totalNatural = natural.reduce((a, b) => a + b); + final totalMin = min.reduce((a, b) => a + b); + + if (totalNatural <= availableWidth) { + return natural; + } + + if (totalMin <= availableWidth) { + final remainingSpace = availableWidth - totalMin; + final extraSpacePerColumn = [ + for (var i = 0; i < natural.length; i++) natural[i] - min[i] + ]; + final totalExtraSpace = extraSpacePerColumn.reduce((a, b) => a + b); + + if (totalExtraSpace <= 0.001) return min; + + return [ + for (var i = 0; i < natural.length; i++) + min[i] + remainingSpace * (extraSpacePerColumn[i] / totalExtraSpace) + ]; + } + return min; } }