mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Merge pull request #1188 from LucasXu0/commands
feat: add commands and update checkbox logic
This commit is contained in:
commit
3df31c43f8
@ -0,0 +1,34 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy_editor/src/commands/text_command_infra.dart';
|
||||
import 'package:appflowy_editor/src/document/node.dart';
|
||||
import 'package:appflowy_editor/src/document/path.dart';
|
||||
import 'package:appflowy_editor/src/editor_state.dart';
|
||||
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
Future<void> insertContextInText(
|
||||
EditorState editorState,
|
||||
int index,
|
||||
String content, {
|
||||
Path? path,
|
||||
TextNode? textNode,
|
||||
}) async {
|
||||
final result = getTextNodeToBeFormatted(
|
||||
editorState,
|
||||
path: path,
|
||||
textNode: textNode,
|
||||
);
|
||||
|
||||
final completer = Completer<void>();
|
||||
|
||||
TransactionBuilder(editorState)
|
||||
..insertText(result, index, content)
|
||||
..commit();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
completer.complete();
|
||||
});
|
||||
|
||||
return completer.future;
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
import 'package:appflowy_editor/src/commands/format_text.dart';
|
||||
import 'package:appflowy_editor/src/commands/text_command_infra.dart';
|
||||
import 'package:appflowy_editor/src/document/attributes.dart';
|
||||
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
|
||||
import 'package:appflowy_editor/src/document/node.dart';
|
||||
import 'package:appflowy_editor/src/document/path.dart';
|
||||
import 'package:appflowy_editor/src/document/selection.dart';
|
||||
import 'package:appflowy_editor/src/editor_state.dart';
|
||||
|
||||
Future<void> formatBuiltInTextAttributes(
|
||||
EditorState editorState,
|
||||
String key,
|
||||
Attributes attributes, {
|
||||
Selection? selection,
|
||||
Path? path,
|
||||
TextNode? textNode,
|
||||
}) async {
|
||||
final result = getTextNodeToBeFormatted(
|
||||
editorState,
|
||||
path: path,
|
||||
textNode: textNode,
|
||||
);
|
||||
if (BuiltInAttributeKey.globalStyleKeys.contains(key)) {
|
||||
// remove all the existing style
|
||||
final newAttributes = result.attributes
|
||||
..removeWhere((key, value) {
|
||||
if (BuiltInAttributeKey.globalStyleKeys.contains(key)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
..addAll(attributes)
|
||||
..addAll({
|
||||
BuiltInAttributeKey.subtype: key,
|
||||
});
|
||||
return updateTextNodeAttributes(
|
||||
editorState,
|
||||
newAttributes,
|
||||
textNode: textNode,
|
||||
);
|
||||
} else if (BuiltInAttributeKey.partialStyleKeys.contains(key)) {
|
||||
return updateTextNodeDeltaAttributes(
|
||||
editorState,
|
||||
selection,
|
||||
attributes,
|
||||
textNode: textNode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> formatTextToCheckbox(
|
||||
EditorState editorState,
|
||||
bool check, {
|
||||
Path? path,
|
||||
TextNode? textNode,
|
||||
}) async {
|
||||
return formatBuiltInTextAttributes(
|
||||
editorState,
|
||||
BuiltInAttributeKey.checkbox,
|
||||
{
|
||||
BuiltInAttributeKey.checkbox: check,
|
||||
},
|
||||
path: path,
|
||||
textNode: textNode,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> formatLinkInText(
|
||||
EditorState editorState,
|
||||
String? link, {
|
||||
Path? path,
|
||||
TextNode? textNode,
|
||||
}) async {
|
||||
return formatBuiltInTextAttributes(
|
||||
editorState,
|
||||
BuiltInAttributeKey.href,
|
||||
{
|
||||
BuiltInAttributeKey.href: link,
|
||||
},
|
||||
path: path,
|
||||
textNode: textNode,
|
||||
);
|
||||
}
|
@ -0,0 +1,67 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy_editor/src/commands/text_command_infra.dart';
|
||||
import 'package:appflowy_editor/src/document/attributes.dart';
|
||||
import 'package:appflowy_editor/src/document/node.dart';
|
||||
import 'package:appflowy_editor/src/document/path.dart';
|
||||
import 'package:appflowy_editor/src/document/selection.dart';
|
||||
import 'package:appflowy_editor/src/editor_state.dart';
|
||||
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
Future<void> updateTextNodeAttributes(
|
||||
EditorState editorState,
|
||||
Attributes attributes, {
|
||||
Path? path,
|
||||
TextNode? textNode,
|
||||
}) async {
|
||||
final result = getTextNodeToBeFormatted(
|
||||
editorState,
|
||||
path: path,
|
||||
textNode: textNode,
|
||||
);
|
||||
|
||||
final completer = Completer<void>();
|
||||
|
||||
TransactionBuilder(editorState)
|
||||
..updateNode(result, attributes)
|
||||
..commit();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
completer.complete();
|
||||
});
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<void> updateTextNodeDeltaAttributes(
|
||||
EditorState editorState,
|
||||
Selection? selection,
|
||||
Attributes attributes, {
|
||||
Path? path,
|
||||
TextNode? textNode,
|
||||
}) {
|
||||
final result = getTextNodeToBeFormatted(
|
||||
editorState,
|
||||
path: path,
|
||||
textNode: textNode,
|
||||
);
|
||||
final newSelection = getSelection(editorState, selection: selection);
|
||||
|
||||
final completer = Completer<void>();
|
||||
|
||||
TransactionBuilder(editorState)
|
||||
..formatText(
|
||||
result,
|
||||
newSelection.startIndex,
|
||||
newSelection.length,
|
||||
attributes,
|
||||
)
|
||||
..commit();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
completer.complete();
|
||||
});
|
||||
|
||||
return completer.future;
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
import 'package:appflowy_editor/src/document/node.dart';
|
||||
import 'package:appflowy_editor/src/document/path.dart';
|
||||
import 'package:appflowy_editor/src/document/selection.dart';
|
||||
import 'package:appflowy_editor/src/editor_state.dart';
|
||||
|
||||
// get formatted [TextNode]
|
||||
TextNode getTextNodeToBeFormatted(
|
||||
EditorState editorState, {
|
||||
Path? path,
|
||||
TextNode? textNode,
|
||||
}) {
|
||||
final currentSelection =
|
||||
editorState.service.selectionService.currentSelection.value;
|
||||
TextNode result;
|
||||
if (textNode != null) {
|
||||
result = textNode;
|
||||
} else if (path != null) {
|
||||
result = editorState.document.nodeAtPath(path) as TextNode;
|
||||
} else if (currentSelection != null && currentSelection.isCollapsed) {
|
||||
result = editorState.document.nodeAtPath(currentSelection.start.path)
|
||||
as TextNode;
|
||||
} else {
|
||||
throw Exception('path and textNode cannot be null at the same time');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Selection getSelection(
|
||||
EditorState editorState, {
|
||||
Selection? selection,
|
||||
}) {
|
||||
final currentSelection =
|
||||
editorState.service.selectionService.currentSelection.value;
|
||||
Selection result;
|
||||
if (selection != null) {
|
||||
result = selection;
|
||||
} else if (currentSelection != null) {
|
||||
result = currentSelection;
|
||||
} else {
|
||||
throw Exception('path and textNode cannot be null at the same time');
|
||||
}
|
||||
return result;
|
||||
}
|
@ -53,6 +53,10 @@ class Selection {
|
||||
|
||||
Selection get reversed => copyWith(start: end, end: start);
|
||||
|
||||
int get startIndex => normalize.start.offset;
|
||||
int get endIndex => normalize.end.offset;
|
||||
int get length => endIndex - startIndex;
|
||||
|
||||
Selection collapse({bool atStart = false}) {
|
||||
if (atStart) {
|
||||
return Selection(start: start, end: start);
|
||||
|
@ -72,6 +72,8 @@ class EditorState {
|
||||
// TODO: only for testing.
|
||||
bool disableSealTimer = false;
|
||||
|
||||
bool editable = true;
|
||||
|
||||
Selection? get cursorSelection {
|
||||
return _cursorSelection;
|
||||
}
|
||||
@ -112,6 +114,9 @@ class EditorState {
|
||||
/// should record the transaction in undo/redo stack.
|
||||
apply(Transaction transaction,
|
||||
[ApplyOptions options = const ApplyOptions()]) {
|
||||
if (!editable) {
|
||||
return;
|
||||
}
|
||||
// TODO: validate the transation.
|
||||
for (final op in transaction.operations) {
|
||||
_applyOperation(op);
|
||||
|
@ -31,6 +31,7 @@ class _LinkMenuState extends State<LinkMenu> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_textEditingController.text = widget.linkText ?? '';
|
||||
_focusNode.requestFocus();
|
||||
_focusNode.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
|
@ -1,15 +1,8 @@
|
||||
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
|
||||
import 'package:appflowy_editor/src/document/node.dart';
|
||||
import 'package:appflowy_editor/src/editor_state.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/src/commands/format_built_in_text.dart';
|
||||
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
|
||||
import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart';
|
||||
import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart';
|
||||
import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart';
|
||||
import 'package:appflowy_editor/src/render/selection/selectable.dart';
|
||||
import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
|
||||
|
||||
import 'package:appflowy_editor/src/service/render_plugin_service.dart';
|
||||
import 'package:appflowy_editor/src/extensions/attributes_extension.dart';
|
||||
import 'package:appflowy_editor/src/extensions/text_style_extension.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@ -81,8 +74,12 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
|
||||
padding: iconPadding,
|
||||
name: check ? 'check' : 'uncheck',
|
||||
),
|
||||
onTap: () {
|
||||
formatCheckbox(widget.editorState, !check);
|
||||
onTap: () async {
|
||||
await formatTextToCheckbox(
|
||||
widget.editorState,
|
||||
!check,
|
||||
textNode: widget.textNode,
|
||||
);
|
||||
},
|
||||
),
|
||||
Flexible(
|
||||
|
@ -18,6 +18,8 @@ import 'package:appflowy_editor/src/extensions/attributes_extension.dart';
|
||||
import 'package:appflowy_editor/src/render/selection/selectable.dart';
|
||||
import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
|
||||
|
||||
const _kRichTextDebugMode = false;
|
||||
|
||||
typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan);
|
||||
|
||||
class FlowyRichText extends StatefulWidget {
|
||||
@ -261,6 +263,17 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_kRichTextDebugMode) {
|
||||
textSpans.add(
|
||||
TextSpan(
|
||||
text: '${widget.textNode.path}',
|
||||
style: const TextStyle(
|
||||
backgroundColor: Colors.red,
|
||||
fontSize: 16.0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return TextSpan(
|
||||
children: textSpans,
|
||||
);
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/src/commands/format_built_in_text.dart';
|
||||
import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart';
|
||||
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
|
||||
import 'package:appflowy_editor/src/render/link_menu/link_menu.dart';
|
||||
@ -345,11 +346,8 @@ void showLinkMenu(
|
||||
onOpenLink: () async {
|
||||
await safeLaunchUrl(linkText);
|
||||
},
|
||||
onSubmitted: (text) {
|
||||
TransactionBuilder(editorState)
|
||||
..formatText(
|
||||
textNode, index, length, {BuiltInAttributeKey.href: text})
|
||||
..commit();
|
||||
onSubmitted: (text) async {
|
||||
await formatLinkInText(editorState, text, textNode: textNode);
|
||||
_dismissLinkMenu();
|
||||
},
|
||||
onCopyLink: () {
|
||||
@ -377,6 +375,7 @@ void showLinkMenu(
|
||||
Overlay.of(context)?.insert(_linkMenuOverlay!);
|
||||
|
||||
editorState.service.scrollService?.disable();
|
||||
editorState.service.keyboardService?.disable();
|
||||
editorState.service.selectionService.currentSelection
|
||||
.addListener(_dismissLinkMenu);
|
||||
}
|
||||
|
@ -103,13 +103,17 @@ bool formatTextNodes(EditorState editorState, Attributes attributes) {
|
||||
final builder = TransactionBuilder(editorState);
|
||||
|
||||
for (final textNode in textNodes) {
|
||||
var newAttributes = {...textNode.attributes};
|
||||
for (final globalStyleKey in BuiltInAttributeKey.globalStyleKeys) {
|
||||
if (newAttributes.keys.contains(globalStyleKey)) {
|
||||
newAttributes[globalStyleKey] = null;
|
||||
}
|
||||
}
|
||||
newAttributes.addAll(attributes);
|
||||
builder
|
||||
..updateNode(
|
||||
textNode,
|
||||
Attributes.fromIterable(
|
||||
BuiltInAttributeKey.globalStyleKeys,
|
||||
value: (_) => null,
|
||||
)..addAll(attributes),
|
||||
newAttributes,
|
||||
)
|
||||
..afterSelection = Selection.collapsed(
|
||||
Position(
|
||||
|
@ -72,6 +72,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
|
||||
editorState.selectionMenuItems = widget.selectionMenuItems;
|
||||
editorState.editorStyle = widget.editorStyle;
|
||||
editorState.service.renderPluginService = _createRenderPlugin();
|
||||
editorState.editable = widget.editable;
|
||||
}
|
||||
|
||||
@override
|
||||
@ -84,6 +85,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
|
||||
}
|
||||
|
||||
editorState.editorStyle = widget.editorStyle;
|
||||
editorState.editable = widget.editable;
|
||||
services = null;
|
||||
}
|
||||
|
||||
|
@ -297,7 +297,11 @@ class _AppFlowyInputState extends State<AppFlowyInput>
|
||||
_updateCaretPosition(textNodes.first, selection);
|
||||
}
|
||||
} else {
|
||||
// close();
|
||||
// https://github.com/flutter/flutter/issues/104944
|
||||
// Disable IME for the Web.
|
||||
if (kIsWeb) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,21 @@
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/src/commands/edit_text.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
ShortcutEventHandler spaceOnWebHandler = (editorState, event) {
|
||||
final selection = editorState.service.selectionService.currentSelection.value;
|
||||
final textNodes = editorState.service.selectionService.currentSelectedNodes
|
||||
.whereType<TextNode>()
|
||||
.toList(growable: false);
|
||||
if (selection == null ||
|
||||
!selection.isCollapsed ||
|
||||
!kIsWeb ||
|
||||
textNodes.length != 1) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
insertContextInText(editorState, selection.startIndex, ' ');
|
||||
|
||||
return KeyEventResult.handled;
|
||||
};
|
@ -9,9 +9,11 @@ import 'package:appflowy_editor/src/service/internal_key_event_handlers/redo_und
|
||||
import 'package:appflowy_editor/src/service/internal_key_event_handlers/select_all_handler.dart';
|
||||
import 'package:appflowy_editor/src/service/internal_key_event_handlers/slash_handler.dart';
|
||||
import 'package:appflowy_editor/src/service/internal_key_event_handlers/format_style_handler.dart';
|
||||
import 'package:appflowy_editor/src/service/internal_key_event_handlers/space_on_web_handler.dart';
|
||||
import 'package:appflowy_editor/src/service/internal_key_event_handlers/tab_handler.dart';
|
||||
import 'package:appflowy_editor/src/service/internal_key_event_handlers/whitespace_handler.dart';
|
||||
import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
//
|
||||
List<ShortcutEvent> builtInShortcutEvents = [
|
||||
@ -249,4 +251,14 @@ List<ShortcutEvent> builtInShortcutEvents = [
|
||||
command: 'tab',
|
||||
handler: tabHandler,
|
||||
),
|
||||
// https://github.com/flutter/flutter/issues/104944
|
||||
// Workaround: Using space editing on the web platform often results in errors,
|
||||
// so adding a shortcut event to handle the space input instead of using the
|
||||
// `input_service`.
|
||||
if (kIsWeb)
|
||||
ShortcutEvent(
|
||||
key: 'Space on the Web',
|
||||
command: 'space',
|
||||
handler: spaceOnWebHandler,
|
||||
),
|
||||
];
|
||||
|
@ -0,0 +1,45 @@
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import '../../infra/test_editor.dart';
|
||||
|
||||
void main() async {
|
||||
setUpAll(() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
});
|
||||
|
||||
group('space_on_web_handler.dart', () {
|
||||
testWidgets('Presses space key on web', (tester) async {
|
||||
if (!kIsWeb) return;
|
||||
const count = 10;
|
||||
const text = 'Welcome to Appflowy 😁';
|
||||
final editor = tester.editor;
|
||||
for (var i = 0; i < count; i++) {
|
||||
editor.insertTextNode(text);
|
||||
}
|
||||
await editor.startTesting();
|
||||
|
||||
for (var i = 0; i < count; i++) {
|
||||
await editor.updateSelection(
|
||||
Selection.single(path: [i], startOffset: 1),
|
||||
);
|
||||
await editor.pressLogicKey(LogicalKeyboardKey.space);
|
||||
expect(
|
||||
(editor.nodeAtPath([i]) as TextNode).toRawString(),
|
||||
'W elcome to Appflowy 😁',
|
||||
);
|
||||
}
|
||||
for (var i = 0; i < count; i++) {
|
||||
await editor.updateSelection(
|
||||
Selection.single(path: [i], startOffset: text.length + 1),
|
||||
);
|
||||
await editor.pressLogicKey(LogicalKeyboardKey.space);
|
||||
expect(
|
||||
(editor.nodeAtPath([i]) as TextNode).toRawString(),
|
||||
'W elcome to Appflowy 😁 ',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue
Block a user