Merge branch 'main' into mobile

This commit is contained in:
Lucas.Xu 2023-04-27 16:23:47 +08:00
commit 6e4b39a8da
25 changed files with 1224 additions and 462 deletions

View file

@ -1,3 +1,15 @@
## 0.1.10
* feat: support single asterisk to italic by @Xazin in https://github.com/AppFlowy-IO/appflowy-editor/pull/92
* fix: remove platform check in theme on web by @Xazin in https://github.com/AppFlowy-IO/appflowy-editor/pull/91
* fix: improves cursor left word delete by @MayurSMahajan in https://github.com/AppFlowy-IO/appflowy-editor/pull/88
* fix: one overlay entry for selection menu by @Xazin in https://github.com/AppFlowy-IO/appflowy-editor/pull/82
* fix: changing heading from one level to another from toolbar by @LucasXu0 in https://github.com/AppFlowy-IO/appflowy-editor/pull/96
## 0.1.9
* feat: dark mode improvement by @hyj1204 in https://github.com/AppFlowy-IO/appflowy-editor/pull/90
* feat: shift+tab to outdent a indented bulletpoint or checkbox. by @MayurSMahajan in https://github.com/AppFlowy-IO/appflowy-editor/pull/63
* fix: enter to outdent checkbox/bullet lists by @Xazin in https://github.com/AppFlowy-IO/appflowy-editor/pull/84
## 0.1.8
* chore: minor updates to documentation.

View file

@ -1,5 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="7" fill="#F2F2F2"/>
<path d="M6 6L10 10" stroke="#BDBDBD" stroke-linecap="round"/>
<path d="M10 6L6 10" stroke="#BDBDBD" stroke-linecap="round"/>
</svg>
<svg width="64px" height="64px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M12 2C6.49 2 2 6.49 2 12C2 17.51 6.49 22 12 22C17.51 22 22 17.51 22 12C22 6.49 17.51 2 12 2ZM15.36 14.3C15.65 14.59 15.65 15.07 15.36 15.36C15.21 15.51 15.02 15.58 14.83 15.58C14.64 15.58 14.45 15.51 14.3 15.36L12 13.06L9.7 15.36C9.55 15.51 9.36 15.58 9.17 15.58C8.98 15.58 8.79 15.51 8.64 15.36C8.35 15.07 8.35 14.59 8.64 14.3L10.94 12L8.64 9.7C8.35 9.41 8.35 8.93 8.64 8.64C8.93 8.35 9.41 8.35 9.7 8.64L12 10.94L14.3 8.64C14.59 8.35 15.07 8.35 15.36 8.64C15.65 8.93 15.65 9.41 15.36 9.7L13.06 12L15.36 14.3Z" fill="#BDBDBD"></path> </svg>

Before

Width:  |  Height:  |  Size: 274 B

After

Width:  |  Height:  |  Size: 649 B

Before After
Before After

View file

