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": "No communities found",
|
||||||
"@no_communities_found": {},
|
"@no_communities_found": {},
|
||||||
"network_error": "Network error",
|
"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:
|
SPEC CHECKSUMS:
|
||||||
DKImagePickerController: 72fd378f244cef3d27288e0aebf217a4467e4012
|
DKImagePickerController: 72fd378f244cef3d27288e0aebf217a4467e4012
|
||||||
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
|
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
|
||||||
file_picker: 3e6c3790de664ccf9b882732d9db5eaf6b8d4eb1
|
file_picker: 817ab1d8cd2da9d2da412a417162deee3500fc95
|
||||||
Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a
|
Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a
|
||||||
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
|
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
|
||||||
image_picker_ios: b786a5dcf033a8336a657191401bfdf12017dabb
|
image_picker_ios: b786a5dcf033a8336a657191401bfdf12017dabb
|
||||||
|
|
|
@ -53,5 +53,7 @@
|
||||||
<string>For uploading images for posts/avatars</string>
|
<string>For uploading images for posts/avatars</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>For recording videos for posts</string>
|
<string>For recording videos for posts</string>
|
||||||
</dict>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
</plist>
|
</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/extensions/spaced.dart';
|
||||||
import '../../util/mobx_provider.dart';
|
import '../../util/mobx_provider.dart';
|
||||||
import '../../util/observer_consumers.dart';
|
import '../../util/observer_consumers.dart';
|
||||||
import '../../widgets/editor.dart';
|
import '../../widgets/editor/editor.dart';
|
||||||
import '../../widgets/markdown_mode_icon.dart';
|
import '../../widgets/markdown_mode_icon.dart';
|
||||||
import 'create_post_community_picker.dart';
|
import 'create_post_community_picker.dart';
|
||||||
import 'create_post_instance_picker.dart';
|
import 'create_post_instance_picker.dart';
|
||||||
|
@ -30,8 +30,10 @@ class CreatePostPage extends HookWidget {
|
||||||
useStore((CreatePostStore store) => store.instanceHost),
|
useStore((CreatePostStore store) => store.instanceHost),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final editorController = useEditorController(
|
||||||
|
instanceHost: context.read<CreatePostStore>().instanceHost,
|
||||||
|
text: context.read<CreatePostStore>().body);
|
||||||
final titleFocusNode = useFocusNode();
|
final titleFocusNode = useFocusNode();
|
||||||
final bodyFocusNode = useFocusNode();
|
|
||||||
|
|
||||||
handleSubmit(Jwt token) async {
|
handleSubmit(Jwt token) async {
|
||||||
if (formKey.currentState!.validate()) {
|
if (formKey.currentState!.validate()) {
|
||||||
|
@ -47,7 +49,6 @@ class CreatePostPage extends HookWidget {
|
||||||
textCapitalization: TextCapitalization.sentences,
|
textCapitalization: TextCapitalization.sentences,
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
validator: Validators.required(L10n.of(context).required_field),
|
validator: Validators.required(L10n.of(context).required_field),
|
||||||
onFieldSubmitted: (_) => bodyFocusNode.requestFocus(),
|
|
||||||
onChanged: (title) => store.title = title,
|
onChanged: (title) => store.title = title,
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
|
@ -57,11 +58,9 @@ class CreatePostPage extends HookWidget {
|
||||||
|
|
||||||
final body = ObserverBuilder<CreatePostStore>(
|
final body = ObserverBuilder<CreatePostStore>(
|
||||||
builder: (context, store) => Editor(
|
builder: (context, store) => Editor(
|
||||||
initialValue: store.body,
|
controller: editorController,
|
||||||
focusNode: bodyFocusNode,
|
|
||||||
onChanged: (body) => store.body = body,
|
onChanged: (body) => store.body = body,
|
||||||
labelText: L10n.of(context).body,
|
labelText: L10n.of(context).body,
|
||||||
instanceHost: store.instanceHost,
|
|
||||||
fancy: store.showFancy,
|
fancy: store.showFancy,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -82,59 +81,68 @@ class CreatePostPage extends HookWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: Stack(
|
||||||
child: SingleChildScrollView(
|
children: [
|
||||||
padding: const EdgeInsets.all(5),
|
SafeArea(
|
||||||
child: Form(
|
child: SingleChildScrollView(
|
||||||
key: formKey,
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
child: Column(
|
padding: const EdgeInsets.all(5),
|
||||||
children: [
|
child: Form(
|
||||||
if (!context.read<CreatePostStore>().isEdit) ...const [
|
key: formKey,
|
||||||
CreatePostInstancePicker(),
|
child: Column(
|
||||||
CreatePostCommunityPicker(),
|
|
||||||
],
|
|
||||||
CreatePostUrlField(titleFocusNode),
|
|
||||||
title,
|
|
||||||
body,
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
children: [
|
||||||
ObserverBuilder<CreatePostStore>(
|
if (!context.read<CreatePostStore>().isEdit) ...const [
|
||||||
builder: (context, store) => GestureDetector(
|
CreatePostInstancePicker(),
|
||||||
onTap: () => store.nsfw = !store.nsfw,
|
CreatePostCommunityPicker(),
|
||||||
child: Row(
|
],
|
||||||
children: [
|
CreatePostUrlField(titleFocusNode),
|
||||||
Checkbox(
|
title,
|
||||||
value: store.nsfw,
|
body,
|
||||||
onChanged: (val) {
|
Row(
|
||||||
if (val != null) store.nsfw = val;
|
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>(
|
EditorToolbar.safeArea,
|
||||||
builder: (context, store) => TextButton(
|
].spaced(6),
|
||||||
onPressed: store.submitState.isLoading
|
|
||||||
? () {}
|
|
||||||
: loggedInAction(handleSubmit),
|
|
||||||
child: store.submitState.isLoading
|
|
||||||
? const CircularProgressIndicator.adaptive()
|
|
||||||
: Text(
|
|
||||||
store.isEdit
|
|
||||||
? L10n.of(context).edit
|
|
||||||
: L10n.of(context).post,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
].spaced(6),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
BottomSticky(
|
||||||
|
child: EditorToolbar(editorController),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -13,7 +13,7 @@ import '../util/pictrs.dart';
|
||||||
import '../widgets/bottom_modal.dart';
|
import '../widgets/bottom_modal.dart';
|
||||||
import '../widgets/bottom_safe.dart';
|
import '../widgets/bottom_safe.dart';
|
||||||
import '../widgets/cached_network_image.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
|
/// Page for managing things like username, email, avatar etc
|
||||||
/// This page will assume the manage account is logged in and
|
/// This page will assume the manage account is logged in and
|
||||||
|
@ -99,7 +99,6 @@ class _ManageAccount extends HookWidget {
|
||||||
|
|
||||||
final displayNameController =
|
final displayNameController =
|
||||||
useTextEditingController(text: user.person.displayName);
|
useTextEditingController(text: user.person.displayName);
|
||||||
final bioController = useTextEditingController(text: user.person.bio);
|
|
||||||
final emailController =
|
final emailController =
|
||||||
useTextEditingController(text: user.localUser.email);
|
useTextEditingController(text: user.localUser.email);
|
||||||
final matrixUserController =
|
final matrixUserController =
|
||||||
|
@ -122,13 +121,15 @@ class _ManageAccount extends HookWidget {
|
||||||
|
|
||||||
final deleteAccountPasswordController = useTextEditingController();
|
final deleteAccountPasswordController = useTextEditingController();
|
||||||
|
|
||||||
final bioFocusNode = useFocusNode();
|
|
||||||
final emailFocusNode = useFocusNode();
|
final emailFocusNode = useFocusNode();
|
||||||
final matrixUserFocusNode = useFocusNode();
|
final matrixUserFocusNode = useFocusNode();
|
||||||
final newPasswordFocusNode = useFocusNode();
|
final newPasswordFocusNode = useFocusNode();
|
||||||
// final verifyPasswordFocusNode = useFocusNode();
|
// final verifyPasswordFocusNode = useFocusNode();
|
||||||
// final oldPasswordFocusNode = useFocusNode();
|
// final oldPasswordFocusNode = useFocusNode();
|
||||||
|
|
||||||
|
final bioController = useEditorController(
|
||||||
|
instanceHost: user.instanceHost, text: user.person.bio);
|
||||||
|
|
||||||
final token =
|
final token =
|
||||||
accountsStore.userDataFor(user.instanceHost, user.person.name)!.jwt;
|
accountsStore.userDataFor(user.instanceHost, user.person.name)!.jwt;
|
||||||
|
|
||||||
|
@ -156,7 +157,9 @@ class _ManageAccount extends HookWidget {
|
||||||
displayName: displayNameController.text.isEmpty
|
displayName: displayNameController.text.isEmpty
|
||||||
? null
|
? null
|
||||||
: displayNameController.text,
|
: 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,
|
email: emailController.text.isEmpty ? null : emailController.text,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
@ -234,150 +237,157 @@ class _ManageAccount extends HookWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView(
|
return Stack(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
|
||||||
children: [
|
children: [
|
||||||
_ImagePicker(
|
ListView(
|
||||||
user: user,
|
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||||
name: L10n.of(context).avatar,
|
children: [
|
||||||
initialUrl: avatar.value,
|
_ImagePicker(
|
||||||
onChange: (value) => avatar.value = value,
|
user: user,
|
||||||
informAcceptedRef: informAcceptedAvatarRef,
|
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),
|
BottomSticky(
|
||||||
_ImagePicker(
|
child: EditorToolbar(bioController),
|
||||||
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(),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
const lemmurRepositoryUrl = 'https://github.com/LemmurOrg/lemmur';
|
const lemmurRepositoryUrl = 'https://github.com/LemmurOrg/lemmur';
|
||||||
const patreonUrl = 'https://patreon.com/lemmur';
|
const patreonUrl = 'https://patreon.com/lemmur';
|
||||||
const buyMeACoffeeUrl = 'https://buymeacoff.ee/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/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.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
|
/// A text field with added functionality for ease of editing
|
||||||
class Editor extends HookWidget {
|
class Editor extends HookWidget {
|
||||||
final TextEditingController? controller;
|
final EditorController controller;
|
||||||
final FocusNode? focusNode;
|
|
||||||
final ValueChanged<String>? onSubmitted;
|
final ValueChanged<String>? onSubmitted;
|
||||||
final ValueChanged<String>? onChanged;
|
final ValueChanged<String>? onChanged;
|
||||||
final int? minLines;
|
final int? minLines;
|
||||||
|
@ -17,12 +44,10 @@ class Editor extends HookWidget {
|
||||||
|
|
||||||
/// Whether the editor should be preview the contents
|
/// Whether the editor should be preview the contents
|
||||||
final bool fancy;
|
final bool fancy;
|
||||||
final String instanceHost;
|
|
||||||
|
|
||||||
const Editor({
|
const Editor({
|
||||||
super.key,
|
super.key,
|
||||||
this.controller,
|
required this.controller,
|
||||||
this.focusNode,
|
|
||||||
this.onSubmitted,
|
this.onSubmitted,
|
||||||
this.onChanged,
|
this.onChanged,
|
||||||
this.minLines = 5,
|
this.minLines = 5,
|
||||||
|
@ -30,28 +55,24 @@ class Editor extends HookWidget {
|
||||||
this.labelText,
|
this.labelText,
|
||||||
this.initialValue,
|
this.initialValue,
|
||||||
this.fancy = false,
|
this.fancy = false,
|
||||||
required this.instanceHost,
|
|
||||||
this.autofocus = false,
|
this.autofocus = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final defaultController = useTextEditingController(text: initialValue);
|
|
||||||
final actualController = controller ?? defaultController;
|
|
||||||
|
|
||||||
if (fancy) {
|
if (fancy) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
child: MarkdownText(
|
child: MarkdownText(
|
||||||
actualController.text,
|
controller.textEditingController.text,
|
||||||
instanceHost: instanceHost,
|
instanceHost: controller.instanceHost,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return TextField(
|
return TextField(
|
||||||
controller: actualController,
|
focusNode: controller.focusNode,
|
||||||
focusNode: focusNode,
|
controller: controller.textEditingController,
|
||||||
autofocus: autofocus,
|
autofocus: autofocus,
|
||||||
keyboardType: TextInputType.multiline,
|
keyboardType: TextInputType.multiline,
|
||||||
textCapitalization: TextCapitalization.sentences,
|
textCapitalization: TextCapitalization.sentences,
|
||||||
|
@ -60,6 +81,7 @@ class Editor extends HookWidget {
|
||||||
maxLines: maxLines,
|
maxLines: maxLines,
|
||||||
minLines: minLines,
|
minLines: minLines,
|
||||||
decoration: InputDecoration(labelText: labelText),
|
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/delayed_loading.dart';
|
||||||
import '../hooks/logged_in_action.dart';
|
import '../hooks/logged_in_action.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
import 'editor.dart';
|
import 'editor/editor.dart';
|
||||||
import 'markdown_mode_icon.dart';
|
import 'markdown_mode_icon.dart';
|
||||||
import 'markdown_text.dart';
|
import 'markdown_text.dart';
|
||||||
|
|
||||||
|
@ -31,12 +31,15 @@ class WriteComment extends HookWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final controller =
|
|
||||||
useTextEditingController(text: _isEdit ? comment?.content : null);
|
|
||||||
final showFancy = useState(false);
|
final showFancy = useState(false);
|
||||||
final delayed = useDelayedLoading();
|
final delayed = useDelayedLoading();
|
||||||
final loggedInAction = useLoggedInAction(post.instanceHost);
|
final loggedInAction = useLoggedInAction(post.instanceHost);
|
||||||
|
|
||||||
|
final editorController = useEditorController(
|
||||||
|
instanceHost: post.instanceHost,
|
||||||
|
text: _isEdit ? comment?.content : null,
|
||||||
|
);
|
||||||
|
|
||||||
final preview = () {
|
final preview = () {
|
||||||
final body = () {
|
final body = () {
|
||||||
final text = comment?.content ?? post.body;
|
final text = comment?.content ?? post.body;
|
||||||
|
@ -69,12 +72,12 @@ class WriteComment extends HookWidget {
|
||||||
if (_isEdit) {
|
if (_isEdit) {
|
||||||
return api.run(EditComment(
|
return api.run(EditComment(
|
||||||
commentId: comment!.id,
|
commentId: comment!.id,
|
||||||
content: controller.text,
|
content: editorController.textEditingController.text,
|
||||||
auth: token.raw,
|
auth: token.raw,
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
return api.run(CreateComment(
|
return api.run(CreateComment(
|
||||||
content: controller.text,
|
content: editorController.textEditingController.text,
|
||||||
postId: post.id,
|
postId: post.id,
|
||||||
parentId: comment?.id,
|
parentId: comment?.id,
|
||||||
auth: token.raw,
|
auth: token.raw,
|
||||||
|
@ -99,37 +102,44 @@ class WriteComment extends HookWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
ConstrainedBox(
|
ListView(
|
||||||
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,
|
|
||||||
children: [
|
children: [
|
||||||
TextButton(
|
ConstrainedBox(
|
||||||
onPressed:
|
constraints: BoxConstraints(
|
||||||
delayed.pending ? () {} : loggedInAction(handleSubmit),
|
maxHeight: MediaQuery.of(context).size.height * .35),
|
||||||
child: delayed.loading
|
child: SingleChildScrollView(
|
||||||
? const CircularProgressIndicator.adaptive()
|
padding: const EdgeInsets.all(8),
|
||||||
: Text(_isEdit
|
child: preview,
|
||||||
? L10n.of(context).edit
|
),
|
||||||
: L10n.of(context).post),
|
),
|
||||||
)
|
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