Merge pull request #338 from LemmurOrg/feature/markdown-editor
This commit is contained in:
commit
eacbda0b5d
|
@ -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)"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -53,5 +53,7 @@
|
|||
<string>For uploading images for posts/avatars</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>For recording videos for posts</string>
|
||||
</dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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<CreatePostStore>().instanceHost,
|
||||
text: context.read<CreatePostStore>().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<CreatePostStore>(
|
||||
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<CreatePostStore>().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<CreatePostStore>(
|
||||
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<CreatePostStore>().isEdit) ...const [
|
||||
CreatePostInstancePicker(),
|
||||
CreatePostCommunityPicker(),
|
||||
],
|
||||
CreatePostUrlField(titleFocusNode),
|
||||
title,
|
||||
body,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ObserverBuilder<CreatePostStore>(
|
||||
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<CreatePostStore>(
|
||||
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<CreatePostStore>(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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<String> get asList {
|
||||
reset();
|
||||
final list = <String>[];
|
||||
while (moveNext()) {
|
||||
list.add(current);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
|
@ -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<String>? onSubmitted;
|
||||
final ValueChanged<String>? 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()],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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<AccountsStore>().defaultUserDataFor(store.instanceHost);
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(L10n.of(context).select_user),
|
||||
content: TypeAheadField<PersonViewSafe>(
|
||||
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<PersonViewSafe?> show(BuildContext context) async {
|
||||
final store = context.read<EditorToolbarStore>();
|
||||
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<AccountsStore>().defaultUserDataFor(store.instanceHost);
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(L10n.of(context).select_community),
|
||||
content: TypeAheadField<CommunityView>(
|
||||
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<CommunityView?> show(BuildContext context) async {
|
||||
final store = context.read<EditorToolbarStore>();
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => PickCommunityDialog._(store),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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<EditorToolbarStore>().imageUploadState,
|
||||
child: AnimatedSwitcher(
|
||||
duration: kThemeAnimationDuration,
|
||||
transitionBuilder: (child, animation) {
|
||||
final offsetAnimation =
|
||||
Tween<Offset>(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<EditorToolbarStore>(
|
||||
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<HeaderLevel>(
|
||||
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));
|
||||
}
|
|
@ -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<PictrsUploadFile>();
|
||||
|
||||
@computed
|
||||
bool get hasUploadedImage => imageUploadState.map(
|
||||
loading: () => false,
|
||||
error: (_) => false,
|
||||
data: (_) => true,
|
||||
);
|
||||
|
||||
@action
|
||||
Future<String?> 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<PictrsUploadFile?>(
|
||||
data: (data) => data,
|
||||
loading: () => null,
|
||||
error: (_) => null,
|
||||
);
|
||||
if (pictrsFile == null) return;
|
||||
|
||||
PictrsApi(instanceHost).delete(pictrsFile).catchError((_) {});
|
||||
|
||||
imageUploadState.reset();
|
||||
url = '';
|
||||
}
|
||||
}
|
|
@ -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<bool>? _$hasUploadedImageComputed;
|
||||
|
||||
@override
|
||||
bool get hasUploadedImage => (_$hasUploadedImageComputed ??= Computed<bool>(
|
||||
() => 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<String?> 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}
|
||||
''';
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
Loading…
Reference in New Issue