@ -1,8 +1,14 @@
import 'package:flutter/foundation.dart';
/// Attributes is used to describe the Node's information.
///
/// Please note: The keywords in [BuiltInAttributeKey] are reserved.
typedef Attributes = Map<String, dynamic>;
bool isAttributesEqual(Attributes? a, Attributes? b) {
return mapEquals(a, b);
}
Attributes? composeAttributes(
Attributes? base,
Attributes? other, {

View file

@ -7,3 +7,4 @@ export 'text_node_extensions.dart';
export 'text_style_extension.dart';
export 'url_launcher_extension.dart';
export 'position_extension.dart';

View file

@ -0,0 +1,94 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
enum SelectionRange {
character,
word,
}
extension PositionExtension on Position {
Position? moveHorizontal(
EditorState editorState, {
bool moveLeft = true,
SelectionRange selectionRange = SelectionRange.character,
}) {
final node = editorState.document.nodeAtPath(path);
if (node == null) {
return null;
}
if (moveLeft && offset == 0) {
final previousEnd = node.previous?.selectable?.end();
if (previousEnd != null) {
return previousEnd;
}
return null;
} else if (!moveLeft) {
final end = node.selectable?.end();
if (end != null && offset >= end.offset) {
return node.next?.selectable?.start();
}
}
switch (selectionRange) {
case SelectionRange.character:
if (node is TextNode) {
return Position(
path: path,
offset: moveLeft
? node.delta.prevRunePosition(offset)
: node.delta.nextRunePosition(offset),
);
}
return Position(path: path, offset: offset);
case SelectionRange.word:
if (node is TextNode) {
final result = moveLeft
? node.selectable?.getWordBoundaryInPosition(
Position(
path: path,
offset: node.delta.prevRunePosition(offset),
),
)
: node.selectable?.getWordBoundaryInPosition(this);
if (result != null) {
return moveLeft ? result.start : result.end;
}
}
return Position(path: path, offset: offset);
}
}
Position? moveVertical(
EditorState editorState, {
bool upwards = true,
}) {
final selection =
editorState.service.selectionService.currentSelection.value;
final rects = editorState.service.selectionService.selectionRects;
if (rects.isEmpty || selection == null) {
return null;
}
Offset offset;
if (selection.isBackward) {
final rect = rects.reduce(
(current, next) => current.bottom >= next.bottom ? current : next,
);
offset = upwards
? rect.topRight.translate(0, -rect.height)
: rect.bottomRight.translate(0, rect.height);
} else {
final rect = rects.reduce(
(current, next) => current.top <= next.top ? current : next,
);
offset = upwards
? rect.topLeft.translate(0, -rect.height)
: rect.bottomLeft.translate(0, rect.height);
}
return editorState.service.selectionService.getPositionInOffset(offset);
}
}

View file

@ -133,6 +133,11 @@ extension TextNodeExtension on TextNode {
}
return true;
}
bool get isNotBulletOrCheckbox => ![
BuiltInAttributeKey.bulletedList,
BuiltInAttributeKey.checkbox
].contains(subtype);
}
extension TextNodesExtension on List<TextNode> {

View file

@ -1,4 +1,6 @@
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/render/style/editor_style.dart';
import 'package:flutter/material.dart';
class ColorOption {
@ -19,6 +21,7 @@ enum _ColorType {
class ColorPicker extends StatefulWidget {
const ColorPicker({
super.key,
this.editorState,
this.selectedFontColorHex,
this.selectedBackgroundColorHex,
required this.pickerBackgroundColor,
@ -29,7 +32,7 @@ class ColorPicker extends StatefulWidget {
required this.onSubmittedbackgroundColorHex,
required this.onSubmittedFontColorHex,
});
final EditorState? editorState;
final String? selectedFontColorHex;
final String? selectedBackgroundColorHex;
final Color pickerBackgroundColor;
@ -53,6 +56,7 @@ class _ColorPickerState extends State<ColorPicker> {
TextEditingController();
final TextEditingController _backgroundColorOpacityController =
TextEditingController();
EditorStyle? get style => widget.editorState?.editorStyle;
@override
void initState() {
@ -131,7 +135,6 @@ class _ColorPickerState extends State<ColorPicker> {
return Text(
text,
style: const TextStyle(
color: Colors.grey,
fontWeight: FontWeight.bold,
),
);
@ -154,47 +157,48 @@ class _ColorPickerState extends State<ColorPicker> {
Widget _buildColorItem(_ColorType type, ColorOption option, bool isChecked) {
return SizedBox(
height: 36,
child: InkWell(
customBorder: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
hoverColor: widget.pickerItemHoverColor,
onTap: () {
child: TextButton.icon(
onPressed: () {
if (type == _ColorType.font) {
widget.onSubmittedFontColorHex(option.colorHex);
} else if (type == _ColorType.background) {
widget.onSubmittedbackgroundColorHex(option.colorHex);
}
},
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// padding
const SizedBox(width: 6),
// icon
SizedBox.square(
dimension: 12,
child: Container(
decoration: BoxDecoration(
color: Color(int.tryParse(option.colorHex) ?? 0xFFFFFFFF),
shape: BoxShape.circle,
),
),
icon: SizedBox.square(
dimension: 12,
child: Container(
decoration: BoxDecoration(
color: Color(int.tryParse(option.colorHex) ?? 0xFFFFFFFF),
shape: BoxShape.circle,
),
// padding
const SizedBox(width: 10),
// text
),
),
style: ButtonStyle(
backgroundColor: MaterialStateProperty.resolveWith<Color>(
(Set<MaterialState> states) {
if (states.contains(MaterialState.hovered)) {
return style!.popupMenuHoverColor!;
}
return Colors.transparent;
},
),
),
label: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
option.name,
style:
TextStyle(fontSize: 12, color: widget.pickerItemTextColor),
softWrap: false,
maxLines: 1,
overflow: TextOverflow.fade,
),
),
// checkbox
if (isChecked) const FlowySvg(name: 'checkmark'),
const SizedBox(width: 6),
],
),
),

View file

@ -77,19 +77,19 @@ class _LinkMenuState extends State<LinkMenu> {
if (widget.linkText != null) ...[
_buildIconButton(
iconName: 'link',
color: style?.selectionMenuItemIconColor,
color: style?.popupMenuFGColor,
text: 'Open link',
onPressed: widget.onOpenLink,
),
_buildIconButton(
iconName: 'copy',
color: style?.selectionMenuItemIconColor,
color: style?.popupMenuFGColor,
text: 'Copy link',
onPressed: widget.onCopyLink,
),
_buildIconButton(
iconName: 'delete',
color: style?.selectionMenuItemIconColor,
color: style?.popupMenuFGColor,
text: 'Remove link',
onPressed: widget.onRemoveLink,
),
@ -102,11 +102,10 @@ class _LinkMenuState extends State<LinkMenu> {
}
Widget _buildHeader() {
return const Text(
return Text(
'Add your link',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.bold,
fontSize: style?.textStyle?.fontSize,
),
);
}
@ -114,13 +113,17 @@ class _LinkMenuState extends State<LinkMenu> {
Widget _buildInput() {
return TextField(
focusNode: _focusNode,
style: const TextStyle(fontSize: 14.0),
style: TextStyle(
fontSize: style?.textStyle?.fontSize,
),
textAlign: TextAlign.left,
controller: _textEditingController,
onSubmitted: widget.onSubmitted,
decoration: InputDecoration(
hintText: 'URL',
hintStyle: const TextStyle(fontSize: 14.0),
hintStyle: TextStyle(
fontSize: style?.textStyle?.fontSize,
),
contentPadding: const EdgeInsets.all(16.0),
isDense: true,
suffixIcon: IconButton(
@ -151,20 +154,24 @@ class _LinkMenuState extends State<LinkMenu> {
name: iconName,
color: color,
),
style: TextButton.styleFrom(
minimumSize: const Size.fromHeight(40),
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
alignment: Alignment.centerLeft,
),
label: Text(
text,
textAlign: TextAlign.left,
style: TextStyle(
color: style?.selectionMenuItemTextColor ?? Colors.black,
color: color,
fontSize: 14.0,
),
),
style: ButtonStyle(
backgroundColor: MaterialStateProperty.resolveWith<Color>(
(Set<MaterialState> states) {
if (states.contains(MaterialState.hovered)) {
return style!.popupMenuHoverColor!;
}
return Colors.transparent;
},
),
),
onPressed: onPressed,
);
}

View file

@ -42,6 +42,10 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
final TextStyle? code;
final String? highlightColorHex;
// Item's pop up menu styles
final Color? popupMenuFGColor;
final Color? popupMenuHoverColor;
EditorStyle({
required this.padding,
required this.backgroundColor,
@ -66,6 +70,8 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
required this.code,
required this.highlightColorHex,
required this.lineHeight,
required this.popupMenuFGColor,
required this.popupMenuHoverColor,
});
@override
@ -92,6 +98,8 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
TextStyle? code,
String? highlightColorHex,
double? lineHeight,
Color? popupMenuFGColor,
Color? popupMenuHoverColor,
}) {
return EditorStyle(
padding: padding ?? this.padding,
@ -123,6 +131,8 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
code: code ?? this.code,
highlightColorHex: highlightColorHex ?? this.highlightColorHex,
lineHeight: lineHeight ?? this.lineHeight,
popupMenuFGColor: popupMenuFGColor ?? this.popupMenuFGColor,
popupMenuHoverColor: popupMenuHoverColor ?? this.popupMenuHoverColor,
);
}
@ -183,6 +193,9 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
code: TextStyle.lerp(code, other.code, t),
highlightColorHex: highlightColorHex,
lineHeight: lineHeight,
popupMenuFGColor: Color.lerp(popupMenuFGColor, other.popupMenuFGColor, t),
popupMenuHoverColor:
Color.lerp(popupMenuHoverColor, other.popupMenuHoverColor, t),
);
}
@ -223,6 +236,8 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
),
highlightColorHex: '0x6000BCF0',
lineHeight: 1.5,
popupMenuFGColor: const Color(0xFF333333),
popupMenuHoverColor: const Color(0xFFE0F8FF),
);
static final dark = light.copyWith(
@ -240,5 +255,7 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
selectionMenuItemSelectedColor: const Color(0xFF00BCF0),
toolbarColor: const Color(0xFF131720),
toolbarElevation: 0.0,
popupMenuFGColor: Colors.white,
popupMenuHoverColor: const Color(0xFF00BCF0),
);
}

View file

@ -364,8 +364,7 @@ bool _allSatisfy(
);
}
OverlayEntry? _linkMenuOverlay;
OverlayEntry? _colorMenuOverlay;
OverlayEntry? _overlay;
EditorState? _editorState;
bool _changeSelectionInner = false;
@ -387,7 +386,7 @@ void showLinkMenu(
editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
matchRect = matchRect.shift(-baseOffset);
_dismissLinkMenu();
_dismissOverlay();
_editorState = editorState;
// Since the link menu will only show in single text selection,
@ -410,7 +409,7 @@ void showLinkMenu(
);
}
_linkMenuOverlay = OverlayEntry(
_overlay = OverlayEntry(
builder: (context) {
return Positioned(
top: matchRect.bottom + 5.0,
@ -428,11 +427,11 @@ void showLinkMenu(
textNode: textNode,
);
_dismissLinkMenu();
_dismissOverlay();
},
onCopyLink: () {
AppFlowyClipboard.setData(text: linkText);
_dismissLinkMenu();
_dismissOverlay();
},
onRemoveLink: () {
final transaction = editorState.transaction
@ -443,7 +442,7 @@ void showLinkMenu(
{BuiltInAttributeKey.href: null},
);
editorState.apply(transaction);
_dismissLinkMenu();
_dismissOverlay();
},
onFocusChange: (value) {
if (value && customSelection != null) {
@ -457,15 +456,15 @@ void showLinkMenu(
);
},
);
Overlay.of(context)?.insert(_linkMenuOverlay!);
Overlay.of(context)?.insert(_overlay!);
editorState.service.scrollService?.disable();
editorState.service.keyboardService?.disable();
editorState.service.selectionService.currentSelection
.addListener(_dismissLinkMenu);
.addListener(_dismissOverlay);
}
void _dismissLinkMenu() {
void _dismissOverlay() {
// workaround: SelectionService has been released after hot reload.
final isSelectionDisposed =
_editorState?.service.selectionServiceKey.currentState == null;
@ -479,37 +478,13 @@ void _dismissLinkMenu() {
_changeSelectionInner = false;
return;
}
_linkMenuOverlay?.remove();
_linkMenuOverlay = null;
_overlay?.remove();
_overlay = null;
_editorState?.service.scrollService?.enable();
_editorState?.service.keyboardService?.enable();
_editorState?.service.selectionService.currentSelection
.removeListener(_dismissLinkMenu);
_editorState = null;
}
void _dismissColorMenu() {
// workaround: SelectionService has been released after hot reload.
final isSelectionDisposed =
_editorState?.service.selectionServiceKey.currentState == null;
if (isSelectionDisposed) {
return;
}
if (_editorState?.service.selectionService.currentSelection.value == null) {
return;
}
if (_changeSelectionInner) {
_changeSelectionInner = false;
return;
}
_colorMenuOverlay?.remove();
_colorMenuOverlay = null;
_editorState?.service.scrollService?.enable();
_editorState?.service.keyboardService?.enable();
_editorState?.service.selectionService.currentSelection
.removeListener(_dismissColorMenu);
.removeListener(_dismissOverlay);
_editorState = null;
}
@ -531,7 +506,7 @@ void showColorMenu(
editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
matchRect = matchRect.shift(-baseOffset);
_dismissColorMenu();
_dismissOverlay();
_editorState = editorState;
// Since the link menu will only show in single text selection,
@ -563,7 +538,7 @@ void showColorMenu(
}
final style = editorState.editorStyle;
_colorMenuOverlay = OverlayEntry(
_overlay = OverlayEntry(
builder: (context) {
return Positioned(
top: matchRect.bottom + 5.0,
@ -571,10 +546,11 @@ void showColorMenu(
child: Material(
color: Colors.transparent,
child: ColorPicker(
editorState: editorState,
pickerBackgroundColor:
style.selectionMenuBackgroundColor ?? Colors.white,
pickerItemHoverColor: style.selectionMenuItemSelectedColor ??
Colors.blue.withOpacity(0.3),
pickerItemHoverColor:
style.popupMenuHoverColor ?? Colors.blue.withOpacity(0.3),
pickerItemTextColor:
style.selectionMenuItemTextColor ?? Colors.black,
selectedFontColorHex: fontColorHex,
@ -587,26 +563,26 @@ void showColorMenu(
editorState,
color,
);
_dismissColorMenu();
_dismissOverlay();
},
onSubmittedFontColorHex: (color) {
formatFontColor(
editorState,
color,
);
_dismissColorMenu();
_dismissOverlay();
},
),
),
);
},
);
Overlay.of(context)?.insert(_colorMenuOverlay!);
Overlay.of(context)?.insert(_overlay!);
editorState.service.scrollService?.disable();
editorState.service.keyboardService?.disable();
editorState.service.selectionService.currentSelection
.addListener(_dismissColorMenu);
.addListener(_dismissOverlay);
}
List<ColorOption> _generateFontColorOptions(EditorState editorState) {

View file

@ -108,21 +108,21 @@ bool formatTextNodes(EditorState editorState, Attributes attributes) {
for (final textNode in textNodes) {
var newAttributes = {...textNode.attributes};
for (final globalStyleKey in BuiltInAttributeKey.globalStyleKeys) {
if (newAttributes.keys.contains(globalStyleKey)) {
newAttributes[globalStyleKey] = null;
if (isAttributesEqual(newAttributes, attributes)) {
for (final key in attributes.keys) {
newAttributes[key] = null;
}
} else {
for (final globalStyleKey in BuiltInAttributeKey.globalStyleKeys) {
if (newAttributes.keys.contains(globalStyleKey)) {
newAttributes[globalStyleKey] = null;
}
}
// if an attribute already exists in the node, it should be removed instead
newAttributes.addAll(attributes);
}
// if an attribute already exists in the node, it should be removed instead
for (final entry in attributes.entries) {
if (textNode.attributes.containsKey(entry.key) &&
textNode.attributes[entry.key] == entry.value) {
// attribute is not added to the node new attributes
} else {
newAttributes.addEntries([entry]);
}
}
transaction
..updateNode(
textNode,

View file

@ -7,7 +7,7 @@ ShortcutEventHandler cursorLeftSelect = (editorState, event) {
if (nodes.isEmpty || selection == null) {
return KeyEventResult.ignored;
}
final end = selection.end.goLeft(editorState);
final end = selection.end.moveHorizontal(editorState);
if (end == null) {
return KeyEventResult.ignored;
}
@ -23,7 +23,7 @@ ShortcutEventHandler cursorRightSelect = (editorState, event) {
if (nodes.isEmpty || selection == null) {
return KeyEventResult.ignored;
}
final end = selection.end.goRight(editorState);
final end = selection.end.moveHorizontal(editorState, moveLeft: false);
if (end == null) {
return KeyEventResult.ignored;
}
@ -39,7 +39,7 @@ ShortcutEventHandler cursorUpSelect = (editorState, event) {
if (nodes.isEmpty || selection == null) {
return KeyEventResult.ignored;
}
final end = _moveVertical(editorState);
final end = selection.end.moveVertical(editorState);
if (end == null) {
return KeyEventResult.ignored;
}
@ -55,7 +55,7 @@ ShortcutEventHandler cursorDownSelect = (editorState, event) {
if (nodes.isEmpty || selection == null) {
return KeyEventResult.ignored;
}
final end = _moveVertical(editorState, upwards: false);
final end = selection.end.moveVertical(editorState, upwards: false);
if (end == null) {
return KeyEventResult.ignored;
}
@ -225,7 +225,7 @@ ShortcutEventHandler cursorUp = (editorState, event) {
return KeyEventResult.ignored;
}
final upPosition = _moveVertical(editorState);
final upPosition = selection.end.moveVertical(editorState);
editorState.updateCursorSelection(
upPosition == null ? null : Selection.collapsed(upPosition),
);
@ -241,7 +241,7 @@ ShortcutEventHandler cursorDown = (editorState, event) {
return KeyEventResult.ignored;
}
final downPosition = _moveVertical(editorState, upwards: false);
final downPosition = selection.end.moveVertical(editorState, upwards: false);
editorState.updateCursorSelection(
downPosition == null ? null : Selection.collapsed(downPosition),
);
@ -258,7 +258,7 @@ ShortcutEventHandler cursorLeft = (editorState, event) {
}
Position newPosition = selection.isCollapsed
? selection.start.goLeft(editorState) ?? selection.start
? selection.start.moveHorizontal(editorState) ?? selection.start
: selection.start;
editorState.service.selectionService.updateSelection(
@ -277,7 +277,8 @@ ShortcutEventHandler cursorRight = (editorState, event) {
}
final newPosition = selection.isCollapsed
? selection.start.goRight(editorState) ?? selection.end
? selection.start.moveHorizontal(editorState, moveLeft: false) ??
selection.end
: selection.end;
editorState.service.selectionService.updateSelection(
@ -294,8 +295,10 @@ ShortcutEventHandler cursorLeftWordSelect = (editorState, event) {
return KeyEventResult.ignored;
}
final end =
selection.end.goLeft(editorState, selectionRange: _SelectionRange.word);
final end = selection.end.moveHorizontal(
editorState,
selectionRange: SelectionRange.word,
);
if (end == null) {
return KeyEventResult.ignored;
}
@ -316,8 +319,10 @@ ShortcutEventHandler cursorLeftWordMove = (editorState, event) {
return KeyEventResult.ignored;
}
final newPosition = selection.start
.goLeft(editorState, selectionRange: _SelectionRange.word) ??
final newPosition = selection.start.moveHorizontal(
editorState,
selectionRange: SelectionRange.word,
) ??
selection.start;
editorState.service.selectionService.updateSelection(
@ -336,8 +341,11 @@ ShortcutEventHandler cursorRightWordMove = (editorState, event) {
return KeyEventResult.ignored;
}
final newPosition = selection.start
.goRight(editorState, selectionRange: _SelectionRange.word) ??
final newPosition = selection.start.moveHorizontal(
editorState,
selectionRange: SelectionRange.word,
moveLeft: false,
) ??
selection.end;
editorState.service.selectionService.updateSelection(
@ -354,8 +362,11 @@ ShortcutEventHandler cursorRightWordSelect = (editorState, event) {
return KeyEventResult.ignored;
}
final end =
selection.end.goRight(editorState, selectionRange: _SelectionRange.word);
final end = selection.end.moveHorizontal(
editorState,
selectionRange: SelectionRange.word,
moveLeft: false,
);
if (end == null) {
return KeyEventResult.ignored;
}
@ -367,63 +378,6 @@ ShortcutEventHandler cursorRightWordSelect = (editorState, event) {
return KeyEventResult.handled;
};
ShortcutEventHandler cursorLeftWordDelete = (editorState, event) {
final textNodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>();
final selection = editorState.service.selectionService.currentSelection.value;
if (textNodes.isEmpty || selection == null) {
return KeyEventResult.ignored;
}
final textNode = textNodes.first;
final startOfWord =
selection.end.goLeft(editorState, selectionRange: _SelectionRange.word);
if (startOfWord == null) {
return KeyEventResult.ignored;
}
final transaction = editorState.transaction;
transaction.deleteText(
textNode,
startOfWord.offset,
selection.end.offset - startOfWord.offset,
);
editorState.apply(transaction);
return KeyEventResult.handled;
};
ShortcutEventHandler cursorLeftSentenceDelete = (editorState, event) {
final nodes = editorState.service.selectionService.currentSelectedNodes;
final selection = editorState.service.selectionService.currentSelection.value;
if (nodes.isEmpty || selection == null) {
return KeyEventResult.ignored;
}
if (nodes.length == 1 && nodes.first is TextNode) {
final textNode = nodes.first as TextNode;
if (textNode.toPlainText().isEmpty) {
return KeyEventResult.ignored;
}
}
if (selection.isCollapsed) {
final deleteTransaction = editorState.transaction;
deleteTransaction.deleteText(
nodes.first as TextNode,
0,
selection.end.offset,
);
editorState.apply(deleteTransaction);
}
return KeyEventResult.handled;
};
enum _SelectionRange {
character,
word,

View file

@ -0,0 +1,88 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
ShortcutEventHandler cursorLeftWordDelete = (editorState, event) {
final textNodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>();
final selection = editorState.service.selectionService.currentSelection.value;
if (textNodes.isEmpty || selection == null) {
return KeyEventResult.ignored;
}
final textNode = textNodes.first;
//we store the position where the current word starts.
var startOfWord = selection.end.moveHorizontal(
editorState,
selectionRange: SelectionRange.word,
);
if (startOfWord == null) {
return KeyEventResult.ignored;
}
//check if the selected word is whitespace
final selectedWord = textNode
.toPlainText()
.substring(startOfWord.offset, selection.end.offset);
//if it is whitespace then we have to update the selection to include
//the left word from the whitespace.
if (selectedWord.trim().isEmpty) {
//make a new selection from the left of the whitespace.
final newSelection = Selection.single(
path: startOfWord.path,
startOffset: startOfWord.offset,
);
//we need to check if this position is not null
final newStartOfWord = newSelection.end.moveHorizontal(
editorState,
selectionRange: SelectionRange.word,
);
//this handles the edge case where the textNode only consists single space.
if (newStartOfWord != null) {
startOfWord = newStartOfWord;
}
}
final transaction = editorState.transaction;
transaction.deleteText(
textNode,
startOfWord.offset,
selection.end.offset - startOfWord.offset,
);
editorState.apply(transaction);
return KeyEventResult.handled;
};
ShortcutEventHandler cursorLeftSentenceDelete = (editorState, event) {
final nodes = editorState.service.selectionService.currentSelectedNodes;
final selection = editorState.service.selectionService.currentSelection.value;
if (nodes.isEmpty || selection == null) {
return KeyEventResult.ignored;
}
if (nodes.length == 1 && nodes.first is TextNode) {
final textNode = nodes.first as TextNode;
if (textNode.toPlainText().isEmpty) {
return KeyEventResult.ignored;
}
}
if (selection.isCollapsed) {
final deleteTransaction = editorState.transaction;
deleteTransaction.deleteText(
nodes.first as TextNode,
0,
selection.end.offset,
);
editorState.apply(deleteTransaction, withUpdateSelection: true);
}
return KeyEventResult.handled;
};

View file

@ -77,15 +77,27 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
// insert a empty text node before.
if (selection.isCollapsed && selection.start.offset == 0) {
if (textNode.toPlainText().isEmpty && textNode.subtype != null) {
final path =
textNode.path.length > 1 ? [++textNode.path.first] : textNode.path;
final afterSelection = Selection.collapsed(
Position(path: textNode.path, offset: 0),
Position(path: path, offset: 0),
);
final transaction = editorState.transaction
..updateNode(textNode, {
BuiltInAttributeKey.subtype: null,
textNode.subtype!: null,
})
..afterSelection = afterSelection;
final transaction = editorState.transaction;
if (textNode.path.length > 1) {
transaction
..deleteNode(textNode)
..insertNode(path, textNode)
..afterSelection = afterSelection;
} else {
transaction
..updateNode(textNode, {
BuiltInAttributeKey.subtype: null,
textNode.subtype!: null,
})
..afterSelection = afterSelection;
}
editorState.apply(transaction);
final nextNode = textNode.next;

View file

@ -272,7 +272,17 @@ ShortcutEventHandler markdownLinkOrImageHandler = (editorState, event) {
return KeyEventResult.handled;
};
ShortcutEventHandler underscoreToItalicHandler = (editorState, event) {
ShortcutEventHandler singleUnderscoreToItalicHandler = (editorState, event) {
return singleCharacterToItalicHandler(editorState, event, '_');
};
ShortcutEventHandler singleAsteriskToItalicHandler = (editorState, event) {
return singleCharacterToItalicHandler(editorState, event, '*');
};
KeyEventResult Function(EditorState, RawKeyEvent?, String)
singleCharacterToItalicHandler =
(EditorState editorState, RawKeyEvent? event, String character) {
// Obtain the selection and selected nodes of the current document through the 'selectionService'
// to determine whether the selection is collapsed and whether the selected node is a text node.
final selectionService = editorState.service.selectionService;
@ -284,24 +294,24 @@ ShortcutEventHandler underscoreToItalicHandler = (editorState, event) {
final textNode = textNodes.first;
final text = textNode.toPlainText();
// Determine if an 'underscore' already exists in the text node and only once.
final firstUnderscore = text.indexOf('_');
final lastUnderscore = text.lastIndexOf('_');
if (firstUnderscore == -1 ||
firstUnderscore != lastUnderscore ||
firstUnderscore == selection.start.offset - 1) {
// Determine if a 'character' already exists in the text node and only once.
final firstCharacter = text.indexOf(character);
final lastCharacter = text.lastIndexOf(character);
if (firstCharacter == -1 ||
firstCharacter != lastCharacter ||
firstCharacter == selection.start.offset - 1) {
return KeyEventResult.ignored;
}
// Delete the previous 'underscore',
// update the style of the text surrounded by the two underscores to 'italic',
// Delete the previous 'character',
// update the style of the text surrounded by the two characters to 'italic',
// and update the cursor position.
final transaction = editorState.transaction
..deleteText(textNode, firstUnderscore, 1)
..deleteText(textNode, firstCharacter, 1)
..formatText(
textNode,
firstUnderscore,
selection.end.offset - firstUnderscore - 1,
firstCharacter,
selection.end.offset - firstCharacter - 1,
{
BuiltInAttributeKey.italic: true,
},

View file

@ -0,0 +1,74 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
ShortcutEventHandler outdentTabHandler = (editorState, event) {
final selection = editorState.service.selectionService.currentSelection.value;
final textNodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>();
if (textNodes.length != 1 || selection == null || !selection.isSingle) {
return KeyEventResult.ignored;
}
final textNode = textNodes.first;
var previous = textNode.previous;
if (textNode.isNotBulletOrCheckbox) {
return KeyEventResult.handled;
}
//if the current node is having a path which is of size 1.
//for example [0], then that means, it is not indented
//thus we ignore this event.
final oldPath = textNode.path;
if (oldPath.length == 1) {
return KeyEventResult.handled;
}
//we need to check if the previous node is a list type or not,
//but the previous getter gives the previous node in the LinkedList
//in case of nested bullet lists, where an element's path is [1,0]
//the previous getter will return null, but in this case: we should
//assign previous's path to be = [1]
if (previous == null) {
if (oldPath.last != 0) {
return KeyEventResult.ignored;
}
final previousPath = oldPath.sublist(0, oldPath.length - 1);
editorState.updateCursorSelection(
Selection.single(path: previousPath, startOffset: 0),
);
final prevSelection =
editorState.service.selectionService.currentSelection.value;
final prevNodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>();
if (prevNodes.length != 1 ||
prevSelection == null ||
!prevSelection.isSingle) {
return KeyEventResult.ignored;
}
previous = prevNodes.first;
}
if (textNode.isNotBulletOrCheckbox) {
return KeyEventResult.ignored;
}
final path = oldPath.sublist(0, oldPath.length - 1)..last += 1;
final afterSelection = Selection(
start: selection.start.copyWith(path: path),
end: selection.end.copyWith(path: path),
);
final transaction = editorState.transaction
..deleteNode(textNode)
..insertNode(path, textNode)
..afterSelection = afterSelection;
editorState.apply(transaction);
return KeyEventResult.handled;
};

View file

@ -2,7 +2,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
ShortcutEventHandler tabHandler = (editorState, event) {
// Only Supports BulletedList For Now.
// Only Supports BulletedList and Checkboxes for now.
final selection = editorState.service.selectionService.currentSelection.value;
final textNodes = editorState.service.selectionService.currentSelectedNodes
@ -14,17 +14,14 @@ ShortcutEventHandler tabHandler = (editorState, event) {
final textNode = textNodes.first;
final previous = textNode.previous;
if (textNode.subtype != BuiltInAttributeKey.bulletedList &&
textNode.subtype != BuiltInAttributeKey.checkbox) {
if (textNode.isNotBulletOrCheckbox) {
final transaction = editorState.transaction
..insertText(textNode, selection.end.offset, ' ' * 4);
editorState.apply(transaction);
return KeyEventResult.handled;
}
if (previous == null ||
(previous.subtype != BuiltInAttributeKey.bulletedList &&
previous.subtype != BuiltInAttributeKey.checkbox)) {
if (previous == null || textNode.isNotBulletOrCheckbox) {
return KeyEventResult.ignored;
}

View file

@ -1,9 +1,11 @@
import 'package:appflowy_editor/src/service/internal_key_event_handlers/arrow_keys_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/backspace_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/copy_paste_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/cursor_left_delete_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/exit_editing_mode_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/outdent_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/page_up_down_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/redo_undo_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/select_all_handler.dart';
@ -289,6 +291,11 @@ List<ShortcutEvent> builtInShortcutEvents = [
command: 'tab',
handler: tabHandler,
),
ShortcutEvent(
key: 'Outdent List',
command: 'shift+tab',
handler: outdentTabHandler,
),
ShortcutEvent(
key: 'Backquote to code',
command: 'backquote',
@ -312,7 +319,12 @@ List<ShortcutEvent> builtInShortcutEvents = [
ShortcutEvent(
key: 'Underscore to italic',
character: '_',
handler: underscoreToItalicHandler,
handler: singleUnderscoreToItalicHandler,
),
ShortcutEvent(
key: 'Asterisk to italic',
character: '*',
handler: singleAsteriskToItalicHandler,
),
ShortcutEvent(
key: 'Double asterisk to bold',

View file

@ -1,6 +1,6 @@
name: appflowy_editor
description: A highly customizable rich-text editor for Flutter. The AppFlowy Editor project for AppFlowy and beyond.
version: 0.1.8
version: 0.1.10
homepage: https://github.com/AppFlowy-IO/appflowy-editor
platforms:

View file

@ -1,4 +1,5 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/render/rich_text/heading_text.dart';
import 'package:appflowy_editor/src/render/rich_text/quoted_text.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../infra/test_editor.dart';
@ -31,5 +32,36 @@ void main() async {
await tester.pumpAndSettle();
expect(find.byType(QuotedTextNodeWidget), findsNothing);
});
testWidgets('formatHeading', (tester) async {
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor
..insertTextNode(
text,
attributes: {
BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading,
BuiltInAttributeKey.heading: BuiltInAttributeKey.h2,
},
);
await editor.startTesting();
await editor.updateSelection(
Selection.single(path: [0], startOffset: 0, endOffset: text.length),
);
// format the text to h1
formatHeading(editor.editorState, BuiltInAttributeKey.h1);
await tester.pumpAndSettle(const Duration(milliseconds: 100));
expect(find.byType(HeadingTextNodeWidget), findsOneWidget);
final textNode = editor.nodeAtPath([0])!;
expect(
textNode.attributes[BuiltInAttributeKey.subtype],
BuiltInAttributeKey.heading,
);
expect(
textNode.attributes[BuiltInAttributeKey.heading],
BuiltInAttributeKey.h1,
);
});
});
}

Some files were not shown because too many files have changed in this diff Show more