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": {
"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';
extension Utilities on String {
int getBeginningOfTheLine(int startingIndex) {
if (startingIndex <= 0) return 0;
for (var i = startingIndex; i >= 0; i--) {
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;
@ -17,9 +17,9 @@ extension Utilities on String {
return length - 1;
}
// returns the line that ends at endingIndex
String lineBefore(int endingIndex) {
return substring(getBeginningOfTheLine(endingIndex), endingIndex + 1);
/// returns the line that ends at endingIndex
String lineUpTo(int characterIndex) {
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 {
@override
TextEditingValue formatEditUpdate(
@ -52,9 +52,10 @@ class MarkdownFormatter extends TextInputFormatter {
if (char == '\n') {
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 match = regex.matchAsPrefix(lineBefore);
if (match == null) {
@ -65,7 +66,7 @@ class MarkdownFormatter extends TextInputFormatter {
return tev.append('$indent$listChar ');
}
TextEditingValue numberedListContinuation(
TextEditingValue orderedListContinuation(
String afterNumberChar, TextEditingValue tev) {
final regex =
RegExp(r'(\s*)(\d+)' '${RegExp.escape(afterNumberChar)} ');
@ -74,15 +75,16 @@ class MarkdownFormatter extends TextInputFormatter {
return tev;
}
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 ');
}
newVal = listContinuation('-', newVal);
newVal = listContinuation('*', newVal);
newVal = numberedListContinuation('.', newVal);
newVal = numberedListContinuation(')', newVal);
newVal = unorderedListContinuation('-', newVal);
newVal = unorderedListContinuation('*', newVal);
newVal = unorderedListContinuation('+', newVal);
newVal = orderedListContinuation('.', newVal);
newVal = orderedListContinuation(')', newVal);
}
return newVal;

View File

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

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../../formatter.dart';
import '../../markdown_formatter.dart';
import '../markdown_text.dart';
export 'editor_toolbar.dart';
@ -52,9 +52,7 @@ class Editor extends HookWidget {
);
}
return Stack(
children: [
TextField(
return TextField(
controller: actualController,
focusNode: focusNode,
autofocus: autofocus,
@ -66,8 +64,6 @@ class Editor extends HookWidget {
minLines: minLines,
decoration: InputDecoration(labelText: labelText),
inputFormatters: [MarkdownFormatter()],
),
],
);
}
}

View File

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