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),
+ ),
],
),
);