diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/editor_transaction_adapter.dart b/frontend/appflowy_flutter/lib/plugins/document/application/editor_transaction_adapter.dart index d66110d950..5f8d9014b9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/editor_transaction_adapter.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/editor_transaction_adapter.dart @@ -7,20 +7,7 @@ import 'package:appflowy/plugins/document/application/document_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_block_component.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; -import 'package:appflowy_editor/appflowy_editor.dart' - show - EditorState, - Transaction, - Operation, - InsertOperation, - UpdateOperation, - DeleteOperation, - PathExtensions, - Node, - Path, - Delta, - composeAttributes, - blockComponentDelta; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:nanoid/nanoid.dart'; @@ -287,11 +274,6 @@ extension on UpdateOperation { // create the external text if the node contains the delta in its data. final prevDelta = oldAttributes[blockComponentDelta]; final delta = attributes[blockComponentDelta]; - final diff = prevDelta != null && delta != null - ? Delta.fromJson(prevDelta).diff( - Delta.fromJson(delta), - ) - : null; final composedAttributes = composeAttributes(oldAttributes, attributes); final composedDelta = composedAttributes?[blockComponentDelta]; @@ -312,12 +294,15 @@ extension on UpdateOperation { // to be compatible with the old version, we create a new text id if the text id is empty. final textId = nanoid(6); final textDelta = composedDelta ?? delta ?? prevDelta; - final textDeltaPayloadPB = textDelta == null + final correctedTextDelta = + textDelta != null ? _correctAttributes(textDelta) : null; + + final textDeltaPayloadPB = correctedTextDelta == null ? null : TextDeltaPayloadPB( documentId: documentId, textId: textId, - delta: jsonEncode(textDelta), + delta: jsonEncode(correctedTextDelta), ); node.externalValues = ExternalValues( @@ -342,12 +327,20 @@ extension on UpdateOperation { ), ); } else { - final textDeltaPayloadPB = delta == null + final diff = prevDelta != null && delta != null + ? Delta.fromJson(prevDelta).diff( + Delta.fromJson(delta), + ) + : null; + + final correctedDiff = diff != null ? _correctDelta(diff) : null; + + final textDeltaPayloadPB = correctedDiff == null ? null : TextDeltaPayloadPB( documentId: documentId, textId: textId, - delta: jsonEncode(diff), + delta: jsonEncode(correctedDiff), ); if (enableDocumentInternalLog) { @@ -370,6 +363,58 @@ extension on UpdateOperation { return actions; } + + // if the value in Delta's attributes is false, we should set the value to null instead. + // because on Yjs, canceling format must use the null value. If we use false, the update will be rejected. + List? _correctDelta(Delta delta) { + // if the value in diff's attributes is false, we should set the value to null instead. + // because on Yjs, canceling format must use the null value. If we use false, the update will be rejected. + final correctedOps = delta.map((op) { + final attributes = op.attributes?.map( + (key, value) => MapEntry( + key, + // if the value is false, we should set the value to null instead. + value == false ? null : value, + ), + ); + + if (attributes != null) { + if (op is TextRetain) { + return TextRetain(op.length, attributes: attributes); + } else if (op is TextInsert) { + return TextInsert(op.text, attributes: attributes); + } + // ignore the other operations that do not contain attributes. + } + + return op; + }); + + return correctedOps.toList(growable: false); + } + + // Refer to [_correctDelta] for more details. + List> _correctAttributes( + List> attributes, + ) { + final correctedAttributes = attributes.map((attribute) { + return attribute.map((key, value) { + if (value is bool) { + return MapEntry(key, value == false ? null : value); + } else if (value is Map) { + return MapEntry( + key, + value.map((key, value) { + return MapEntry(key, value == false ? null : value); + }), + ); + } + return MapEntry(key, value); + }); + }).toList(growable: false); + + return correctedAttributes; + } } extension on DeleteOperation { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart index 67ce95d59c..9108098f94 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart @@ -2,10 +2,6 @@ import 'dart:async'; import 'dart:io'; import 'dart:math'; -import 'package:appflowy/util/theme_extension.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; - import 'package:appflowy/core/frameless_window.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; @@ -13,6 +9,7 @@ import 'package:appflowy/plugins/blank/blank.dart'; import 'package:appflowy/shared/window_title_bar.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; @@ -25,6 +22,8 @@ import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import 'package:time/time.dart'; @@ -697,6 +696,10 @@ class PageManager { value: _notifier, child: Consumer( builder: (_, notifier, __) { + if (notifier.plugin.pluginType == PluginType.blank) { + return const BlankPage(); + } + return FadingIndexedStack( index: getIt().indexOf(notifier.plugin.pluginType), children: getIt().supportPluginTypes.map( diff --git a/frontend/appflowy_flutter/test/unit_test/editor/transaction_adapter_test.dart b/frontend/appflowy_flutter/test/unit_test/editor/transaction_adapter_test.dart index 4a7457a43b..9e60c13ed7 100644 --- a/frontend/appflowy_flutter/test/unit_test/editor/transaction_adapter_test.dart +++ b/frontend/appflowy_flutter/test/unit_test/editor/transaction_adapter_test.dart @@ -290,5 +290,107 @@ void main() { await editorState.apply(transaction); await completer.future; }); + + test('text retain with attributes that are false', () async { + final node = paragraphNode( + delta: Delta() + ..insert( + 'Hello AppFlowy', + attributes: { + 'bold': true, + }, + ), + ); + final document = Document( + root: pageNode( + children: [ + node, + ], + ), + ); + final transactionAdapter = TransactionAdapter( + documentId: '', + documentService: DocumentService(), + ); + + final editorState = EditorState( + document: document, + ); + + int counter = 0; + final completer = Completer(); + editorState.transactionStream.listen((event) { + final time = event.$1; + if (time == TransactionTime.before) { + final actions = transactionAdapter.transactionToBlockActions( + event.$2, + editorState, + ); + final textActions = + transactionAdapter.filterTextDeltaActions(actions); + final blockActions = transactionAdapter.filterBlockActions(actions); + expect(textActions.length, 1); + expect(blockActions.length, 1); + if (counter == 1) { + // check text operation + final textAction = textActions.first; + final textId = textAction.textDeltaPayloadPB?.textId; + { + expect(textAction.textDeltaType, TextDeltaType.create); + + expect(textId, isNotEmpty); + final delta = textAction.textDeltaPayloadPB?.delta; + expect( + delta, + equals( + '[{"insert":"Hello","attributes":{"bold":null}},{"insert":" AppFlowy","attributes":{"bold":true}}]', + ), + ); + } + } else if (counter == 3) { + final textAction = textActions.first; + final textId = textAction.textDeltaPayloadPB?.textId; + { + expect(textAction.textDeltaType, TextDeltaType.update); + + expect(textId, isNotEmpty); + final delta = textAction.textDeltaPayloadPB?.delta; + expect( + delta, + equals( + '[{"retain":5,"attributes":{"bold":null}}]', + ), + ); + } + } + } else if (time == TransactionTime.after && counter == 3) { + completer.complete(); + } + }); + + counter = 1; + final insertTransaction = editorState.transaction; + insertTransaction.formatText(node, 0, 5, { + 'bold': false, + }); + + await editorState.apply(insertTransaction); + + counter = 2; + final updateTransaction = editorState.transaction; + updateTransaction.formatText(node, 0, 5, { + 'bold': true, + }); + await editorState.apply(updateTransaction); + + counter = 3; + final formatTransaction = editorState.transaction; + formatTransaction.formatText(node, 0, 5, { + 'bold': false, + }); + await editorState.apply(formatTransaction); + + await completer.future; + }); }); }