CR Small Changes

* regex fix http?s -> https?
* file rename: formatter.dart -> markdown_formatter.dart to match the class name inside
* add + as continuoable list
* rename: listContinuation          -> unorderedListContinuation
           numberedListContinuation -> orderedListContinuation
* fix typo: convenience
* fix: doc instead of comment
* rename for readability: startingIndex -> from
* function & arg rename: lineBefore(int endingIndex) -> lineUpTo(int characterIndex)
* parse -> tryParse
* localize user picker & commmunity picker
* HookWidget -> StatelessWidget where needed
* Toolbar -> EditorToolbar for less ambiguity
* fix typo: surroungs -> surrounds
* remove debug logA
* more localization stuff
* title -> label on add link dialog
* Reformat -> _Reformat
* use store when in scope instead of context.read
* remove useless Stack (oops)
This commit is contained in:
Filip Krawczyk 2022-08-23 00:34:10 +02:00
parent 821558314e
commit cda72a1174
7 changed files with 197 additions and 181 deletions

View File

@ -415,5 +415,29 @@
"insert_text_here_placeholder": "[write text here]", "insert_text_here_placeholder": "[write text here]",
"@insert_text_here_placeholder": { "@insert_text_here_placeholder": {
"description": "placeholder for text in markdown editor when inserting stuff like * for italics or ** for bold, etc." "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

@ -1,9 +1,9 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
extension Utilities on String { extension Utilities on String {
int getBeginningOfTheLine(int startingIndex) { int getBeginningOfTheLine(int from) {
if (startingIndex <= 0) return 0; if (from <= 0) return 0;
for (var i = startingIndex; i >= 0; i--) { for (var i = from; i >= 0; i--) {
if (this[i] == '\n') return i + 1; if (this[i] == '\n') return i + 1;
} }
return 0; return 0;
@ -17,9 +17,9 @@ extension Utilities on String {
return length - 1; return length - 1;
} }
// returns the line that ends at endingIndex /// returns the line that ends at endingIndex
String lineBefore(int endingIndex) { String lineUpTo(int characterIndex) {
return substring(getBeginningOfTheLine(endingIndex), endingIndex + 1); return substring(getBeginningOfTheLine(characterIndex), characterIndex + 1);
} }
} }
@ -39,7 +39,7 @@ extension on TextEditingValue {
} }
} }
/// Provides convinience formatting in markdown text fields /// Provides convenience formatting in markdown text fields
class MarkdownFormatter extends TextInputFormatter { class MarkdownFormatter extends TextInputFormatter {
@override @override
TextEditingValue formatEditUpdate( TextEditingValue formatEditUpdate(
@ -52,9 +52,10 @@ class MarkdownFormatter extends TextInputFormatter {
if (char == '\n') { if (char == '\n') {
final lineBefore = final lineBefore =
newValue.text.lineBefore(newValue.selection.baseOffset - 2); newValue.text.lineUpTo(newValue.selection.baseOffset - 2);
TextEditingValue listContinuation(String listChar, TextEditingValue tev) { TextEditingValue unorderedListContinuation(
String listChar, TextEditingValue tev) {
final regex = RegExp(r'(\s*)' '${RegExp.escape(listChar)} '); final regex = RegExp(r'(\s*)' '${RegExp.escape(listChar)} ');
final match = regex.matchAsPrefix(lineBefore); final match = regex.matchAsPrefix(lineBefore);
if (match == null) { if (match == null) {
@ -65,7 +66,7 @@ class MarkdownFormatter extends TextInputFormatter {
return tev.append('$indent$listChar '); return tev.append('$indent$listChar ');
} }
TextEditingValue numberedListContinuation( TextEditingValue orderedListContinuation(
String afterNumberChar, TextEditingValue tev) { String afterNumberChar, TextEditingValue tev) {
final regex = final regex =
RegExp(r'(\s*)(\d+)' '${RegExp.escape(afterNumberChar)} '); RegExp(r'(\s*)(\d+)' '${RegExp.escape(afterNumberChar)} ');
@ -74,15 +75,16 @@ class MarkdownFormatter extends TextInputFormatter {
return tev; return tev;
} }
final indent = match.group(1); final indent = match.group(1);
final number = int.parse(match.group(2)!) + 1; final number = int.tryParse(match.group(2)!) ?? 0 + 1;
return tev.append('$indent$number$afterNumberChar '); return tev.append('$indent$number$afterNumberChar ');
} }
newVal = listContinuation('-', newVal); newVal = unorderedListContinuation('-', newVal);
newVal = listContinuation('*', newVal); newVal = unorderedListContinuation('*', newVal);
newVal = numberedListContinuation('.', newVal); newVal = unorderedListContinuation('+', newVal);
newVal = numberedListContinuation(')', newVal); newVal = orderedListContinuation('.', newVal);
newVal = orderedListContinuation(')', newVal);
} }
return newVal; return newVal;

View File

@ -134,7 +134,7 @@ class CreatePostPage extends HookWidget {
) )
], ],
), ),
Toolbar.safeArea, EditorToolbar.safeArea,
].spaced(6), ].spaced(6),
), ),
), ),
@ -145,7 +145,7 @@ class CreatePostPage extends HookWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Spacer(), const Spacer(),
Toolbar( EditorToolbar(
controller: bodyController, controller: bodyController,
instanceHost: context.read<CreatePostStore>().instanceHost, instanceHost: context.read<CreatePostStore>().instanceHost,
), ),

