mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Integrate appflowy editor (#1040)
This commit is contained in:
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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> {
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
61
frontend/app_flowy/lib/plugins/doc/editor_styles.dart
Normal file
61
frontend/app_flowy/lib/plugins/doc/editor_styles.dart
Normal 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);
|
||||
},
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
@ -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);
|
||||
}
|
Reference in New Issue
Block a user