diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 989d925..327ce54 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -351,5 +351,93 @@ "no_communities_found": "No communities found", "@no_communities_found": {}, "network_error": "Network error", - "@network_error": {} + "@network_error": {}, + "editor_bold": "bold", + "@editor_bold": { + "description": "tooltip for button making text bold in markdown editor toolbar" + }, + "editor_italics": "italics", + "@editor_italics": { + "description": "tooltip for button making text italics in markdown editor toolbar" + }, + "editor_link": "insert link", + "@editor_link": { + "description": "tooltip for button that inserts link in markdown editor toolbar" + }, + "editor_image": "insert image", + "@editor_image": { + "description": "tooltip for button that inserts image in markdown editor toolbar" + }, + "editor_user": "link user", + "@editor_user": { + "description": "tooltip for button that opens a popup to select user to be linked in markdown editor toolbar" + }, + "editor_community": "link community", + "@editor_community": { + "description": "tooltip for button that opens a popup to select community to be linked in markdown editor toolbar" + }, + "editor_header": "insert header", + "@editor_header": { + "description": "tooltip for button that inserts header in markdown editor toolbar" + }, + "editor_strikethrough": "strikethrough", + "@editor_strikethrough": { + "description": "tooltip for button that makes text strikethrough in markdown editor toolbar" + }, + "editor_quote": "quote", + "@editor_quote": { + "description": "tooltip for button that makes selected text into quote blocks in markdown editor toolbar" + }, + "editor_list": "list", + "@editor_list": { + "description": "tooltip for button that makes selected text into list in markdown editor toolbar" + }, + "editor_code": "code", + "@editor_code": { + "description": "tooltip for button that makes text into code in markdown editor toolbar" + }, + "editor_subscript": "subscript", + "@editor_subscript": { + "description": "tooltip for button that makes text into subscript in markdown editor toolbar" + }, + "editor_superscript": "superscript", + "@editor_superscript": { + "description": "tooltip for button that makes text into superscript in markdown editor toolbar" + }, + "editor_spoiler": "spoiler", + "@editor_spoiler": { + "description": "tooltip for button that inserts spoiler in markdown editor toolbar" + }, + "editor_help": "markdown guide", + "@editor_help": { + "description": "tooltip for button that goes to page containing a guide for markdown" + }, + "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/ios/Podfile.lock b/ios/Podfile.lock index 051e876..4a96e4e 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -94,7 +94,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: DKImagePickerController: 72fd378f244cef3d27288e0aebf217a4467e4012 DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 - file_picker: 3e6c3790de664ccf9b882732d9db5eaf6b8d4eb1 + file_picker: 817ab1d8cd2da9d2da412a417162deee3500fc95 Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 image_picker_ios: b786a5dcf033a8336a657191401bfdf12017dabb diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 7e54588..ac91e64 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -53,5 +53,7 @@ For uploading images for posts/avatars NSMicrophoneUsageDescription For recording videos for posts - + CADisableMinimumFrameDurationOnPhone + + diff --git a/lib/markdown_formatter.dart b/lib/markdown_formatter.dart new file mode 100644 index 0000000..fb65b1a --- /dev/null +++ b/lib/markdown_formatter.dart @@ -0,0 +1,125 @@ +import 'package:flutter/services.dart'; + +const unorderedListTypes = ['*', '+', '-']; +const orderedListTypes = [')', '.']; + +extension Utilities on String { + 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; + } + + int getEndOfTheLine(int from) { + for (var i = from; i < length; i++) { + if (this[i] == '\n') return i + 1; + } + + return length; + } + + /// returns the line that ends at endingIndex + String lineUpTo(int characterIndex) { + return substring(getBeginningOfTheLine(characterIndex), characterIndex + 1); + } +} + +extension on TextEditingValue { + /// Append a string after the cursor + TextEditingValue append(String s) { + final beg = text.substring(0, selection.baseOffset); + final end = text.substring(selection.baseOffset); + + return copyWith( + text: '$beg$s$end', + selection: selection.copyWith( + baseOffset: selection.baseOffset + s.length, + extentOffset: selection.extentOffset + s.length, + ), + ); + } + + /// cuts [characterCount] number of chars from before the cursor + TextEditingValue trimBeforeCursor(int characterCount) { + final beg = text.substring(0, selection.baseOffset); + final end = text.substring(selection.baseOffset); + + return copyWith( + text: beg.substring(0, beg.length - characterCount - 1) + end, + selection: selection.copyWith( + baseOffset: selection.baseOffset - characterCount, + extentOffset: selection.extentOffset - characterCount, + )); + } +} + +/// Provides convenience formatting in markdown text fields +class MarkdownFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, TextEditingValue newValue) { + if (oldValue.text.length > newValue.text.length) return newValue; + + var newVal = newValue; + + final char = newValue.text[newValue.selection.baseOffset - 1]; + + if (char == '\n') { + final lineBefore = + newValue.text.lineUpTo(newValue.selection.baseOffset - 2); + + TextEditingValue unorderedListContinuation( + String listChar, TextEditingValue tev) { + final regex = RegExp(r'(\s*)' '${RegExp.escape(listChar)} (.*)'); + final match = regex.matchAsPrefix(lineBefore); + + if (match == null) { + return tev; + } + + final listItemBody = match.group(2); + final indent = match.group(1); + + if (listItemBody == null || listItemBody.isEmpty) { + return tev.trimBeforeCursor(listChar.length + (indent?.length ?? 1)); + } + + return tev.append('$indent$listChar '); + } + + TextEditingValue orderedListContinuation( + String afterNumberChar, TextEditingValue tev) { + final regex = + RegExp(r'(\s*)(\d+)' '${RegExp.escape(afterNumberChar)} (.*)'); + final match = regex.matchAsPrefix(lineBefore); + + if (match == null) { + return tev; + } + + final listItemBody = match.group(3)!; + final indent = match.group(1)!; + final numberStr = match.group(2)!; + + if (listItemBody.isEmpty) { + return tev.trimBeforeCursor( + indent.length + numberStr.length + afterNumberChar.length + 1); + } + + final number = (int.tryParse(match.group(2)!) ?? 0) + 1; + return tev.append('$indent$number$afterNumberChar '); + } + + for (final c in unorderedListTypes) { + newVal = unorderedListContinuation(c, newVal); + } + for (final c in orderedListTypes) { + newVal = orderedListContinuation(c, newVal); + } + } + + return newVal; + } +} diff --git a/lib/pages/create_post/create_post.dart b/lib/pages/create_post/create_post.dart index eb016ec..5f504c8 100644 --- a/lib/pages/create_post/create_post.dart +++ b/lib/pages/create_post/create_post.dart @@ -11,7 +11,7 @@ import '../../util/async_store_listener.dart'; import '../../util/extensions/spaced.dart'; import '../../util/mobx_provider.dart'; import '../../util/observer_consumers.dart'; -import '../../widgets/editor.dart'; +import '../../widgets/editor/editor.dart'; import '../../widgets/markdown_mode_icon.dart'; import 'create_post_community_picker.dart'; import 'create_post_instance_picker.dart'; @@ -30,8 +30,10 @@ class CreatePostPage extends HookWidget { useStore((CreatePostStore store) => store.instanceHost), ); + final editorController = useEditorController( + instanceHost: context.read().instanceHost, + text: context.read().body); final titleFocusNode = useFocusNode(); - final bodyFocusNode = useFocusNode(); handleSubmit(Jwt token) async { if (formKey.currentState!.validate()) { @@ -47,7 +49,6 @@ class CreatePostPage extends HookWidget { textCapitalization: TextCapitalization.sentences, textInputAction: TextInputAction.next, validator: Validators.required(L10n.of(context).required_field), - onFieldSubmitted: (_) => bodyFocusNode.requestFocus(), onChanged: (title) => store.title = title, minLines: 1, maxLines: 2, @@ -57,11 +58,9 @@ class CreatePostPage extends HookWidget { final body = ObserverBuilder( builder: (context, store) => Editor( - initialValue: store.body, - focusNode: bodyFocusNode, + controller: editorController, onChanged: (body) => store.body = body, labelText: L10n.of(context).body, - instanceHost: store.instanceHost, fancy: store.showFancy, ), ); @@ -82,59 +81,68 @@ class CreatePostPage extends HookWidget { ), ], ), - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.all(5), - child: Form( - key: formKey, - child: Column( - children: [ - if (!context.read().isEdit) ...const [ - CreatePostInstancePicker(), - CreatePostCommunityPicker(), - ], - CreatePostUrlField(titleFocusNode), - title, - body, - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + body: Stack( + children: [ + SafeArea( + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(5), + child: Form( + key: formKey, + child: Column( children: [ - ObserverBuilder( - builder: (context, store) => GestureDetector( - onTap: () => store.nsfw = !store.nsfw, - child: Row( - children: [ - Checkbox( - value: store.nsfw, - onChanged: (val) { - if (val != null) store.nsfw = val; - }, + if (!context.read().isEdit) ...const [ + CreatePostInstancePicker(), + CreatePostCommunityPicker(), + ], + CreatePostUrlField(titleFocusNode), + title, + body, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ObserverBuilder( + builder: (context, store) => GestureDetector( + onTap: () => store.nsfw = !store.nsfw, + child: Row( + children: [ + Checkbox( + value: store.nsfw, + onChanged: (val) { + if (val != null) store.nsfw = val; + }, + ), + Text(L10n.of(context).nsfw) + ], ), - Text(L10n.of(context).nsfw) - ], + ), ), - ), + ObserverBuilder( + builder: (context, store) => TextButton( + onPressed: store.submitState.isLoading + ? () {} + : loggedInAction(handleSubmit), + child: store.submitState.isLoading + ? const CircularProgressIndicator.adaptive() + : Text( + store.isEdit + ? L10n.of(context).edit + : L10n.of(context).post, + ), + ), + ) + ], ), - ObserverBuilder( - builder: (context, store) => TextButton( - onPressed: store.submitState.isLoading - ? () {} - : loggedInAction(handleSubmit), - child: store.submitState.isLoading - ? const CircularProgressIndicator.adaptive() - : Text( - store.isEdit - ? L10n.of(context).edit - : L10n.of(context).post, - ), - ), - ) - ], + EditorToolbar.safeArea, + ].spaced(6), ), - ].spaced(6), + ), ), ), - ), + BottomSticky( + child: EditorToolbar(editorController), + ), + ], ), ), ); diff --git a/lib/pages/manage_account.dart b/lib/pages/manage_account.dart index e9a5121..8edc551 100644 --- a/lib/pages/manage_account.dart +++ b/lib/pages/manage_account.dart @@ -13,7 +13,7 @@ import '../util/pictrs.dart'; import '../widgets/bottom_modal.dart'; import '../widgets/bottom_safe.dart'; import '../widgets/cached_network_image.dart'; -import '../widgets/editor.dart'; +import '../widgets/editor/editor.dart'; /// Page for managing things like username, email, avatar etc /// This page will assume the manage account is logged in and @@ -99,7 +99,6 @@ class _ManageAccount extends HookWidget { final displayNameController = useTextEditingController(text: user.person.displayName); - final bioController = useTextEditingController(text: user.person.bio); final emailController = useTextEditingController(text: user.localUser.email); final matrixUserController = @@ -122,13 +121,15 @@ class _ManageAccount extends HookWidget { final deleteAccountPasswordController = useTextEditingController(); - final bioFocusNode = useFocusNode(); final emailFocusNode = useFocusNode(); final matrixUserFocusNode = useFocusNode(); final newPasswordFocusNode = useFocusNode(); // final verifyPasswordFocusNode = useFocusNode(); // final oldPasswordFocusNode = useFocusNode(); + final bioController = useEditorController( + instanceHost: user.instanceHost, text: user.person.bio); + final token = accountsStore.userDataFor(user.instanceHost, user.person.name)!.jwt; @@ -156,7 +157,9 @@ class _ManageAccount extends HookWidget { displayName: displayNameController.text.isEmpty ? null : displayNameController.text, - bio: bioController.text.isEmpty ? null : bioController.text, + bio: bioController.textEditingController.text.isEmpty + ? null + : bioController.textEditingController.text, email: emailController.text.isEmpty ? null : emailController.text, )); @@ -234,150 +237,157 @@ class _ManageAccount extends HookWidget { } } - return ListView( - padding: const EdgeInsets.symmetric(horizontal: 15), + return Stack( children: [ - _ImagePicker( - user: user, - name: L10n.of(context).avatar, - initialUrl: avatar.value, - onChange: (value) => avatar.value = value, - informAcceptedRef: informAcceptedAvatarRef, + ListView( + padding: const EdgeInsets.symmetric(horizontal: 15), + children: [ + _ImagePicker( + user: user, + name: L10n.of(context).avatar, + initialUrl: avatar.value, + onChange: (value) => avatar.value = value, + informAcceptedRef: informAcceptedAvatarRef, + ), + const SizedBox(height: 8), + _ImagePicker( + user: user, + name: L10n.of(context).banner, + initialUrl: banner.value, + onChange: (value) => banner.value = value, + informAcceptedRef: informAcceptedBannerRef, + ), + const SizedBox(height: 8), + Text(L10n.of(context).display_name, + style: theme.textTheme.headline6), + TextField( + controller: displayNameController, + onSubmitted: (_) => bioController.focusNode.requestFocus(), + ), + const SizedBox(height: 8), + Text(L10n.of(context).bio, style: theme.textTheme.headline6), + Editor( + controller: bioController, + onSubmitted: (_) => emailFocusNode.requestFocus(), + maxLines: 10, + ), + const SizedBox(height: 8), + Text(L10n.of(context).email, style: theme.textTheme.headline6), + TextField( + focusNode: emailFocusNode, + controller: emailController, + autofillHints: const [AutofillHints.email], + keyboardType: TextInputType.emailAddress, + onSubmitted: (_) => matrixUserFocusNode.requestFocus(), + ), + const SizedBox(height: 8), + Text(L10n.of(context).matrix_user, + style: theme.textTheme.headline6), + TextField( + focusNode: matrixUserFocusNode, + controller: matrixUserController, + onSubmitted: (_) => newPasswordFocusNode.requestFocus(), + ), + const SizedBox(height: 8), + // Text(L10n.of(context)!.new_password, style: theme.textTheme.headline6), + // TextField( + // focusNode: newPasswordFocusNode, + // controller: newPasswordController, + // autofillHints: const [AutofillHints.newPassword], + // keyboardType: TextInputType.visiblePassword, + // obscureText: true, + // onSubmitted: (_) => verifyPasswordFocusNode.requestFocus(), + // ), + // const SizedBox(height: 8), + // Text(L10n.of(context)!.verify_password, + // style: theme.textTheme.headline6), + // TextField( + // focusNode: verifyPasswordFocusNode, + // controller: newPasswordVerifyController, + // autofillHints: const [AutofillHints.newPassword], + // keyboardType: TextInputType.visiblePassword, + // obscureText: true, + // onSubmitted: (_) => oldPasswordFocusNode.requestFocus(), + // ), + // const SizedBox(height: 8), + // Text(L10n.of(context)!.old_password, style: theme.textTheme.headline6), + // TextField( + // focusNode: oldPasswordFocusNode, + // controller: oldPasswordController, + // autofillHints: const [AutofillHints.password], + // keyboardType: TextInputType.visiblePassword, + // obscureText: true, + // ), + // const SizedBox(height: 8), + SwitchListTile.adaptive( + value: showNsfw.value, + onChanged: (checked) { + showNsfw.value = checked; + }, + title: Text(L10n.of(context).show_nsfw), + dense: true, + ), + const SizedBox(height: 8), + SwitchListTile.adaptive( + value: botAccount.value, + onChanged: (checked) { + botAccount.value = checked; + }, + title: Text(L10n.of(context).bot_account), + dense: true, + ), + const SizedBox(height: 8), + SwitchListTile.adaptive( + value: showBotAccounts.value, + onChanged: (checked) { + showBotAccounts.value = checked; + }, + title: Text(L10n.of(context).show_bot_accounts), + dense: true, + ), + const SizedBox(height: 8), + SwitchListTile.adaptive( + value: showReadPosts.value, + onChanged: (checked) { + showReadPosts.value = checked; + }, + title: Text(L10n.of(context).show_read_posts), + dense: true, + ), + const SizedBox(height: 8), + SwitchListTile.adaptive( + value: sendNotificationsToEmail.value, + onChanged: (checked) { + sendNotificationsToEmail.value = checked; + }, + title: Text(L10n.of(context).send_notifications_to_email), + dense: true, + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: saveDelayedLoading.loading ? null : handleSubmit, + child: saveDelayedLoading.loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator.adaptive(), + ) + : Text(L10n.of(context).save), + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: deleteAccountDialog, + style: ElevatedButton.styleFrom( + primary: Colors.red, + ), + child: Text(L10n.of(context).delete_account.toUpperCase()), + ), + const BottomSafe(), + ], ), - const SizedBox(height: 8), - _ImagePicker( - user: user, - name: L10n.of(context).banner, - initialUrl: banner.value, - onChange: (value) => banner.value = value, - informAcceptedRef: informAcceptedBannerRef, - ), - const SizedBox(height: 8), - Text(L10n.of(context).display_name, style: theme.textTheme.headline6), - TextField( - controller: displayNameController, - onSubmitted: (_) => bioFocusNode.requestFocus(), - ), - const SizedBox(height: 8), - Text(L10n.of(context).bio, style: theme.textTheme.headline6), - Editor( - controller: bioController, - focusNode: bioFocusNode, - onSubmitted: (_) => emailFocusNode.requestFocus(), - instanceHost: user.instanceHost, - maxLines: 10, - ), - const SizedBox(height: 8), - Text(L10n.of(context).email, style: theme.textTheme.headline6), - TextField( - focusNode: emailFocusNode, - controller: emailController, - autofillHints: const [AutofillHints.email], - keyboardType: TextInputType.emailAddress, - onSubmitted: (_) => matrixUserFocusNode.requestFocus(), - ), - const SizedBox(height: 8), - Text(L10n.of(context).matrix_user, style: theme.textTheme.headline6), - TextField( - focusNode: matrixUserFocusNode, - controller: matrixUserController, - onSubmitted: (_) => newPasswordFocusNode.requestFocus(), - ), - const SizedBox(height: 8), - // Text(L10n.of(context)!.new_password, style: theme.textTheme.headline6), - // TextField( - // focusNode: newPasswordFocusNode, - // controller: newPasswordController, - // autofillHints: const [AutofillHints.newPassword], - // keyboardType: TextInputType.visiblePassword, - // obscureText: true, - // onSubmitted: (_) => verifyPasswordFocusNode.requestFocus(), - // ), - // const SizedBox(height: 8), - // Text(L10n.of(context)!.verify_password, - // style: theme.textTheme.headline6), - // TextField( - // focusNode: verifyPasswordFocusNode, - // controller: newPasswordVerifyController, - // autofillHints: const [AutofillHints.newPassword], - // keyboardType: TextInputType.visiblePassword, - // obscureText: true, - // onSubmitted: (_) => oldPasswordFocusNode.requestFocus(), - // ), - // const SizedBox(height: 8), - // Text(L10n.of(context)!.old_password, style: theme.textTheme.headline6), - // TextField( - // focusNode: oldPasswordFocusNode, - // controller: oldPasswordController, - // autofillHints: const [AutofillHints.password], - // keyboardType: TextInputType.visiblePassword, - // obscureText: true, - // ), - // const SizedBox(height: 8), - SwitchListTile.adaptive( - value: showNsfw.value, - onChanged: (checked) { - showNsfw.value = checked; - }, - title: Text(L10n.of(context).show_nsfw), - dense: true, - ), - const SizedBox(height: 8), - SwitchListTile.adaptive( - value: botAccount.value, - onChanged: (checked) { - botAccount.value = checked; - }, - title: Text(L10n.of(context).bot_account), - dense: true, - ), - const SizedBox(height: 8), - SwitchListTile.adaptive( - value: showBotAccounts.value, - onChanged: (checked) { - showBotAccounts.value = checked; - }, - title: Text(L10n.of(context).show_bot_accounts), - dense: true, - ), - const SizedBox(height: 8), - SwitchListTile.adaptive( - value: showReadPosts.value, - onChanged: (checked) { - showReadPosts.value = checked; - }, - title: Text(L10n.of(context).show_read_posts), - dense: true, - ), - const SizedBox(height: 8), - SwitchListTile.adaptive( - value: sendNotificationsToEmail.value, - onChanged: (checked) { - sendNotificationsToEmail.value = checked; - }, - title: Text(L10n.of(context).send_notifications_to_email), - dense: true, - ), - const SizedBox(height: 8), - ElevatedButton( - onPressed: saveDelayedLoading.loading ? null : handleSubmit, - child: saveDelayedLoading.loading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator.adaptive(), - ) - : Text(L10n.of(context).save), - ), - const SizedBox(height: 8), - ElevatedButton( - onPressed: deleteAccountDialog, - style: ElevatedButton.styleFrom( - primary: Colors.red, - ), - child: Text(L10n.of(context).delete_account.toUpperCase()), - ), - const BottomSafe(), + BottomSticky( + child: EditorToolbar(bioController), + ) ], ); } diff --git a/lib/resources/links.dart b/lib/resources/links.dart index ed26356..34bab73 100644 --- a/lib/resources/links.dart +++ b/lib/resources/links.dart @@ -1,3 +1,5 @@ const lemmurRepositoryUrl = 'https://github.com/LemmurOrg/lemmur'; const patreonUrl = 'https://patreon.com/lemmur'; const buyMeACoffeeUrl = 'https://buymeacoff.ee/lemmur'; +const markdownGuide = + 'https://join-lemmy.org/docs/en/about/guide.html#using-markdown'; diff --git a/lib/util/text_lines_iterator.dart b/lib/util/text_lines_iterator.dart new file mode 100644 index 0000000..1a46e2b --- /dev/null +++ b/lib/util/text_lines_iterator.dart @@ -0,0 +1,89 @@ +import 'package:flutter/widgets.dart'; + +/// utililty class for traversing through multiline text +class TextLinesIterator extends Iterator { + String text; + int beg; + int end; + TextSelection? selection; + + TextLinesIterator(this.text, {this.selection}) + : end = -1, + beg = -1; + + TextLinesIterator.fromController(TextEditingController controller) + : this(controller.text, selection: controller.selection); + + bool get isWithinSelection { + final selection = this.selection; + if (selection == null || beg == -1) { + return false; + } else { + return (selection.end >= beg && beg >= selection.start) || + (selection.end >= end && end >= selection.start) || + (end >= selection.start && selection.start >= beg) || + (end >= selection.end && selection.end >= beg) || + (beg <= selection.start && + selection.start <= end && + beg <= selection.end && + selection.end <= end); + } + } + + @override + String get current { + return text.substring(beg, end); + } + + set current(String newVal) { + final selected = isWithinSelection; + text = text.replaceRange(beg, end, newVal); + final wordLen = end - beg; + final dif = newVal.length - wordLen; + end += dif; + + final selection = this.selection; + if (selection == null) return; + + if (selected || selection.baseOffset > end) { + this.selection = + selection.copyWith(extentOffset: selection.extentOffset + dif); + } + } + + void reset() { + end = -1; + beg = -1; + } + + @override + bool moveNext() { + if (end == text.length) { + return false; + } + if (beg == -1) { + end = 0; + beg = 0; + } else { + end += 1; + beg = end; + } + for (; end < text.length; end++) { + if (text[end] == '\n') { + return true; + } + } + end = text.length; + return true; + } + + /// returns the lines as a list but also moves the pointer to the back + List get asList { + reset(); + final list = []; + while (moveNext()) { + list.add(current); + } + return list; + } +} diff --git a/lib/widgets/editor.dart b/lib/widgets/editor/editor.dart similarity index 58% rename from lib/widgets/editor.dart rename to lib/widgets/editor/editor.dart index 145ab94..07536e1 100644 --- a/lib/widgets/editor.dart +++ b/lib/widgets/editor/editor.dart @@ -1,12 +1,39 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'markdown_text.dart'; +import '../../markdown_formatter.dart'; +import '../markdown_text.dart'; + +export 'editor_toolbar.dart'; + +class EditorController { + final TextEditingController textEditingController; + final FocusNode focusNode; + final String instanceHost; + + EditorController({ + required this.textEditingController, + required this.focusNode, + required this.instanceHost, + }); +} + +EditorController useEditorController({ + required String instanceHost, + String? text, +}) { + final focusNode = useFocusNode(); + final textEditingController = useTextEditingController(text: text); + return EditorController( + textEditingController: textEditingController, + focusNode: focusNode, + instanceHost: instanceHost); +} /// A text field with added functionality for ease of editing class Editor extends HookWidget { - final TextEditingController? controller; - final FocusNode? focusNode; + final EditorController controller; + final ValueChanged? onSubmitted; final ValueChanged? onChanged; final int? minLines; @@ -17,12 +44,10 @@ class Editor extends HookWidget { /// Whether the editor should be preview the contents final bool fancy; - final String instanceHost; const Editor({ super.key, - this.controller, - this.focusNode, + required this.controller, this.onSubmitted, this.onChanged, this.minLines = 5, @@ -30,28 +55,24 @@ class Editor extends HookWidget { this.labelText, this.initialValue, this.fancy = false, - required this.instanceHost, this.autofocus = false, }); @override Widget build(BuildContext context) { - final defaultController = useTextEditingController(text: initialValue); - final actualController = controller ?? defaultController; - if (fancy) { return Padding( padding: const EdgeInsets.all(8), child: MarkdownText( - actualController.text, - instanceHost: instanceHost, + controller.textEditingController.text, + instanceHost: controller.instanceHost, ), ); } return TextField( - controller: actualController, - focusNode: focusNode, + focusNode: controller.focusNode, + controller: controller.textEditingController, autofocus: autofocus, keyboardType: TextInputType.multiline, textCapitalization: TextCapitalization.sentences, @@ -60,6 +81,7 @@ class Editor extends HookWidget { 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 new file mode 100644 index 0000000..9fe7239 --- /dev/null +++ b/lib/widgets/editor/editor_picking_dialog.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.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 StatelessWidget { + final EditorToolbarStore store; + + const PickPersonDialog._(this.store); + + @override + Widget build(BuildContext context) { + final userData = + context.read().defaultUserDataFor(store.instanceHost); + + return AlertDialog( + title: Text(L10n.of(context).select_user), + content: TypeAheadField( + suggestionsCallback: (pattern) async { + if (pattern.trim().isEmpty) return const Iterable.empty(); + return LemmyApiV3(store.instanceHost) + .run(Search( + q: pattern, + auth: userData?.jwt.raw, + type: SearchType.users, + limit: 10, + )) + .then((value) => value.users); + }, + itemBuilder: (context, user) { + return ListTile( + leading: Avatar( + url: user.person.avatar, + radius: 20, + ), + title: Text(user.person.originPreferredName), + ); + }, + onSuggestionSelected: (suggestion) => + Navigator.of(context).pop(suggestion), + loadingBuilder: (context) => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Padding( + padding: EdgeInsets.all(16), + child: CircularProgressIndicator.adaptive(), + ), + ], + ), + keepSuggestionsOnLoading: false, + noItemsFoundBuilder: (context) => const SizedBox(), + hideOnEmpty: true, + textFieldConfiguration: const TextFieldConfiguration(autofocus: true), + ), + ); + } + + static Future show(BuildContext context) async { + final store = context.read(); + return showDialog( + context: context, + builder: (context) => PickPersonDialog._(store), + ); + } +} + +class PickCommunityDialog extends StatelessWidget { + final EditorToolbarStore store; + + const PickCommunityDialog._(this.store); + + @override + Widget build(BuildContext context) { + final userData = + context.read().defaultUserDataFor(store.instanceHost); + + return AlertDialog( + title: Text(L10n.of(context).select_community), + content: TypeAheadField( + suggestionsCallback: (pattern) async { + if (pattern.trim().isEmpty) return const Iterable.empty(); + return LemmyApiV3(store.instanceHost) + .run(Search( + q: pattern, + auth: userData?.jwt.raw, + type: SearchType.communities, + limit: 10, + )) + .then((value) => value.communities); + }, + itemBuilder: (context, community) { + return ListTile( + leading: Avatar( + url: community.community.icon, + radius: 20, + ), + title: Text(community.community.originPreferredName), + ); + }, + onSuggestionSelected: (suggestion) => + Navigator.of(context).pop(suggestion), + loadingBuilder: (context) => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Padding( + padding: EdgeInsets.all(16), + child: CircularProgressIndicator.adaptive(), + ), + ], + ), + keepSuggestionsOnLoading: false, + noItemsFoundBuilder: (context) => const SizedBox(), + hideOnEmpty: true, + textFieldConfiguration: const TextFieldConfiguration(autofocus: true), + ), + ); + } + + static Future show(BuildContext context) async { + final store = context.read(); + return showDialog( + context: context, + builder: (context) => PickCommunityDialog._(store), + ); + } +} diff --git a/lib/widgets/editor/editor_toolbar.dart b/lib/widgets/editor/editor_toolbar.dart new file mode 100644 index 0000000..f6ee42e --- /dev/null +++ b/lib/widgets/editor/editor_toolbar.dart @@ -0,0 +1,504 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.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'; +import '../../util/extensions/api.dart'; +import '../../util/extensions/spaced.dart'; +import '../../util/files.dart'; +import '../../util/mobx_provider.dart'; +import '../../util/observer_consumers.dart'; +import '../../util/text_lines_iterator.dart'; +import 'editor.dart'; +import 'editor_picking_dialog.dart'; +import 'editor_toolbar_store.dart'; + +class _Reformat { + final String text; + final int selectionBeginningShift; + final int selectionEndingShift; + _Reformat({ + required this.text, + this.selectionBeginningShift = 0, + this.selectionEndingShift = 0, + }); +} + +enum HeaderLevel { + h1(1), + h2(2), + h3(3), + h4(4), + h5(5), + h6(6); + + const HeaderLevel(this.value); + final int value; +} + +class EditorToolbar extends HookWidget { + final EditorController controller; + + static const _height = 50.0; + + const EditorToolbar(this.controller); + + @override + Widget build(BuildContext context) { + final visible = useListenable(controller.focusNode).hasFocus; + + return MobxProvider( + create: (context) => EditorToolbarStore(controller.instanceHost), + child: Builder(builder: (context) { + return AsyncStoreListener( + asyncStore: context.read().imageUploadState, + child: AnimatedSwitcher( + duration: kThemeAnimationDuration, + transitionBuilder: (child, animation) { + final offsetAnimation = + Tween(begin: const Offset(0, 1.5), end: Offset.zero) + .animate(animation); + + return SlideTransition( + position: offsetAnimation, + child: child, + ); + }, + child: visible + ? Material( + color: Theme.of(context).canvasColor, + child: SafeArea( + child: SizedBox( + height: _height, + width: double.infinity, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: _ToolbarBody( + controller: controller.textEditingController, + instanceHost: controller.instanceHost, + ), + ), + ), + ), + ) + : const SizedBox.shrink(), + ), + ); + }), + ); + } + + static const safeArea = SizedBox(height: _height); +} + +class BottomSticky extends StatelessWidget { + final Widget child; + const BottomSticky({required this.child}); + + @override + Widget build(BuildContext context) => Positioned( + bottom: 0, + child: SizedBox( + width: MediaQuery.of(context).size.width, + child: child, + ), + ); +} + +class _ToolbarBody extends HookWidget { + const _ToolbarBody({ + required this.controller, + required this.instanceHost, + }); + + final TextEditingController controller; + final String instanceHost; + + @override + Widget build(BuildContext context) { + final loggedInAction = useLoggedInAction(instanceHost); + return Row( + children: [ + IconButton( + onPressed: () => controller.surround( + before: '**', + placeholder: L10n.of(context).insert_text_here_placeholder), + icon: const Icon(Icons.format_bold), + tooltip: L10n.of(context).editor_bold, + ), + IconButton( + onPressed: () => controller.surround( + before: '*', + placeholder: L10n.of(context).insert_text_here_placeholder), + icon: const Icon(Icons.format_italic), + tooltip: L10n.of(context).editor_italics, + ), + IconButton( + onPressed: () async { + final r = + await AddLinkDialog.show(context, controller.selectionText); + if (r != null) controller.reformat((_) => r); + }, + icon: const Icon(Icons.link), + tooltip: L10n.of(context).editor_link, + ), + // Insert image + ObserverBuilder( + builder: (context, store) { + return IconButton( + onPressed: loggedInAction((token) async { + if (store.imageUploadState.isLoading) { + return; + } + try { + final pic = await pickImage(); + // pic is null when the picker was cancelled + + if (pic != null) { + final picUrl = await store.uploadImage(pic.path, token); + + if (picUrl != null) { + controller.reformatSimple('![]($picUrl)'); + } + } + } on Exception catch (_) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(L10n.of(context).failed_to_upload_image))); + } + }), + icon: store.imageUploadState.isLoading + ? const CircularProgressIndicator.adaptive() + : const Icon(Icons.image), + tooltip: L10n.of(context).editor_image, + ); + }, + ), + IconButton( + onPressed: () async { + final person = await PickPersonDialog.show(context); + + if (person != null) { + final name = + '@${person.person.name}@${person.person.originInstanceHost}'; + final link = person.person.actorId; + + controller.reformatSimple('[$name]($link)'); + } + }, + icon: const Icon(Icons.person), + tooltip: L10n.of(context).editor_user, + ), + IconButton( + onPressed: () async { + final community = await PickCommunityDialog.show(context); + if (community != null) { + final name = + '!${community.community.name}@${community.community.originInstanceHost}'; + final link = community.community.actorId; + + controller.reformatSimple('[$name]($link)'); + } + }, + icon: const Icon(Icons.home), + tooltip: L10n.of(context).editor_community, + ), + PopupMenuButton( + itemBuilder: (context) => [ + for (final h in HeaderLevel.values) + PopupMenuItem( + value: h, + child: Text(h.name.toUpperCase()), + onTap: () { + final header = '${'#' * h.value} '; + + if (!controller.firstSelectedLine.startsWith(header)) { + controller.insertAtBeginningOfFirstSelectedLine(header); + } + }, + ), + ], + tooltip: L10n.of(context).editor_header, + child: const Icon(Icons.h_mobiledata), + ), + IconButton( + onPressed: () => controller.surround( + before: '~~', + placeholder: L10n.of(context).insert_text_here_placeholder, + ), + icon: const Icon(Icons.format_strikethrough), + tooltip: L10n.of(context).editor_strikethrough, + ), + IconButton( + onPressed: () { + controller.insertAtBeginningOfEverySelectedLine('> '); + }, + icon: const Icon(Icons.format_quote), + tooltip: L10n.of(context).editor_quote, + ), + IconButton( + onPressed: () { + final line = controller.firstSelectedLine; + + // if theres a list in place, remove it + final listRemoved = () { + for (final c in unorderedListTypes) { + if (line.startsWith('$c ')) { + controller.removeAtBeginningOfEverySelectedLine('$c '); + return true; + } + } + return false; + }(); + + // if no list, then let's add one + if (!listRemoved) { + controller.insertAtBeginningOfEverySelectedLine( + '${unorderedListTypes.last} '); + } + }, + icon: const Icon(Icons.format_list_bulleted), + tooltip: L10n.of(context).editor_list, + ), + IconButton( + onPressed: () => controller.surround( + before: '`', + placeholder: L10n.of(context).insert_text_here_placeholder, + ), + icon: const Icon(Icons.code), + tooltip: L10n.of(context).editor_code, + ), + IconButton( + onPressed: () => controller.surround( + before: '~', + placeholder: L10n.of(context).insert_text_here_placeholder, + ), + icon: const Icon(Icons.subscript), + tooltip: L10n.of(context).editor_subscript, + ), + IconButton( + onPressed: () => controller.surround( + before: '^', + placeholder: L10n.of(context).insert_text_here_placeholder, + ), + icon: const Icon(Icons.superscript), + tooltip: L10n.of(context).editor_superscript, + ), + //spoiler + IconButton( + onPressed: () { + controller.reformat((selection) { + const textBeg = '\n::: spoiler spoiler\n'; + final textMid = selection.isNotEmpty ? selection : '___'; + const textEnd = '\n:::\n'; + + return _Reformat( + text: textBeg + textMid + textEnd, + selectionBeginningShift: textBeg.length, + selectionEndingShift: + textBeg.length + textMid.length - selection.length, + ); + }); + }, + icon: const Icon(Icons.warning), + tooltip: L10n.of(context).editor_spoiler, + ), + IconButton( + onPressed: () { + launchLink(link: markdownGuide, context: context); + }, + icon: const Icon(Icons.question_mark), + tooltip: L10n.of(context).editor_help, + ), + ], + ); + } +} + +class AddLinkDialog extends HookWidget { + final String label; + final String url; + final String selection; + + static final _websiteRegex = RegExp(r'https?:\/\/', caseSensitive: false); + + AddLinkDialog(this.selection) + : label = selection.startsWith(_websiteRegex) ? '' : selection, + url = selection.startsWith(_websiteRegex) ? selection : ''; + + @override + Widget build(BuildContext context) { + final labelController = useTextEditingController(text: label); + final urlController = useTextEditingController(text: url); + + void submit() { + final link = () { + if (urlController.text.startsWith(RegExp('https?://'))) { + return urlController.text; + } else { + return 'https://${urlController.text}'; + } + }(); + final finalString = '[${labelController.text}]($link)'; + Navigator.of(context).pop(_Reformat( + text: finalString, + selectionBeginningShift: finalString.length, + selectionEndingShift: finalString.length - selection.length, + )); + } + + return AlertDialog( + title: Text(L10n.of(context).add_link), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: labelController, + decoration: InputDecoration( + hintText: L10n.of(context).editor_add_link_label), + textInputAction: TextInputAction.next, + autofocus: true, + ), + TextField( + controller: urlController, + decoration: const InputDecoration(hintText: 'https://example.com'), + onEditingComplete: submit, + autocorrect: false, + ), + ].spaced(10), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(L10n.of(context).cancel)), + ElevatedButton( + onPressed: submit, + child: Text(L10n.of(context).add_link), + ) + ], + ); + } + + 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 ''; + } + final val = text.substring(text.getBeginningOfTheLine(selection.start - 1), + text.getEndOfTheLine(selection.end) - 1); + return val; + } + + 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(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(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/editor/editor_toolbar_store.dart b/lib/widgets/editor/editor_toolbar_store.dart new file mode 100644 index 0000000..d53f2b5 --- /dev/null +++ b/lib/widgets/editor/editor_toolbar_store.dart @@ -0,0 +1,63 @@ +import 'package:lemmy_api_client/pictrs.dart'; +import 'package:lemmy_api_client/v3.dart'; +import 'package:mobx/mobx.dart'; + +import '../../util/async_store.dart'; +import '../../util/pictrs.dart'; + +part 'editor_toolbar_store.g.dart'; + +class EditorToolbarStore = _EditorToolbarStore with _$EditorToolbarStore; + +abstract class _EditorToolbarStore with Store { + final String instanceHost; + + _EditorToolbarStore(this.instanceHost); + + @observable + String? url; + + final imageUploadState = AsyncStore(); + + @computed + bool get hasUploadedImage => imageUploadState.map( + loading: () => false, + error: (_) => false, + data: (_) => true, + ); + + @action + Future uploadImage(String filePath, Jwt token) async { + final instanceHost = this.instanceHost; + + final upload = await imageUploadState.run( + () => PictrsApi(instanceHost) + .upload( + filePath: filePath, + auth: token.raw, + ) + .then((value) => value.files.single), + ); + + if (upload != null) { + final url = pathToPictrs(instanceHost, upload.file); + return url; + } + return null; + } + + @action + void removeImage() { + final pictrsFile = imageUploadState.map( + data: (data) => data, + loading: () => null, + error: (_) => null, + ); + if (pictrsFile == null) return; + + PictrsApi(instanceHost).delete(pictrsFile).catchError((_) {}); + + imageUploadState.reset(); + url = ''; + } +} diff --git a/lib/widgets/editor/editor_toolbar_store.g.dart b/lib/widgets/editor/editor_toolbar_store.g.dart new file mode 100644 index 0000000..a8f256d --- /dev/null +++ b/lib/widgets/editor/editor_toolbar_store.g.dart @@ -0,0 +1,66 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'editor_toolbar_store.dart'; + +// ************************************************************************** +// StoreGenerator +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers + +mixin _$EditorToolbarStore on _EditorToolbarStore, Store { + Computed? _$hasUploadedImageComputed; + + @override + bool get hasUploadedImage => (_$hasUploadedImageComputed ??= Computed( + () => super.hasUploadedImage, + name: '_EditorToolbarStore.hasUploadedImage')) + .value; + + late final _$urlAtom = + Atom(name: '_EditorToolbarStore.url', context: context); + + @override + String? get url { + _$urlAtom.reportRead(); + return super.url; + } + + @override + set url(String? value) { + _$urlAtom.reportWrite(value, super.url, () { + super.url = value; + }); + } + + late final _$uploadImageAsyncAction = + AsyncAction('_EditorToolbarStore.uploadImage', context: context); + + @override + Future uploadImage(String filePath, Jwt token) { + return _$uploadImageAsyncAction + .run(() => super.uploadImage(filePath, token)); + } + + late final _$_EditorToolbarStoreActionController = + ActionController(name: '_EditorToolbarStore', context: context); + + @override + void removeImage() { + final _$actionInfo = _$_EditorToolbarStoreActionController.startAction( + name: '_EditorToolbarStore.removeImage'); + try { + return super.removeImage(); + } finally { + _$_EditorToolbarStoreActionController.endAction(_$actionInfo); + } + } + + @override + String toString() { + return ''' +url: ${url}, +hasUploadedImage: ${hasUploadedImage} + '''; + } +} diff --git a/lib/widgets/write_comment.dart b/lib/widgets/write_comment.dart index f7f7a5e..960a95b 100644 --- a/lib/widgets/write_comment.dart +++ b/lib/widgets/write_comment.dart @@ -5,7 +5,7 @@ import 'package:lemmy_api_client/v3.dart'; import '../hooks/delayed_loading.dart'; import '../hooks/logged_in_action.dart'; import '../l10n/l10n.dart'; -import 'editor.dart'; +import 'editor/editor.dart'; import 'markdown_mode_icon.dart'; import 'markdown_text.dart'; @@ -31,12 +31,15 @@ class WriteComment extends HookWidget { @override Widget build(BuildContext context) { - final controller = - useTextEditingController(text: _isEdit ? comment?.content : null); final showFancy = useState(false); final delayed = useDelayedLoading(); final loggedInAction = useLoggedInAction(post.instanceHost); + final editorController = useEditorController( + instanceHost: post.instanceHost, + text: _isEdit ? comment?.content : null, + ); + final preview = () { final body = () { final text = comment?.content ?? post.body; @@ -69,12 +72,12 @@ class WriteComment extends HookWidget { if (_isEdit) { return api.run(EditComment( commentId: comment!.id, - content: controller.text, + content: editorController.textEditingController.text, auth: token.raw, )); } else { return api.run(CreateComment( - content: controller.text, + content: editorController.textEditingController.text, postId: post.id, parentId: comment?.id, auth: token.raw, @@ -99,37 +102,44 @@ class WriteComment extends HookWidget { ), ], ), - body: ListView( + body: Stack( children: [ - ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * .35), - child: SingleChildScrollView( - padding: const EdgeInsets.all(8), - child: preview, - ), - ), - const Divider(), - Editor( - instanceHost: post.instanceHost, - controller: controller, - autofocus: true, - fancy: showFancy.value, - ), - Row( - mainAxisAlignment: MainAxisAlignment.end, + ListView( children: [ - TextButton( - onPressed: - delayed.pending ? () {} : loggedInAction(handleSubmit), - child: delayed.loading - ? const CircularProgressIndicator.adaptive() - : Text(_isEdit - ? L10n.of(context).edit - : L10n.of(context).post), - ) + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * .35), + child: SingleChildScrollView( + padding: const EdgeInsets.all(8), + child: preview, + ), + ), + const Divider(), + Editor( + controller: editorController, + autofocus: true, + fancy: showFancy.value, + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: + delayed.pending ? () {} : loggedInAction(handleSubmit), + child: delayed.loading + ? const CircularProgressIndicator.adaptive() + : Text(_isEdit + ? L10n.of(context).edit + : L10n.of(context).post), + ) + ], + ), + EditorToolbar.safeArea, ], ), + BottomSticky( + child: EditorToolbar(editorController), + ), ], ), );