View File

@ -1,7 +1,7 @@
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 '../../formatter.dart'; import '../../markdown_formatter.dart';
import '../markdown_text.dart'; import '../markdown_text.dart';
export 'editor_toolbar.dart'; export 'editor_toolbar.dart';
@ -52,22 +52,18 @@ class Editor extends HookWidget {
); );
} }
return Stack( return TextField(
children: [ controller: actualController,
TextField( focusNode: focusNode,
controller: actualController, autofocus: autofocus,
focusNode: focusNode, keyboardType: TextInputType.multiline,
autofocus: autofocus, textCapitalization: TextCapitalization.sentences,
keyboardType: TextInputType.multiline, onChanged: onChanged,
textCapitalization: TextCapitalization.sentences, onSubmitted: onSubmitted,
onChanged: onChanged, maxLines: maxLines,
onSubmitted: onSubmitted, minLines: minLines,
maxLines: maxLines, decoration: InputDecoration(labelText: labelText),
minLines: minLines, inputFormatters: [MarkdownFormatter()],
decoration: InputDecoration(labelText: labelText),
inputFormatters: [MarkdownFormatter()],
),
],
); );
} }
} }

View File

@ -1,15 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:lemmy_api_client/v3.dart'; import 'package:lemmy_api_client/v3.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../../util/extensions/api.dart'; import '../../../util/extensions/api.dart';
import '../../../widgets/avatar.dart'; import '../../../widgets/avatar.dart';
import '../../l10n/l10n.dart';
import '../../stores/accounts_store.dart'; import '../../stores/accounts_store.dart';
import 'editor_toolbar_store.dart'; import 'editor_toolbar_store.dart';
class PickPersonDialog extends HookWidget { class PickPersonDialog extends StatelessWidget {
final EditorToolbarStore store; final EditorToolbarStore store;
const PickPersonDialog._(this.store); const PickPersonDialog._(this.store);
@ -20,7 +20,7 @@ class PickPersonDialog extends HookWidget {
context.read<AccountsStore>().defaultUserDataFor(store.instanceHost); context.read<AccountsStore>().defaultUserDataFor(store.instanceHost);
return AlertDialog( return AlertDialog(
title: const Text('Select User'), title: Text(L10n.of(context).select_user),
content: TypeAheadField<PersonViewSafe>( content: TypeAheadField<PersonViewSafe>(
suggestionsCallback: (pattern) async { suggestionsCallback: (pattern) async {
if (pattern.trim().isEmpty) return const Iterable.empty(); if (pattern.trim().isEmpty) return const Iterable.empty();
@ -70,7 +70,7 @@ class PickPersonDialog extends HookWidget {
} }
} }
class PickCommunityDialog extends HookWidget { class PickCommunityDialog extends StatelessWidget {
final EditorToolbarStore store; final EditorToolbarStore store;
const PickCommunityDialog._(this.store); const PickCommunityDialog._(this.store);
@ -81,7 +81,7 @@ class PickCommunityDialog extends HookWidget {
context.read<AccountsStore>().defaultUserDataFor(store.instanceHost); context.read<AccountsStore>().defaultUserDataFor(store.instanceHost);
return AlertDialog( return AlertDialog(
title: const Text('Select Community'), title: Text(L10n.of(context).select_community),
content: TypeAheadField<CommunityView>( content: TypeAheadField<CommunityView>(
suggestionsCallback: (pattern) async { suggestionsCallback: (pattern) async {
if (pattern.trim().isEmpty) return const Iterable.empty(); if (pattern.trim().isEmpty) return const Iterable.empty();

View File

@ -1,11 +1,9 @@
import 'package:flutter/foundation.dart';
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 'package:logging/logging.dart';
import '../../formatter.dart';
import '../../hooks/logged_in_action.dart'; import '../../hooks/logged_in_action.dart';
import '../../l10n/l10n.dart'; import '../../l10n/l10n.dart';
import '../../markdown_formatter.dart';
import '../../resources/links.dart'; import '../../resources/links.dart';
import '../../url_launcher.dart'; import '../../url_launcher.dart';
import '../../util/async_store_listener.dart'; import '../../util/async_store_listener.dart';
@ -18,127 +16,17 @@ import '../../util/text_lines_iterator.dart';
import 'editor_picking_dialog.dart'; import 'editor_picking_dialog.dart';
import 'editor_toolbar_store.dart'; import 'editor_toolbar_store.dart';
class Reformat { class _Reformat {
final String text; final String text;
final int selectionBeginningShift; final int selectionBeginningShift;
final int selectionEndingShift; final int selectionEndingShift;
Reformat({ _Reformat({
required this.text, required this.text,
this.selectionBeginningShift = 0, this.selectionBeginningShift = 0,
this.selectionEndingShift = 0, this.selectionEndingShift = 0,
}); });
} }
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);
/// surroungs 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 '';
return text.substring(text.getBeginningOfTheLine(selection.start - 1),
text.getEndOfTheLine(selection.end));
}
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(RegExp.escape(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(RegExp.escape(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));
}
enum HeaderLevel { enum HeaderLevel {
h1(1), h1(1),
h2(2), h2(2),
@ -151,21 +39,21 @@ enum HeaderLevel {
final int value; final int value;
} }
class Toolbar extends HookWidget { class EditorToolbar extends StatelessWidget {
final TextEditingController controller; final TextEditingController controller;
final String instanceHost; final String instanceHost;
final EditorToolbarStore store; final EditorToolbarStore store;
static const _height = 50.0; static const _height = 50.0;
Toolbar({ EditorToolbar({
required this.controller, required this.controller,
required this.instanceHost, required this.instanceHost,
}) : store = EditorToolbarStore(instanceHost); }) : store = EditorToolbarStore(instanceHost);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MobxProvider.value( return MobxProvider(
value: store, create: (context) => store,
child: AsyncStoreListener( child: AsyncStoreListener(
asyncStore: store.imageUploadState, asyncStore: store.imageUploadState,
child: Container( child: Container(
@ -235,22 +123,19 @@ class _ToolbarBody extends HookWidget {
return; return;
} }
try { try {
// FIXME: for some reason it doesn't go past this line on iOS. idk why
final pic = await pickImage(); final pic = await pickImage();
// pic is null when the picker was cancelled // pic is null when the picker was cancelled
if (pic != null) { if (pic != null) {
final picUrl = await context final picUrl = await store.uploadImage(pic.path, token);
.read<EditorToolbarStore>()
.uploadImage(pic.path, token);
if (picUrl != null) { if (picUrl != null) {
controller.reformatSimple('![]($picUrl)'); controller.reformatSimple('![]($picUrl)');
} }
} }
} on Exception catch (_) { } on Exception catch (_) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(SnackBar(
const SnackBar(content: Text('Failed to upload image'))); content: Text(L10n.of(context).failed_to_upload_image)));
} }
}), }),
icon: store.imageUploadState.isLoading icon: store.imageUploadState.isLoading
@ -366,9 +251,7 @@ class _ToolbarBody extends HookWidget {
onPressed: () { onPressed: () {
controller.reformat((selection) { controller.reformat((selection) {
final insides = selection.isNotEmpty ? selection : '___'; final insides = selection.isNotEmpty ? selection : '___';
Logger.root return _Reformat(
.info([21, 21 + insides.length, insides, insides.length]);
return Reformat(
text: '\n::: spoiler spoiler\n$insides\n:::\n', text: '\n::: spoiler spoiler\n$insides\n:::\n',
selectionBeginningShift: 21, selectionBeginningShift: 21,
selectionEndingShift: 21 + insides.length - selection.length, selectionEndingShift: 21 + insides.length - selection.length,
@ -391,31 +274,31 @@ class _ToolbarBody extends HookWidget {
} }
class AddLinkDialog extends HookWidget { class AddLinkDialog extends HookWidget {
final String title; final String label;
final String url; final String url;
final String selection; final String selection;
static final _websiteRegex = RegExp(r'https?:\/\/', caseSensitive: false); static final _websiteRegex = RegExp(r'https?:\/\/', caseSensitive: false);
AddLinkDialog(this.selection) AddLinkDialog(this.selection)
: title = selection.startsWith(_websiteRegex) ? '' : selection, : label = selection.startsWith(_websiteRegex) ? '' : selection,
url = selection.startsWith(_websiteRegex) ? selection : ''; url = selection.startsWith(_websiteRegex) ? selection : '';
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final titleController = useTextEditingController(text: title); final labelController = useTextEditingController(text: label);
final urlController = useTextEditingController(text: url); final urlController = useTextEditingController(text: url);
void submit() { void submit() {
final link = () { final link = () {
if (urlController.text.startsWith('http?s://')) { if (urlController.text.startsWith(RegExp('https?://'))) {
return urlController.text; return urlController.text;
} else { } else {
return 'https://${urlController.text}'; return 'https://${urlController.text}';
} }
}(); }();
final finalString = '(${titleController.text})[$link]'; final finalString = '[${labelController.text}]($link)';
Navigator.of(context).pop(Reformat( Navigator.of(context).pop(_Reformat(
text: finalString, text: finalString,
selectionBeginningShift: finalString.length, selectionBeginningShift: finalString.length,
selectionEndingShift: finalString.length - selection.length, selectionEndingShift: finalString.length - selection.length,
@ -423,13 +306,14 @@ class AddLinkDialog extends HookWidget {
} }
return AlertDialog( return AlertDialog(
title: const Text('Add link'), title: Text(L10n.of(context).add_link),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
TextField( TextField(
controller: titleController, controller: labelController,
decoration: const InputDecoration(hintText: 'title'), decoration: InputDecoration(
hintText: L10n.of(context).editor_add_link_label),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
autofocus: true, autofocus: true,
), ),
@ -444,19 +328,129 @@ class AddLinkDialog extends HookWidget {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel')), child: Text(L10n.of(context).cancel)),
ElevatedButton( ElevatedButton(
onPressed: submit, onPressed: submit,
child: const Text('Add link'), child: Text(L10n.of(context).add_link),
) )
], ],
); );
} }
static Future<Reformat?> show(BuildContext context, String selection) async { static Future<_Reformat?> show(BuildContext context, String selection) async {
return showDialog( return showDialog(
context: context, context: context,
builder: (context) => AddLinkDialog(selection), 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 '';
return text.substring(text.getBeginningOfTheLine(selection.start - 1),
text.getEndOfTheLine(selection.end));
}
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(RegExp.escape(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(RegExp.escape(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

@ -132,7 +132,7 @@ class WriteComment extends HookWidget {
) )
], ],
), ),
Toolbar.safeArea, EditorToolbar.safeArea,
], ],
), ),
SafeArea( SafeArea(
@ -140,7 +140,7 @@ class WriteComment extends HookWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Spacer(), const Spacer(),
Toolbar( EditorToolbar(
controller: controller, controller: controller,
instanceHost: post.instanceHost, instanceHost: post.instanceHost,
), ),