Compare commits
10 Commits
cc8441dabc
...
8aefdfbf27
Author | SHA1 | Date |
---|---|---|
Filip Krawczyk | 8aefdfbf27 | |
Filip Krawczyk | b2ef0883e3 | |
Filip Krawczyk | e3b561835b | |
Filip Krawczyk | 9ec5410273 | |
Filip Krawczyk | d11a46393b | |
Filip Krawczyk | c83e93c755 | |
Filip Krawczyk | 2cc82e6a45 | |
Filip Krawczyk | 90553794b2 | |
Filip Krawczyk | b39d6b06d7 | |
Filip Krawczyk | 6729a040ea |
|
@ -1,5 +1,8 @@
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
const unorderedListTypes = ['*', '+', '-'];
|
||||||
|
const orderedListTypes = [')', '.'];
|
||||||
|
|
||||||
extension Utilities on String {
|
extension Utilities on String {
|
||||||
int getBeginningOfTheLine(int from) {
|
int getBeginningOfTheLine(int from) {
|
||||||
if (from <= 0) return 0;
|
if (from <= 0) return 0;
|
||||||
|
@ -37,6 +40,19 @@ extension on TextEditingValue {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// Provides convenience formatting in markdown text fields
|
||||||
|
@ -56,35 +72,52 @@ class MarkdownFormatter extends TextInputFormatter {
|
||||||
|
|
||||||
TextEditingValue unorderedListContinuation(
|
TextEditingValue unorderedListContinuation(
|
||||||
String listChar, TextEditingValue tev) {
|
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) {
|
||||||
return tev;
|
return tev;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final listItemBody = match.group(2);
|
||||||
final indent = match.group(1);
|
final indent = match.group(1);
|
||||||
|
|
||||||
|
if (listItemBody == null || listItemBody.isEmpty) {
|
||||||
|
return tev.trimBeforeCursor(listChar.length + (indent?.length ?? 1));
|
||||||
|
}
|
||||||
|
|
||||||
return tev.append('$indent$listChar ');
|
return tev.append('$indent$listChar ');
|
||||||
}
|
}
|
||||||
|
|
||||||
TextEditingValue orderedListContinuation(
|
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)} (.*)');
|
||||||
final match = regex.matchAsPrefix(lineBefore);
|
final match = regex.matchAsPrefix(lineBefore);
|
||||||
|
|
||||||
if (match == null) {
|
if (match == null) {
|
||||||
return tev;
|
return tev;
|
||||||
}
|
}
|
||||||
final indent = match.group(1);
|
|
||||||
final number = int.tryParse(match.group(2)!) ?? 0 + 1;
|
|
||||||
|
|
||||||
|
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 ');
|
return tev.append('$indent$number$afterNumberChar ');
|
||||||
}
|
}
|
||||||
|
|
||||||
newVal = unorderedListContinuation('-', newVal);
|
for (final c in unorderedListTypes) {
|
||||||
newVal = unorderedListContinuation('*', newVal);
|
newVal = unorderedListContinuation(c, newVal);
|
||||||
newVal = unorderedListContinuation('+', newVal);
|
}
|
||||||
newVal = orderedListContinuation('.', newVal);
|
for (final c in orderedListTypes) {
|
||||||
newVal = orderedListContinuation(')', newVal);
|
newVal = orderedListContinuation(c, newVal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return newVal;
|
return newVal;
|
||||||
|
|
|
@ -142,17 +142,11 @@ class CreatePostPage extends HookWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SafeArea(
|
BottomSticky(
|
||||||
child: Column(
|
child: EditorToolbar(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
editorFocusNode: editorFocusNode,
|
||||||
children: [
|
controller: bodyController,
|
||||||
const Spacer(),
|
instanceHost: context.read<CreatePostStore>().instanceHost,
|
||||||
EditorToolbar(
|
|
||||||
editorFocusNode: editorFocusNode,
|
|
||||||
controller: bodyController,
|
|
||||||
instanceHost: context.read<CreatePostStore>().instanceHost,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
@ -384,19 +384,13 @@ class _ManageAccount extends HookWidget {
|
||||||
const BottomSafe(),
|
const BottomSafe(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SafeArea(
|
BottomSticky(
|
||||||
child: Column(
|
child: EditorToolbar(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
editorFocusNode: bioFocusNode,
|
||||||
children: [
|
controller: bioController,
|
||||||
const Spacer(),
|
instanceHost: user.instanceHost,
|
||||||
EditorToolbar(
|
|
||||||
editorFocusNode: bioFocusNode,
|
|
||||||
controller: bioController,
|
|
||||||
instanceHost: user.instanceHost,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,22 +52,18 @@ class Editor extends HookWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return FocusScope(
|
return TextField(
|
||||||
child: Focus(
|
focusNode: focusNode,
|
||||||
focusNode: focusNode,
|
controller: actualController,
|
||||||
child: TextField(
|
autofocus: autofocus,
|
||||||
controller: actualController,
|
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()],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,52 +42,73 @@ enum HeaderLevel {
|
||||||
class EditorToolbar extends HookWidget {
|
class EditorToolbar extends HookWidget {
|
||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
final String instanceHost;
|
final String instanceHost;
|
||||||
final EditorToolbarStore store;
|
|
||||||
final FocusNode editorFocusNode;
|
final FocusNode editorFocusNode;
|
||||||
static const _height = 50.0;
|
static const _height = 50.0;
|
||||||
|
|
||||||
EditorToolbar({
|
const EditorToolbar({
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.instanceHost,
|
required this.instanceHost,
|
||||||
required this.editorFocusNode,
|
required this.editorFocusNode,
|
||||||
}) : store = EditorToolbarStore(instanceHost);
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final visible = useState(editorFocusNode.hasFocus);
|
final visible = useListenable(editorFocusNode).hasFocus;
|
||||||
useEffect(() {
|
|
||||||
editorFocusNode.addListener(() {
|
|
||||||
visible.value = editorFocusNode.hasFocus;
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}, [editorFocusNode]);
|
|
||||||
|
|
||||||
return MobxProvider(
|
return MobxProvider(
|
||||||
create: (context) => store,
|
create: (context) => EditorToolbarStore(instanceHost),
|
||||||
child: AsyncStoreListener(
|
child: ObserverBuilder<EditorToolbarStore>(builder: (context, store) {
|
||||||
asyncStore: store.imageUploadState,
|
return AsyncStoreListener(
|
||||||
child: visible.value
|
asyncStore: store.imageUploadState,
|
||||||
? Container(
|
child: AnimatedSwitcher(
|
||||||
height: _height,
|
duration: kThemeAnimationDuration,
|
||||||
width: double.infinity,
|
transitionBuilder: (child, animation) {
|
||||||
color: Theme.of(context).cardColor,
|
final offsetAnimation =
|
||||||
child: Material(
|
Tween<Offset>(begin: const Offset(0, 1.5), end: Offset.zero)
|
||||||
child: SingleChildScrollView(
|
.animate(animation);
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
child: _ToolbarBody(
|
return SlideTransition(
|
||||||
controller: controller,
|
position: offsetAnimation,
|
||||||
instanceHost: instanceHost,
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: visible
|
||||||
|
? Container(
|
||||||
|
height: _height,
|
||||||
|
width: double.infinity,
|
||||||
|
color: Theme.of(context).canvasColor,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: _ToolbarBody(
|
||||||
|
controller: controller,
|
||||||
|
instanceHost: instanceHost,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
),
|
: const SizedBox.shrink(),
|
||||||
)
|
),
|
||||||
: const SizedBox.shrink(),
|
);
|
||||||
),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Widget safeArea = const SizedBox(height: _height);
|
static const safeArea = SizedBox(height: _height);
|
||||||
|
}
|
||||||
|
|
||||||
|
class BottomSticky extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
const BottomSticky({required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Spacer(),
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ToolbarBody extends HookWidget {
|
class _ToolbarBody extends HookWidget {
|
||||||
|
@ -224,12 +245,21 @@ class _ToolbarBody extends HookWidget {
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final line = controller.firstSelectedLine;
|
final line = controller.firstSelectedLine;
|
||||||
|
|
||||||
if (line.startsWith('* ')) {
|
// if theres a list in place, remove it
|
||||||
controller.removeAtBeginningOfEverySelectedLine('* ');
|
final listRemoved = () {
|
||||||
} else if (line.startsWith('- ')) {
|
for (final c in unorderedListTypes) {
|
||||||
controller.removeAtBeginningOfEverySelectedLine('- ');
|
if (line.startsWith('$c ')) {
|
||||||
} else {
|
controller.removeAtBeginningOfEverySelectedLine('$c ');
|
||||||
controller.insertAtBeginningOfEverySelectedLine('- ');
|
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),
|
icon: const Icon(Icons.format_list_bulleted),
|
||||||
|
@ -263,11 +293,15 @@ class _ToolbarBody extends HookWidget {
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
controller.reformat((selection) {
|
controller.reformat((selection) {
|
||||||
final insides = selection.isNotEmpty ? selection : '___';
|
const textBeg = '\n::: spoiler spoiler\n';
|
||||||
|
final textMid = selection.isNotEmpty ? selection : '___';
|
||||||
|
const textEnd = '\n:::\n';
|
||||||
|
|
||||||
return _Reformat(
|
return _Reformat(
|
||||||
text: '\n::: spoiler spoiler\n$insides\n:::\n',
|
text: textBeg + textMid + textEnd,
|
||||||
selectionBeginningShift: 21,
|
selectionBeginningShift: textBeg.length,
|
||||||
selectionEndingShift: 21 + insides.length - selection.length,
|
selectionEndingShift:
|
||||||
|
textBeg.length + textMid.length - selection.length,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
@ -137,17 +137,11 @@ class WriteComment extends HookWidget {
|
||||||
EditorToolbar.safeArea,
|
EditorToolbar.safeArea,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SafeArea(
|
BottomSticky(
|
||||||
child: Column(
|
child: EditorToolbar(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
editorFocusNode: editorFocusNode,
|
||||||
children: [
|
controller: controller,
|
||||||
const Spacer(),
|
instanceHost: post.instanceHost,
|
||||||
EditorToolbar(
|
|
||||||
editorFocusNode: editorFocusNode,
|
|
||||||
controller: controller,
|
|
||||||
instanceHost: post.instanceHost,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
Loading…
Reference in New Issue