From 363f55104ca792d866228a8b517cdf712c0d75d3 Mon Sep 17 00:00:00 2001 From: sembauke Date: Tue, 2 Jun 2026 08:54:29 +0200 Subject: [PATCH] Add copy action to code blocks --- .../views/news/html_handler/html_handler.dart | 103 ++++++++++++------ mobile-app/test/widget/html_handler_test.dart | 69 ++++++++++++ 2 files changed, 138 insertions(+), 34 deletions(-) create mode 100644 mobile-app/test/widget/html_handler_test.dart diff --git a/mobile-app/lib/ui/views/news/html_handler/html_handler.dart b/mobile-app/lib/ui/views/news/html_handler/html_handler.dart index fc9ed9abc..d31483bed 100644 --- a/mobile-app/lib/ui/views/news/html_handler/html_handler.dart +++ b/mobile-app/lib/ui/views/news/html_handler/html_handler.dart @@ -110,6 +110,23 @@ class HTMLParser { final BuildContext context; + void _copyToClipboard(String text, String copiedMessage) { + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: FccColors.gray75, + content: Text( + copiedMessage, + style: const TextStyle( + color: FccSemanticColors.foregroundPrimary, + fontSize: 20, + ), + ), + duration: const Duration(seconds: 1), + ), + ); + } + List parse( String html, { bool isSelectable = true, @@ -201,19 +218,59 @@ class HTMLParser { } List classes = codeElement.classes.toList(); + String codeText = codeElement.text.trimRight(); - return Editor( - options: EditorOptions( - fontFamily: 'Hack', - takeFullHeight: false, - isEditable: false, - showLinebar: false, + return Container( + color: FccColors.gray80, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Editor( + options: EditorOptions( + fontFamily: 'Hack', + takeFullHeight: false, + isEditable: false, + showLinebar: false, + ), + defaultLanguage: codeLanguageIsPresent(classes) + ? currentClass!.split('-')[1] + : '', + defaultValue: codeText, + path: 'example', + ), + ), + SelectionContainer.disabled( + child: Padding( + padding: const EdgeInsets.only(top: 4, right: 12), + child: Tooltip( + message: 'Copy code', + child: Semantics( + label: 'Copy code block', + button: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + _copyToClipboard( + codeText, + 'Code copied to clipboard!', + ); + }, + child: const SizedBox.square( + dimension: 28, + child: Icon( + Icons.copy, + size: 20, + color: FccSemanticColors.foregroundPrimary, + ), + ), + ), + ), + ), + ), + ), + ], ), - defaultLanguage: codeLanguageIsPresent(classes) - ? currentClass!.split('-')[1] - : '', - defaultValue: codeElement.text.trimRight(), - path: 'example', ); }, ), @@ -253,29 +310,7 @@ class HTMLParser { return InkWell( onTap: () { - Clipboard.setData(ClipboardData(text: parsed)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text.rich( - TextSpan( - children: [ - TextSpan( - text: parsed, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 20, - ), - ), - const TextSpan( - text: ' copied to clipboard!', - style: TextStyle(fontSize: 20), - ), - ], - ), - ), - duration: const Duration(seconds: 1), - ), - ); + _copyToClipboard(parsed, '$parsed copied to clipboard!'); }, child: Text( parsed, diff --git a/mobile-app/test/widget/html_handler_test.dart b/mobile-app/test/widget/html_handler_test.dart new file mode 100644 index 000000000..00a754270 --- /dev/null +++ b/mobile-app/test/widget/html_handler_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + String? clipboardText; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + clipboardText = null; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + switch (call.method) { + case 'Clipboard.setData': + clipboardText = call.arguments['text'] as String?; + return null; + case 'Clipboard.getData': + return {'text': clipboardText}; + } + + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + + Widget htmlParserFixture(String html) { + return MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + final parser = HTMLParser(context: context); + + return Column( + children: parser.parse(html), + ); + }, + ), + ), + ); + } + + testWidgets('copies the contents of a pre code block', (tester) async { + const code = '

Hello

\nconst answer = 42;'; + + await tester.pumpWidget( + htmlParserFixture( + '
<p>Hello</p>\nconst answer = 42;
', + ), + ); + await tester.pump(); + + expect(find.byTooltip('Copy code'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.copy)); + await tester.pump(); + + expect(clipboardText, code); + expect(find.text('Code copied to clipboard!'), findsOneWidget); + }); +}