diff --git a/lib/widgets/slides/slide_preview.dart b/lib/widgets/slides/slide_preview.dart index 7332338..37f3030 100644 --- a/lib/widgets/slides/slide_preview.dart +++ b/lib/widgets/slides/slide_preview.dart @@ -1,5 +1,10 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter_highlight/flutter_highlight.dart'; +import 'package:flutter_highlight/themes/github.dart'; +import 'package:flutter_math_fork/flutter_math.dart'; +import 'package:highlight/highlight.dart' show highlight; +import 'package:highlight/languages/all.dart' show allLanguages; import 'package:video_player/video_player.dart'; import '../../models/deck.dart'; import '../../models/settings.dart'; @@ -1849,7 +1854,6 @@ class _MarkdownPreview extends StatelessWidget { Widget build(BuildContext context) { final pad = w * 0.07; final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; - final lines = slide.customMarkdown.split('\n'); return Container( color: Colors.white, @@ -1869,52 +1873,7 @@ class _MarkdownPreview extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, - children: lines.take(20).map((line) { - final link = _hexColor(profile.accentColor); - if (line.startsWith('# ')) { - return _md( - context, - line.substring(2), - _applyFont( - font, - TextStyle( - fontSize: w * 0.04, - fontWeight: FontWeight.bold, - color: AppTheme.navy, - ), - ), - linkColor: link, - ); - } else if (line.startsWith('## ')) { - return _md( - context, - line.substring(3), - _applyFont( - font, - TextStyle( - fontSize: w * 0.03, - fontWeight: FontWeight.w600, - ), - ), - linkColor: link, - ); - } else if (line.startsWith('- ')) { - return _md( - context, - '• ${line.substring(2)}', - _applyFont(font, TextStyle(fontSize: w * 0.024)), - linkColor: link, - ); - } else if (line.isEmpty) { - return SizedBox(height: w * 0.01); - } - return _md( - context, - line, - _applyFont(font, TextStyle(fontSize: w * 0.024)), - linkColor: link, - ); - }).toList(), + children: _buildBlocks(context), ), ), ), @@ -1922,6 +1881,162 @@ class _MarkdownPreview extends StatelessWidget { ), ); } + + /// Parse the free Markdown into block widgets: fenced ```code``` (syntax + /// highlighted), `$$…$$` display math, and ordinary heading/bullet/text lines. + List _buildBlocks(BuildContext context) { + final link = _hexColor(profile.accentColor); + final lines = slide.customMarkdown.split('\n'); + final widgets = []; + var i = 0; + // Cap rendered blocks so a huge slide can't blow up layout (the preview is a + // thumbnail; FittedBox scales the rest down). + while (i < lines.length && widgets.length < 24) { + final line = lines[i]; + + // Fenced code block: ``` or ```language … ``` + final fence = RegExp(r'^\s*```(.*)$').firstMatch(line); + if (fence != null) { + final language = fence.group(1)!.trim(); + final code = []; + i++; + while (i < lines.length && !RegExp(r'^\s*```\s*$').hasMatch(lines[i])) { + code.add(lines[i]); + i++; + } + if (i < lines.length) i++; // consume the closing fence + widgets.add(_codeBlock(code.join('\n'), language)); + continue; + } + + // Display math fenced by lines containing only `$$`. + if (line.trim() == r'$$') { + final tex = []; + i++; + while (i < lines.length && lines[i].trim() != r'$$') { + tex.add(lines[i]); + i++; + } + if (i < lines.length) i++; // consume the closing $$ + widgets.add(_mathBlock(tex.join('\n'))); + continue; + } + // Single-line display math: $$ … $$ + final oneLine = RegExp(r'^\s*\$\$(.+)\$\$\s*$').firstMatch(line); + if (oneLine != null) { + widgets.add(_mathBlock(oneLine.group(1)!.trim())); + i++; + continue; + } + + widgets.add(_textLine(context, line, link)); + i++; + } + return widgets; + } + + Widget _textLine(BuildContext context, String line, Color link) { + if (line.startsWith('# ')) { + return _md( + context, + line.substring(2), + _applyFont( + font, + TextStyle( + fontSize: w * 0.04, + fontWeight: FontWeight.bold, + color: AppTheme.navy, + ), + ), + linkColor: link, + ); + } else if (line.startsWith('## ')) { + return _md( + context, + line.substring(3), + _applyFont( + font, + TextStyle(fontSize: w * 0.03, fontWeight: FontWeight.w600), + ), + linkColor: link, + ); + } else if (line.startsWith('- ')) { + return _md( + context, + '• ${line.substring(2)}', + _applyFont(font, TextStyle(fontSize: w * 0.024)), + linkColor: link, + ); + } else if (line.isEmpty) { + return SizedBox(height: w * 0.01); + } + return _md( + context, + line, + _applyFont(font, TextStyle(fontSize: w * 0.024)), + linkColor: link, + ); + } + + Widget _codeBlock(String code, String language) { + _ensureHighlightLanguages(); + final mono = TextStyle( + fontFamily: 'monospace', + fontSize: w * 0.02, + height: 1.3, + color: const Color(0xFF24292E), + ); + // HighlightView throws on an unregistered language, so only use it for ones + // we actually know; otherwise fall back to plain monospace. + final known = language.isNotEmpty && allLanguages.containsKey(language); + final Widget content = known + ? HighlightView( + code, + language: language, + theme: githubTheme, + padding: EdgeInsets.zero, + textStyle: mono, + ) + : Text(code, style: mono); + return Container( + width: double.infinity, + margin: EdgeInsets.symmetric(vertical: w * 0.008), + padding: EdgeInsets.all(w * 0.018), + decoration: BoxDecoration( + color: const Color(0xFFF6F8FA), + borderRadius: BorderRadius.circular(w * 0.008), + border: Border.all(color: const Color(0xFFE1E4E8)), + ), + child: content, + ); + } + + Widget _mathBlock(String tex) { + return Padding( + padding: EdgeInsets.symmetric(vertical: w * 0.012), + child: Math.tex( + tex, + textStyle: _applyFont(font, TextStyle(fontSize: w * 0.032)), + onErrorFallback: (err) => Text( + '\$\$$tex\$\$', + style: TextStyle( + fontFamily: 'monospace', + fontSize: w * 0.022, + color: Colors.red, + ), + ), + ), + ); + } +} + +/// Register highlight.js language definitions once, so [HighlightView] can +/// colour any common language without throwing. +bool _highlightReady = false; +void _ensureHighlightLanguages() { + if (_highlightReady) return; + allLanguages.forEach(highlight.registerLanguage); + _highlightReady = true; } // ── Shared helper ───────────────────────────────────────────────────────────── diff --git a/pubspec.lock b/pubspec.lock index 6635329..e4fd007 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -214,6 +214,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_highlight: + dependency: "direct main" + description: + name: flutter_highlight + sha256: "7b96333867aa07e122e245c033b8ad622e4e3a42a1a2372cbb098a2541d8782c" + url: "https://pub.dev" + source: hosted + version: "0.7.0" flutter_lints: dependency: "direct dev" description: @@ -222,6 +230,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_math_fork: + dependency: "direct main" + description: + name: flutter_math_fork + sha256: "6d5f2f1aa57ae539ffb0a04bb39d2da67af74601d685a161aff7ce5bda5fa407" + url: "https://pub.dev" + source: hosted + version: "0.7.4" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -238,6 +254,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.1" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -264,6 +288,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + highlight: + dependency: "direct main" + description: + name: highlight + sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21" + url: "https://pub.dev" + source: hosted + version: "0.7.0" hooks: dependency: transitive description: @@ -280,6 +312,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.15.6" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -408,6 +448,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" node_preamble: dependency: transitive description: @@ -552,6 +600,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" + provider: + dependency: transitive + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" pub_semver: dependency: transitive description: @@ -805,6 +861,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.17" + tuple: + dependency: transitive + description: + name: tuple + sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 + url: "https://pub.dev" + source: hosted + version: "2.0.2" typed_data: dependency: transitive description: @@ -893,6 +957,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.3" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "7ee12e6dffe0fc8e755179d6d91b3b34f5924223fc104d85572ef9180d73d172" + url: "https://pub.dev" + source: hosted + version: "1.2.5" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fff422a..a78b92b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,6 +25,9 @@ dependencies: url_launcher: ^6.3.0 desktop_drop: ^0.7.1 image: ^4.8.0 + flutter_highlight: ^0.7.0 + flutter_math_fork: ^0.7.4 + highlight: ^0.7.0 dev_dependencies: flutter_test: diff --git a/test/free_markdown_preview_test.dart b/test/free_markdown_preview_test.dart new file mode 100644 index 0000000..dbe4044 --- /dev/null +++ b/test/free_markdown_preview_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_highlight/flutter_highlight.dart'; +import 'package:flutter_math_fork/flutter_math.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/models/slide.dart'; +import 'package:ocideck/widgets/slides/slide_preview.dart'; + +Widget _host(Slide slide) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 800, + height: 450, + child: SlidePreviewWidget(slide: slide), + ), + ), + ); +} + +void main() { + testWidgets('free Markdown renders a highlighted code block', (tester) async { + final slide = Slide.create(SlideType.freeMarkdown).copyWith( + customMarkdown: '# Demo\n\n```dart\nvoid main() => print(42);\n```\n', + ); + await tester.pumpWidget(_host(slide)); + + expect(find.byType(HighlightView), findsOneWidget); + }); + + testWidgets('free Markdown renders display math', (tester) async { + final slide = Slide.create(SlideType.freeMarkdown).copyWith( + customMarkdown: 'Stelling:\n\n\$\$E = mc^2\$\$\n', + ); + await tester.pumpWidget(_host(slide)); + + expect(find.byType(Math), findsOneWidget); + }); + + testWidgets('an unknown code language falls back without throwing', ( + tester, + ) async { + final slide = Slide.create(SlideType.freeMarkdown).copyWith( + customMarkdown: '```nonexistentlang\nsome code\n```\n', + ); + await tester.pumpWidget(_host(slide)); + + // No HighlightView (unknown language) and no exception during build. + expect(find.byType(HighlightView), findsNothing); + expect(tester.takeException(), isNull); + expect(find.text('some code'), findsOneWidget); + }); +}