Merge pull request #338 from LemmurOrg/feature/markdown-editor

This commit is contained in:
Filip Krawczyk 2022-08-26 22:47:29 +02:00 committed by GitHub
commit eacbda0b5d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 1368 additions and 247 deletions

View File

@ -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)"
}
}

View File

@ -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

View File

@ -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>

125
lib/markdown_formatter.dart Normal file
View File

@ -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;
}
}

View File

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

View File

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

View File

@ -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';

View File

@ -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;
}
}

View File

@ -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()],
);
}
}

View File

@ -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),
);
}
}

View File

@ -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));
}

View File

@ -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 = '';
}
}

View File

@ -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}
''';
}
}

View File

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