diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 9034a2b..327ce54 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -415,5 +415,29 @@ "insert_text_here_placeholder": "[write text here]", "@insert_text_here_placeholder": { "description": "placeholder for text in markdown editor when inserting stuff like * for italics or ** for bold, etc." + }, + "select_user": "Select User", + "@select_user": { + "description": "Title on a popup that lets a user search and select another user" + }, + "select_community": "Select Community", + "@select_community": { + "description": "Title on a popup that lets a user search and select a community" + }, + "add_link": "Add link", + "@add_link": { + "description": "title on top of a link insertion popup in a markdown editor" + }, + "cancel": "Cancel", + "@cancel": { + "description": "Cancel button on popup" + }, + "editor_add_link_label": "label", + "@editor_add_link_label": { + "description": "palceholder for link label on an Add link popup in markdown editor" + }, + "failed_to_upload_image": "Failed to upload image", + "@failed_to_upload_image": { + "description": "shows up on a snackbar when the image upload failed (duh)" } } diff --git a/lib/formatter.dart b/lib/markdown_formatter.dart similarity index 67% rename from lib/formatter.dart rename to lib/markdown_formatter.dart index a1ddf55..6c621f9 100644 --- a/lib/formatter.dart +++ b/lib/markdown_formatter.dart @@ -1,9 +1,9 @@ import 'package:flutter/services.dart'; extension Utilities on String { - int getBeginningOfTheLine(int startingIndex) { - if (startingIndex <= 0) return 0; - for (var i = startingIndex; i >= 0; i--) { + int getBeginningOfTheLine(int from) { + if (from <= 0) return 0; + for (var i = from; i >= 0; i--) { if (this[i] == '\n') return i + 1; } return 0; @@ -17,9 +17,9 @@ extension Utilities on String { return length - 1; } - // returns the line that ends at endingIndex - String lineBefore(int endingIndex) { - return substring(getBeginningOfTheLine(endingIndex), endingIndex + 1); + /// returns the line that ends at endingIndex + String lineUpTo(int characterIndex) { + return substring(getBeginningOfTheLine(characterIndex), characterIndex + 1); } } @@ -39,7 +39,7 @@ extension on TextEditingValue { } } -/// Provides convinience formatting in markdown text fields +/// Provides convenience formatting in markdown text fields class MarkdownFormatter extends TextInputFormatter { @override TextEditingValue formatEditUpdate( @@ -52,9 +52,10 @@ class MarkdownFormatter extends TextInputFormatter { if (char == '\n') { final lineBefore = - newValue.text.lineBefore(newValue.selection.baseOffset - 2); + newValue.text.lineUpTo(newValue.selection.baseOffset - 2); - TextEditingValue listContinuation(String listChar, TextEditingValue tev) { + TextEditingValue unorderedListContinuation( + String listChar, TextEditingValue tev) { final regex = RegExp(r'(\s*)' '${RegExp.escape(listChar)} '); final match = regex.matchAsPrefix(lineBefore); if (match == null) { @@ -65,7 +66,7 @@ class MarkdownFormatter extends TextInputFormatter { return tev.append('$indent$listChar '); } - TextEditingValue numberedListContinuation( + TextEditingValue orderedListContinuation( String afterNumberChar, TextEditingValue tev) { final regex = RegExp(r'(\s*)(\d+)' '${RegExp.escape(afterNumberChar)} '); @@ -74,15 +75,16 @@ class MarkdownFormatter extends TextInputFormatter { return tev; } final indent = match.group(1); - final number = int.parse(match.group(2)!) + 1; + final number = int.tryParse(match.group(2)!) ?? 0 + 1; return tev.append('$indent$number$afterNumberChar '); } - newVal = listContinuation('-', newVal); - newVal = listContinuation('*', newVal); - newVal = numberedListContinuation('.', newVal); - newVal = numberedListContinuation(')', newVal); + newVal = unorderedListContinuation('-', newVal); + newVal = unorderedListContinuation('*', newVal); + newVal = unorderedListContinuation('+', newVal); + newVal = orderedListContinuation('.', newVal); + newVal = orderedListContinuation(')', newVal); } return newVal; diff --git a/lib/pages/create_post/create_post.dart b/lib/pages/create_post/create_post.dart index 79c8799..8a74e8e 100644 --- a/lib/pages/create_post/create_post.dart +++ b/lib/pages/create_post/create_post.dart @@ -134,7 +134,7 @@ class CreatePostPage extends HookWidget { ) ], ), - Toolbar.safeArea, + EditorToolbar.safeArea, ].spaced(6), ), ), @@ -145,7 +145,7 @@ class CreatePostPage extends HookWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Spacer(), - Toolbar( + EditorToolbar( controller: bodyController, instanceHost: context.read().instanceHost, ), diff --git a/lib/widgets/editor/editor.dart b/lib/widgets/editor/editor.dart index 651179a..be4b99e 100644 --- a/lib/widgets/editor/editor.dart +++ b/lib/widgets/editor/editor.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import '../../formatter.dart'; +import '../../markdown_formatter.dart'; import '../markdown_text.dart'; export 'editor_toolbar.dart'; @@ -52,22 +52,18 @@ class Editor extends HookWidget { ); } - return Stack( - children: [ - TextField( - controller: actualController, - focusNode: focusNode, - autofocus: autofocus, - keyboardType: TextInputType.multiline, - textCapitalization: TextCapitalization.sentences, - onChanged: onChanged, - onSubmitted: onSubmitted, - maxLines: maxLines, - minLines: minLines, - decoration: InputDecoration(labelText: labelText), - inputFormatters: [MarkdownFormatter()], - ), - ], + return TextField( + controller: actualController, + focusNode: focusNode, + autofocus: autofocus, + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + onChanged: onChanged, + onSubmitted: onSubmitted, + maxLines: maxLines, + minLines: minLines, + decoration: InputDecoration(labelText: labelText), + inputFormatters: [MarkdownFormatter()], ); } } diff --git a/lib/widgets/editor/editor_picking_dialog.dart b/lib/widgets/editor/editor_picking_dialog.dart index df9cc26..9fe7239 100644 --- a/lib/widgets/editor/editor_picking_dialog.dart +++ b/lib/widgets/editor/editor_picking_dialog.dart @@ -1,15 +1,15 @@ import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:lemmy_api_client/v3.dart'; import 'package:provider/provider.dart'; import '../../../util/extensions/api.dart'; import '../../../widgets/avatar.dart'; +import '../../l10n/l10n.dart'; import '../../stores/accounts_store.dart'; import 'editor_toolbar_store.dart'; -class PickPersonDialog extends HookWidget { +class PickPersonDialog extends StatelessWidget { final EditorToolbarStore store; const PickPersonDialog._(this.store); @@ -20,7 +20,7 @@ class PickPersonDialog extends HookWidget { context.read().defaultUserDataFor(store.instanceHost); return AlertDialog( - title: const Text('Select User'), + title: Text(L10n.of(context).select_user), content: TypeAheadField( suggestionsCallback: (pattern) async { if (pattern.trim().isEmpty) return const Iterable.empty(); @@ -70,7 +70,7 @@ class PickPersonDialog extends HookWidget { } } -class PickCommunityDialog extends HookWidget { +class PickCommunityDialog extends StatelessWidget { final EditorToolbarStore store; const PickCommunityDialog._(this.store); @@ -81,7 +81,7 @@ class PickCommunityDialog extends HookWidget { context.read().defaultUserDataFor(store.instanceHost); return AlertDialog( - title: const Text('Select Community'), + title: Text(L10n.of(context).select_community), content: TypeAheadField( suggestionsCallback: (pattern) async { if (pattern.trim().isEmpty) return const Iterable.empty(); diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart index 1488b7f..74895ea 100644 --- a/lib/widgets/editor/editor_toolbar.dart +++ b/lib/widgets/editor/editor_toolbar.dart @@ -1,11 +1,9 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:logging/logging.dart'; -import '../../formatter.dart'; import '../../hooks/logged_in_action.dart'; import '../../l10n/l10n.dart'; +import '../../markdown_formatter.dart'; import '../../resources/links.dart'; import '../../url_launcher.dart'; import '../../util/async_store_listener.dart'; @@ -18,127 +16,17 @@ import '../../util/text_lines_iterator.dart'; import 'editor_picking_dialog.dart'; import 'editor_toolbar_store.dart'; -class Reformat { +class _Reformat { final String text; final int selectionBeginningShift; final int selectionEndingShift; - Reformat({ + _Reformat({ required this.text, this.selectionBeginningShift = 0, this.selectionEndingShift = 0, }); } -extension on TextEditingController { - String get selectionText => - text.substring(selection.baseOffset, selection.extentOffset); - String get beforeSelectionText => text.substring(0, selection.baseOffset); - String get afterSelectionText => text.substring(selection.extentOffset); - - /// surroungs selection with given strings. If nothing is selected, placeholder is used in the middle - void surround({ - required String before, - required String placeholder, - - /// after = before if null - String? after, - }) { - after ??= before; - final beg = text.substring(0, selection.baseOffset); - final mid = () { - final m = text.substring(selection.baseOffset, selection.extentOffset); - if (m.isEmpty) return placeholder; - return m; - }(); - final end = text.substring(selection.extentOffset); - - value = value.copyWith( - text: '$beg$before$mid$after$end', - selection: selection.copyWith( - baseOffset: selection.baseOffset + before.length, - extentOffset: selection.baseOffset + before.length + mid.length, - )); - } - - String get firstSelectedLine { - if (text.isEmpty) return ''; - return text.substring(text.getBeginningOfTheLine(selection.start - 1), - text.getEndOfTheLine(selection.end)); - } - - void insertAtBeginningOfFirstSelectedLine(String s) { - final lines = TextLinesIterator.fromController(this)..moveNext(); - lines.current = s + lines.current; - value = value.copyWith( - text: lines.text, - selection: selection.copyWith( - baseOffset: selection.baseOffset + s.length, - extentOffset: selection.extentOffset + s.length, - ), - ); - } - - void removeAtBeginningOfEverySelectedLine(String s) { - final lines = TextLinesIterator.fromController(this); - var linesCount = 0; - while (lines.moveNext()) { - if (lines.isWithinSelection) { - if (lines.current.startsWith(RegExp.escape(s))) { - lines.current = lines.current.substring(s.length); - linesCount++; - } - } - } - - value = value.copyWith( - text: lines.text, - selection: selection.copyWith( - baseOffset: selection.baseOffset - s.length, - extentOffset: selection.extentOffset - s.length * linesCount, - ), - ); - } - - void insertAtBeginningOfEverySelectedLine(String s) { - final lines = TextLinesIterator.fromController(this); - var linesCount = 0; - while (lines.moveNext()) { - if (lines.isWithinSelection) { - if (!lines.current.startsWith(RegExp.escape(s))) { - lines.current = '$s${lines.current}'; - linesCount++; - } - } - } - - value = value.copyWith( - text: lines.text, - selection: selection.copyWith( - baseOffset: selection.baseOffset + s.length, - extentOffset: selection.extentOffset + s.length * linesCount, - ), - ); - } - - void reformat(Reformat Function(String selection) reformatter) { - final beg = beforeSelectionText; - final mid = selectionText; - final end = afterSelectionText; - - final r = reformatter(mid); - value = value.copyWith( - text: '$beg${r.text}$end', - selection: selection.copyWith( - baseOffset: selection.baseOffset + r.selectionBeginningShift, - extentOffset: selection.extentOffset + r.selectionEndingShift, - ), - ); - } - - void reformatSimple(String text) => - reformat((selection) => Reformat(text: text)); -} - enum HeaderLevel { h1(1), h2(2), @@ -151,21 +39,21 @@ enum HeaderLevel { final int value; } -class Toolbar extends HookWidget { +class EditorToolbar extends StatelessWidget { final TextEditingController controller; final String instanceHost; final EditorToolbarStore store; static const _height = 50.0; - Toolbar({ + EditorToolbar({ required this.controller, required this.instanceHost, }) : store = EditorToolbarStore(instanceHost); @override Widget build(BuildContext context) { - return MobxProvider.value( - value: store, + return MobxProvider( + create: (context) => store, child: AsyncStoreListener( asyncStore: store.imageUploadState, child: Container( @@ -235,22 +123,19 @@ class _ToolbarBody extends HookWidget { return; } try { - // FIXME: for some reason it doesn't go past this line on iOS. idk why final pic = await pickImage(); // pic is null when the picker was cancelled if (pic != null) { - final picUrl = await context - .read() - .uploadImage(pic.path, token); + final picUrl = await store.uploadImage(pic.path, token); if (picUrl != null) { controller.reformatSimple('![]($picUrl)'); } } } on Exception catch (_) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to upload image'))); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(L10n.of(context).failed_to_upload_image))); } }), icon: store.imageUploadState.isLoading @@ -366,9 +251,7 @@ class _ToolbarBody extends HookWidget { onPressed: () { controller.reformat((selection) { final insides = selection.isNotEmpty ? selection : '___'; - Logger.root - .info([21, 21 + insides.length, insides, insides.length]); - return Reformat( + return _Reformat( text: '\n::: spoiler spoiler\n$insides\n:::\n', selectionBeginningShift: 21, selectionEndingShift: 21 + insides.length - selection.length, @@ -391,31 +274,31 @@ class _ToolbarBody extends HookWidget { } class AddLinkDialog extends HookWidget { - final String title; + final String label; final String url; final String selection; static final _websiteRegex = RegExp(r'https?:\/\/', caseSensitive: false); AddLinkDialog(this.selection) - : title = selection.startsWith(_websiteRegex) ? '' : selection, + : label = selection.startsWith(_websiteRegex) ? '' : selection, url = selection.startsWith(_websiteRegex) ? selection : ''; @override Widget build(BuildContext context) { - final titleController = useTextEditingController(text: title); + final labelController = useTextEditingController(text: label); final urlController = useTextEditingController(text: url); void submit() { final link = () { - if (urlController.text.startsWith('http?s://')) { + if (urlController.text.startsWith(RegExp('https?://'))) { return urlController.text; } else { return 'https://${urlController.text}'; } }(); - final finalString = '(${titleController.text})[$link]'; - Navigator.of(context).pop(Reformat( + final finalString = '[${labelController.text}]($link)'; + Navigator.of(context).pop(_Reformat( text: finalString, selectionBeginningShift: finalString.length, selectionEndingShift: finalString.length - selection.length, @@ -423,13 +306,14 @@ class AddLinkDialog extends HookWidget { } return AlertDialog( - title: const Text('Add link'), + title: Text(L10n.of(context).add_link), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( - controller: titleController, - decoration: const InputDecoration(hintText: 'title'), + controller: labelController, + decoration: InputDecoration( + hintText: L10n.of(context).editor_add_link_label), textInputAction: TextInputAction.next, autofocus: true, ), @@ -444,19 +328,129 @@ class AddLinkDialog extends HookWidget { actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel')), + child: Text(L10n.of(context).cancel)), ElevatedButton( onPressed: submit, - child: const Text('Add link'), + child: Text(L10n.of(context).add_link), ) ], ); } - static Future show(BuildContext context, String selection) async { + static Future<_Reformat?> show(BuildContext context, String selection) async { return showDialog( context: context, builder: (context) => AddLinkDialog(selection), ); } } + +extension on TextEditingController { + String get selectionText => + text.substring(selection.baseOffset, selection.extentOffset); + String get beforeSelectionText => text.substring(0, selection.baseOffset); + String get afterSelectionText => text.substring(selection.extentOffset); + + /// surrounds selection with given strings. If nothing is selected, placeholder is used in the middle + void surround({ + required String before, + required String placeholder, + + /// after = before if null + String? after, + }) { + after ??= before; + final beg = text.substring(0, selection.baseOffset); + final mid = () { + final m = text.substring(selection.baseOffset, selection.extentOffset); + if (m.isEmpty) return placeholder; + return m; + }(); + final end = text.substring(selection.extentOffset); + + value = value.copyWith( + text: '$beg$before$mid$after$end', + selection: selection.copyWith( + baseOffset: selection.baseOffset + before.length, + extentOffset: selection.baseOffset + before.length + mid.length, + )); + } + + String get firstSelectedLine { + if (text.isEmpty) return ''; + return text.substring(text.getBeginningOfTheLine(selection.start - 1), + text.getEndOfTheLine(selection.end)); + } + + void insertAtBeginningOfFirstSelectedLine(String s) { + final lines = TextLinesIterator.fromController(this)..moveNext(); + lines.current = s + lines.current; + value = value.copyWith( + text: lines.text, + selection: selection.copyWith( + baseOffset: selection.baseOffset + s.length, + extentOffset: selection.extentOffset + s.length, + ), + ); + } + + void removeAtBeginningOfEverySelectedLine(String s) { + final lines = TextLinesIterator.fromController(this); + var linesCount = 0; + while (lines.moveNext()) { + if (lines.isWithinSelection) { + if (lines.current.startsWith(RegExp.escape(s))) { + lines.current = lines.current.substring(s.length); + linesCount++; + } + } + } + + value = value.copyWith( + text: lines.text, + selection: selection.copyWith( + baseOffset: selection.baseOffset - s.length, + extentOffset: selection.extentOffset - s.length * linesCount, + ), + ); + } + + void insertAtBeginningOfEverySelectedLine(String s) { + final lines = TextLinesIterator.fromController(this); + var linesCount = 0; + while (lines.moveNext()) { + if (lines.isWithinSelection) { + if (!lines.current.startsWith(RegExp.escape(s))) { + lines.current = '$s${lines.current}'; + linesCount++; + } + } + } + + value = value.copyWith( + text: lines.text, + selection: selection.copyWith( + baseOffset: selection.baseOffset + s.length, + extentOffset: selection.extentOffset + s.length * linesCount, + ), + ); + } + + void reformat(_Reformat Function(String selection) reformatter) { + final beg = beforeSelectionText; + final mid = selectionText; + final end = afterSelectionText; + + final r = reformatter(mid); + value = value.copyWith( + text: '$beg${r.text}$end', + selection: selection.copyWith( + baseOffset: selection.baseOffset + r.selectionBeginningShift, + extentOffset: selection.extentOffset + r.selectionEndingShift, + ), + ); + } + + void reformatSimple(String text) => + reformat((selection) => _Reformat(text: text)); +} diff --git a/lib/widgets/write_comment.dart b/lib/widgets/write_comment.dart index 3256b77..25d29f3 100644 --- a/lib/widgets/write_comment.dart +++ b/lib/widgets/write_comment.dart @@ -132,7 +132,7 @@ class WriteComment extends HookWidget { ) ], ), - Toolbar.safeArea, + EditorToolbar.safeArea, ], ), SafeArea( @@ -140,7 +140,7 @@ class WriteComment extends HookWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Spacer(), - Toolbar( + EditorToolbar( controller: controller, instanceHost: post.instanceHost, ),