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 ## 0.1.8
* chore: minor updates to documentation. * 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"> <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>
<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>

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. /// Attributes is used to describe the Node's information.
/// ///
/// Please note: The keywords in [BuiltInAttributeKey] are reserved. /// Please note: The keywords in [BuiltInAttributeKey] are reserved.
typedef Attributes = Map<String, dynamic>; typedef Attributes = Map<String, dynamic>;
bool isAttributesEqual(Attributes? a, Attributes? b) {
return mapEquals(a, b);
}
Attributes? composeAttributes( Attributes? composeAttributes(
Attributes? base, Attributes? base,
Attributes? other, { Attributes? other, {

View file

@ -7,3 +7,4 @@ export 'text_node_extensions.dart';
export 'text_style_extension.dart'; export 'text_style_extension.dart';
export 'url_launcher_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; return true;
} }
bool get isNotBulletOrCheckbox => ![
BuiltInAttributeKey.bulletedList,
BuiltInAttributeKey.checkbox
].contains(subtype);
} }
extension TextNodesExtension on List<TextNode> { 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/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/render/style/editor_style.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class ColorOption { class ColorOption {
@ -19,6 +21,7 @@ enum _ColorType {
class ColorPicker extends StatefulWidget { class ColorPicker extends StatefulWidget {
const ColorPicker({ const ColorPicker({
super.key, super.key,
this.editorState,
this.selectedFontColorHex, this.selectedFontColorHex,
this.selectedBackgroundColorHex, this.selectedBackgroundColorHex,
required this.pickerBackgroundColor, required this.pickerBackgroundColor,
@ -29,7 +32,7 @@ class ColorPicker extends StatefulWidget {
required this.onSubmittedbackgroundColorHex, required this.onSubmittedbackgroundColorHex,
required this.onSubmittedFontColorHex, required this.onSubmittedFontColorHex,
}); });
final EditorState? editorState;
final String? selectedFontColorHex; final String? selectedFontColorHex;
final String? selectedBackgroundColorHex; final String? selectedBackgroundColorHex;
final Color pickerBackgroundColor; final Color pickerBackgroundColor;
@ -53,6 +56,7 @@ class _ColorPickerState extends State<ColorPicker> {
TextEditingController(); TextEditingController();
final TextEditingController _backgroundColorOpacityController = final TextEditingController _backgroundColorOpacityController =
TextEditingController(); TextEditingController();
EditorStyle? get style => widget.editorState?.editorStyle;
@override @override
void initState() { void initState() {
@ -131,7 +135,6 @@ class _ColorPickerState extends State<ColorPicker> {
return Text( return Text(
text, text,
style: const TextStyle( style: const TextStyle(
color: Colors.grey,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
); );
@ -154,47 +157,48 @@ class _ColorPickerState extends State<ColorPicker> {
Widget _buildColorItem(_ColorType type, ColorOption option, bool isChecked) { Widget _buildColorItem(_ColorType type, ColorOption option, bool isChecked) {
return SizedBox( return SizedBox(
height: 36, height: 36,
child: InkWell( child: TextButton.icon(
customBorder: RoundedRectangleBorder( onPressed: () {
borderRadius: BorderRadius.circular(6),
),
hoverColor: widget.pickerItemHoverColor,
onTap: () {
if (type == _ColorType.font) { if (type == _ColorType.font) {
widget.onSubmittedFontColorHex(option.colorHex); widget.onSubmittedFontColorHex(option.colorHex);
} else if (type == _ColorType.background) { } else if (type == _ColorType.background) {
widget.onSubmittedbackgroundColorHex(option.colorHex); widget.onSubmittedbackgroundColorHex(option.colorHex);
} }
}, },
child: Row( icon: SizedBox.square(
mainAxisAlignment: MainAxisAlignment.start, dimension: 12,
mainAxisSize: MainAxisSize.min, child: Container(
children: [ decoration: BoxDecoration(
// padding color: Color(int.tryParse(option.colorHex) ?? 0xFFFFFFFF),
const SizedBox(width: 6), 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( Expanded(
child: Text( child: Text(
option.name, option.name,
style: style:
TextStyle(fontSize: 12, color: widget.pickerItemTextColor), TextStyle(fontSize: 12, color: widget.pickerItemTextColor),
softWrap: false,
maxLines: 1,
overflow: TextOverflow.fade,
), ),
), ),
// checkbox // checkbox
if (isChecked) const FlowySvg(name: 'checkmark'), 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) ...[ if (widget.linkText != null) ...[
_buildIconButton( _buildIconButton(
iconName: 'link', iconName: 'link',
color: style?.selectionMenuItemIconColor, color: style?.popupMenuFGColor,
text: 'Open link', text: 'Open link',
onPressed: widget.onOpenLink, onPressed: widget.onOpenLink,
), ),
_buildIconButton( _buildIconButton(
iconName: 'copy', iconName: 'copy',
color: style?.selectionMenuItemIconColor, color: style?.popupMenuFGColor,
text: 'Copy link', text: 'Copy link',
onPressed: widget.onCopyLink, onPressed: widget.onCopyLink,
), ),
_buildIconButton( _buildIconButton(
iconName: 'delete', iconName: 'delete',
color: style?.selectionMenuItemIconColor, color: style?.popupMenuFGColor,
text: 'Remove link', text: 'Remove link',
onPressed: widget.onRemoveLink, onPressed: widget.onRemoveLink,
), ),
@ -102,11 +102,10 @@ class _LinkMenuState extends State<LinkMenu> {
} }
Widget _buildHeader() { Widget _buildHeader() {
return const Text( return Text(
'Add your link', 'Add your link',
style: TextStyle( style: TextStyle(
color: Colors.grey, fontSize: style?.textStyle?.fontSize,
fontWeight: FontWeight.bold,
), ),
); );
} }
@ -114,13 +113,17 @@ class _LinkMenuState extends State<LinkMenu> {
Widget _buildInput() { Widget _buildInput() {
return TextField( return TextField(
focusNode: _focusNode, focusNode: _focusNode,
style: const TextStyle(fontSize: 14.0), style: TextStyle(
fontSize: style?.textStyle?.fontSize,
),
textAlign: TextAlign.left, textAlign: TextAlign.left,
controller: _textEditingController, controller: _textEditingController,
onSubmitted: widget.onSubmitted, onSubmitted: widget.onSubmitted,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'URL', hintText: 'URL',
hintStyle: const TextStyle(fontSize: 14.0), hintStyle: TextStyle(
fontSize: style?.textStyle?.fontSize,
),
contentPadding: const EdgeInsets.all(16.0), contentPadding: const EdgeInsets.all(16.0),
isDense: true, isDense: true,
suffixIcon: IconButton( suffixIcon: IconButton(
@ -151,20 +154,24 @@ class _LinkMenuState extends State<LinkMenu> {
name: iconName, name: iconName,
color: color, color: color,
), ),
style: TextButton.styleFrom(
minimumSize: const Size.fromHeight(40),
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
alignment: Alignment.centerLeft,
),
label: Text( label: Text(
text, text,
textAlign: TextAlign.left, textAlign: TextAlign.left,
style: TextStyle( style: TextStyle(
color: style?.selectionMenuItemTextColor ?? Colors.black, color: color,
fontSize: 14.0, 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, onPressed: onPressed,
); );
} }

View file

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

View file

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

View file

@ -108,21 +108,21 @@ bool formatTextNodes(EditorState editorState, Attributes attributes) {
for (final textNode in textNodes) { for (final textNode in textNodes) {
var newAttributes = {...textNode.attributes}; var newAttributes = {...textNode.attributes};
for (final globalStyleKey in BuiltInAttributeKey.globalStyleKeys) { if (isAttributesEqual(newAttributes, attributes)) {
if (newAttributes.keys.contains(globalStyleKey)) { for (final key in attributes.keys) {
newAttributes[globalStyleKey] = null; 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 transaction
..updateNode( ..updateNode(
textNode, textNode,

View file

@ -7,7 +7,7 @@ ShortcutEventHandler cursorLeftSelect = (editorState, event) {
if (nodes.isEmpty || selection == null) { if (nodes.isEmpty || selection == null) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
final end = selection.end.goLeft(editorState); final end = selection.end.moveHorizontal(editorState);
if (end == null) { if (end == null) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
@ -23,7 +23,7 @@ ShortcutEventHandler cursorRightSelect = (editorState, event) {
if (nodes.isEmpty || selection == null) { if (nodes.isEmpty || selection == null) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
final end = selection.end.goRight(editorState); final end = selection.end.moveHorizontal(editorState, moveLeft: false);
if (end == null) { if (end == null) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
@ -39,7 +39,7 @@ ShortcutEventHandler cursorUpSelect = (editorState, event) {
if (nodes.isEmpty || selection == null) { if (nodes.isEmpty || selection == null) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
final end = _moveVertical(editorState); final end = selection.end.moveVertical(editorState);
if (end == null) { if (end == null) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
@ -55,7 +55,7 @@ ShortcutEventHandler cursorDownSelect = (editorState, event) {
if (nodes.isEmpty || selection == null) { if (nodes.isEmpty || selection == null) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
final end = _moveVertical(editorState, upwards: false); final end = selection.end.moveVertical(editorState, upwards: false);
if (end == null) { if (end == null) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
@ -225,7 +225,7 @@ ShortcutEventHandler cursorUp = (editorState, event) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
final upPosition = _moveVertical(editorState); final upPosition = selection.end.moveVertical(editorState);
editorState.updateCursorSelection( editorState.updateCursorSelection(
upPosition == null ? null : Selection.collapsed(upPosition), upPosition == null ? null : Selection.collapsed(upPosition),
); );
@ -241,7 +241,7 @@ ShortcutEventHandler cursorDown = (editorState, event) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
final downPosition = _moveVertical(editorState, upwards: false); final downPosition = selection.end.moveVertical(editorState, upwards: false);
editorState.updateCursorSelection( editorState.updateCursorSelection(
downPosition == null ? null : Selection.collapsed(downPosition), downPosition == null ? null : Selection.collapsed(downPosition),
); );
@ -258,7 +258,7 @@ ShortcutEventHandler cursorLeft = (editorState, event) {
} }
Position newPosition = selection.isCollapsed Position newPosition = selection.isCollapsed
? selection.start.goLeft(editorState) ?? selection.start ? selection.start.moveHorizontal(editorState) ?? selection.start
: selection.start; : selection.start;
editorState.service.selectionService.updateSelection( editorState.service.selectionService.updateSelection(
@ -277,7 +277,8 @@ ShortcutEventHandler cursorRight = (editorState, event) {
} }
final newPosition = selection.isCollapsed final newPosition = selection.isCollapsed
? selection.start.goRight(editorState) ?? selection.end ? selection.start.moveHorizontal(editorState, moveLeft: false) ??
selection.end
: selection.end; : selection.end;
editorState.service.selectionService.updateSelection( editorState.service.selectionService.updateSelection(
@ -294,8 +295,10 @@ ShortcutEventHandler cursorLeftWordSelect = (editorState, event) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
final end = final end = selection.end.moveHorizontal(
selection.end.goLeft(editorState, selectionRange: _SelectionRange.word); editorState,
selectionRange: SelectionRange.word,
);
if (end == null) { if (end == null) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
@ -316,8 +319,10 @@ ShortcutEventHandler cursorLeftWordMove = (editorState, event) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
final newPosition = selection.start final newPosition = selection.start.moveHorizontal(
.goLeft(editorState, selectionRange: _SelectionRange.word) ?? editorState,
selectionRange: SelectionRange.word,
) ??
selection.start; selection.start;
editorState.service.selectionService.updateSelection( editorState.service.selectionService.updateSelection(
@ -336,8 +341,11 @@ ShortcutEventHandler cursorRightWordMove = (editorState, event) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
final newPosition = selection.start final newPosition = selection.start.moveHorizontal(
.goRight(editorState, selectionRange: _SelectionRange.word) ?? editorState,
selectionRange: SelectionRange.word,
moveLeft: false,
) ??
selection.end; selection.end;
editorState.service.selectionService.updateSelection( editorState.service.selectionService.updateSelection(
@ -354,8 +362,11 @@ ShortcutEventHandler cursorRightWordSelect = (editorState, event) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
final end = final end = selection.end.moveHorizontal(
selection.end.goRight(editorState, selectionRange: _SelectionRange.word); editorState,
selectionRange: SelectionRange.word,
moveLeft: false,
);
if (end == null) { if (end == null) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
@ -367,63 +378,6 @@ ShortcutEventHandler cursorRightWordSelect = (editorState, event) {
return KeyEventResult.handled; 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 { enum _SelectionRange {
character, character,
word, 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. // insert a empty text node before.
if (selection.isCollapsed && selection.start.offset == 0) { if (selection.isCollapsed && selection.start.offset == 0) {
if (textNode.toPlainText().isEmpty && textNode.subtype != null) { if (textNode.toPlainText().isEmpty && textNode.subtype != null) {
final path =
textNode.path.length > 1 ? [++textNode.path.first] : textNode.path;
final afterSelection = Selection.collapsed( final afterSelection = Selection.collapsed(
Position(path: textNode.path, offset: 0), Position(path: path, offset: 0),
); );
final transaction = editorState.transaction
..updateNode(textNode, { final transaction = editorState.transaction;
BuiltInAttributeKey.subtype: null, if (textNode.path.length > 1) {
textNode.subtype!: null, transaction
}) ..deleteNode(textNode)
..afterSelection = afterSelection; ..insertNode(path, textNode)
..afterSelection = afterSelection;
} else {
transaction
..updateNode(textNode, {
BuiltInAttributeKey.subtype: null,
textNode.subtype!: null,
})
..afterSelection = afterSelection;
}
editorState.apply(transaction); editorState.apply(transaction);
final nextNode = textNode.next; final nextNode = textNode.next;

View file

@ -272,7 +272,17 @@ ShortcutEventHandler markdownLinkOrImageHandler = (editorState, event) {
return KeyEventResult.handled; 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' // 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. // to determine whether the selection is collapsed and whether the selected node is a text node.
final selectionService = editorState.service.selectionService; final selectionService = editorState.service.selectionService;
@ -284,24 +294,24 @@ ShortcutEventHandler underscoreToItalicHandler = (editorState, event) {
final textNode = textNodes.first; final textNode = textNodes.first;
final text = textNode.toPlainText(); final text = textNode.toPlainText();
// Determine if an 'underscore' already exists in the text node and only once. // Determine if a 'character' already exists in the text node and only once.
final firstUnderscore = text.indexOf('_'); final firstCharacter = text.indexOf(character);
final lastUnderscore = text.lastIndexOf('_'); final lastCharacter = text.lastIndexOf(character);
if (firstUnderscore == -1 || if (firstCharacter == -1 ||
firstUnderscore != lastUnderscore || firstCharacter != lastCharacter ||
firstUnderscore == selection.start.offset - 1) { firstCharacter == selection.start.offset - 1) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
// Delete the previous 'underscore', // Delete the previous 'character',
// update the style of the text surrounded by the two underscores to 'italic', // update the style of the text surrounded by the two characters to 'italic',
// and update the cursor position. // and update the cursor position.
final transaction = editorState.transaction final transaction = editorState.transaction
..deleteText(textNode, firstUnderscore, 1) ..deleteText(textNode, firstCharacter, 1)
..formatText( ..formatText(
textNode, textNode,
firstUnderscore, firstCharacter,
selection.end.offset - firstUnderscore - 1, selection.end.offset - firstCharacter - 1,
{ {
BuiltInAttributeKey.italic: true, 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'; import 'package:flutter/material.dart';
ShortcutEventHandler tabHandler = (editorState, event) { ShortcutEventHandler tabHandler = (editorState, event) {
// Only Supports BulletedList For Now. // Only Supports BulletedList and Checkboxes for now.
final selection = editorState.service.selectionService.currentSelection.value; final selection = editorState.service.selectionService.currentSelection.value;
final textNodes = editorState.service.selectionService.currentSelectedNodes final textNodes = editorState.service.selectionService.currentSelectedNodes
@ -14,17 +14,14 @@ ShortcutEventHandler tabHandler = (editorState, event) {
final textNode = textNodes.first; final textNode = textNodes.first;
final previous = textNode.previous; final previous = textNode.previous;
if (textNode.subtype != BuiltInAttributeKey.bulletedList && if (textNode.isNotBulletOrCheckbox) {
textNode.subtype != BuiltInAttributeKey.checkbox) {
final transaction = editorState.transaction final transaction = editorState.transaction
..insertText(textNode, selection.end.offset, ' ' * 4); ..insertText(textNode, selection.end.offset, ' ' * 4);
editorState.apply(transaction); editorState.apply(transaction);
return KeyEventResult.handled; return KeyEventResult.handled;
} }
if (previous == null || if (previous == null || textNode.isNotBulletOrCheckbox) {
(previous.subtype != BuiltInAttributeKey.bulletedList &&
previous.subtype != BuiltInAttributeKey.checkbox)) {
return KeyEventResult.ignored; 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/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/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/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/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/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/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/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/redo_undo_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/select_all_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', command: 'tab',
handler: tabHandler, handler: tabHandler,
), ),
ShortcutEvent(
key: 'Outdent List',
command: 'shift+tab',
handler: outdentTabHandler,
),
ShortcutEvent( ShortcutEvent(
key: 'Backquote to code', key: 'Backquote to code',
command: 'backquote', command: 'backquote',
@ -312,7 +319,12 @@ List<ShortcutEvent> builtInShortcutEvents = [
ShortcutEvent( ShortcutEvent(
key: 'Underscore to italic', key: 'Underscore to italic',
character: '_', character: '_',
handler: underscoreToItalicHandler, handler: singleUnderscoreToItalicHandler,
),
ShortcutEvent(
key: 'Asterisk to italic',
character: '*',
handler: singleAsteriskToItalicHandler,
), ),
ShortcutEvent( ShortcutEvent(
key: 'Double asterisk to bold', key: 'Double asterisk to bold',

View file

@ -1,6 +1,6 @@
name: appflowy_editor name: appflowy_editor
description: A highly customizable rich-text editor for Flutter. The AppFlowy Editor project for AppFlowy and beyond. 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 homepage: https://github.com/AppFlowy-IO/appflowy-editor
platforms: platforms:

View file

@ -1,4 +1,5 @@
import 'package:appflowy_editor/appflowy_editor.dart'; 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:appflowy_editor/src/render/rich_text/quoted_text.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../../infra/test_editor.dart'; import '../../infra/test_editor.dart';
@ -31,5 +32,36 @@ void main() async {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(QuotedTextNodeWidget), findsNothing); 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