Integrate appflowy editor (#1040)

This commit is contained in:
Lucas.Xu
2022-10-22 21:57:44 +08:00
committed by GitHub
parent 8dff9dc67c
commit ad9a4b7d71
177 changed files with 4183 additions and 1007 deletions

View File

@ -2,10 +2,11 @@ import 'dart:convert';
import 'package:app_flowy/plugins/trash/application/trash_service.dart';
import 'package:app_flowy/workspace/application/view/view_listener.dart';
import 'package:app_flowy/plugins/doc/application/doc_service.dart';
import 'package:appflowy_editor/appflowy_editor.dart'
show EditorState, Document, Transaction;
import 'package:flowy_sdk/protobuf/flowy-folder/trash.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter_quill/flutter_quill.dart' show Document, Delta;
import 'package:flowy_sdk/log.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@ -14,15 +15,13 @@ import 'dart:async';
part 'doc_bloc.freezed.dart';
typedef FlutterQuillDocument = Document;
class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
final ViewPB view;
final DocumentService service;
final ViewListener listener;
final TrashService trashService;
late FlutterQuillDocument document;
late EditorState editorState;
StreamSubscription? _subscription;
DocumentBloc({
@ -35,6 +34,7 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
await event.map(
initial: (Initial value) async {
await _initial(value, emit);
_listenOnViewChange();
},
deleted: (Deleted value) async {
emit(state.copyWith(isDeleted: true));
@ -73,6 +73,29 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
}
Future<void> _initial(Initial value, Emitter<DocumentState> emit) async {
final result = await service.openDocument(view: view);
result.fold(
(block) {
final document = Document.fromJson(jsonDecode(block.snapshot));
editorState = EditorState(document: document);
_listenOnDocumentChange();
emit(
state.copyWith(
loadingState: DocumentLoadingState.finish(left(unit)),
),
);
},
(err) {
emit(
state.copyWith(
loadingState: DocumentLoadingState.finish(right(err)),
),
);
},
);
}
void _listenOnViewChange() {
listener.start(
onViewDeleted: (result) {
result.fold(
@ -87,46 +110,18 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
);
},
);
final result = await service.openDocument(docId: view.id);
result.fold(
(block) {
document = _decodeJsonToDocument(block.snapshot);
_subscription = document.changes.listen((event) {
final delta = event.item2;
final documentDelta = document.toDelta();
_composeDelta(delta, documentDelta);
});
emit(state.copyWith(
loadingState: DocumentLoadingState.finish(left(unit))));
},
(err) {
emit(state.copyWith(
loadingState: DocumentLoadingState.finish(right(err))));
},
);
}
// Document _decodeListToDocument(Uint8List data) {
// final json = jsonDecode(utf8.decode(data));
// final document = Document.fromJson(json);
// return document;
// }
void _composeDelta(Delta composedDelta, Delta documentDelta) async {
final json = jsonEncode(composedDelta.toJson());
Log.debug("doc_id: $view.id - Send json: $json");
final result = await service.applyEdit(docId: view.id, operations: json);
result.fold(
(_) {},
(r) => Log.error(r),
);
}
Document _decodeJsonToDocument(String data) {
final json = jsonDecode(data);
final document = Document.fromJson(json);
return document;
void _listenOnDocumentChange() {
_subscription = editorState.transactionStream.listen((transaction) {
final json = jsonEncode(TransactionAdaptor(transaction).toJson());
service.applyEdit(docId: view.id, operations: json).then((result) {
result.fold(
(l) => null,
(err) => Log.error(err),
);
});
});
}
}
@ -160,3 +155,44 @@ class DocumentLoadingState with _$DocumentLoadingState {
const factory DocumentLoadingState.finish(
Either<Unit, FlowyError> successOrFail) = _Finish;
}
/// Uses to erase the different between appflowy editor and the backend
class TransactionAdaptor {
final Transaction transaction;
TransactionAdaptor(this.transaction);
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (transaction.operations.isNotEmpty) {
// The backend uses [0,0] as the beginning path, but the editor uses [0].
// So it needs to extend the path by inserting `0` at the head for all
// operations before passing to the backend.
json['operations'] = transaction.operations
.map((e) => e.copyWith(path: [0, ...e.path]).toJson())
.toList();
}
if (transaction.afterSelection != null) {
final selection = transaction.afterSelection!;
final start = selection.start;
final end = selection.end;
json['after_selection'] = selection
.copyWith(
start: start.copyWith(path: [0, ...start.path]),
end: end.copyWith(path: [0, ...end.path]),
)
.toJson();
}
if (transaction.beforeSelection != null) {
final selection = transaction.beforeSelection!;
final start = selection.start;
final end = selection.end;
json['before_selection'] = selection
.copyWith(
start: start.copyWith(path: [0, ...start.path]),
end: end.copyWith(path: [0, ...end.path]),
)
.toJson();
}
return json;
}
}

View File

@ -3,16 +3,25 @@ import 'package:flowy_sdk/dispatch/dispatch.dart';
import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-sync/document.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-document/entities.pb.dart';
class DocumentService {
Future<Either<DocumentSnapshotPB, FlowyError>> openDocument({
required String docId,
required ViewPB view,
}) async {
await FolderEventSetLatestView(ViewIdPB(value: docId)).send();
await FolderEventSetLatestView(ViewIdPB(value: view.id)).send();
final payload = OpenDocumentContextPB()
..documentId = view.id
..documentVersion = DocumentVersionPB.V1;
// switch (view.dataFormat) {
// case ViewDataFormatPB.DeltaFormat:
// payload.documentVersion = DocumentVersionPB.V0;
// break;
// default:
// break;
// }
final payload = DocumentIdPB(value: docId);
return DocumentEventGetDocument(payload).send();
}

View File

@ -1,14 +1,16 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:app_flowy/startup/tasks/rust_sdk.dart';
import 'package:app_flowy/workspace/application/markdown/delta_markdown.dart';
import 'package:app_flowy/plugins/doc/application/share_service.dart';
import 'package:app_flowy/workspace/application/markdown/document_markdown.dart';
import 'package:flowy_sdk/protobuf/flowy-document/entities.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:dartz/dartz.dart';
import 'package:appflowy_editor/appflowy_editor.dart' show Document;
part 'share_bloc.freezed.dart';
class DocShareBloc extends Bloc<DocShareEvent, DocShareState> {
@ -19,10 +21,10 @@ class DocShareBloc extends Bloc<DocShareEvent, DocShareState> {
on<DocShareEvent>((event, emit) async {
await event.map(
shareMarkdown: (ShareMarkdown value) async {
await service.exportMarkdown(view.id).then((result) {
await service.exportMarkdown(view).then((result) {
result.fold(
(value) => emit(
DocShareState.finish(left(_convertDeltaToMarkdown(value)))),
(value) => emit(DocShareState.finish(
left(_convertDocumentToMarkdown(value)))),
(error) => emit(DocShareState.finish(right(error))),
);
});
@ -35,8 +37,10 @@ class DocShareBloc extends Bloc<DocShareEvent, DocShareState> {
});
}
ExportDataPB _convertDeltaToMarkdown(ExportDataPB value) {
final result = deltaToMarkdown(value.data);
ExportDataPB _convertDocumentToMarkdown(ExportDataPB value) {
final json = jsonDecode(value.data);
final document = Document.fromJson(json);
final result = documentToMarkdown(document);
value.data = result;
writeFile(result);
return value;

View File

@ -3,26 +3,28 @@ import 'package:dartz/dartz.dart';
import 'package:flowy_sdk/dispatch/dispatch.dart';
import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-document/protobuf.dart';
import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart';
class ShareService {
Future<Either<ExportDataPB, FlowyError>> export(
String docId, ExportType type) {
final request = ExportPayloadPB.create()
..viewId = docId
..exportType = type;
ViewPB view, ExportType type) {
var payload = ExportPayloadPB.create()
..viewId = view.id
..exportType = type
..documentVersion = DocumentVersionPB.V1;
return DocumentEventExportDocument(request).send();
return DocumentEventExportDocument(payload).send();
}
Future<Either<ExportDataPB, FlowyError>> exportText(String docId) {
return export(docId, ExportType.Text);
Future<Either<ExportDataPB, FlowyError>> exportText(ViewPB view) {
return export(view, ExportType.Text);
}
Future<Either<ExportDataPB, FlowyError>> exportMarkdown(String docId) {
return export(docId, ExportType.Markdown);
Future<Either<ExportDataPB, FlowyError>> exportMarkdown(ViewPB view) {
return export(view, ExportType.Markdown);
}
Future<Either<ExportDataPB, FlowyError>> exportURL(String docId) {
return export(docId, ExportType.Link);
Future<Either<ExportDataPB, FlowyError>> exportURL(ViewPB view) {
return export(view, ExportType.Link);
}
}

View File

@ -44,7 +44,7 @@ class DocumentPluginBuilder extends PluginBuilder {
PluginType get pluginType => PluginType.editor;
@override
ViewDataTypePB get dataType => ViewDataTypePB.Text;
ViewDataFormatPB get dataFormatType => ViewDataFormatPB.TreeFormat;
}
class DocumentPlugin extends Plugin<int> {

View File

@ -1,17 +1,14 @@
import 'package:app_flowy/plugins/doc/editor_styles.dart';
import 'package:app_flowy/plugins/doc/presentation/plugins/horizontal_rule_node_widget.dart';
import 'package:app_flowy/startup/startup.dart';
import 'package:app_flowy/workspace/application/appearance.dart';
import 'package:app_flowy/plugins/doc/presentation/banner.dart';
import 'package:app_flowy/plugins/doc/presentation/toolbar/tool_bar.dart';
import 'package:flowy_infra_ui/style_widget/scrolling/styled_scroll_bar.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter_quill/flutter_quill.dart' as quill;
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import 'application/doc_bloc.dart';
import 'styles.dart';
class DocumentPage extends StatefulWidget {
final VoidCallback onDeleted;
@ -29,11 +26,12 @@ class DocumentPage extends StatefulWidget {
class _DocumentPageState extends State<DocumentPage> {
late DocumentBloc documentBloc;
final scrollController = ScrollController();
final FocusNode _focusNode = FocusNode();
@override
void initState() {
// The appflowy editor use Intl as locatization, set the default language as fallback.
Intl.defaultLocale = 'en_US';
documentBloc = getIt<DocumentBloc>(param1: super.widget.view)
..add(const DocumentEvent.initial());
super.initState();
@ -48,9 +46,9 @@ class _DocumentPageState extends State<DocumentPage> {
child:
BlocBuilder<DocumentBloc, DocumentState>(builder: (context, state) {
return state.loadingState.map(
// loading: (_) => const FlowyProgressIndicator(),
loading: (_) =>
SizedBox.expand(child: Container(color: Colors.transparent)),
loading: (_) => SizedBox.expand(
child: Container(color: Colors.transparent),
),
finish: (result) => result.successOrFail.fold(
(_) {
if (state.forceClose) {
@ -75,24 +73,11 @@ class _DocumentPageState extends State<DocumentPage> {
}
Widget _renderDocument(BuildContext context, DocumentState state) {
quill.QuillController controller = quill.QuillController(
document: context.read<DocumentBloc>().document,
selection: const TextSelection.collapsed(offset: 0),
);
return Column(
children: [
if (state.isDeleted) _renderBanner(context),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_renderEditor(controller),
const VSpace(10),
_renderToolbar(controller),
const VSpace(10),
],
),
),
// AppFlowy Editor
_renderAppFlowyEditor(context.read<DocumentBloc>().editorState),
],
);
}
@ -107,36 +92,20 @@ class _DocumentPageState extends State<DocumentPage> {
);
}
Widget _renderEditor(quill.QuillController controller) {
final editor = quill.QuillEditor(
controller: controller,
focusNode: _focusNode,
scrollable: true,
paintCursorAboveText: true,
autoFocus: controller.document.isEmpty(),
expands: false,
padding: const EdgeInsets.symmetric(horizontal: 8.0),
readOnly: false,
scrollBottomInset: 0,
scrollController: scrollController,
customStyles: customStyles(context),
Widget _renderAppFlowyEditor(EditorState editorState) {
final editor = AppFlowyEditor(
editorState: editorState,
editorStyle: customEditorStyle(context),
customBuilders: {
'horizontal_rule': HorizontalRuleWidgetBuilder(),
},
shortcutEvents: [
insertHorizontalRule,
],
);
return Expanded(
child: ScrollbarListStack(
axis: Axis.vertical,
controller: scrollController,
barSize: 6.0,
child: SizedBox.expand(child: editor),
),
);
}
Widget _renderToolbar(quill.QuillController controller) {
return ChangeNotifierProvider.value(
value: Provider.of<AppearanceSetting>(context, listen: true),
child: EditorToolbar.basic(
controller: controller,
child: SizedBox.expand(
child: editor,
),
);
}

View File

@ -0,0 +1,61 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra/theme.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
EditorStyle customEditorStyle(BuildContext context) {
final theme = context.watch<AppTheme>();
const baseFontSize = 14.0;
const basePadding = 12.0;
var textStyle = theme.isDark
? BuiltInTextStyle.builtInDarkMode()
: BuiltInTextStyle.builtIn();
textStyle = textStyle.copyWith(
defaultTextStyle: textStyle.defaultTextStyle.copyWith(
fontFamily: 'poppins',
fontSize: baseFontSize,
),
bold: textStyle.bold.copyWith(
fontWeight: FontWeight.w500,
),
);
return EditorStyle.defaultStyle().copyWith(
padding: const EdgeInsets.symmetric(horizontal: 80),
textStyle: textStyle,
pluginStyles: {
'text/heading': builtInPluginStyle
..update(
'textStyle',
(_) => (EditorState editorState, Node node) {
final headingToFontSize = {
'h1': baseFontSize + 12,
'h2': baseFontSize + 8,
'h3': baseFontSize + 4,
'h4': baseFontSize,
'h5': baseFontSize,
'h6': baseFontSize,
};
final fontSize =
headingToFontSize[node.attributes.heading] ?? baseFontSize;
return TextStyle(fontSize: fontSize, fontWeight: FontWeight.w600);
},
)
..update(
'padding',
(_) => (EditorState editorState, Node node) {
final headingToPadding = {
'h1': basePadding + 6,
'h2': basePadding + 4,
'h3': basePadding + 2,
'h4': basePadding,
'h5': basePadding,
'h6': basePadding,
};
final padding =
headingToPadding[node.attributes.heading] ?? basePadding;
return EdgeInsets.only(bottom: padding);
},
)
},
);
}

View File

@ -0,0 +1,166 @@
import 'dart:collection';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
ShortcutEvent insertHorizontalRule = ShortcutEvent(
key: 'Horizontal rule',
command: 'Minus',
handler: _insertHorzaontalRule,
);
ShortcutEventHandler _insertHorzaontalRule = (editorState, event) {
final selection = editorState.service.selectionService.currentSelection.value;
final textNodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>();
if (textNodes.length != 1 || selection == null) {
return KeyEventResult.ignored;
}
final textNode = textNodes.first;
if (textNode.toPlainText() == '--') {
final transaction = editorState.transaction
..deleteText(textNode, 0, 2)
..insertNode(
textNode.path,
Node(
type: 'horizontal_rule',
children: LinkedList(),
attributes: {},
),
)
..afterSelection =
Selection.single(path: textNode.path.next, startOffset: 0);
editorState.apply(transaction);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
};
SelectionMenuItem horizontalRuleMenuItem = SelectionMenuItem(
name: () => 'Horizontal rule',
icon: const Icon(
Icons.horizontal_rule,
color: Colors.black,
size: 18.0,
),
keywords: ['horizontal rule'],
handler: (editorState, _, __) {
final selection =
editorState.service.selectionService.currentSelection.value;
final textNodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>();
if (selection == null || textNodes.isEmpty) {
return;
}
final textNode = textNodes.first;
if (textNode.toPlainText().isEmpty) {
final transaction = editorState.transaction
..insertNode(
textNode.path,
Node(
type: 'horizontal_rule',
children: LinkedList(),
attributes: {},
),
)
..afterSelection =
Selection.single(path: textNode.path.next, startOffset: 0);
editorState.apply(transaction);
} else {
final transaction = editorState.transaction
..insertNode(
selection.end.path.next,
TextNode(
children: LinkedList(),
attributes: {
'subtype': 'horizontal_rule',
},
delta: Delta()..insert('---'),
),
)
..afterSelection = selection;
editorState.apply(transaction);
}
},
);
class HorizontalRuleWidgetBuilder extends NodeWidgetBuilder<Node> {
@override
Widget build(NodeWidgetContext<Node> context) {
return _HorizontalRuleWidget(
key: context.node.key,
node: context.node,
editorState: context.editorState,
);
}
@override
NodeValidator<Node> get nodeValidator => (node) {
return true;
};
}
class _HorizontalRuleWidget extends StatefulWidget {
const _HorizontalRuleWidget({
Key? key,
required this.node,
required this.editorState,
}) : super(key: key);
final Node node;
final EditorState editorState;
@override
State<_HorizontalRuleWidget> createState() => __HorizontalRuleWidgetState();
}
class __HorizontalRuleWidgetState extends State<_HorizontalRuleWidget>
with SelectableMixin {
RenderBox get _renderBox => context.findRenderObject() as RenderBox;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Container(
height: 1,
color: Colors.grey,
),
);
}
@override
Position start() => Position(path: widget.node.path, offset: 0);
@override
Position end() => Position(path: widget.node.path, offset: 1);
@override
Position getPositionInOffset(Offset start) => end();
@override
bool get shouldCursorBlink => false;
@override
CursorStyle get cursorStyle => CursorStyle.borderLine;
@override
Rect? getCursorRectInPosition(Position position) {
final size = _renderBox.size;
return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height);
}
@override
List<Rect> getRectsInSelection(Selection selection) =>
[Offset.zero & _renderBox.size];
@override
Selection getSelectionInRange(Offset start, Offset end) => Selection.single(
path: widget.node.path,
startOffset: 0,
endOffset: 1,
);
@override
Offset localToGlobal(Offset offset) => _renderBox.localToGlobal(offset);
}