feat: integrate new editor (#2536)

* feat: update editor

* feat: integrate new editor

* feat: integrate the document2 into folder2

* feat: integrate the new editor

* chore: cargo fix

* chore: active document feature for flowy-error

* feat: convert the editor action to collab action

* feat: migrate the grid and board

* feat: migrate the callout block

* feat: migrate the divider

* chore: migrate the callout and math equation

* feat: migrate the code block

* feat: add shift + enter command in code block

* feat: support tab and shift+tab in code block

* fix: cursor error after inserting divider

* feat: migrate the grid and board

* feat: migrate the emoji picker

* feat: migrate openai

* feat: integrate floating toolbar

* feat: migrate the smart editor

* feat: migrate the cover

* feat: add option block action

* chore: implement block selection and delete option

* feat: support background color

* feat: dismiss color picker afer setting color

* feat: migrate the cover block

* feat: resize the font

* chore: cutomsize the padding

* chore: wrap the option button with ignore widget

* feat: customize the heading style

* chore: optimize the divider line height

* fix: the option button can't dismiss

* ci: rust test

* chore: flutter analyze

* fix: code block selection

* fix: dismiss the emoji picker after selecting one

* chore: optimize the transaction adapter

* fix: can't save the new content

* feat: show export page when some errors happen

* feat: implement get_view_data for document

---------

Co-authored-by: nathan <nathan@appflowy.io>
This commit is contained in:
Lucas.Xu 2023-05-16 14:58:24 +08:00 committed by GitHub
parent 99c48f7100
commit 2202326278
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
125 changed files with 5000 additions and 3287 deletions

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path stroke="currentColor" stroke-linecap="round" stroke-width="2.6" d="M9.41 7.3H9.4M14.6 7.3h-.01M9.31 12H9.3M14.6 12h-.01M9.41 16.7H9.4M14.6 16.7h-.01"/></svg>

After

Width:  |  Height:  |  Size: 238 B

View File

@ -1,4 +1,4 @@
import 'package:appflowy/plugins/document/presentation/plugins/base/built_in_page_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';

View File

@ -1,4 +1,4 @@
import 'package:appflowy/plugins/document/presentation/plugins/openai/service/openai_client.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';

View File

@ -1,8 +1,8 @@
import 'package:appflowy/plugins/document/presentation/plugins/openai/service/openai_client.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart';
import 'package:mocktail/mocktail.dart';
import 'dart:convert';
import 'package:appflowy/plugins/document/presentation/plugins/openai/service/text_completion.dart';
import 'package:appflowy/plugins/document/presentation/plugins/openai/service/error.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/text_completion.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart';
import 'package:http/http.dart' as http;
import 'dart:async';

View File

@ -66,7 +66,12 @@ class SelectOptionTypeOptionEditor extends StatelessWidget {
if (showOptions) {
cells.add(const TypeOptionSeparator());
cells.add(
SelectOptionColorList(selectedColor: state.option.color),
SelectOptionColorList(
selectedColor: state.option.color,
onSelectedColor: (color) => context
.read<EditSelectOptionBloc>()
.add(EditSelectOptionEvent.updateColor(color)),
),
);
}
@ -147,16 +152,22 @@ class _OptionNameTextField extends StatelessWidget {
}
class SelectOptionColorList extends StatelessWidget {
final SelectOptionColorPB selectedColor;
const SelectOptionColorList({required this.selectedColor, Key? key})
: super(key: key);
const SelectOptionColorList({
super.key,
this.selectedColor,
required this.onSelectedColor,
});
final SelectOptionColorPB? selectedColor;
final void Function(SelectOptionColorPB color) onSelectedColor;
@override
Widget build(BuildContext context) {
final cells = SelectOptionColorPB.values.map((color) {
return _SelectOptionColorCell(
color: color,
isSelected: selectedColor == color,
isSelected: selectedColor != null ? selectedColor == color : false,
onSelectedColor: onSelectedColor,
);
}).toList();
@ -193,14 +204,17 @@ class SelectOptionColorList extends StatelessWidget {
}
class _SelectOptionColorCell extends StatelessWidget {
final SelectOptionColorPB color;
final bool isSelected;
const _SelectOptionColorCell({
required this.color,
required this.isSelected,
required this.onSelectedColor,
Key? key,
}) : super(key: key);
final SelectOptionColorPB color;
final bool isSelected;
final void Function(SelectOptionColorPB color) onSelectedColor;
@override
Widget build(BuildContext context) {
Widget? checkmark;
@ -228,11 +242,7 @@ class _SelectOptionColorCell extends StatelessWidget {
),
leftIcon: colorIcon,
rightIcon: checkmark,
onTap: () {
context
.read<EditSelectOptionBloc>()
.add(EditSelectOptionEvent.updateColor(color));
},
onTap: () => onSelectedColor(color),
),
);
}

View File

@ -1,48 +1,71 @@
import 'dart:convert';
import 'package:appflowy/plugins/document/presentation/plugins/cover/cover_node_widget.dart';
import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart';
import 'package:appflowy/plugins/trash/application/trash_service.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/util/json_print.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy/workspace/application/doc/doc_listener.dart';
import 'package:appflowy/plugins/document/application/doc_service.dart';
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pbserver.dart';
import 'package:appflowy_editor/appflowy_editor.dart'
show EditorState, Document, Transaction, Node;
show EditorState, LogLevel;
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_backend/log.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:dartz/dartz.dart';
import 'dart:async';
import 'package:appflowy/util/either_extension.dart';
part 'doc_bloc.freezed.dart';
class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
final ViewPB view;
final DocumentService _documentService;
final DocumentListener _docListener;
DocumentBloc({
required this.view,
}) : _documentListener = DocumentListener(id: view.id),
_viewListener = ViewListener(view: view),
_documentService = DocumentService(),
_trashService = TrashService(),
super(DocumentState.initial()) {
_transactionAdapter = TransactionAdapter(
documentId: view.id,
documentService: _documentService,
);
on<DocumentEvent>(_onDocumentEvent);
}
final ViewListener _listener;
final ViewPB view;
final DocumentListener _documentListener;
final ViewListener _viewListener;
final DocumentService _documentService;
final TrashService _trashService;
late final TransactionAdapter _transactionAdapter;
EditorState? editorState;
StreamSubscription? _subscription;
DocumentBloc({
required this.view,
}) : _documentService = DocumentService(),
_docListener = DocumentListener(id: view.id),
_listener = ViewListener(view: view),
_trashService = TrashService(),
super(DocumentState.initial()) {
on<DocumentEvent>((event, emit) async {
@override
Future<void> close() async {
await _viewListener.stop();
await _subscription?.cancel();
await _documentService.closeDocument(view: view);
editorState?.cancelSubscription();
return super.close();
}
Future<void> _onDocumentEvent(
DocumentEvent event,
Emitter<DocumentState> emit,
) async {
await event.map(
initial: (Initial value) async {
_listenOnDocChange();
await _initial(value, emit);
_listenOnViewChange();
final state = await _fetchDocumentState();
await _subscribe(state);
emit(state);
},
deleted: (Deleted value) async {
emit(state.copyWith(isDeleted: true));
@ -52,121 +75,87 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
},
deletePermanently: (DeletePermanently value) async {
final result = await _trashService.deleteViews([view.id]);
final newState = result.fold(
(l) => state.copyWith(forceClose: true),
(r) => state,
);
emit(newState);
emit(state.copyWith(forceClose: result.swap().isLeft()));
},
restorePage: (RestorePage value) async {
final result = await _trashService.putback(view.id);
final newState = result.fold(
(l) => state.copyWith(isDeleted: false),
(r) => state,
);
emit(newState);
emit(state.copyWith(isDeleted: result.swap().isRight()));
},
);
});
}
@override
Future<void> close() async {
await _listener.stop();
Future<void> _subscribe(DocumentState state) async {
_onViewChanged();
_onDocumentChanged();
if (_subscription != null) {
await _subscription?.cancel();
// create the editor state
await state.loadingState.whenOrNull(
finish: (data) async => data.map((r) {
_initAppFlowyEditorState(r);
}),
);
}
await _documentService.closeDocument(docId: view.id);
await _documentService.closeDocumentV2(view: view);
return super.close();
/// subscribe to the view(document page) change
void _onViewChanged() {
_viewListener.start(
onViewDeleted: (r) =>
r.swap().map((r) => add(const DocumentEvent.deleted())),
onViewRestored: (r) =>
r.swap().map((r) => add(const DocumentEvent.restore())),
);
}
Future<void> _initial(Initial value, Emitter<DocumentState> emit) async {
final userProfile = await UserBackendService.getCurrentUserProfile();
if (userProfile.isRight()) {
return emit(
state.copyWith(
loadingState: DocumentLoadingState.finish(
right(userProfile.asRight()),
),
/// subscribe to the document content change
void _onDocumentChanged() {
_documentListener.start(
didReceiveUpdate: (docEvent) {
// todo: integrate the document change to the editor
// prettyPrintJson(docEvent.toProto3Json());
},
);
}
/// Fetch document
Future<DocumentState> _fetchDocumentState() async {
final result = await UserBackendService.getCurrentUserProfile().then(
(value) async => value.andThen(
// open the document
await _documentService.openDocument(view: view),
),
);
}
final result = await _documentService.openDocument(view: view);
return result.fold(
(documentData) async {
await _initEditorState(documentData).whenComplete(() {
emit(
state.copyWith(
loadingState: DocumentLoadingState.finish(left(unit)),
userProfilePB: userProfile.asLeft(),
),
);
});
},
(err) async {
emit(
state.copyWith(
loadingState: DocumentLoadingState.finish(right(err)),
),
);
},
return state.copyWith(
loadingState: DocumentLoadingState.finish(result),
);
}
void _listenOnViewChange() {
_listener.start(
onViewDeleted: (result) {
result.fold(
(view) => add(const DocumentEvent.deleted()),
(error) {},
);
},
onViewRestored: (result) {
result.fold(
(view) => add(const DocumentEvent.restore()),
(error) {},
);
},
);
Future<void> _initAppFlowyEditorState(DocumentDataPB2 data) async {
if (kDebugMode) {
prettyPrintJson(data.toProto3Json());
}
void _listenOnDocChange() {
_docListener.start(
didReceiveUpdate: () {},
);
final document = data.toDocument();
if (document == null) {
assert(false, 'document is null');
return;
}
Future<void> _initEditorState(DocumentDataPB documentData) async {
final document = Document.fromJson(jsonDecode(documentData.content));
final editorState = EditorState(document: document);
this.editorState = editorState;
// listen on document change
_subscription = editorState.transactionStream.listen((transaction) {
final json = jsonEncode(TransactionAdaptor(transaction).toJson());
_documentService
.applyEdit(docId: view.id, operations: json)
.then((result) {
result.fold(
(l) => null,
(err) => Log.error(err),
);
// subscribe to the document change from the editor
_subscription = editorState.transactionStream.listen((transaction) async {
await _transactionAdapter.apply(transaction, editorState);
});
});
// log
// output the log from the editor when debug mode
if (kDebugMode) {
editorState.logConfiguration.handler = (log) {
editorState.logConfiguration
..level = LogLevel.all
..handler = (log) {
Log.debug(log);
};
}
// migration
final migration = DocumentMigration(editorState: editorState);
await migration.apply();
}
}
@ -200,77 +189,6 @@ class DocumentState with _$DocumentState {
class DocumentLoadingState with _$DocumentLoadingState {
const factory DocumentLoadingState.loading() = _Loading;
const factory DocumentLoadingState.finish(
Either<Unit, FlowyError> successOrFail,
Either<FlowyError, DocumentDataPB2> 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;
}
}
class DocumentMigration {
const DocumentMigration({
required this.editorState,
});
final EditorState editorState;
/// Migrate the document to the latest version.
Future<void> apply() async {
final transaction = editorState.transaction;
// A temporary solution to migrate the document to the latest version.
// Once the editor is stable, we can remove this.
// cover plugin
if (editorState.document.nodeAtPath([0])?.type != kCoverType) {
transaction.insertNode(
[0],
Node(type: kCoverType),
);
}
transaction.afterSelection = null;
if (transaction.operations.isNotEmpty) {
editorState.apply(transaction);
}
}
}

View File

@ -3,69 +3,50 @@ import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-document2/entities.pb.dart';
class DocumentService {
Future<Either<DocumentDataPB, FlowyError>> openDocument({
// unused now.
Future<Either<FlowyError, Unit>> createDocument({
required ViewPB view,
}) async {
await FolderEventSetLatestView(ViewIdPB(value: view.id)).send();
final payload = OpenDocumentPayloadPB()
..documentId = view.id
..version = DocumentVersionPB.V1;
// switch (view.dataFormat) {
// case ViewDataFormatPB.DeltaFormat:
// payload.documentVersion = DocumentVersionPB.V0;
// break;
// default:
// break;
// }
return DocumentEventGetDocument(payload).send();
final canOpen = await openDocument(view: view);
if (canOpen.isRight()) {
return const Right(unit);
}
final payload = CreateDocumentPayloadPBV2()..documentId = view.id;
final result = await DocumentEvent2CreateDocument(payload).send();
return result.swap();
}
Future<Either<Unit, FlowyError>> applyEdit({
required String docId,
required String operations,
}) {
final payload = EditPayloadPB.create()
..docId = docId
..operations = operations;
return DocumentEventApplyEdit(payload).send();
}
Future<Either<Unit, FlowyError>> closeDocument({required String docId}) {
final payload = ViewIdPB(value: docId);
return FolderEventCloseView(payload).send();
}
Future<Either<DocumentDataPB2, FlowyError>> openDocumentV2({
Future<Either<FlowyError, DocumentDataPB2>> openDocument({
required ViewPB view,
}) async {
// set the latest view
await FolderEventSetLatestView(ViewIdPB(value: view.id)).send();
final payload = OpenDocumentPayloadPBV2()..documentId = view.id;
return DocumentEvent2OpenDocument(payload).send();
final result = await DocumentEvent2OpenDocument(payload).send();
return result.swap();
}
Future<Either<Unit, FlowyError>> closeDocumentV2({
Future<Either<FlowyError, Unit>> closeDocument({
required ViewPB view,
}) async {
final payload = CloseDocumentPayloadPBV2()..documentId = view.id;
return DocumentEvent2CloseDocument(payload).send();
final result = await DocumentEvent2CloseDocument(payload).send();
return result.swap();
}
Future<Either<Unit, FlowyError>> applyAction({
required ViewPB view,
required List<BlockActionPB> actions,
Future<Either<FlowyError, Unit>> applyAction({
required String documentId,
required Iterable<BlockActionPB> actions,
}) async {
final payload = ApplyActionPayloadPBV2(
documentId: view.id,
documentId: documentId,
actions: actions,
);
return DocumentEvent2ApplyAction(payload).send();
final result = await DocumentEvent2ApplyAction(payload).send();
return result.swap();
}
}

View File

@ -0,0 +1,93 @@
import 'dart:convert';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart'
show Document, Node, Attributes, Delta, ParagraphBlockKeys;
extension AppFlowyEditor on DocumentDataPB2 {
Document? toDocument() {
final rootId = pageId;
try {
final root = buildNode(rootId);
return Document(root: root);
} catch (e) {
Log.error('create document error: $e');
return null;
}
}
Node buildNode(String id) {
final block = blocks[id]!; // TODO: don't use force unwrap
final childrenId = block.childrenId;
final childrenIds = meta.childrenMap[childrenId]?.children;
final children = <Node>[];
if (childrenIds != null && childrenIds.isNotEmpty) {
children.addAll(childrenIds.map((e) => buildNode(e)));
}
return block.toNode(children: children);
}
}
class _BackendKeys {
const _BackendKeys._();
static const String page = 'page';
static const String text = 'text';
}
extension BlockToNode on BlockPB {
Node toNode({
Iterable<Node>? children,
}) {
return Node(
id: id,
type: _typeAdapter(ty),
attributes: _dataAdapter(ty, data),
children: children ?? [],
);
}
String _typeAdapter(String ty) {
final adapter = {
_BackendKeys.page: 'document',
_BackendKeys.text: ParagraphBlockKeys.type,
};
return adapter[ty] ?? ty;
}
Attributes _dataAdapter(String ty, String data) {
final map = Attributes.from(jsonDecode(data));
final adapter = {
'text': (Attributes map) => map
..putIfAbsent(
'delta',
() => Delta().toJson(),
),
};
return adapter[ty]?.call(map) ?? map;
}
}
extension NodeToBlock on Node {
BlockPB toBlock() {
assert(id.isNotEmpty);
final block = BlockPB.create()
..id = id
..ty = _typeAdapter(type)
..data = _dataAdapter(type, attributes);
return block;
}
String _typeAdapter(String type) {
final adapter = {
'document': 'page',
'paragraph': 'text',
};
return adapter[type] ?? type;
}
String _dataAdapter(String type, Attributes attributes) {
return jsonEncode(attributes);
}
}

View File

@ -0,0 +1,133 @@
import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
import 'package:appflowy/plugins/document/application/doc_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart'
show
EditorState,
Transaction,
Operation,
InsertOperation,
UpdateOperation,
DeleteOperation,
PathExtensions,
Node;
import 'package:collection/collection.dart';
import 'dart:async';
/// Uses to adjust the data structure between the editor and the backend.
///
/// The editor uses a tree structure to represent the document, while the backend uses a flat structure.
/// This adapter is used to convert the editor's transaction to the backend's transaction.
class TransactionAdapter {
TransactionAdapter({
required this.documentId,
required this.documentService,
});
final DocumentService documentService;
final String documentId;
Future<void> apply(Transaction transaction, EditorState editorState) async {
final actions = transaction.operations
.map((op) => op.toBlockAction(editorState))
.whereNotNull()
.expand((element) => element);
Log.debug('actions => $actions');
await documentService.applyAction(
documentId: documentId,
actions: actions,
);
}
}
extension on Operation {
List<BlockActionPB> toBlockAction(EditorState editorState) {
final op = this;
if (op is InsertOperation) {
return op.toBlockAction(editorState);
} else if (op is UpdateOperation) {
return op.toBlockAction(editorState);
} else if (op is DeleteOperation) {
return op.toBlockAction(editorState);
}
throw UnimplementedError();
}
}
extension on InsertOperation {
List<BlockActionPB> toBlockAction(EditorState editorState) {
final List<BlockActionPB> actions = [];
// store the previous node for continuous insertion.
// because the backend needs to know the previous node's id.
Node? previousNode;
for (final node in nodes) {
final parentId =
node.parent?.id ?? editorState.getNodeAtPath(path.parent)?.id ?? '';
final prevId = previousNode?.id ??
node.previous?.id ??
editorState.getNodeAtPath(path.previous)?.id ??
'';
assert(parentId.isNotEmpty && prevId.isNotEmpty);
final payload = BlockActionPayloadPB()
..block = node.toBlock()
..parentId = parentId
..prevId = prevId;
actions.add(
BlockActionPB()
..action = BlockActionTypePB.Insert
..payload = payload,
);
previousNode = node;
}
return actions;
}
}
extension on UpdateOperation {
List<BlockActionPB> toBlockAction(EditorState editorState) {
final List<BlockActionPB> actions = [];
// if the attributes are both empty, we don't need to update
if (const DeepCollectionEquality().equals(attributes, oldAttributes)) {
return actions;
}
final node = editorState.getNodeAtPath(path);
if (node == null) {
assert(false, 'node not found at path: $path');
return actions;
}
final parentId =
node.parent?.id ?? editorState.getNodeAtPath(path.parent)?.id ?? '';
assert(parentId.isNotEmpty);
final payload = BlockActionPayloadPB()
..block = node.toBlock()
..parentId = parentId;
actions.add(
BlockActionPB()
..action = BlockActionTypePB.Update
..payload = payload,
);
return actions;
}
}
extension on DeleteOperation {
List<BlockActionPB> toBlockAction(EditorState editorState) {
final List<BlockActionPB> actions = [];
for (final node in nodes) {
final parentId =
node.parent?.id ?? editorState.getNodeAtPath(path.parent)?.id ?? '';
final payload = BlockActionPayloadPB()
..block = node.toBlock()
..parentId = parentId;
assert(parentId.isNotEmpty);
actions.add(
BlockActionPB()
..action = BlockActionTypePB.Delete
..payload = payload,
);
}
return actions;
}
}

View File

@ -1,9 +1,9 @@
import 'dart:convert';
import 'dart:io';
import 'package:appflowy/plugins/document/application/share_service.dart';
import 'package:appflowy/plugins/document/presentation/plugins/parsers/divider_node_parser.dart';
import 'package:appflowy/plugins/document/presentation/plugins/parsers/math_equation_node_parser.dart';
import 'package:appflowy/plugins/document/presentation/plugins/parsers/code_block_node_parser.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/divider_node_parser.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/math_equation_node_parser.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/code_block_node_parser.dart';
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';

View File

@ -1,41 +1,51 @@
import 'package:appflowy/plugins/document/presentation/plugins/plugins.dart';
import 'dart:convert';
import 'dart:io';
import 'package:appflowy/plugins/document/application/doc_bloc.dart';
import 'package:appflowy/plugins/document/presentation/banner.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/export_page_widget.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/base64_string.dart';
import 'package:appflowy/util/file_picker/file_picker_service.dart';
import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:dartz/dartz.dart' as dartz;
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../startup/startup.dart';
import 'application/doc_bloc.dart';
import 'editor_styles.dart';
import 'presentation/banner.dart';
import 'package:path/path.dart' as p;
class DocumentPage extends StatefulWidget {
const DocumentPage({
super.key,
required this.onDeleted,
required this.view,
});
final VoidCallback onDeleted;
final ViewPB view;
DocumentPage({
required this.view,
required this.onDeleted,
Key? key,
}) : super(key: ValueKey(view.id));
@override
State<DocumentPage> createState() => _DocumentPageState();
}
class _DocumentPageState extends State<DocumentPage> {
late DocumentBloc documentBloc;
late final DocumentBloc documentBloc;
EditorState? editorState;
@override
void initState() {
super.initState();
documentBloc = getIt<DocumentBloc>(param1: widget.view)
..add(const DocumentEvent.initial());
// The appflowy editor use Intl as localization, set the default language as fallback.
Intl.defaultLocale = 'en_US';
documentBloc = getIt<DocumentBloc>(param1: super.widget.view)
..add(const DocumentEvent.initial());
super.initState();
}
@override
@ -46,28 +56,29 @@ class _DocumentPageState extends State<DocumentPage> {
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<DocumentBloc>.value(value: documentBloc),
],
return BlocProvider.value(
value: documentBloc,
child: BlocBuilder<DocumentBloc, DocumentState>(
builder: (context, state) {
return state.loadingState.map(
loading: (_) => SizedBox.expand(
child: Container(color: Colors.transparent),
),
finish: (result) => result.successOrFail.fold(
(_) {
return state.loadingState.when(
loading: () => const SizedBox.shrink(),
finish: (result) => result.fold(
(error) => FlowyErrorPage(error.toString()),
(data) {
if (state.forceClose) {
widget.onDeleted();
return const SizedBox();
return const SizedBox.shrink();
} else if (documentBloc.editorState == null) {
return const SizedBox();
return Center(
child: ExportPageWidget(
onTap: () async => await _exportPage(data),
),
);
} else {
return _renderDocument(context, state);
editorState = documentBloc.editorState!;
return _buildEditorPage(context, state);
}
},
(err) => FlowyErrorPage(err.toString()),
),
);
},
@ -75,177 +86,61 @@ class _DocumentPageState extends State<DocumentPage> {
);
}
Widget _renderDocument(BuildContext context, DocumentState state) {
Widget _buildEditorPage(BuildContext context, DocumentState state) {
final appflowyEditorPage = AppFlowyEditorPage(
editorState: editorState!,
);
return Column(
children: [
if (state.isDeleted) _renderBanner(context),
// AppFlowy Editor
const _AppFlowyEditorPage(),
if (state.isDeleted) _buildBanner(context),
_buildCoverAndIcon(context),
Expanded(
child: appflowyEditorPage,
),
],
);
}
Widget _renderBanner(BuildContext context) {
Widget _buildBanner(BuildContext context) {
return DocumentBanner(
onRestore: () =>
context.read<DocumentBloc>().add(const DocumentEvent.restorePage()),
onDelete: () => context
.read<DocumentBloc>()
.add(const DocumentEvent.deletePermanently()),
);
}
}
class _AppFlowyEditorPage extends StatefulWidget {
const _AppFlowyEditorPage({
Key? key,
}) : super(key: key);
@override
State<_AppFlowyEditorPage> createState() => _AppFlowyEditorPageState();
}
class _AppFlowyEditorPageState extends State<_AppFlowyEditorPage> {
late DocumentBloc documentBloc;
late EditorState editorState;
String? get openAIKey => documentBloc.state.userProfilePB?.openaiKey;
@override
void initState() {
super.initState();
documentBloc = context.read<DocumentBloc>();
editorState = documentBloc.editorState ?? EditorState.empty();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final autoFocusParameters = _autoFocusParameters();
final editor = AppFlowyEditor(
editorState: editorState,
autoFocus: autoFocusParameters.value1,
focusedSelection: autoFocusParameters.value2,
customBuilders: {
// Divider
kDividerType: DividerWidgetBuilder(),
// Math Equation
kMathEquationType: MathEquationNodeWidgetBuidler(),
// Code Block
kCodeBlockType: CodeBlockNodeWidgetBuilder(),
// Board
kBoardType: BoardNodeWidgetBuilder(),
// Grid
kGridType: GridNodeWidgetBuilder(),
// Card
kCalloutType: CalloutNodeWidgetBuilder(),
// Auto Generator,
kAutoCompletionInputType: AutoCompletionInputBuilder(),
// Cover
kCoverType: CoverNodeWidgetBuilder(),
// Smart Edit,
kSmartEditType: SmartEditInputBuilder(),
},
shortcutEvents: [
// Divider
insertDividerEvent,
// Code Block
enterInCodeBlock,
ignoreKeysInCodeBlock,
pasteInCodeBlock,
],
selectionMenuItems: [
// Divider
dividerMenuItem,
// Math Equation
mathEquationMenuItem,
// Code Block
codeBlockMenuItem,
// Emoji
emojiMenuItem,
// Board
boardMenuItem,
// Create Board
boardViewMenuItem(documentBloc),
// Grid
gridMenuItem,
// Create Grid
gridViewMenuItem(documentBloc),
// Callout
calloutMenuItem,
// AI
// enable open ai features if needed.
if (openAIKey != null && openAIKey!.isNotEmpty) ...[
autoGeneratorMenuItem,
],
],
toolbarItems: [
smartEditItem,
],
themeData: theme.copyWith(
extensions: [
...theme.extensions.values,
customEditorTheme(context),
...customPluginTheme(context),
],
),
);
return Expanded(
child: Center(
child: Container(
constraints: const BoxConstraints(
maxWidth: double.infinity,
),
child: editor,
),
),
onRestore: () => documentBloc.add(const DocumentEvent.restorePage()),
onDelete: () => documentBloc.add(const DocumentEvent.deletePermanently()),
);
}
@override
void dispose() {
_clearTemporaryNodes();
super.dispose();
Widget _buildCoverAndIcon(BuildContext context) {
if (editorState == null) {
return const Placeholder();
}
final page = editorState!.document.root;
return CoverImageNodeWidget(
node: page,
editorState: editorState!,
);
}
Future<void> _clearTemporaryNodes() async {
final document = editorState.document;
if (document.root.children.isEmpty) {
Future<void> _exportPage(DocumentDataPB2 data) async {
final picker = getIt<FilePickerService>();
final dir = await picker.getDirectoryPath();
if (dir == null) {
return;
}
final temporaryNodeTypes = [
kAutoCompletionInputType,
kSmartEditType,
];
final iterator = NodeIterator(
document: document,
startNode: document.root.children.first,
);
final transaction = editorState.transaction;
while (iterator.moveNext()) {
final node = iterator.current;
if (temporaryNodeTypes.contains(node.type)) {
transaction.deleteNode(node);
}
if (kCoverType == node.type && !node.path.equals([0])) {
transaction.deleteNode(node);
}
}
if (transaction.operations.isNotEmpty) {
await editorState.apply(transaction, withUpdateCursor: false);
}
final path = p.join(dir, '${documentBloc.view.name}.json');
const encoder = JsonEncoder.withIndent(' ');
final json = encoder.convert(data.toProto3Json());
await File(path).writeAsString(json.base64.base64);
_showMessage('Export success to $path');
}
dartz.Tuple2<bool, Selection?> _autoFocusParameters() {
if (editorState.document.isEmpty) {
return dartz.Tuple2(true, Selection.single(path: [0], startOffset: 0));
void _showMessage(String message) {
if (!mounted) {
return;
}
final texts = editorState.document.root.children.whereType<TextNode>();
if (texts.every((element) => element.toPlainText().isEmpty)) {
return dartz.Tuple2(
true,
Selection.single(path: texts.first.path, startOffset: 0),
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: FlowyText(message),
),
);
}
return const dartz.Tuple2(false, null);
}
}

View File

@ -1,124 +0,0 @@
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
EditorStyle customEditorTheme(BuildContext context) {
final documentStyle = context.watch<DocumentAppearanceCubit>().state;
final theme = Theme.of(context);
var editorStyle = EditorStyle(
// Editor styles
padding: const EdgeInsets.symmetric(horizontal: 100),
backgroundColor: theme.colorScheme.surface,
cursorColor: theme.colorScheme.primary,
// Text styles
textPadding: const EdgeInsets.symmetric(vertical: 8.0),
textStyle: TextStyle(
fontFamily: 'poppins',
fontSize: documentStyle.fontSize,
color: theme.colorScheme.onBackground,
),
selectionColor: theme.colorScheme.tertiary.withOpacity(0.2),
// Selection menu
selectionMenuBackgroundColor: theme.cardColor,
selectionMenuItemTextColor: theme.iconTheme.color,
selectionMenuItemIconColor: theme.colorScheme.onBackground,
selectionMenuItemSelectedIconColor: theme.colorScheme.onSurface,
selectionMenuItemSelectedTextColor: theme.colorScheme.onSurface,
selectionMenuItemSelectedColor: theme.hoverColor,
// Toolbar and its item's style
toolbarColor: theme.colorScheme.onTertiary,
toolbarElevation: 0,
lineHeight: 1.5,
placeholderTextStyle:
TextStyle(fontSize: documentStyle.fontSize, color: theme.hintColor),
bold: const TextStyle(
fontFamily: 'poppins-Bold',
fontWeight: FontWeight.w600,
),
italic: const TextStyle(fontStyle: FontStyle.italic),
underline: const TextStyle(decoration: TextDecoration.underline),
strikethrough: const TextStyle(decoration: TextDecoration.lineThrough),
href: TextStyle(
color: theme.colorScheme.primary,
decoration: TextDecoration.underline,
),
highlightColorHex: '0x6000BCF0',
code: GoogleFonts.robotoMono(
textStyle: TextStyle(
fontSize: documentStyle.fontSize,
fontWeight: FontWeight.normal,
color: Colors.red,
backgroundColor: theme.colorScheme.inverseSurface,
),
),
popupMenuFGColor: theme.iconTheme.color,
popupMenuHoverColor: theme.colorScheme.tertiaryContainer,
);
return editorStyle;
}
Iterable<ThemeExtension<dynamic>> customPluginTheme(BuildContext context) {
final documentStyle = context.watch<DocumentAppearanceCubit>().state;
final baseFontSize = documentStyle.fontSize;
const basePadding = 12.0;
var headingPluginStyle = Theme.of(context).brightness == Brightness.dark
? HeadingPluginStyle.dark
: HeadingPluginStyle.light;
headingPluginStyle = headingPluginStyle.copyWith(
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);
},
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);
},
);
var numberListPluginStyle = Theme.of(context).brightness == Brightness.dark
? NumberListPluginStyle.dark
: NumberListPluginStyle.light;
numberListPluginStyle = numberListPluginStyle.copyWith(
icon: (_, textNode) {
const iconPadding = EdgeInsets.only(left: 5.0, right: 5.0);
return Container(
padding: iconPadding,
child: Text(
'${textNode.attributes.number.toString()}.',
style: customEditorTheme(context).textStyle,
),
);
},
);
final pluginTheme = Theme.of(context).brightness == Brightness.dark
? darkPluginStyleExtension
: lightPluginStyleExtension;
return pluginTheme.toList()
..removeWhere(
(element) =>
element is HeadingPluginStyle || element is NumberListPluginStyle,
)
..add(headingPluginStyle)
..add(numberListPluginStyle);
}

View File

@ -0,0 +1,247 @@
import 'package:appflowy/plugins/document/application/doc_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option_action.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option_action_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:tuple/tuple.dart';
/// Wrapper for the appflowy editor.
class AppFlowyEditorPage extends StatefulWidget {
const AppFlowyEditorPage({
super.key,
required this.editorState,
});
final EditorState editorState;
@override
State<AppFlowyEditorPage> createState() => _AppFlowyEditorPageState();
}
class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
final scrollController = ScrollController();
final slashMenuItems = [
boardMenuItem,
gridMenuItem,
calloutItem,
dividerMenuItem,
mathEquationItem,
codeBlockItem,
emojiMenuItem,
autoGeneratorMenuItem,
];
final List<CommandShortcutEvent> commandShortcutEvents = [
...codeBlockCommands,
...standardCommandShortcutEvents,
];
final List<ToolbarItem> toolbarItems = [
smartEditItem,
placeholderItem,
paragraphItem,
...headingItems,
placeholderItem,
...markdownFormatItems,
placeholderItem,
quoteItem,
bulletedListItem,
numberedListItem,
placeholderItem,
linkItem,
textColorItem,
highlightColorItem,
];
late final Map<String, BlockComponentBuilder> blockComponentBuilders =
_customAppFlowyBlockComponentBuilders();
late final List<CharacterShortcutEvent> characterShortcutEvents = [
// divider
convertMinusesToDivider,
// code block
...codeBlockCharacterEvents,
...standardCharacterShortcutEvents
..removeWhere(
(element) => element == slashCommand,
), // remove the default slash command.
customSlashCommand(slashMenuItems),
];
late final styleCustomizer = EditorStyleCustomizer(context: context);
DocumentBloc get documentBloc => context.read<DocumentBloc>();
@override
Widget build(BuildContext context) {
final autoFocusParameters = _computeAutoFocusParameters();
final editor = AppFlowyEditor.custom(
editorState: widget.editorState,
editable: true,
scrollController: scrollController,
// setup the auto focus parameters
autoFocus: autoFocusParameters.item1,
focusedSelection: autoFocusParameters.item2,
// setup the theme
editorStyle: styleCustomizer.style(),
// customize the block builder
blockComponentBuilders: blockComponentBuilders,
// customize the shortcuts
characterShortcutEvents: characterShortcutEvents,
commandShortcutEvents: commandShortcutEvents,
);
return Center(
child: Container(
constraints: const BoxConstraints(
maxWidth: double.infinity,
),
child: FloatingToolbar(
items: toolbarItems,
editorState: widget.editorState,
scrollController: scrollController,
child: editor,
),
),
);
}
Map<String, BlockComponentBuilder> _customAppFlowyBlockComponentBuilders() {
final standardActions = [
OptionAction.delete,
OptionAction.duplicate,
OptionAction.divider,
OptionAction.moveUp,
OptionAction.moveDown,
];
final configuration = BlockComponentConfiguration(
padding: (_) => const EdgeInsets.symmetric(vertical: 4.0),
);
final customBlockComponentBuilderMap = {
'document': DocumentComponentBuilder(),
ParagraphBlockKeys.type: TextBlockComponentBuilder(
configuration: configuration,
),
TodoListBlockKeys.type: TodoListBlockComponentBuilder(
configuration: configuration.copyWith(
placeholderText: (_) => 'To-do',
),
),
BulletedListBlockKeys.type: BulletedListBlockComponentBuilder(
configuration: configuration.copyWith(
placeholderText: (_) => 'List',
),
),
NumberedListBlockKeys.type: NumberedListBlockComponentBuilder(
configuration: configuration.copyWith(
placeholderText: (_) => 'List',
),
),
QuoteBlockKeys.type: QuoteBlockComponentBuilder(
configuration: configuration.copyWith(
placeholderText: (_) => 'Quote',
),
),
HeadingBlockKeys.type: HeadingBlockComponentBuilder(
configuration: configuration.copyWith(
padding: (_) => const EdgeInsets.only(top: 12.0, bottom: 4.0),
placeholderText: (node) =>
'Heading ${node.attributes[HeadingBlockKeys.level]}',
),
textStyleBuilder: (level) => styleCustomizer.headingStyleBuilder(level),
),
ImageBlockKeys.type: ImageBlockComponentBuilder(),
BoardBlockKeys.type: BoardBlockComponentBuilder(
configuration: configuration,
),
GridBlockKeys.type: GridBlockComponentBuilder(
configuration: configuration,
),
CalloutBlockKeys.type: CalloutBlockComponentBuilder(
configuration: configuration,
),
DividerBlockKeys.type: DividerBlockComponentBuilder(),
MathEquationBlockKeys.type: MathEquationBlockComponentBuilder(
configuration: configuration.copyWith(
padding: (_) => const EdgeInsets.symmetric(vertical: 20),
),
),
CodeBlockKeys.type: CodeBlockComponentBuilder(
configuration: configuration.copyWith(
textStyle: (_) => styleCustomizer.codeBlockStyleBuilder(),
placeholderTextStyle: (_) => styleCustomizer.codeBlockStyleBuilder(),
),
padding: const EdgeInsets.only(
left: 30,
right: 30,
bottom: 36,
),
),
AutoCompletionBlockKeys.type: AutoCompletionBlockComponentBuilder(),
SmartEditBlockKeys.type: SmartEditBlockComponentBuilder(),
};
final builders = {
...standardBlockComponentBuilderMap,
...customBlockComponentBuilderMap,
};
// customize the action builder. actually, we can customize them in their own builder. Put them here just for convenience.
for (final entry in builders.entries) {
if (entry.key == 'document') {
continue;
}
final builder = entry.value;
// customize the action builder.
final supportColorBuilderTypes = [
ParagraphBlockKeys.type,
HeadingBlockKeys.type,
BulletedListBlockKeys.type,
NumberedListBlockKeys.type,
QuoteBlockKeys.type,
TodoListBlockKeys.type,
CalloutBlockKeys.type
];
if (!supportColorBuilderTypes.contains(entry.key)) {
builder.actionBuilder = (context, state) => OptionActionList(
blockComponentContext: context,
blockComponentState: state,
editorState: widget.editorState,
actions: standardActions,
);
continue;
}
final colorAction = [
OptionAction.divider,
OptionAction.color,
];
builder.actionBuilder = (context, state) => OptionActionList(
blockComponentContext: context,
blockComponentState: state,
editorState: widget.editorState,
actions: standardActions + colorAction,
);
}
return builders;
}
Tuple2<bool, Selection?> _computeAutoFocusParameters() {
if (widget.editorState.document.isEmpty) {
return Tuple2(true, Selection.collapse([0], 0));
}
final nodes = widget.editorState.document.root.children
.where((element) => element.delta != null);
final isAllEmpty =
nodes.isNotEmpty && nodes.every((element) => element.delta!.isEmpty);
if (isAllEmpty) {
return Tuple2(true, Selection.collapse(nodes.first.path, 0));
}
return const Tuple2(false, null);
}
}

View File

@ -0,0 +1,171 @@
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/type_option/select_option_editor.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/select_option_cell/extension.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra/image.dart';
import 'package:flutter/material.dart';
enum OptionAction {
delete,
duplicate,
turnInto,
moveUp,
moveDown,
color,
divider,
}
class DividerOptionAction extends CustomActionCell {
@override
Widget buildWithContext(BuildContext context) {
return const Divider(
height: 1.0,
thickness: 1.0,
);
}
}
class ColorOptionAction extends PopoverActionCell {
ColorOptionAction({
required this.editorState,
});
final EditorState editorState;
@override
Widget? leftIcon(Color iconColor) {
return svgWidget(
'editor/delete', // todo: add color icon
color: iconColor,
);
}
@override
String get name {
return 'Color'; // todo: l10n
}
@override
Widget Function(BuildContext context, PopoverController controller)
get builder => (context, controller) {
final selection = editorState.selection?.normalized;
if (selection == null) {
return const SizedBox.shrink();
}
// TODO: should we support multiple selection?
final node = editorState.getNodeAtPath(selection.start.path);
if (node == null) {
return const SizedBox.shrink();
}
final bgColor =
node.attributes[blockComponentBackgroundColor] as String?;
final selectedColor = convertHexToSelectOptionColorPB(
bgColor,
context,
);
return SelectOptionColorList(
selectedColor: selectedColor,
onSelectedColor: (color) {
final nodes = editorState.getNodesInSelection(selection);
final transaction = editorState.transaction;
for (final node in nodes) {
transaction.updateNode(node, {
blockComponentBackgroundColor: color.make(context).toHex(),
});
}
editorState.apply(transaction);
controller.close();
},
);
};
SelectOptionColorPB? convertHexToSelectOptionColorPB(
String? hexColor,
BuildContext context,
) {
if (hexColor == null) {
return null;
}
for (final value in SelectOptionColorPB.values) {
if (value.make(context).toHex() == hexColor) {
return value;
}
}
return null;
}
}
class OptionActionWrapper extends ActionCell {
final OptionAction inner;
OptionActionWrapper(this.inner);
@override
Widget? leftIcon(Color iconColor) {
var name = '';
// TODO: add icons.
switch (inner) {
case OptionAction.delete:
name = 'editor/delete';
break;
case OptionAction.duplicate:
name = 'editor/duplicate';
break;
case OptionAction.turnInto:
name = 'editor/turn_into';
break;
case OptionAction.moveUp:
name = 'editor/move_up';
break;
case OptionAction.moveDown:
name = 'editor/move_down';
break;
case OptionAction.color:
name = 'editor/color';
break;
case OptionAction.divider:
throw UnimplementedError();
}
if (name.isEmpty) {
return null;
}
name = 'editor/delete';
return svgWidget(
name,
color: iconColor,
);
}
@override
String get name {
var description = '';
switch (inner) {
// TODO: l10n
case OptionAction.delete:
description = 'Delete';
break;
case OptionAction.duplicate:
description = 'Duplicate';
break;
case OptionAction.turnInto:
description = 'Turn into';
break;
case OptionAction.moveUp:
description = 'Move up';
break;
case OptionAction.moveDown:
description = 'Move down';
break;
case OptionAction.color:
description = 'Color';
break;
case OptionAction.divider:
throw UnimplementedError();
}
return description;
}
}

View File

@ -0,0 +1,170 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option_action.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra/image.dart';
import 'package:flowy_infra_ui/widget/ignore_parent_gesture.dart';
import 'package:flutter/material.dart';
class OptionActionList extends StatelessWidget {
const OptionActionList({
Key? key,
required this.blockComponentContext,
required this.blockComponentState,
required this.actions,
required this.editorState,
}) : super(key: key);
final BlockComponentContext blockComponentContext;
final BlockComponentState blockComponentState;
final List<OptionAction> actions;
final EditorState editorState;
@override
Widget build(BuildContext context) {
final popoverActions = actions.map((e) {
if (e == OptionAction.divider) {
return DividerOptionAction();
} else if (e == OptionAction.color) {
return ColorOptionAction(
editorState: editorState,
);
} else {
return OptionActionWrapper(e);
}
}).toList();
return PopoverActionList<PopoverAction>(
direction: PopoverDirection.leftWithCenterAligned,
actions: popoverActions,
onPopupBuilder: () => blockComponentState.alwaysShowActions = true,
onClosed: () {
editorState.selectionType = null;
editorState.selection = null;
blockComponentState.alwaysShowActions = false;
},
onSelected: (action, controller) {
if (action is OptionActionWrapper) {
_onSelectAction(action.inner);
controller.close();
}
},
buildChild: (controller) => OptionActionButton(
onTap: () {
controller.show();
// update selection
_updateBlockSelection();
},
),
);
}
void _updateBlockSelection() {
final startNode = blockComponentContext.node;
var endNode = startNode;
while (endNode.children.isNotEmpty) {
endNode = endNode.children.last;
}
final start = Position(path: startNode.path, offset: 0);
final end = endNode.selectable?.end() ??
Position(
path: endNode.path,
offset: endNode.delta?.length ?? 0,
);
editorState.selectionType = SelectionType.block;
editorState.selection = Selection(
start: start,
end: end,
);
}
void _onSelectAction(OptionAction action) {
final node = blockComponentContext.node;
final transaction = editorState.transaction;
switch (action) {
case OptionAction.delete:
transaction.deleteNode(node);
break;
case OptionAction.duplicate:
transaction.insertNode(
node.path.next,
node.copyWith(),
);
break;
case OptionAction.turnInto:
break;
case OptionAction.moveUp:
transaction.moveNode(node.path.previous, node);
break;
case OptionAction.moveDown:
transaction.moveNode(node.path.next.next, node);
break;
case OptionAction.color:
// show the color picker
break;
case OptionAction.divider:
throw UnimplementedError();
}
editorState.apply(transaction);
}
}
class BlockComponentActionButton extends StatelessWidget {
const BlockComponentActionButton({
super.key,
required this.icon,
required this.onTap,
});
final bool isHovering = false;
final Widget icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.grab,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
onTapDown: (details) {},
onTapUp: (details) {},
child: icon,
),
);
}
}
class OptionActionButton extends StatelessWidget {
const OptionActionButton({
super.key,
required this.onTap,
});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.center,
child: MouseRegion(
cursor: SystemMouseCursors.grab,
child: IgnoreParentGestureWidget(
child: GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.deferToChild,
child: svgWidget(
'editor/option',
size: const Size.square(24.0),
color: Theme.of(context).iconTheme.color,
),
),
),
),
);
}
}

View File

@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
extension BuildContextExtension on BuildContext {
/// returns a boolean value indicating whether the given offset is contained within the bounds of the specified RenderBox or not.
bool isOffsetInside(Offset offset) {
final box = findRenderObject() as RenderBox?;
if (box == null) {
return false;
}
var result = BoxHitTestResult();
box.hitTest(result, position: box.globalToLocal(offset));
return result.path.any((entry) => entry.target == box);
}
}

View File

@ -1,4 +1,4 @@
import 'package:appflowy/plugins/document/presentation/plugins/base/insert_page_command.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/insert_page_command.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/app/app_service.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
@ -35,31 +35,17 @@ class BuiltInPageWidget extends StatefulWidget {
}
class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
late Future<dartz.Either<FlowyError, ViewPB>> future;
final focusNode = FocusNode();
String get gridID {
return widget.node.attributes[kViewID];
}
String get appID {
return widget.node.attributes[kAppID];
}
String get appId => widget.node.attributes[DatabaseBlockKeys.kAppID];
String get viewId => widget.node.attributes[DatabaseBlockKeys.kViewID];
@override
Widget build(BuildContext context) {
return FutureBuilder<dartz.Either<ViewPB, FlowyError>>(
builder: (context, snapshot) {
if (snapshot.hasData) {
final board = snapshot.data?.getLeftOrNull<ViewPB>();
if (board != null) {
return _build(context, board);
}
}
return const Center(
child: CircularProgressIndicator(),
);
},
future: AppBackendService().getChildView(appID, gridID),
void initState() {
super.initState();
future = AppBackendService().getChildView(viewId, appId).then(
(value) => value.swap(),
);
}
@ -69,27 +55,44 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
super.dispose();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<dartz.Either<FlowyError, ViewPB>>(
builder: (context, snapshot) {
final page = snapshot.data?.toOption().toNullable();
if (snapshot.hasData && page != null) {
return _build(context, page);
}
if (snapshot.connectionState == ConnectionState.done) {
return const Center(
child: FlowyText('Cannot load the page'),
);
}
return const Center(
child: CircularProgressIndicator(),
);
},
future: future,
);
}
Widget _build(BuildContext context, ViewPB viewPB) {
return MouseRegion(
onEnter: (event) {
widget.editorState.service.scrollService?.disable();
},
onExit: (event) {
widget.editorState.service.scrollService?.enable();
},
onEnter: (_) => widget.editorState.service.scrollService?.disable(),
onExit: (_) => widget.editorState.service.scrollService?.enable(),
child: SizedBox(
height: 400,
child: Stack(
children: [
_buildMenu(context, viewPB),
_buildGrid(context, viewPB),
_buildPage(context, viewPB),
],
),
),
);
}
Widget _buildGrid(BuildContext context, ViewPB viewPB) {
Widget _buildPage(BuildContext context, ViewPB viewPB) {
return Focus(
focusNode: focusNode,
onFocusChange: (value) {
@ -112,9 +115,7 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
// information
FlowyIconButton(
tooltipText: LocaleKeys.tooltip_referencePage.tr(
namedArgs: {
'name': viewPB.layout.name,
},
namedArgs: {'name': viewPB.layout.name},
),
width: 24,
height: 24,
@ -137,8 +138,7 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
actions: _ActionType.values
.map((action) => _ActionWrapper(action))
.toList(),
buildChild: (controller) {
return FlowyIconButton(
buildChild: (controller) => FlowyIconButton(
tooltipText: LocaleKeys.tooltip_openMenu.tr(),
width: 24,
height: 24,
@ -148,8 +148,7 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
color: Theme.of(context).iconTheme.color,
),
onPressed: () => controller.show(),
);
},
),
onSelected: (action, controller) async {
switch (action.inner) {
case _ActionType.viewDatabase:

View File

@ -0,0 +1,56 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/emoji_picker/emoji_picker.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class EmojiPickerButton extends StatelessWidget {
EmojiPickerButton({
super.key,
required this.emoji,
required this.onSubmitted,
this.emojiPickerSize = const Size(300, 250),
this.emojiSize = 18.0,
});
final String emoji;
final double emojiSize;
final Size emojiPickerSize;
final void Function(Emoji emoji, PopoverController controller) onSubmitted;
final PopoverController popoverController = PopoverController();
@override
Widget build(BuildContext context) {
return AppFlowyPopover(
controller: popoverController,
triggerActions: PopoverTriggerFlags.click,
constraints: BoxConstraints.expand(
width: emojiPickerSize.width,
height: emojiPickerSize.height,
),
popupBuilder: (context) => _buildEmojiPicker(),
child: FlowyTextButton(
emoji,
fontSize: emojiSize,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 35.0),
fillColor: Colors.transparent,
onPressed: () {
popoverController.show();
},
),
);
}
Widget _buildEmojiPicker() {
return Container(
width: emojiPickerSize.width,
height: emojiPickerSize.height,
padding: const EdgeInsets.all(4.0),
child: EmojiSelectionMenu(
onSubmitted: (emoji) => onSubmitted(emoji, popoverController),
onExit: () {},
),
);
}
}

View File

@ -1,38 +1,41 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/database_view_service.dart';
import 'package:appflowy/plugins/document/presentation/plugins/board/board_node_widget.dart';
import 'package:appflowy/plugins/document/presentation/plugins/grid/grid_node_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/board/board_node_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/grid/grid_node_widget.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy/workspace/application/app/app_service.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
const String kAppID = 'app_id';
const String kViewID = 'view_id';
class DatabaseBlockKeys {
const DatabaseBlockKeys._();
extension InsertPage on EditorState {
static const String kAppID = 'app_id';
static const String kViewID = 'view_id';
}
extension InsertDatabase on EditorState {
Future<void> insertPage(ViewPB appPB, ViewPB viewPB) async {
final selection = service.selectionService.currentSelection.value;
final textNodes =
service.selectionService.currentSelectedNodes.whereType<TextNode>();
if (selection == null || textNodes.isEmpty) {
final selection = this.selection;
if (selection == null || !selection.isCollapsed) {
return;
}
final node = getNodeAtPath(selection.end.path);
if (node == null) {
return;
}
// get the database that the view is associated with
final database =
await DatabaseViewBackendService(viewId: viewPB.id).openGrid().then(
(value) => value.getLeftOrNull(),
);
final database = await DatabaseViewBackendService(viewId: viewPB.id)
.openGrid()
.then((value) => value.swap().toOption().toNullable());
if (database == null) {
throw StateError(
'The database associated with ${viewPB.id} could not be found while attempting to create a referenced ${viewPB.layout.name}.',
);
}
final prefix = referencedBoardPrefix(viewPB.layout);
final prefix = _referencedDatabasePrefix(viewPB.layout);
final ref = await AppBackendService().createView(
appId: appPB.id,
name: "$prefix ${viewPB.name}",
@ -40,9 +43,7 @@ extension InsertPage on EditorState {
ext: {
'database_id': database.id,
},
).then(
(value) => value.getLeftOrNull(),
);
).then((value) => value.swap().toOption().toNullable());
// TODO(a-wallen): Show error dialog here.
if (ref == null) {
@ -55,15 +56,15 @@ extension InsertPage on EditorState {
Node(
type: _convertPageType(viewPB),
attributes: {
kAppID: appPB.id,
kViewID: ref.id,
DatabaseBlockKeys.kAppID: appPB.id,
DatabaseBlockKeys.kViewID: ref.id,
},
),
);
apply(transaction);
await apply(transaction);
}
String referencedBoardPrefix(ViewLayoutPB layout) {
String _referencedDatabasePrefix(ViewLayoutPB layout) {
switch (layout) {
case ViewLayoutPB.Grid:
return LocaleKeys.grid_referencedGridPrefix.tr();
@ -77,9 +78,9 @@ extension InsertPage on EditorState {
String _convertPageType(ViewPB viewPB) {
switch (viewPB.layout) {
case ViewLayoutPB.Grid:
return kGridType;
return GridBlockKeys.type;
case ViewLayoutPB.Board:
return kBoardType;
return BoardBlockKeys.type;
default:
throw Exception('Unknown layout type');
}

View File

@ -1,3 +1,4 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/insert_page_command.dart';
import 'package:appflowy/workspace/application/app/app_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@ -9,70 +10,37 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'insert_page_command.dart';
EditorState? _editorState;
OverlayEntry? _linkToPageMenu;
void showLinkToPageMenu(
OverlayState container,
EditorState editorState,
SelectionMenuService menuService,
BuildContext context,
ViewLayoutPB pageType,
) {
final alignment = menuService.alignment;
final offset = menuService.offset;
menuService.dismiss();
_editorState = editorState;
final alignment = menuService.alignment;
final offset = menuService.offset;
final top = alignment == Alignment.bottomLeft ? offset.dy : null;
final bottom = alignment == Alignment.topLeft ? offset.dy : null;
String hintText = '';
switch (pageType) {
case ViewLayoutPB.Grid:
hintText = LocaleKeys.document_slashMenu_grid_selectAGridToLinkTo.tr();
break;
case ViewLayoutPB.Board:
hintText = LocaleKeys.document_slashMenu_board_selectABoardToLinkTo.tr();
break;
default:
throw Exception('Unknown layout type');
}
_linkToPageMenu?.remove();
_linkToPageMenu = OverlayEntry(
builder: (context) {
return Positioned(
top: alignment == Alignment.bottomLeft ? offset.dy : null,
bottom: alignment == Alignment.topLeft ? offset.dy : null,
final linkToPageMenuEntry = FullScreenOverlayEntry(
top: top,
bottom: bottom,
left: offset.dx,
child: Material(
builder: (context) => Material(
color: Colors.transparent,
child: LinkToPageMenu(
editorState: editorState,
layoutType: pageType,
hintText: hintText,
hintText: pageType.toHintText(),
onSelected: (appPB, viewPB) {
editorState.insertPage(appPB, viewPB);
},
),
),
);
},
);
Overlay.of(context).insert(_linkToPageMenu!);
editorState.service.selectionService.currentSelection
.addListener(dismissLinkToPageMenu);
}
void dismissLinkToPageMenu() {
_linkToPageMenu?.remove();
_linkToPageMenu = null;
_editorState?.service.selectionService.currentSelection
.removeListener(dismissLinkToPageMenu);
_editorState = null;
).build();
container.insert(linkToPageMenuEntry);
}
class LinkToPageMenu extends StatefulWidget {
@ -133,6 +101,7 @@ class _LinkToPageMenuState extends State<LinkToPageMenu> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Focus(
focusNode: _focusNode,
onKey: _onKey,
@ -140,7 +109,7 @@ class _LinkToPageMenuState extends State<LinkToPageMenu> {
width: 300,
padding: const EdgeInsets.fromLTRB(10, 6, 10, 6),
decoration: BoxDecoration(
color: style.selectionMenuBackgroundColor,
color: theme.cardColor,
boxShadow: [
BoxShadow(
blurRadius: 5,
@ -150,7 +119,11 @@ class _LinkToPageMenuState extends State<LinkToPageMenu> {
],
borderRadius: BorderRadius.circular(6.0),
),
child: _buildListWidget(context, _selectedIndex, _availableLayout),
child: _buildListWidget(
context,
_selectedIndex,
_availableLayout,
),
),
);
}
@ -272,3 +245,18 @@ class _LinkToPageMenuState extends State<LinkToPageMenu> {
}
}
}
extension on ViewLayoutPB {
String toHintText() {
switch (this) {
case ViewLayoutPB.Grid:
return LocaleKeys.document_slashMenu_grid_selectAGridToLinkTo.tr();
case ViewLayoutPB.Board:
return LocaleKeys.document_slashMenu_board_selectABoardToLinkTo.tr();
default:
throw Exception('Unknown layout type');
}
}
}

View File

@ -0,0 +1,56 @@
import 'package:flowy_infra/image.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class SelectableItemListMenu extends StatelessWidget {
const SelectableItemListMenu({
super.key,
required this.items,
required this.selectedIndex,
required this.onSelected,
});
final List<String> items;
final int selectedIndex;
final void Function(int) onSelected;
@override
Widget build(BuildContext context) {
return ListView.builder(
itemBuilder: (context, index) {
final item = items[index];
return SelectableItem(
isSelected: index == selectedIndex,
item: item,
onTap: () => onSelected(index),
);
},
itemCount: items.length,
);
}
}
class SelectableItem extends StatelessWidget {
const SelectableItem({
super.key,
required this.isSelected,
required this.item,
required this.onTap,
});
final bool isSelected;
final String item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 32,
child: FlowyButton(
text: FlowyText.medium(item),
rightIcon: isSelected ? const FlowySvg(name: 'grid/checkmark') : null,
onTap: onTap,
),
);
}
}

View File

@ -0,0 +1,48 @@
import 'package:flowy_infra/image.dart';
import 'package:flutter/material.dart';
class SelectableSvgWidget extends StatelessWidget {
const SelectableSvgWidget({
super.key,
required this.name,
required this.isSelected,
});
final String name;
final bool isSelected;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return svgWidget(
name,
size: const Size.square(18.0),
color: isSelected
? theme.colorScheme.onSurface
: theme.colorScheme.onBackground,
);
}
}
class SelectableIconWidget extends StatelessWidget {
const SelectableIconWidget({
super.key,
required this.icon,
required this.isSelected,
});
final IconData icon;
final bool isSelected;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Icon(
icon,
size: 18.0,
color: isSelected
? theme.colorScheme.onSurface
: theme.colorScheme.onBackground,
);
}
}

View File

@ -0,0 +1,5 @@
extension Capitalize on String {
String capitalize() {
return "${this[0].toUpperCase()}${substring(1)}";
}
}

View File

@ -5,30 +5,35 @@ enum TextRobotInputType {
word,
}
extension TextRobot on EditorState {
class TextRobot {
const TextRobot({
required this.editorState,
});
final EditorState editorState;
Future<void> autoInsertText(
String text, {
TextRobotInputType inputType = TextRobotInputType.word,
Duration delay = const Duration(milliseconds: 10),
}) async {
if (text == '\n') {
await insertNewLineAtCurrentSelection();
return;
return editorState.insertNewLine();
}
final lines = text.split('\n');
for (final line in lines) {
if (line.isEmpty) {
await insertNewLineAtCurrentSelection();
await editorState.insertNewLine();
continue;
}
switch (inputType) {
case TextRobotInputType.character:
final iterator = line.runes.iterator;
while (iterator.moveNext()) {
await insertTextAtCurrentSelection(
await editorState.insertTextAtCurrentSelection(
iterator.currentAsString,
);
await Future.delayed(delay, () {});
await Future.delayed(delay);
}
break;
case TextRobotInputType.word:
@ -36,17 +41,17 @@ extension TextRobot on EditorState {
if (words.length == 1 ||
(words.length == 2 &&
(words.first.isEmpty || words.last.isEmpty))) {
await insertTextAtCurrentSelection(
await editorState.insertTextAtCurrentSelection(
line,
);
} else {
for (final word in words.map((e) => '$e ')) {
await insertTextAtCurrentSelection(
await editorState.insertTextAtCurrentSelection(
word,
);
}
}
await Future.delayed(delay, () {});
await Future.delayed(delay);
break;
}
}

View File

@ -1,29 +1,24 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/plugins/base/link_to_page_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart';
import 'package:flutter/material.dart';
SelectionMenuItem boardMenuItem = SelectionMenuItem(
name: LocaleKeys.document_plugins_referencedBoard.tr(),
icon: (editorState, onSelected) {
return svgWidget(
'editor/board',
size: const Size.square(18.0),
color: onSelected
? editorState.editorStyle.selectionMenuItemSelectedIconColor
: editorState.editorStyle.selectionMenuItemIconColor,
);
},
// TODO(a-wallen): Translate keywords
icon: (editorState, onSelected) => SelectableSvgWidget(
name: 'editor/board',
isSelected: onSelected,
),
keywords: ['referenced', 'board', 'kanban'],
handler: (editorState, menuService, context) {
final container = Overlay.of(context);
showLinkToPageMenu(
container,
editorState,
menuService,
context,
ViewLayoutPB.Board,
);
},

View File

@ -0,0 +1,76 @@
import 'package:appflowy/plugins/database_view/board/presentation/board_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/insert_page_command.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class BoardBlockKeys {
const BoardBlockKeys._();
static const String type = 'board';
}
class BoardBlockComponentBuilder extends BlockComponentBuilder {
BoardBlockComponentBuilder({
this.configuration = const BlockComponentConfiguration(),
});
@override
final BlockComponentConfiguration configuration;
@override
Widget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return BoardBlockComponentWidget(
key: node.key,
node: node,
configuration: configuration,
);
}
@override
bool validate(Node node) =>
node.children.isEmpty &&
node.attributes[DatabaseBlockKeys.kAppID] is String &&
node.attributes[DatabaseBlockKeys.kViewID] is String;
}
class BoardBlockComponentWidget extends StatefulWidget {
const BoardBlockComponentWidget({
super.key,
required this.configuration,
required this.node,
});
final Node node;
final BlockComponentConfiguration configuration;
@override
State<BoardBlockComponentWidget> createState() =>
_BoardBlockComponentWidgetState();
}
class _BoardBlockComponentWidgetState extends State<BoardBlockComponentWidget>
with BlockComponentConfigurable {
@override
Node get node => widget.node;
@override
BlockComponentConfiguration get configuration => widget.configuration;
@override
Widget build(BuildContext context) {
final editorState = Provider.of<EditorState>(context, listen: false);
return BuiltInPageWidget(
node: widget.node,
editorState: editorState,
builder: (viewPB) {
return BoardPage(
key: ValueKey(viewPB.id),
view: viewPB,
);
},
);
}
}

View File

@ -1,26 +1,19 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/plugins/document/presentation/plugins/base/insert_page_command.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/insert_page_command.dart';
import 'package:appflowy/workspace/application/app/app_service.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart';
import 'package:flutter/material.dart';
SelectionMenuItem boardViewMenuItem(DocumentBloc documentBloc) =>
SelectionMenuItem(
name: LocaleKeys.document_slashMenu_board_createANewBoard.tr(),
icon: (editorState, onSelected) {
return svgWidget(
'editor/board',
size: const Size.square(18.0),
color: onSelected
? editorState.editorStyle.selectionMenuItemSelectedIconColor
: editorState.editorStyle.selectionMenuItemIconColor,
);
},
// TODO(a-wallen): Translate keywords.
icon: (editorState, onSelected) => SelectableSvgWidget(
name: 'editor/board',
isSelected: onSelected,
),
keywords: ['board', 'kanban'],
handler: (editorState, menuService, context) async {
if (!documentBloc.view.hasParentViewId()) {

View File

@ -0,0 +1,203 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../base/emoji_picker_button.dart';
// defining the keys of the callout block's attributes for easy access
class CalloutBlockKeys {
const CalloutBlockKeys._();
static const String type = 'callout';
/// The content of a code block.
///
/// The value is a String.
static const String delta = 'delta';
/// The background color of a callout block.
///
/// The value is a String.
static const String backgroundColor = blockComponentBackgroundColor;
/// The emoji icon of a callout block.
///
/// The value is a String.
static const String icon = 'icon';
}
// creating a new callout node
Node calloutNode({
Delta? delta,
String emoji = '📌',
String backgroundColor = '#F0F0F0',
}) {
final attributes = {
CalloutBlockKeys.delta: (delta ?? Delta()).toJson(),
CalloutBlockKeys.icon: emoji,
CalloutBlockKeys.backgroundColor: backgroundColor,
};
return Node(
type: CalloutBlockKeys.type,
attributes: attributes,
);
}
// defining the callout block menu item for selection
SelectionMenuItem calloutItem = SelectionMenuItem.node(
name: 'Callout',
iconData: Icons.note,
keywords: ['callout'],
nodeBuilder: (editorState) => calloutNode(),
replace: (_, node) => node.delta?.isEmpty ?? false,
updateSelection: (_, path, __, ___) {
return Selection.single(path: [...path, 0], startOffset: 0);
},
);
// building the callout block widget
class CalloutBlockComponentBuilder extends BlockComponentBuilder {
CalloutBlockComponentBuilder({
this.configuration = const BlockComponentConfiguration(),
});
@override
final BlockComponentConfiguration configuration;
@override
Widget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return CalloutBlockComponentWidget(
key: node.key,
node: node,
configuration: configuration,
);
}
// validate the data of the node, if the result is false, the node will be rendered as a placeholder
@override
bool validate(Node node) =>
node.delta != null &&
node.children.isEmpty &&
node.attributes[CalloutBlockKeys.icon] is String &&
node.attributes[CalloutBlockKeys.backgroundColor] is String;
}
// the main widget for rendering the callout block
class CalloutBlockComponentWidget extends StatefulWidget {
const CalloutBlockComponentWidget({
super.key,
required this.node,
required this.configuration,
});
final Node node;
final BlockComponentConfiguration configuration;
@override
State<CalloutBlockComponentWidget> createState() =>
_CalloutBlockComponentWidgetState();
}
class _CalloutBlockComponentWidgetState
extends State<CalloutBlockComponentWidget>
with SelectableMixin, DefaultSelectable, BlockComponentConfigurable {
// the key used to forward focus to the richtext child
@override
final forwardKey = GlobalKey(debugLabel: 'flowy_rich_text');
// the key used to identify this component
@override
GlobalKey<State<StatefulWidget>> get containerKey => widget.node.key;
@override
BlockComponentConfiguration get configuration => widget.configuration;
@override
Node get node => widget.node;
// get the background color of the note block from the node's attributes
Color get backgroundColor {
final colorString =
node.attributes[CalloutBlockKeys.backgroundColor] as String?;
if (colorString == null) {
return Colors.transparent;
}
return colorString.toColor();
}
// get the emoji of the note block from the node's attributes or default to '📌'
String get emoji => node.attributes[CalloutBlockKeys.icon] ?? '📌';
// get access to the editor state via provider
late final editorState = Provider.of<EditorState>(context, listen: false);
// build the callout block widget
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
color: backgroundColor,
),
width: double.infinity,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// the emoji picker button for the note
Padding(
padding: const EdgeInsets.all(2.0),
child: EmojiPickerButton(
key: ValueKey(emoji), // force to refresh the popover state
emoji: emoji,
onSubmitted: (emoji, controller) {
setEmoji(emoji.emoji);
controller.close();
},
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: buildCalloutBlockComponent(context),
),
),
const SizedBox(
width: 10.0,
)
],
),
);
}
// build the richtext child
Widget buildCalloutBlockComponent(BuildContext context) {
return Padding(
padding: padding,
child: FlowyRichText(
key: forwardKey,
node: widget.node,
editorState: editorState,
placeholderText: placeholderText,
textSpanDecorator: (textSpan) => textSpan.updateTextStyle(
textStyle,
),
placeholderTextSpanDecorator: (textSpan) => textSpan.updateTextStyle(
placeholderTextStyle,
),
),
);
}
// set the emoji of the note block
Future<void> setEmoji(String emoji) async {
final transaction = editorState.transaction
..updateNode(node, {
CalloutBlockKeys.icon: emoji,
})
..afterSelection = Selection.collapse(
node.path,
node.delta?.length ?? 0,
);
await editorState.apply(transaction);
}
}

View File

@ -0,0 +1,348 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_item_list_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:highlight/highlight.dart' as highlight;
import 'package:highlight/languages/all.dart';
import 'package:provider/provider.dart';
class CodeBlockKeys {
const CodeBlockKeys._();
static const String type = 'code';
/// The content of a code block.
///
/// The value is a String.
static const String delta = 'delta';
/// The language of a code block.
///
/// The value is a String.
static const String language = 'language';
}
Node codeBlockNode({
Delta? delta,
String? language,
}) {
final attributes = {
CodeBlockKeys.delta: (delta ?? Delta()).toJson(),
CodeBlockKeys.language: language,
};
return Node(
type: CodeBlockKeys.type,
attributes: attributes,
);
}
// defining the callout block menu item for selection
SelectionMenuItem codeBlockItem = SelectionMenuItem.node(
name: 'Code Block',
iconData: Icons.abc,
keywords: ['code', 'codeblock'],
nodeBuilder: (editorState) => codeBlockNode(),
replace: (_, node) => node.delta?.isEmpty ?? false,
);
class CodeBlockComponentBuilder extends BlockComponentBuilder {
CodeBlockComponentBuilder({
this.configuration = const BlockComponentConfiguration(),
this.padding = const EdgeInsets.all(0),
});
@override
final BlockComponentConfiguration configuration;
final EdgeInsets padding;
@override
Widget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return CodeBlockComponentWidget(
key: node.key,
node: node,
configuration: configuration,
padding: padding,
);
}
@override
bool validate(Node node) => node.delta != null;
}
class CodeBlockComponentWidget extends StatefulWidget {
const CodeBlockComponentWidget({
Key? key,
required this.node,
this.configuration = const BlockComponentConfiguration(),
this.padding = const EdgeInsets.all(0),
}) : super(key: key);
final Node node;
final BlockComponentConfiguration configuration;
final EdgeInsets padding;
@override
State<CodeBlockComponentWidget> createState() =>
_CodeBlockComponentWidgetState();
}
class _CodeBlockComponentWidgetState extends State<CodeBlockComponentWidget>
with SelectableMixin, DefaultSelectable, BlockComponentConfigurable {
// the key used to forward focus to the richtext child
@override
final forwardKey = GlobalKey(debugLabel: 'flowy_rich_text');
@override
BlockComponentConfiguration get configuration => widget.configuration;
@override
GlobalKey<State<StatefulWidget>> get containerKey => node.key;
@override
Node get node => widget.node;
final popoverController = PopoverController();
final supportedLanguages = [
'Assembly',
'Bash',
'BASIC',
'C',
'C#',
'C++',
'Clojure',
'CSS',
'Dart',
'Docker',
'Elixir',
'Elm',
'Erlang',
'Fortran',
'Go',
'GraphQL',
'Haskell',
'HTML',
'Java',
'JavaScript',
'JSON',
'Kotlin',
'LaTeX',
'Lisp',
'Lua',
'Markdown',
'MATLAB',
'Objective-C',
'OCaml',
'Perl',
'PHP',
'PowerShell',
'Python',
'R',
'Ruby',
'Rust',
'Scala',
'Shell',
'SQL',
'Swift',
'TypeScript',
'Visual Basic',
'XML',
'YAML',
];
late final languages = supportedLanguages
.map((e) => e.toLowerCase())
.toSet()
.intersection(allLanguages.keys.toSet())
.toList();
late final editorState = context.read<EditorState>();
String? get language => node.attributes[CodeBlockKeys.language] as String?;
String? autoDetectLanguage;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
color: Colors.grey.withOpacity(0.1),
),
width: MediaQuery.of(context).size.width,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildSwitchLanguageButton(context),
_buildCodeBlock(context),
],
),
);
}
Widget _buildCodeBlock(BuildContext context) {
final delta = node.delta ?? Delta();
final content = delta.toPlainText();
final result = highlight.highlight.parse(
content,
language: language,
autoDetection: language == null,
);
autoDetectLanguage = language ?? result.language;
final codeNodes = result.nodes;
if (codeNodes == null) {
throw Exception('Code block parse error.');
}
final codeTextSpans = _convert(codeNodes);
return Padding(
padding: widget.padding,
child: FlowyRichText(
key: forwardKey,
node: widget.node,
editorState: editorState,
placeholderText: placeholderText,
textSpanDecorator: (textSpan) => TextSpan(
style: textStyle,
children: codeTextSpans,
),
placeholderTextSpanDecorator: (textSpan) => TextSpan(
style: textStyle,
),
),
);
}
Widget _buildSwitchLanguageButton(BuildContext context) {
return AppFlowyPopover(
controller: popoverController,
child: Container(
width: 100,
padding: const EdgeInsets.symmetric(horizontal: 4),
child: FlowyTextButton(
'${language?.capitalize() ?? 'auto'} ',
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 4.0,
),
fontColor: Theme.of(context).colorScheme.onBackground,
fillColor: Colors.transparent,
onPressed: () {},
),
),
popupBuilder: (BuildContext context) {
return SelectableItemListMenu(
items: languages.map((e) => e.capitalize()).toList(),
selectedIndex: languages.indexOf(language ?? ''),
onSelected: (index) {
updateLanguage(languages[index]);
popoverController.close();
},
);
},
);
}
Future<void> updateLanguage(String language) async {
final transaction = editorState.transaction
..updateNode(node, {
CodeBlockKeys.language: language,
})
..afterSelection = Selection.collapse(
node.path,
node.delta?.length ?? 0,
);
await editorState.apply(transaction);
}
// Copy from flutter.highlight package.
// https://github.com/git-touch/highlight.dart/blob/master/flutter_highlight/lib/flutter_highlight.dart
List<TextSpan> _convert(List<highlight.Node> nodes) {
List<TextSpan> spans = [];
var currentSpans = spans;
List<List<TextSpan>> stack = [];
void traverse(highlight.Node node) {
if (node.value != null) {
currentSpans.add(
node.className == null
? TextSpan(text: node.value)
: TextSpan(
text: node.value,
style: _builtInCodeBlockTheme[node.className!],
),
);
} else if (node.children != null) {
List<TextSpan> tmp = [];
currentSpans.add(
TextSpan(
children: tmp,
style: _builtInCodeBlockTheme[node.className!],
),
);
stack.add(currentSpans);
currentSpans = tmp;
for (var n in node.children!) {
traverse(n);
if (n == node.children!.last) {
currentSpans = stack.isEmpty ? spans : stack.removeLast();
}
}
}
}
for (var node in nodes) {
traverse(node);
}
return spans;
}
}
const _builtInCodeBlockTheme = {
'root':
TextStyle(backgroundColor: Color(0xffffffff), color: Color(0xff000000)),
'comment': TextStyle(color: Color(0xff007400)),
'quote': TextStyle(color: Color(0xff007400)),
'tag': TextStyle(color: Color(0xffaa0d91)),
'attribute': TextStyle(color: Color(0xffaa0d91)),
'keyword': TextStyle(color: Color(0xffaa0d91)),
'selector-tag': TextStyle(color: Color(0xffaa0d91)),
'literal': TextStyle(color: Color(0xffaa0d91)),
'name': TextStyle(color: Color(0xffaa0d91)),
'variable': TextStyle(color: Color(0xff3F6E74)),
'template-variable': TextStyle(color: Color(0xff3F6E74)),
'code': TextStyle(color: Color(0xffc41a16)),
'string': TextStyle(color: Color(0xffc41a16)),
'meta-string': TextStyle(color: Color(0xffc41a16)),
'regexp': TextStyle(color: Color(0xff0E0EFF)),
'link': TextStyle(color: Color(0xff0E0EFF)),
'title': TextStyle(color: Color(0xff1c00cf)),
'symbol': TextStyle(color: Color(0xff1c00cf)),
'bullet': TextStyle(color: Color(0xff1c00cf)),
'number': TextStyle(color: Color(0xff1c00cf)),
'section': TextStyle(color: Color(0xff643820)),
'meta': TextStyle(color: Color(0xff643820)),
'type': TextStyle(color: Color(0xff5c2699)),
'built_in': TextStyle(color: Color(0xff5c2699)),
'builtin-name': TextStyle(color: Color(0xff5c2699)),
'params': TextStyle(color: Color(0xff5c2699)),
'attr': TextStyle(color: Color(0xff836C28)),
'subst': TextStyle(color: Color(0xff000000)),
'formula': TextStyle(
backgroundColor: Color(0xffeeeeee),
fontStyle: FontStyle.italic,
),
'addition': TextStyle(backgroundColor: Color(0xffbaeeba)),
'deletion': TextStyle(backgroundColor: Color(0xffffc8bd)),
'selector-id': TextStyle(color: Color(0xff9b703f)),
'selector-class': TextStyle(color: Color(0xff9b703f)),
'doctag': TextStyle(fontWeight: FontWeight.bold),
'strong': TextStyle(fontWeight: FontWeight.bold),
'emphasis': TextStyle(fontStyle: FontStyle.italic),
};

View File

@ -0,0 +1,229 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
final List<CharacterShortcutEvent> codeBlockCharacterEvents = [
enterInCodeBlock,
...ignoreKeysInCodeBlock,
];
final List<CommandShortcutEvent> codeBlockCommands = [
insertNewParagraphNextToCodeBlockCommand,
tabToInsertSpacesInCodeBlockCommand,
tabToDeleteSpacesInCodeBlockCommand,
];
/// press the enter key in code block to insert a new line in it.
///
/// - support
/// - desktop
/// - web
/// - mobile
///
final CharacterShortcutEvent enterInCodeBlock = CharacterShortcutEvent(
key: 'press enter in code block',
character: '\n',
handler: _enterInCodeBlockCommandHandler,
);
/// ignore ' ', '/', '_', '*' in code block.
///
/// - support
/// - desktop
/// - web
/// - mobile
///
final List<CharacterShortcutEvent> ignoreKeysInCodeBlock =
[' ', '/', '_', '*', '~']
.map(
(e) => CharacterShortcutEvent(
key: 'press enter in code block',
character: e,
handler: (editorState) => _ignoreKeysInCodeBlockCommandHandler(
editorState,
e,
),
),
)
.toList();
/// shift + enter to insert a new node next to the code block.
///
/// - support
/// - desktop
/// - web
///
final CommandShortcutEvent insertNewParagraphNextToCodeBlockCommand =
CommandShortcutEvent(
key: 'insert a new paragraph next to the code block',
command: 'shift+enter',
handler: _insertNewParagraphNextToCodeBlockCommandHandler,
);
/// tab to insert two spaces at the line start in code block.
///
/// - support
/// - desktop
/// - web
final CommandShortcutEvent tabToInsertSpacesInCodeBlockCommand =
CommandShortcutEvent(
key: 'tab to insert two spaces at the line start in code block',
command: 'tab',
handler: _tabToInsertSpacesInCodeBlockCommandHandler,
);
/// shift+tab to delete two spaces at the line start in code block if needed.
///
/// - support
/// - desktop
/// - web
final CommandShortcutEvent tabToDeleteSpacesInCodeBlockCommand =
CommandShortcutEvent(
key: 'shift + tab to delete two spaces at the line start in code block',
command: 'shift+tab',
handler: _tabToDeleteSpacesInCodeBlockCommandHandler,
);
CharacterShortcutEventHandler _enterInCodeBlockCommandHandler =
(editorState) async {
final selection = editorState.selection;
if (selection == null || !selection.isCollapsed) {
return false;
}
final node = editorState.getNodeAtPath(selection.end.path);
if (node == null || node.type != CodeBlockKeys.type) {
return false;
}
final transaction = editorState.transaction
..insertText(
node,
selection.end.offset,
'\n',
);
await editorState.apply(transaction);
return true;
};
Future<bool> _ignoreKeysInCodeBlockCommandHandler(
EditorState editorState,
String key,
) async {
final selection = editorState.selection;
if (selection == null || !selection.isCollapsed) {
return false;
}
final node = editorState.getNodeAtPath(selection.end.path);
if (node == null || node.type != CodeBlockKeys.type) {
return false;
}
await editorState.insertTextAtCurrentSelection(key);
return true;
}
CommandShortcutEventHandler _insertNewParagraphNextToCodeBlockCommandHandler =
(editorState) {
final selection = editorState.selection;
if (selection == null || !selection.isCollapsed) {
return KeyEventResult.ignored;
}
final node = editorState.getNodeAtPath(selection.end.path);
final delta = node?.delta;
if (node == null || delta == null || node.type != CodeBlockKeys.type) {
return KeyEventResult.ignored;
}
final sliced = delta.slice(selection.startIndex);
final transaction = editorState.transaction
..deleteText(
// delete the text after the cursor in the code block
node,
selection.startIndex,
delta.length - selection.startIndex,
)
..insertNode(
// insert a new paragraph node with the sliced delta after the code block
selection.end.path.next,
paragraphNode(
attributes: {
'delta': sliced.toJson(),
},
),
)
..afterSelection = Selection.collapse(
selection.end.path.next,
0,
);
editorState.apply(transaction);
return KeyEventResult.handled;
};
CommandShortcutEventHandler _tabToInsertSpacesInCodeBlockCommandHandler =
(editorState) {
final selection = editorState.selection;
if (selection == null || !selection.isCollapsed) {
return KeyEventResult.ignored;
}
final node = editorState.getNodeAtPath(selection.end.path);
final delta = node?.delta;
if (node == null || delta == null || node.type != CodeBlockKeys.type) {
return KeyEventResult.ignored;
}
const spaces = ' ';
final lines = delta.toPlainText().split('\n');
var index = 0;
for (final line in lines) {
if (index <= selection.endIndex &&
selection.endIndex <= index + line.length) {
final transaction = editorState.transaction
..insertText(
node,
index,
spaces, // two spaces
)
..afterSelection = Selection.collapse(
selection.end.path,
selection.endIndex + spaces.length,
);
editorState.apply(transaction);
break;
}
index += line.length + 1;
}
return KeyEventResult.handled;
};
CommandShortcutEventHandler _tabToDeleteSpacesInCodeBlockCommandHandler =
(editorState) {
final selection = editorState.selection;
if (selection == null || !selection.isCollapsed) {
return KeyEventResult.ignored;
}
final node = editorState.getNodeAtPath(selection.end.path);
final delta = node?.delta;
if (node == null || delta == null || node.type != CodeBlockKeys.type) {
return KeyEventResult.ignored;
}
const spaces = ' ';
final lines = delta.toPlainText().split('\n');
var index = 0;
for (final line in lines) {
if (index <= selection.endIndex &&
selection.endIndex <= index + line.length) {
if (line.startsWith(spaces)) {
final transaction = editorState.transaction
..deleteText(
node,
index,
spaces.length, // two spaces
)
..afterSelection = Selection.collapse(
selection.end.path,
selection.endIndex - spaces.length,
);
editorState.apply(transaction);
}
break;
}
index += line.length + 1;
}
return KeyEventResult.handled;
};

View File

@ -2,7 +2,7 @@ import 'dart:io';
import 'dart:ui';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart';
@ -55,7 +55,7 @@ class CoverColorPicker extends StatefulWidget {
final Color pickerBackgroundColor;
final Color pickerItemHoverColor;
final void Function(String color) onSubmittedbackgroundColorHex;
final void Function(String color) onSubmittedBackgroundColorHex;
final List<ColorOption> backgroundColorOptions;
const CoverColorPicker({
super.key,
@ -63,7 +63,7 @@ class CoverColorPicker extends StatefulWidget {
required this.pickerBackgroundColor,
required this.backgroundColorOptions,
required this.pickerItemHoverColor,
required this.onSubmittedbackgroundColorHex,
required this.onSubmittedBackgroundColorHex,
});
@override
@ -215,21 +215,18 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
}
Widget _buildColorPickerList() {
final theme = Theme.of(context);
return CoverColorPicker(
pickerBackgroundColor:
widget.editorState.editorStyle.selectionMenuBackgroundColor ??
Colors.white,
pickerItemHoverColor:
widget.editorState.editorStyle.selectionMenuItemSelectedColor ??
Colors.blue.withOpacity(0.3),
pickerBackgroundColor: theme.cardColor,
pickerItemHoverColor: theme.hoverColor,
selectedBackgroundColorHex:
widget.node.attributes[kCoverSelectionTypeAttribute] ==
CoverSelectionType.color.toString()
? widget.node.attributes[kCoverSelectionAttribute]
: "ffffff",
: 'ffffff',
backgroundColorOptions:
_generateBackgroundColorOptions(widget.editorState),
onSubmittedbackgroundColorHex: (color) {
onSubmittedBackgroundColorHex: (color) {
widget.onCoverChanged(CoverSelectionType.color, color);
setState(() {});
},
@ -497,7 +494,7 @@ class _CoverColorPickerState extends State<CoverColorPicker> {
),
hoverColor: widget.pickerItemHoverColor,
onTap: () {
widget.onSubmittedbackgroundColorHex(option.colorHex);
widget.onSubmittedBackgroundColorHex(option.colorHex);
},
child: Padding(
padding: const EdgeInsets.only(right: 10.0),
@ -544,9 +541,3 @@ class _CoverColorPickerState extends State<CoverColorPicker> {
);
}
}
extension on Color {
String toHex() {
return '0x${value.toRadixString(16)}';
}
}

View File

@ -1,8 +1,8 @@
import 'dart:async';
import 'dart:io';
import 'package:appflowy/plugins/document/presentation/plugins/cover/change_cover_popover.dart';
import 'package:appflowy/plugins/document/presentation/plugins/cover/cover_node_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/change_cover_popover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/cover_node_widget.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

View File

@ -1,6 +1,6 @@
import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/plugins/cover/cover_image_picker_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/cover_image_picker_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/snap_bar.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

View File

@ -1,9 +1,10 @@
import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/plugins/cover/change_cover_popover.dart';
import 'package:appflowy/plugins/document/presentation/plugins/cover/emoji_popover.dart';
import 'package:appflowy/plugins/document/presentation/plugins/cover/icon_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/change_cover_popover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/emoji_popover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/emoji_icon_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/workspace/presentation/widgets/emoji_picker/emoji_picker.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide FlowySvg;
import 'package:appflowy_popover/appflowy_popover.dart';
@ -39,7 +40,7 @@ enum CoverSelectionType {
class CoverNodeWidgetBuilder implements NodeWidgetBuilder {
@override
Widget build(NodeWidgetContext<Node> context) {
return _CoverImageNodeWidget(
return CoverImageNodeWidget(
key: context.node.key,
node: context.node,
editorState: context.editorState,
@ -52,8 +53,8 @@ class CoverNodeWidgetBuilder implements NodeWidgetBuilder {
};
}
class _CoverImageNodeWidget extends StatefulWidget {
const _CoverImageNodeWidget({
class CoverImageNodeWidget extends StatefulWidget {
const CoverImageNodeWidget({
Key? key,
required this.node,
required this.editorState,
@ -63,14 +64,32 @@ class _CoverImageNodeWidget extends StatefulWidget {
final EditorState editorState;
@override
State<_CoverImageNodeWidget> createState() => _CoverImageNodeWidgetState();
State<CoverImageNodeWidget> createState() => _CoverImageNodeWidgetState();
}
class _CoverImageNodeWidgetState extends State<_CoverImageNodeWidget> {
class _CoverImageNodeWidgetState extends State<CoverImageNodeWidget> {
CoverSelectionType get selectionType => CoverSelectionType.fromString(
widget.node.attributes[kCoverSelectionTypeAttribute],
);
@override
void initState() {
super.initState();
widget.node.addListener(_reload);
}
@override
void dispose() {
widget.node.removeListener(_reload);
super.dispose();
}
void _reload() {
setState(() {});
}
PopoverController iconPopoverController = PopoverController();
@override
Widget build(BuildContext context) {
@ -141,7 +160,11 @@ class _AddCoverButtonState extends State<_AddCoverButton> {
height: widget.hasIcon ? 180 : 50.0,
alignment: Alignment.bottomLeft,
width: double.infinity,
padding: const EdgeInsets.only(top: 20, bottom: 5),
padding: EdgeInsets.only(
left: EditorStyleCustomizer.horizontalPadding + 30,
top: 20,
bottom: 5,
),
child: isHidden
? Container()
: Row(
@ -304,7 +327,8 @@ class _CoverImageState extends State<_CoverImage> {
),
hasIcon
? Positioned(
bottom: !hasCover ? 30 : 10,
left: EditorStyleCustomizer.horizontalPadding + 30,
bottom: !hasCover ? 30 : 40,
child: AppFlowyPopover(
offset: const Offset(100, 0),
controller: iconPopoverController,
@ -313,9 +337,6 @@ class _CoverImageState extends State<_CoverImage> {
margin: EdgeInsets.zero,
child: EmojiIconWidget(
emoji: widget.node.attributes[kIconSelectionAttribute],
onEmojiTapped: () {
iconPopoverController.show();
},
),
popupBuilder: (BuildContext popoverContext) {
return EmojiPopover(
@ -454,7 +475,6 @@ class _CoverImageState extends State<_CoverImage> {
}
Widget _buildCoverImage(BuildContext context, EditorState editorState) {
final screenSize = MediaQuery.of(context).size;
const height = 250.0;
final Widget coverImage;
switch (selectionType) {
@ -493,7 +513,7 @@ class _CoverImageState extends State<_CoverImage> {
coverImage = const SizedBox();
break;
}
//OverflowBox needs to be wraped by a widget with constraints(or from its parent) first,otherwise it will occur an error
// OverflowBox needs to be wraped by a widget with constraints(or from its parent) first,otherwise it will occur an error
return MouseRegion(
onEnter: (event) {
setOverlayButtonsHidden(false);
@ -503,8 +523,6 @@ class _CoverImageState extends State<_CoverImage> {
},
child: SizedBox(
height: height,
child: OverflowBox(
maxWidth: screenSize.width,
child: Stack(
children: [
Container(
@ -519,7 +537,6 @@ class _CoverImageState extends State<_CoverImage> {
],
),
),
),
);
}

View File

@ -1,17 +1,18 @@
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class EmojiIconWidget extends StatefulWidget {
final String? emoji;
final void Function() onEmojiTapped;
const EmojiIconWidget({
super.key,
required this.emoji,
required this.onEmojiTapped,
this.size = 80,
this.emojiSize = 60,
});
final String emoji;
final double size;
final double emojiSize;
@override
State<EmojiIconWidget> createState() => _EmojiIconWidgetState();
}
@ -22,31 +23,22 @@ class _EmojiIconWidgetState extends State<EmojiIconWidget> {
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (event) {
setHidden(false);
},
onExit: (event) {
setHidden(true);
},
onEnter: (_) => setHidden(false),
onExit: (_) => setHidden(true),
child: Container(
height: 130,
width: 130,
margin: const EdgeInsets.only(top: 18),
height: widget.size,
width: widget.size,
decoration: BoxDecoration(
color: !hover
? Theme.of(context).colorScheme.inverseSurface
: Colors.transparent,
borderRadius: Corners.s8Border,
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Stack(
clipBehavior: Clip.none,
children: [
FlowyText(
widget.emoji.toString(),
fontSize: 80,
),
],
child: FlowyText(
widget.emoji,
fontSize: widget.emojiSize,
textAlign: TextAlign.center,
),
),
);

View File

@ -0,0 +1,65 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/divider/divider_node_widget.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
/// insert divider into a document by typing three minuses(-).
///
/// - support
/// - desktop
/// - web
/// - mobile
///
final CharacterShortcutEvent convertMinusesToDivider = CharacterShortcutEvent(
key: 'insert a divider',
character: '-',
handler: _convertMinusesToDividerHandler,
);
CharacterShortcutEventHandler _convertMinusesToDividerHandler =
(editorState) async {
final selection = editorState.selection;
if (selection == null || !selection.isCollapsed) {
return false;
}
final path = selection.end.path;
final node = editorState.getNodeAtPath(path);
final delta = node?.delta;
if (node == null || delta == null) {
return false;
}
if (delta.toPlainText() != '--') {
return false;
}
final transaction = editorState.transaction
..insertNode(path, dividerNode())
..insertNode(path, paragraphNode())
..deleteNode(node)
..afterSelection = Selection.collapse(path.next, 0);
editorState.apply(transaction);
return true;
};
SelectionMenuItem dividerMenuItem = SelectionMenuItem(
name: 'Divider',
icon: (editorState, onSelected) => const Icon(
Icons.horizontal_rule,
size: 18.0,
),
keywords: ['horizontal rule', 'divider'],
handler: (editorState, _, __) {
final selection = editorState.selection;
if (selection == null || !selection.isCollapsed) {
return;
}
final path = selection.end.path;
final node = editorState.getNodeAtPath(path);
final delta = node?.delta;
if (node == null || delta == null) {
return;
}
final insertedPath = delta.isEmpty ? path : path.next;
final transaction = editorState.transaction
..insertNode(insertedPath, dividerNode());
editorState.apply(transaction);
},
);

View File

@ -0,0 +1,107 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
class DividerBlockKeys {
const DividerBlockKeys._();
static const String type = 'divider';
}
// creating a new callout node
Node dividerNode() {
return Node(
type: DividerBlockKeys.type,
);
}
class DividerBlockComponentBuilder extends BlockComponentBuilder {
DividerBlockComponentBuilder({
this.padding = const EdgeInsets.symmetric(vertical: 8.0),
this.lineColor = Colors.grey,
});
final EdgeInsets padding;
final Color lineColor;
@override
Widget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return DividerBlockComponentWidget(
key: node.key,
node: node,
padding: padding,
lineColor: lineColor,
);
}
@override
bool validate(Node node) => node.children.isEmpty;
}
class DividerBlockComponentWidget extends StatefulWidget {
const DividerBlockComponentWidget({
Key? key,
required this.node,
this.padding = const EdgeInsets.symmetric(vertical: 8.0),
this.lineColor = Colors.grey,
}) : super(key: key);
final Node node;
final EdgeInsets padding;
final Color lineColor;
@override
State<DividerBlockComponentWidget> createState() =>
_DividerBlockComponentWidgetState();
}
class _DividerBlockComponentWidgetState
extends State<DividerBlockComponentWidget> with SelectableMixin {
RenderBox get _renderBox => context.findRenderObject() as RenderBox;
@override
Widget build(BuildContext context) {
return Padding(
padding: widget.padding,
child: Container(
height: 1,
color: widget.lineColor,
),
);
}
@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.cover;
@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);
}

View File

@ -0,0 +1,120 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'emoji_picker.dart';
SelectionMenuItem emojiMenuItem = SelectionMenuItem(
name: 'Emoji',
icon: (editorState, onSelected) => SelectableIconWidget(
icon: Icons.emoji_emotions_outlined,
isSelected: onSelected,
),
keywords: ['emoji'],
handler: (editorState, menuService, context) {
final container = Overlay.of(context);
showEmojiPickerMenu(
container,
editorState,
menuService,
);
},
);
void showEmojiPickerMenu(
OverlayState container,
EditorState editorState,
SelectionMenuService menuService,
) {
menuService.dismiss();
final alignment = menuService.alignment;
final offset = menuService.offset;
final top = alignment == Alignment.bottomLeft ? offset.dy : null;
final bottom = alignment == Alignment.topLeft ? offset.dy : null;
final emojiPickerMenuEntry = FullScreenOverlayEntry(
top: top,
bottom: bottom,
left: offset.dx,
builder: (context) => Material(
child: Container(
width: 300,
height: 250,
padding: const EdgeInsets.all(4.0),
child: EmojiSelectionMenu(
onSubmitted: (emoji) {
editorState.insertTextAtCurrentSelection(emoji.emoji);
},
onExit: () {
// close emoji panel
},
),
),
),
).build();
container.insert(emojiPickerMenuEntry);
}
class EmojiSelectionMenu extends StatefulWidget {
const EmojiSelectionMenu({
Key? key,
required this.onSubmitted,
required this.onExit,
}) : super(key: key);
final void Function(Emoji emoji) onSubmitted;
final void Function() onExit;
@override
State<EmojiSelectionMenu> createState() => _EmojiSelectionMenuState();
}
class _EmojiSelectionMenuState extends State<EmojiSelectionMenu> {
@override
void initState() {
HardwareKeyboard.instance.addHandler(_handleGlobalKeyEvent);
super.initState();
}
bool _handleGlobalKeyEvent(KeyEvent event) {
if (event.logicalKey == LogicalKeyboardKey.escape &&
event is KeyDownEvent) {
//triggers on esc
widget.onExit();
return true;
} else {
return false;
}
}
@override
void deactivate() {
HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent);
super.deactivate();
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
return EmojiPicker(
onEmojiSelected: (category, emoji) => widget.onSubmitted(emoji),
config: const Config(
columns: 7,
emojiSizeMax: 28,
bgColor: Colors.transparent,
iconColor: Colors.grey,
iconColorSelected: Color(0xff333333),
indicatorColor: Color(0xff333333),
progressIndicatorColor: Color(0xff333333),
buttonMode: ButtonMode.CUPERTINO,
initCategory: Category.RECENT,
),
);
}
}

View File

@ -1,28 +1,24 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/plugins/base/link_to_page_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart';
import 'package:flutter/material.dart';
SelectionMenuItem gridMenuItem = SelectionMenuItem(
name: LocaleKeys.document_plugins_referencedGrid.tr(),
icon: (editorState, onSelected) {
return svgWidget(
'editor/grid',
size: const Size.square(18.0),
color: onSelected
? editorState.editorStyle.selectionMenuItemSelectedIconColor
: editorState.editorStyle.selectionMenuItemIconColor,
);
},
icon: (editorState, onSelected) => SelectableSvgWidget(
name: 'editor/board',
isSelected: onSelected,
),
keywords: ['referenced', 'grid'],
handler: (editorState, menuService, context) {
final container = Overlay.of(context);
showLinkToPageMenu(
container,
editorState,
menuService,
context,
ViewLayoutPB.Grid,
);
},

View File

@ -0,0 +1,76 @@
import 'package:appflowy/plugins/database_view/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/insert_page_command.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class GridBlockKeys {
const GridBlockKeys._();
static const String type = 'grid';
}
class GridBlockComponentBuilder extends BlockComponentBuilder {
GridBlockComponentBuilder({
this.configuration = const BlockComponentConfiguration(),
});
@override
final BlockComponentConfiguration configuration;
@override
Widget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return GridBlockComponentWidget(
key: node.key,
node: node,
configuration: configuration,
);
}
@override
bool validate(Node node) =>
node.children.isEmpty &&
node.attributes[DatabaseBlockKeys.kAppID] is String &&
node.attributes[DatabaseBlockKeys.kViewID] is String;
}
class GridBlockComponentWidget extends StatefulWidget {
const GridBlockComponentWidget({
super.key,
required this.configuration,
required this.node,
});
final Node node;
final BlockComponentConfiguration configuration;
@override
State<GridBlockComponentWidget> createState() =>
_GridBlockComponentWidgetState();
}
class _GridBlockComponentWidgetState extends State<GridBlockComponentWidget>
with BlockComponentConfigurable {
@override
Node get node => widget.node;
@override
BlockComponentConfiguration get configuration => widget.configuration;
@override
Widget build(BuildContext context) {
final editorState = Provider.of<EditorState>(context, listen: false);
return BuiltInPageWidget(
node: widget.node,
editorState: editorState,
builder: (viewPB) {
return GridPage(
key: ValueKey(viewPB.id),
view: viewPB,
);
},
);
}
}

View File

@ -1,25 +1,19 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/doc_bloc.dart';
import 'package:appflowy/plugins/document/presentation/plugins/base/insert_page_command.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/insert_page_command.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart';
import 'package:appflowy/workspace/application/app/app_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart';
import 'package:flutter/material.dart';
SelectionMenuItem gridViewMenuItem(DocumentBloc documentBloc) =>
SelectionMenuItem(
name: LocaleKeys.document_slashMenu_grid_createANewGrid.tr(),
icon: (editorState, onSelected) {
return svgWidget(
'editor/grid',
size: const Size.square(18.0),
color: onSelected
? editorState.editorStyle.selectionMenuItemSelectedIconColor
: editorState.editorStyle.selectionMenuItemIconColor,
);
},
icon: (editorState, onSelected) => SelectableSvgWidget(
name: 'editor/grid',
isSelected: onSelected,
),
keywords: ['grid'],
handler: (editorState, menuService, context) async {
if (!documentBloc.view.hasParentViewId()) {

View File

@ -0,0 +1,214 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_math_fork/flutter_math.dart';
import 'package:provider/provider.dart';
class MathEquationBlockKeys {
const MathEquationBlockKeys._();
static const String type = 'math_equation';
/// The content of a math equation block.
///
/// The value is a String.
static const String formula = 'formula';
}
Node mathEquationNode({
String formula = '',
}) {
final attributes = {
MathEquationBlockKeys.formula: formula,
};
return Node(
type: MathEquationBlockKeys.type,
attributes: attributes,
);
}
// defining the callout block menu item for selection
SelectionMenuItem mathEquationItem = SelectionMenuItem.node(
name: 'MathEquation',
iconData: Icons.text_fields_rounded,
keywords: ['tex, latex, katex', 'math equation', 'formula'],
nodeBuilder: (editorState) => mathEquationNode(),
replace: (_, node) => node.delta?.isEmpty ?? false,
updateSelection: (editorState, path, __, ___) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
final mathEquationState =
editorState.getNodeAtPath(path)?.key.currentState;
if (mathEquationState != null &&
mathEquationState is _MathEquationBlockComponentWidgetState) {
mathEquationState.showEditingDialog();
}
});
return null;
},
);
class MathEquationBlockComponentBuilder extends BlockComponentBuilder {
MathEquationBlockComponentBuilder({
this.configuration = const BlockComponentConfiguration(),
});
@override
final BlockComponentConfiguration configuration;
@override
Widget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return MathEquationBlockComponentWidget(
key: node.key,
node: node,
configuration: configuration,
);
}
@override
bool validate(Node node) =>
node.children.isEmpty &&
node.attributes[MathEquationBlockKeys.formula] is String;
}
class MathEquationBlockComponentWidget extends StatefulWidget {
const MathEquationBlockComponentWidget({
Key? key,
required this.node,
this.configuration = const BlockComponentConfiguration(),
}) : super(key: key);
final Node node;
final BlockComponentConfiguration configuration;
@override
State<MathEquationBlockComponentWidget> createState() =>
_MathEquationBlockComponentWidgetState();
}
class _MathEquationBlockComponentWidgetState
extends State<MathEquationBlockComponentWidget>
with BlockComponentConfigurable {
@override
BlockComponentConfiguration get configuration => widget.configuration;
@override
Node get node => widget.node;
bool isHover = false;
String get formula =>
widget.node.attributes[MathEquationBlockKeys.formula] as String;
late final editorState = context.read<EditorState>();
@override
Widget build(BuildContext context) {
return InkWell(
onHover: (value) => setState(() => isHover = value),
onTap: showEditingDialog,
child: _buildMathEquation(context),
);
}
Widget _buildMathEquation(BuildContext context) {
return Container(
width: double.infinity,
constraints: const BoxConstraints(minHeight: 50),
padding: padding,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
color: isHover || formula.isEmpty
? Theme.of(context).colorScheme.tertiaryContainer
: Colors.transparent,
),
child: Center(
child: formula.isEmpty
? FlowyText.medium(
LocaleKeys.document_plugins_mathEquation_addMathEquation.tr(),
fontSize: 16,
)
: Math.tex(
formula,
textStyle: const TextStyle(fontSize: 20),
mathStyle: MathStyle.display,
),
),
);
}
void showEditingDialog() {
showDialog(
context: context,
builder: (context) {
final controller = TextEditingController(text: formula);
return AlertDialog(
backgroundColor: Theme.of(context).canvasColor,
title: Text(
LocaleKeys.document_plugins_mathEquation_editMathEquation.tr(),
),
content: RawKeyboardListener(
focusNode: FocusNode(),
onKey: (key) {
if (key is! RawKeyDownEvent) return;
if (key.logicalKey == LogicalKeyboardKey.enter &&
!key.isShiftPressed) {
updateMathEquation(controller.text, context);
} else if (key.logicalKey == LogicalKeyboardKey.escape) {
dismiss(context);
}
},
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.3,
child: TextField(
autofocus: true,
controller: controller,
maxLines: null,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'E = MC^2',
),
),
),
),
actions: [
SecondaryTextButton(
LocaleKeys.button_Cancel.tr(),
onPressed: () => dismiss(context),
),
PrimaryTextButton(
LocaleKeys.button_Done.tr(),
onPressed: () => updateMathEquation(controller.text, context),
),
],
actionsPadding: const EdgeInsets.only(bottom: 20),
actionsAlignment: MainAxisAlignment.spaceAround,
);
},
);
}
void updateMathEquation(String mathEquation, BuildContext context) {
if (mathEquation == formula) {
dismiss(context);
return;
}
final transaction = editorState.transaction
..updateNode(
widget.node,
{
MathEquationBlockKeys.formula: mathEquation,
},
);
editorState.apply(transaction);
dismiss(context);
}
void dismiss(BuildContext context) {
Navigator.of(context).pop();
}
}

View File

@ -1,6 +1,6 @@
import 'dart:convert';
import 'package:appflowy/plugins/document/presentation/plugins/openai/service/text_edit.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/text_edit.dart';
import 'text_completion.dart';
import 'package:dartz/dartz.dart';

View File

@ -0,0 +1,418 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/build_context_extension.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/text_robot.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/util/learn_more_action.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/discard_dialog.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:provider/provider.dart';
class AutoCompletionBlockKeys {
const AutoCompletionBlockKeys._();
static const String type = 'auto_completion';
static const String prompt = 'prompt';
static const String startSelection = 'start_selection';
}
Node autoCompletionNode({
String prompt = '',
required Selection start,
}) {
return Node(
type: AutoCompletionBlockKeys.type,
attributes: {
AutoCompletionBlockKeys.prompt: prompt,
AutoCompletionBlockKeys.startSelection: start.toJson(),
},
);
}
SelectionMenuItem autoGeneratorMenuItem = SelectionMenuItem.node(
name: LocaleKeys.document_plugins_autoGeneratorMenuItemName.tr(),
iconData: Icons.generating_tokens,
keywords: ['ai', 'openai' 'writer', 'autogenerator'],
nodeBuilder: (editorState) {
final node = autoCompletionNode(start: editorState.selection!);
return node;
},
replace: (_, node) => false,
);
class AutoCompletionBlockComponentBuilder extends BlockComponentBuilder {
AutoCompletionBlockComponentBuilder();
@override
Widget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return AutoCompletionBlockComponent(
key: node.key,
node: node,
);
}
@override
bool validate(Node node) {
return node.children.isEmpty &&
node.attributes[AutoCompletionBlockKeys.prompt] is String &&
node.attributes[AutoCompletionBlockKeys.startSelection] is Map;
}
}
class AutoCompletionBlockComponent extends StatefulWidget {
const AutoCompletionBlockComponent({
super.key,
required this.node,
});
final Node node;
@override
State<AutoCompletionBlockComponent> createState() =>
_AutoCompletionBlockComponentState();
}
class _AutoCompletionBlockComponentState
extends State<AutoCompletionBlockComponent> {
final controller = TextEditingController();
final textFieldFocusNode = FocusNode();
late final editorState = context.read<EditorState>();
late final SelectionGestureInterceptor interceptor;
String get prompt => widget.node.attributes[AutoCompletionBlockKeys.prompt];
@override
void initState() {
super.initState();
_subscribeSelectionGesture();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
editorState.selection = null;
textFieldFocusNode.requestFocus();
});
}
@override
void dispose() {
_unsubscribeSelectionGesture();
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Card(
elevation: 5,
color: Theme.of(context).colorScheme.surface,
child: Container(
margin: const EdgeInsets.all(10),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const AutoCompletionHeader(),
const Space(0, 10),
if (prompt.isEmpty) ...[
_buildInputWidget(context),
const Space(0, 10),
AutoCompletionInputFooter(
onGenerate: _onGenerate,
onExit: _onExit,
),
] else ...[
AutoCompletionFooter(
onKeep: _onExit,
onDiscard: _onDiscard,
)
],
],
),
),
);
}
Widget _buildInputWidget(BuildContext context) {
return FlowyTextField(
hintText: LocaleKeys.document_plugins_autoGeneratorHintText.tr(),
controller: controller,
maxLines: 3,
focusNode: textFieldFocusNode,
autoFocus: false,
);
}
Future<void> _onExit() async {
final transaction = editorState.transaction..deleteNode(widget.node);
await editorState.apply(
transaction,
options: const ApplyOptions(
// disable undo/redo
recordRedo: false,
recordUndo: false,
),
);
}
Future<void> _onGenerate() async {
final loading = Loading(context);
loading.start();
await _updateEditingText();
final userProfile = await UserBackendService.getCurrentUserProfile()
.then((value) => value.toOption().toNullable());
if (userProfile == null) {
loading.stop();
await _showError(
LocaleKeys.document_plugins_autoGeneratorCantGetOpenAIKey.tr(),
);
return;
}
final textRobot = TextRobot(editorState: editorState);
BarrierDialog? barrierDialog;
final openAIRepository = HttpOpenAIRepository(
client: http.Client(),
apiKey: userProfile.openaiKey,
);
await openAIRepository.getStreamedCompletions(
prompt: controller.text,
onStart: () async {
loading.stop();
barrierDialog = BarrierDialog(context);
barrierDialog?.show();
await _makeSurePreviousNodeIsEmptyParagraphNode();
},
onProcess: (response) async {
if (response.choices.isNotEmpty) {
final text = response.choices.first.text;
await textRobot.autoInsertText(
text,
inputType: TextRobotInputType.word,
delay: Duration.zero,
);
}
},
onEnd: () async {
await barrierDialog?.dismiss();
},
onError: (error) async {
loading.stop();
await _showError(error.message);
},
);
}
Future<void> _onDiscard() async {
final selection =
widget.node.attributes[AutoCompletionBlockKeys.startSelection];
if (selection != null) {
final start = Selection.fromJson(selection).start.path;
final end = widget.node.previous?.path;
if (end != null) {
final transaction = editorState.transaction;
transaction.deleteNodesAtPath(
start,
end.last - start.last + 1,
);
await editorState.apply(transaction);
}
}
_onExit();
}
Future<void> _updateEditingText() async {
final transaction = editorState.transaction;
transaction.updateNode(
widget.node,
{
AutoCompletionBlockKeys.prompt: controller.text,
},
);
await editorState.apply(transaction);
}
Future<void> _makeSurePreviousNodeIsEmptyParagraphNode() async {
// make sure the previous node is a empty paragraph node without any styles.
final transaction = editorState.transaction;
final previous = widget.node.previous;
final Selection selection;
if (previous == null ||
previous.type != 'paragraph' ||
(previous.delta?.toPlainText().isNotEmpty ?? false)) {
selection = Selection.single(
path: widget.node.path,
startOffset: 0,
);
transaction.insertNode(
widget.node.path,
paragraphNode(),
);
} else {
selection = Selection.single(
path: previous.path,
startOffset: 0,
);
}
transaction.updateNode(widget.node, {
AutoCompletionBlockKeys.startSelection: selection.toJson(),
});
transaction.afterSelection = selection;
await editorState.apply(transaction);
}
Future<void> _showError(String message) async {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
action: SnackBarAction(
label: LocaleKeys.button_Cancel.tr(),
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
},
),
content: FlowyText(message),
),
);
}
void _subscribeSelectionGesture() {
interceptor = SelectionGestureInterceptor(
key: AutoCompletionBlockKeys.type,
canTap: (details) {
if (!context.isOffsetInside(details.globalPosition)) {
if (prompt.isNotEmpty || controller.text.isNotEmpty) {
// show dialog
showDialog(
context: context,
builder: (context) {
return DiscardDialog(
onConfirm: () => _onDiscard(),
onCancel: () {},
);
},
);
} else if (controller.text.isEmpty) {
_onExit();
}
}
editorState.service.keyboardService?.disable();
return false;
},
);
editorState.service.selectionService.registerGestureInterceptor(
interceptor,
);
}
void _unsubscribeSelectionGesture() {
editorState.service.selectionService.unregisterGestureInterceptor(
AutoCompletionBlockKeys.type,
);
}
}
class AutoCompletionHeader extends StatelessWidget {
const AutoCompletionHeader({
super.key,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
FlowyText.medium(
LocaleKeys.document_plugins_autoGeneratorTitleName.tr(),
fontSize: 14,
),
const Spacer(),
FlowyButton(
useIntrinsicWidth: true,
text: FlowyText.regular(
LocaleKeys.document_plugins_autoGeneratorLearnMore.tr(),
),
onTap: () async {
await openLearnMorePage();
},
)
],
);
}
}
class AutoCompletionInputFooter extends StatelessWidget {
const AutoCompletionInputFooter({
super.key,
required this.onGenerate,
required this.onExit,
});
final VoidCallback onGenerate;
final VoidCallback onExit;
@override
Widget build(BuildContext context) {
return Row(
children: [
PrimaryTextButton(
LocaleKeys.button_generate.tr(),
onPressed: onGenerate,
),
const Space(10, 0),
SecondaryTextButton(
LocaleKeys.button_Cancel.tr(),
onPressed: onExit,
),
Expanded(
child: Container(
alignment: Alignment.centerRight,
child: FlowyText.regular(
LocaleKeys.document_plugins_warning.tr(),
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
),
),
),
],
);
}
}
class AutoCompletionFooter extends StatelessWidget {
const AutoCompletionFooter({
super.key,
required this.onKeep,
required this.onDiscard,
});
final VoidCallback onKeep;
final VoidCallback onDiscard;
@override
Widget build(BuildContext context) {
return Row(
children: [
PrimaryTextButton(
LocaleKeys.button_keep.tr(),
onPressed: onKeep,
),
const Space(10, 0),
SecondaryTextButton(
LocaleKeys.button_discard.tr(),
onPressed: onDiscard,
),
],
);
}
}

View File

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
class Loading {
Loading(this.context);
late BuildContext loadingContext;
final BuildContext context;
Future<void> start() async => await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
loadingContext = context;
return const SimpleDialog(
elevation: 0.0,
backgroundColor:
Colors.transparent, // can change this to your preferred color
children: [
Center(
child: CircularProgressIndicator(),
)
],
);
},
);
Future<void> stop() async => Navigator.of(loadingContext).pop();
}
class BarrierDialog {
BarrierDialog(this.context);
late BuildContext loadingContext;
final BuildContext context;
Future<void> show() async => await showDialog<void>(
context: context,
barrierDismissible: false,
barrierColor: Colors.transparent,
builder: (BuildContext context) {
loadingContext = context;
return Container();
},
);
Future<void> dismiss() async => Navigator.of(loadingContext).pop();
}

View File

@ -1,9 +1,9 @@
import 'dart:async';
import 'package:appflowy/plugins/document/presentation/plugins/openai/service/openai_client.dart';
import 'package:appflowy/plugins/document/presentation/plugins/openai/util/learn_more_action.dart';
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/discard_dialog.dart';
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_action.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/util/learn_more_action.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/discard_dialog.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_action.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
@ -13,59 +13,98 @@ import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:http/http.dart' as http;
import 'package:provider/provider.dart';
const String kSmartEditType = 'smart_edit_input';
const String kSmartEditInstructionType = 'smart_edit_instruction';
const String kSmartEditInputType = 'smart_edit_input';
class SmartEditInputBuilder extends NodeWidgetBuilder<Node> {
@override
NodeValidator<Node> get nodeValidator => (node) {
return SmartEditAction.values
.map((e) => e.index)
.contains(node.attributes[kSmartEditInstructionType]) &&
node.attributes[kSmartEditInputType] is String;
};
class SmartEditBlockKeys {
const SmartEditBlockKeys._();
@override
Widget build(NodeWidgetContext<Node> context) {
return _HoverSmartInput(
key: context.node.key,
node: context.node,
editorState: context.editorState,
);
}
static const type = 'smart_edit';
/// The instruction of the smart edit.
///
/// It is a [SmartEditAction] value.
static const action = 'action';
/// The input of the smart edit.
static const content = 'content';
}
class _HoverSmartInput extends StatefulWidget {
const _HoverSmartInput({
Node smartEditNode({
required SmartEditAction action,
required String content,
}) {
return Node(
type: SmartEditBlockKeys.type,
attributes: {
SmartEditBlockKeys.action: action.index,
SmartEditBlockKeys.content: content,
},
);
}
class SmartEditBlockComponentBuilder extends BlockComponentBuilder {
SmartEditBlockComponentBuilder();
@override
Widget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return SmartEditBlockComponentWidget(
key: node.key,
node: node,
);
}
@override
bool validate(Node node) =>
node.attributes[SmartEditBlockKeys.action] is int &&
node.attributes[SmartEditBlockKeys.content] is String;
}
class SmartEditBlockComponentWidget extends StatefulWidget {
const SmartEditBlockComponentWidget({
required super.key,
required this.node,
required this.editorState,
});
final Node node;
final EditorState editorState;
@override
State<_HoverSmartInput> createState() => _HoverSmartInputState();
State<SmartEditBlockComponentWidget> createState() =>
_SmartEditBlockComponentWidgetState();
}
class _HoverSmartInputState extends State<_HoverSmartInput> {
class _SmartEditBlockComponentWidgetState
extends State<SmartEditBlockComponentWidget> {
final popoverController = PopoverController();
final key = GlobalKey(debugLabel: 'smart_edit_input');
late final editorState = context.read<EditorState>();
@override
void initState() {
super.initState();
// todo: don't use a popover to show the content of the smart edit.
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
popoverController.show();
});
}
@override
void reassemble() {
super.reassemble();
final transaction = editorState.transaction..deleteNode(widget.node);
editorState.apply(transaction);
}
@override
Widget build(BuildContext context) {
final width = _maxWidth();
final width = _getEditorWidth();
return AppFlowyPopover(
controller: popoverController,
@ -82,7 +121,7 @@ class _HoverSmartInputState extends State<_HoverSmartInput> {
),
canClose: () async {
final completer = Completer<bool>();
final state = key.currentState as _SmartEditInputState;
final state = key.currentState as _SmartEditInputWidgetState;
if (state.result.isEmpty) {
completer.complete(true);
} else {
@ -98,20 +137,24 @@ class _HoverSmartInputState extends State<_HoverSmartInput> {
}
return completer.future;
},
onClose: () {
final transaction = editorState.transaction..deleteNode(widget.node);
editorState.apply(transaction);
},
popupBuilder: (BuildContext popoverContext) {
return _SmartEditInput(
return SmartEditInputWidget(
key: key,
node: widget.node,
editorState: widget.editorState,
editorState: editorState,
);
},
);
}
double _maxWidth() {
double _getEditorWidth() {
var width = double.infinity;
final editorSize = widget.editorState.renderBox?.size;
final padding = widget.editorState.editorStyle.padding;
final editorSize = editorState.renderBox?.size;
final padding = editorState.editorStyle.padding;
if (editorSize != null && padding != null) {
width = editorSize.width - padding.left - padding.right;
}
@ -119,8 +162,8 @@ class _HoverSmartInputState extends State<_HoverSmartInput> {
}
}
class _SmartEditInput extends StatefulWidget {
const _SmartEditInput({
class SmartEditInputWidget extends StatefulWidget {
const SmartEditInputWidget({
required super.key,
required this.node,
required this.editorState,
@ -130,16 +173,19 @@ class _SmartEditInput extends StatefulWidget {
final EditorState editorState;
@override
State<_SmartEditInput> createState() => _SmartEditInputState();
State<SmartEditInputWidget> createState() => _SmartEditInputWidgetState();
}
class _SmartEditInputState extends State<_SmartEditInput> {
SmartEditAction get action =>
SmartEditAction.from(widget.node.attributes[kSmartEditInstructionType]);
String get input => widget.node.attributes[kSmartEditInputType];
class _SmartEditInputWidgetState extends State<SmartEditInputWidget> {
final focusNode = FocusNode();
final client = http.Client();
SmartEditAction get action => SmartEditAction.from(
widget.node.attributes[SmartEditBlockKeys.action],
);
String get content => widget.node.attributes[SmartEditBlockKeys.content];
EditorState get editorState => widget.editorState;
bool loading = true;
String result = '';
@ -147,19 +193,19 @@ class _SmartEditInputState extends State<_SmartEditInput> {
void initState() {
super.initState();
widget.editorState.service.keyboardService?.disable(showCursor: true);
focusNode.requestFocus();
focusNode.addListener(() {
if (!focusNode.hasFocus) {
widget.editorState.service.keyboardService?.enable();
}
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
editorState.service.keyboardService?.disable();
// editorState.selection = null;
});
focusNode.requestFocus();
_requestCompletions();
}
@override
void dispose() {
client.close();
focusNode.dispose();
super.dispose();
}
@ -272,12 +318,15 @@ class _SmartEditInputState extends State<_SmartEditInput> {
),
onPressed: () async => await _onExit(),
),
const Spacer(flex: 2),
const Spacer(flex: 1),
Expanded(
child: Container(
alignment: Alignment.centerRight,
child: FlowyText.regular(
overflow: TextOverflow.ellipsis,
LocaleKeys.document_plugins_warning.tr(),
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
),
),
),
],
@ -285,72 +334,66 @@ class _SmartEditInputState extends State<_SmartEditInput> {
}
Future<void> _onReplace() async {
final selection = widget.editorState.service.selectionService
.currentSelection.value?.normalized;
final selectedNodes = widget
.editorState.service.selectionService.currentSelectedNodes.normalized
.whereType<TextNode>();
if (selection == null || result.isEmpty) {
final selection = editorState.selection?.normalized;
if (selection == null) {
return;
}
final texts = result.split('\n')..removeWhere((element) => element.isEmpty);
final transaction = widget.editorState.transaction;
final nodes = editorState.getNodesInSelection(selection);
if (nodes.isEmpty || !nodes.every((element) => element.delta != null)) {
return;
}
final replaceTexts = result.split('\n')
..removeWhere((element) => element.isEmpty);
final transaction = editorState.transaction;
transaction.replaceTexts(
selectedNodes.toList(growable: false),
nodes,
selection,
texts,
replaceTexts,
);
await widget.editorState.apply(transaction);
await editorState.apply(transaction);
int endOffset = texts.last.length;
if (texts.length == 1) {
int endOffset = replaceTexts.last.length;
if (replaceTexts.length == 1) {
endOffset += selection.start.offset;
}
await widget.editorState.updateCursorSelection(
Selection(
editorState.selection = Selection(
start: selection.start,
end: Position(
path: [selection.start.path.first + texts.length - 1],
path: [selection.start.path.first + replaceTexts.length - 1],
offset: endOffset,
),
),
);
}
Future<void> _onInsertBelow() async {
final selection = widget.editorState.service.selectionService
.currentSelection.value?.normalized;
if (selection == null || result.isEmpty) {
final selection = editorState.selection?.normalized;
if (selection == null) {
return;
}
final texts = result.split('\n')..removeWhere((element) => element.isEmpty);
final transaction = widget.editorState.transaction;
final insertedText = result.split('\n')
..removeWhere((element) => element.isEmpty);
final transaction = editorState.transaction;
transaction.insertNodes(
selection.normalized.end.path.next,
texts.map(
(e) => TextNode(
delta: Delta()..insert(e),
selection.end.path.next,
insertedText.map(
(e) => paragraphNode(
text: e,
),
),
);
await widget.editorState.apply(transaction);
await widget.editorState.updateCursorSelection(
Selection(
transaction.afterSelection = Selection(
start: Position(path: selection.end.path.next, offset: 0),
end: Position(
path: [selection.end.path.next.first + texts.length],
),
path: [selection.end.path.next.first + insertedText.length],
),
);
await editorState.apply(transaction);
}
Future<void> _onExit() async {
final transaction = widget.editorState.transaction;
transaction.deleteNode(widget.node);
return widget.editorState.apply(
final transaction = editorState.transaction..deleteNode(widget.node);
return editorState.apply(
transaction,
options: const ApplyOptions(
recordRedo: false,
@ -362,7 +405,7 @@ class _SmartEditInputState extends State<_SmartEditInput> {
Future<void> _requestCompletions() async {
final openAIRepository = await getIt.getAsync<OpenAIRepository>();
var lines = input.split('\n\n');
var lines = content.split('\n\n');
if (action == SmartEditAction.summarize) {
lines = [lines.join('\n')];
}

View File

@ -1,5 +1,5 @@
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_action.dart';
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_action.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@ -10,33 +10,34 @@ import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
ToolbarItem smartEditItem = ToolbarItem(
id: 'appflowy.toolbar.smart_edit',
type: 0, // headmost
validator: (editorState) {
// All selected nodes must be text.
final nodes = editorState.service.selectionService.currentSelectedNodes;
return nodes.whereType<TextNode>().length == nodes.length;
final ToolbarItem smartEditItem = ToolbarItem(
id: 'appflowy.editor.smart_edit',
isActive: (editorState) {
final selection = editorState.selection;
if (selection == null) {
return false;
}
final nodes = editorState.getNodesInSelection(selection);
return nodes.every((element) => element.delta != null);
},
itemBuilder: (context, editorState) {
return _SmartEditWidget(
builder: (context, editorState) => SmartEditActionList(
editorState: editorState,
);
},
),
);
class _SmartEditWidget extends StatefulWidget {
const _SmartEditWidget({
class SmartEditActionList extends StatefulWidget {
const SmartEditActionList({
super.key,
required this.editorState,
});
final EditorState editorState;
@override
State<_SmartEditWidget> createState() => _SmartEditWidgetState();
State<SmartEditActionList> createState() => _SmartEditActionListState();
}
class _SmartEditWidgetState extends State<_SmartEditWidget> {
class _SmartEditActionListState extends State<SmartEditActionList> {
bool isOpenAIEnabled = false;
@override
@ -45,8 +46,10 @@ class _SmartEditWidgetState extends State<_SmartEditWidget> {
UserBackendService.getCurrentUserProfile().then((value) {
setState(() {
isOpenAIEnabled =
value.fold((l) => l.openaiKey.isNotEmpty, (r) => false);
isOpenAIEnabled = value.fold(
(l) => false,
(r) => r.openaiKey.isNotEmpty,
);
});
});
}
@ -67,7 +70,7 @@ class _SmartEditWidgetState extends State<_SmartEditWidget> {
preferBelow: false,
icon: const Icon(
Icons.lightbulb_outline,
size: 13,
size: 15,
color: Colors.white,
),
onPressed: () {
@ -89,40 +92,29 @@ class _SmartEditWidgetState extends State<_SmartEditWidget> {
Future<void> _insertSmartEditNode(
SmartEditActionWrapper actionWrapper,
) async {
final selection =
widget.editorState.service.selectionService.currentSelection.value;
final selection = widget.editorState.selection?.normalized;
if (selection == null) {
return;
}
final textNodes = widget
.editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>()
.toList(growable: false);
final input = widget.editorState.getTextInSelection(
textNodes.normalized,
selection.normalized,
);
final input = widget.editorState.getTextInSelection(selection);
while (input.last.isEmpty) {
input.removeLast();
}
final transaction = widget.editorState.transaction;
transaction.insertNode(
selection.normalized.end.path.next,
Node(
type: kSmartEditType,
attributes: {
kSmartEditInstructionType: actionWrapper.inner.index,
kSmartEditInputType: input.join('\n\n'),
},
smartEditNode(
action: actionWrapper.inner,
content: input.join('\n\n'),
),
);
return widget.editorState.apply(
await widget.editorState.apply(
transaction,
options: const ApplyOptions(
recordUndo: false,
recordRedo: false,
),
withUpdateCursor: false,
withUpdateSelection: false,
);
}

View File

@ -1,10 +1,11 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
class MathEquationNodeParser extends NodeParser {
const MathEquationNodeParser();
@override
String get id => 'math_equation';
String get id => MathEquationBlockKeys.type;
@override
String transform(Node node) {

View File

@ -1,21 +1,20 @@
export 'board/board_node_widget.dart';
export 'board/board_menu_item.dart';
export 'board/board_view_menu_item.dart';
export 'callout/callout_node_widget.dart';
export 'code_block/code_block_node_widget.dart';
export 'callout/callout_block_component.dart';
export 'code_block/code_block_component.dart';
export 'code_block/code_block_shortcut_event.dart';
export 'cover/change_cover_popover_bloc.dart';
export 'cover/cover_node_widget.dart';
export 'cover/cover_image_picker.dart';
export 'divider/divider_node_widget.dart';
export 'divider/divider_shortcut_event.dart';
export 'divider/divider_character_shortcut_event.dart';
export 'emoji_picker/emoji_menu_item.dart';
export 'extensions/flowy_tint_extension.dart';
export 'grid/grid_menu_item.dart';
export 'grid/grid_node_widget.dart';
export 'grid/grid_view_menu_item.dart';
export 'math_equation/math_equation_node_widget.dart';
export 'math_equation/math_equation_block_component.dart';
export 'openai/widgets/auto_completion_node_widget.dart';
export 'openai/widgets/auto_completion_plugins.dart';
export 'openai/widgets/smart_edit_node_widget.dart';
export 'openai/widgets/smart_edit_toolbar_item.dart';

View File

@ -0,0 +1,125 @@
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:google_fonts/google_fonts.dart';
class EditorStyleCustomizer {
EditorStyleCustomizer({
required this.context,
});
static double get horizontalPadding =>
PlatformExtension.isDesktop ? 100.0 : 10.0;
final BuildContext context;
EditorStyle style() {
if (PlatformExtension.isDesktopOrWeb) {
return desktop();
} else if (PlatformExtension.isMobile) {
return mobile();
}
throw UnimplementedError();
}
EditorStyle desktop() {
final theme = Theme.of(context);
final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
return EditorStyle.desktop(
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
backgroundColor: theme.colorScheme.surface,
cursorColor: theme.colorScheme.primary,
textStyleConfiguration: TextStyleConfiguration(
text: TextStyle(
fontFamily: 'poppins',
fontSize: fontSize,
color: theme.colorScheme.onBackground,
height: 1.5,
),
bold: const TextStyle(
fontFamily: 'poppins-Bold',
fontWeight: FontWeight.w600,
),
italic: const TextStyle(fontStyle: FontStyle.italic),
underline: const TextStyle(decoration: TextDecoration.underline),
strikethrough: const TextStyle(decoration: TextDecoration.lineThrough),
href: TextStyle(
color: theme.colorScheme.primary,
decoration: TextDecoration.underline,
),
code: GoogleFonts.robotoMono(
textStyle: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.normal,
color: Colors.red,
backgroundColor: theme.colorScheme.inverseSurface,
),
),
),
);
}
EditorStyle mobile() {
final theme = Theme.of(context);
final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
return EditorStyle.desktop(
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
backgroundColor: theme.colorScheme.surface,
cursorColor: theme.colorScheme.primary,
textStyleConfiguration: TextStyleConfiguration(
text: TextStyle(
fontFamily: 'poppins',
fontSize: fontSize,
color: theme.colorScheme.onBackground,
height: 1.5,
),
bold: const TextStyle(
fontFamily: 'poppins-Bold',
fontWeight: FontWeight.w600,
),
italic: const TextStyle(fontStyle: FontStyle.italic),
underline: const TextStyle(decoration: TextDecoration.underline),
strikethrough: const TextStyle(decoration: TextDecoration.lineThrough),
href: TextStyle(
color: theme.colorScheme.primary,
decoration: TextDecoration.underline,
),
code: GoogleFonts.robotoMono(
textStyle: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.normal,
color: Colors.red,
backgroundColor: theme.colorScheme.inverseSurface,
),
),
),
);
}
TextStyle headingStyleBuilder(int level) {
final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
final fontSizes = [
fontSize + 16,
fontSize + 12,
fontSize + 8,
fontSize + 4,
fontSize + 2,
fontSize
];
return TextStyle(
fontSize: fontSizes.elementAtOrNull(level - 1) ?? fontSize,
fontWeight: FontWeight.bold,
);
}
TextStyle codeBlockStyleBuilder() {
final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
return TextStyle(
fontFamily: 'poppins',
fontSize: fontSize,
height: 1.5,
);
}
}

View File

@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
class ExportPageWidget extends StatelessWidget {
const ExportPageWidget({
super.key,
required this.onTap,
});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
const FlowyText.regular(
'There are some errors.',
fontSize: 16.0,
),
const SizedBox(
height: 10,
),
const FlowyText.regular(
'Please try to export the page and contact us.',
fontSize: 14.0,
),
const SizedBox(
height: 5,
),
FlowyTextButton(
'Export page',
constraints: const BoxConstraints(maxWidth: 100),
mainAxisAlignment: MainAxisAlignment.center,
onPressed: onTap,
)
],
);
}
}

View File

@ -20,11 +20,11 @@ class DocumentAppearance {
}
class DocumentAppearanceCubit extends Cubit<DocumentAppearance> {
DocumentAppearanceCubit() : super(const DocumentAppearance(fontSize: 14.0));
DocumentAppearanceCubit() : super(const DocumentAppearance(fontSize: 16.0));
void fetch() async {
final prefs = await SharedPreferences.getInstance();
final fontSize = prefs.getDouble(_kDocumentAppearanceFontSize) ?? 14.0;
final fontSize = prefs.getDouble(_kDocumentAppearanceFontSize) ?? 16.0;
emit(
state.copyWith(
fontSize: fontSize,

View File

@ -18,9 +18,9 @@ class FontSizeSwitcher extends StatefulWidget {
class _FontSizeSwitcherState extends State<FontSizeSwitcher> {
final List<Tuple3<String, double, bool>> _fontSizes = [
Tuple3(LocaleKeys.moreAction_small.tr(), 12.0, false),
Tuple3(LocaleKeys.moreAction_medium.tr(), 14.0, true),
Tuple3(LocaleKeys.moreAction_large.tr(), 18.0, false),
Tuple3(LocaleKeys.moreAction_small.tr(), 14.0, false),
Tuple3(LocaleKeys.moreAction_medium.tr(), 18.0, true),
Tuple3(LocaleKeys.moreAction_large.tr(), 22.0, false),
];
@override

View File

@ -1,54 +0,0 @@
import 'package:appflowy/plugins/database_view/board/presentation/board_page.dart';
import 'package:appflowy/plugins/document/presentation/plugins/base/built_in_page_widget.dart';
import 'package:appflowy/plugins/document/presentation/plugins/base/insert_page_command.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
const String kBoardType = 'board';
class BoardNodeWidgetBuilder extends NodeWidgetBuilder<Node> {
@override
Widget build(NodeWidgetContext<Node> context) {
return _BoardWidget(
key: context.node.key,
node: context.node,
editorState: context.editorState,
);
}
@override
NodeValidator<Node> get nodeValidator => (node) {
return node.attributes[kViewID] is String &&
node.attributes[kAppID] is String;
};
}
class _BoardWidget extends StatefulWidget {
const _BoardWidget({
Key? key,
required this.node,
required this.editorState,
}) : super(key: key);
final Node node;
final EditorState editorState;
@override
State<_BoardWidget> createState() => _BoardWidgetState();
}
class _BoardWidgetState extends State<_BoardWidget> {
@override
Widget build(BuildContext context) {
return BuiltInPageWidget(
node: widget.node,
editorState: widget.editorState,
builder: (viewPB) {
return BoardPage(
key: ValueKey(viewPB.id),
view: viewPB,
);
},
);
}
}

View File

@ -1,300 +0,0 @@
import 'package:appflowy/plugins/document/presentation/plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
const String kCalloutType = 'callout';
const String kCalloutAttrColor = 'color';
const String kCalloutAttrEmoji = 'emoji';
SelectionMenuItem calloutMenuItem = SelectionMenuItem.node(
name: 'Callout',
iconData: Icons.note,
keywords: ['callout'],
nodeBuilder: (editorState) {
final node = Node(type: kCalloutType);
node.insert(TextNode.empty());
return node;
},
replace: (_, textNode) => textNode.toPlainText().isEmpty,
updateSelection: (_, path, __, ___) {
return Selection.single(path: [...path, 0], startOffset: 0);
},
);
class CalloutNodeWidgetBuilder extends NodeWidgetBuilder<Node>
with ActionProvider<Node> {
@override
Widget build(NodeWidgetContext<Node> context) {
return _CalloutWidget(
key: context.node.key,
node: context.node,
editorState: context.editorState,
);
}
@override
NodeValidator<Node> get nodeValidator => (node) => node.type == kCalloutType;
_CalloutWidgetState? _getState(NodeWidgetContext<Node> context) {
return context.node.key.currentState as _CalloutWidgetState?;
}
BuildContext? _getBuildContext(NodeWidgetContext<Node> context) {
return context.node.key.currentContext;
}
@override
List<ActionMenuItem> actions(NodeWidgetContext<Node> context) {
return [
ActionMenuItem.icon(
iconData: Icons.color_lens_outlined,
onPressed: () {
final state = _getState(context);
final ctx = _getBuildContext(context);
if (state == null || ctx == null) {
return;
}
final menuState = Provider.of<ActionMenuState>(ctx, listen: false);
menuState.isPinned = true;
state.colorPopoverController.show();
},
itemWrapper: (item) {
final state = _getState(context);
final ctx = _getBuildContext(context);
if (state == null || ctx == null) {
return item;
}
return AppFlowyPopover(
controller: state.colorPopoverController,
popupBuilder: (context) => state._buildColorPicker(),
constraints: BoxConstraints.loose(const Size(200, 460)),
triggerActions: 0,
offset: const Offset(0, 30),
child: item,
onClose: () {
final menuState =
Provider.of<ActionMenuState>(ctx, listen: false);
menuState.isPinned = false;
},
);
},
),
ActionMenuItem.svg(
name: 'delete',
onPressed: () {
final transaction = context.editorState.transaction
..deleteNode(context.node);
context.editorState.apply(transaction);
},
),
];
}
}
class _CalloutWidget extends StatefulWidget {
const _CalloutWidget({
super.key,
required this.node,
required this.editorState,
});
final Node node;
final EditorState editorState;
@override
State<_CalloutWidget> createState() => _CalloutWidgetState();
}
class _CalloutWidgetState extends State<_CalloutWidget> with SelectableMixin {
final PopoverController colorPopoverController = PopoverController();
final PopoverController emojiPopoverController = PopoverController();
RenderBox get _renderBox => context.findRenderObject() as RenderBox;
@override
void initState() {
widget.node.addListener(nodeChanged);
super.initState();
}
@override
void dispose() {
widget.node.removeListener(nodeChanged);
super.dispose();
}
void nodeChanged() {
if (widget.node.children.isEmpty) {
deleteNode();
}
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
color: tint.color(context),
),
padding: const EdgeInsets.only(top: 8, bottom: 8, left: 0, right: 15),
width: double.infinity,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildEmoji(),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widget.node.children
.map(
(child) => widget.editorState.service.renderPluginService
.buildPluginWidget(
child is TextNode
? NodeWidgetContext<TextNode>(
context: context,
node: child,
editorState: widget.editorState,
)
: NodeWidgetContext<Node>(
context: context,
node: child,
editorState: widget.editorState,
),
),
)
.toList(),
),
),
],
),
);
}
Widget _popover({
required PopoverController controller,
required Widget Function(BuildContext context) popupBuilder,
required Widget child,
Size size = const Size(200, 460),
}) {
return AppFlowyPopover(
controller: controller,
constraints: BoxConstraints.loose(size),
triggerActions: 0,
popupBuilder: popupBuilder,
child: child,
);
}
Widget _buildColorPicker() {
return FlowyColorPicker(
colors: FlowyTint.values
.map(
(t) => ColorOption(
color: t.color(context),
name: t.tintName(AppFlowyEditorLocalizations.current),
),
)
.toList(),
selected: tint.color(context),
onTap: (color, index) {
setColor(FlowyTint.values[index]);
colorPopoverController.close();
},
);
}
Widget _buildEmoji() {
return _popover(
controller: emojiPopoverController,
popupBuilder: (context) => _buildEmojiPicker(),
size: const Size(300, 200),
child: FlowyTextButton(
emoji,
fontSize: 18,
fillColor: Colors.transparent,
onPressed: () {
emojiPopoverController.show();
},
),
);
}
Widget _buildEmojiPicker() {
return EmojiSelectionMenu(
editorState: widget.editorState,
onSubmitted: (emoji) {
setEmoji(emoji.emoji);
emojiPopoverController.close();
},
onExit: () {},
);
}
void setColor(FlowyTint tint) {
final transaction = widget.editorState.transaction
..updateNode(widget.node, {
kCalloutAttrColor: tint.name,
});
widget.editorState.apply(transaction);
}
void setEmoji(String emoji) {
final transaction = widget.editorState.transaction
..updateNode(widget.node, {
kCalloutAttrEmoji: emoji,
});
widget.editorState.apply(transaction);
}
void deleteNode() {
final transaction = widget.editorState.transaction..deleteNode(widget.node);
widget.editorState.apply(transaction);
}
FlowyTint get tint {
final name = widget.node.attributes[kCalloutAttrColor];
return (name is String) ? FlowyTint.fromJson(name) : FlowyTint.tint1;
}
String get emoji {
return widget.node.attributes[kCalloutAttrEmoji] ?? "💡";
}
@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);
}

View File

@ -1,224 +0,0 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:highlight/highlight.dart' as highlight;
import 'package:highlight/languages/all.dart';
const String kCodeBlockType = 'text/$kCodeBlockSubType';
const String kCodeBlockSubType = 'code_block';
const String kCodeBlockAttrTheme = 'theme';
const String kCodeBlockAttrLanguage = 'language';
class CodeBlockNodeWidgetBuilder extends NodeWidgetBuilder<TextNode>
with ActionProvider<TextNode> {
@override
Widget build(NodeWidgetContext<TextNode> context) {
return _CodeBlockNodeWidge(
key: context.node.key,
textNode: context.node,
editorState: context.editorState,
);
}
@override
NodeValidator<Node> get nodeValidator => (node) {
return node is TextNode &&
node.attributes[kCodeBlockAttrTheme] is String;
};
@override
List<ActionMenuItem> actions(NodeWidgetContext<TextNode> context) {
return [
ActionMenuItem.svg(
name: 'delete',
onPressed: () {
final transaction = context.editorState.transaction
..deleteNode(context.node);
context.editorState.apply(transaction);
},
),
];
}
}
class _CodeBlockNodeWidge extends StatefulWidget {
const _CodeBlockNodeWidge({
Key? key,
required this.textNode,
required this.editorState,
}) : super(key: key);
final TextNode textNode;
final EditorState editorState;
@override
State<_CodeBlockNodeWidge> createState() => __CodeBlockNodeWidgeState();
}
class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge>
with SelectableMixin, DefaultSelectable {
final _richTextKey = GlobalKey(debugLabel: kCodeBlockType);
final _padding = const EdgeInsets.only(left: 20, top: 30, bottom: 30);
String? get _language =>
widget.textNode.attributes[kCodeBlockAttrLanguage] as String?;
String? _detectLanguage;
@override
SelectableMixin<StatefulWidget> get forward =>
_richTextKey.currentState as SelectableMixin;
@override
GlobalKey<State<StatefulWidget>>? get iconKey => null;
@override
Offset get baseOffset => super.baseOffset + _padding.topLeft;
@override
Widget build(BuildContext context) {
return Stack(
children: [
_buildCodeBlock(context),
_buildSwitchCodeButton(context),
],
);
}
Widget _buildCodeBlock(BuildContext context) {
final result = highlight.highlight.parse(
widget.textNode.toPlainText(),
language: _language,
autoDetection: _language == null,
);
_detectLanguage = _language ?? result.language;
final code = result.nodes;
final codeTextSpan = _convert(code!);
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
color: Colors.grey.withOpacity(0.1),
),
padding: _padding,
width: MediaQuery.of(context).size.width,
child: FlowyRichText(
key: _richTextKey,
textNode: widget.textNode,
editorState: widget.editorState,
lineHeight: 1.0,
cursorHeight: 15.0,
textSpanDecorator: (textSpan) => TextSpan(
style: widget.editorState.editorStyle.textStyle,
children: codeTextSpan,
),
),
);
}
Widget _buildSwitchCodeButton(BuildContext context) {
return Positioned(
top: -5,
left: 10,
child: SizedBox(
height: 35,
child: DropdownButton<String>(
value: _detectLanguage,
iconSize: 14.0,
onChanged: (value) {
final transaction = widget.editorState.transaction
..updateNode(widget.textNode, {
kCodeBlockAttrLanguage: value,
});
widget.editorState.apply(transaction);
},
items:
allLanguages.keys.map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: FlowyText.medium(
value,
color: Theme.of(context).colorScheme.tertiary,
),
);
}).toList(growable: false),
),
),
);
}
// Copy from flutter.highlight package.
// https://github.com/git-touch/highlight.dart/blob/master/flutter_highlight/lib/flutter_highlight.dart
List<TextSpan> _convert(List<highlight.Node> nodes) {
List<TextSpan> spans = [];
var currentSpans = spans;
List<List<TextSpan>> stack = [];
void traverse(highlight.Node node) {
if (node.value != null) {
currentSpans.add(node.className == null
? TextSpan(text: node.value)
: TextSpan(
text: node.value,
style: _builtInCodeBlockTheme[node.className!],),);
} else if (node.children != null) {
List<TextSpan> tmp = [];
currentSpans.add(TextSpan(
children: tmp, style: _builtInCodeBlockTheme[node.className!],),);
stack.add(currentSpans);
currentSpans = tmp;
for (var n in node.children!) {
traverse(n);
if (n == node.children!.last) {
currentSpans = stack.isEmpty ? spans : stack.removeLast();
}
}
}
}
for (var node in nodes) {
traverse(node);
}
return spans;
}
}
const _builtInCodeBlockTheme = {
'root':
TextStyle(backgroundColor: Color(0xffffffff), color: Color(0xff000000)),
'comment': TextStyle(color: Color(0xff007400)),
'quote': TextStyle(color: Color(0xff007400)),
'tag': TextStyle(color: Color(0xffaa0d91)),
'attribute': TextStyle(color: Color(0xffaa0d91)),
'keyword': TextStyle(color: Color(0xffaa0d91)),
'selector-tag': TextStyle(color: Color(0xffaa0d91)),
'literal': TextStyle(color: Color(0xffaa0d91)),
'name': TextStyle(color: Color(0xffaa0d91)),
'variable': TextStyle(color: Color(0xff3F6E74)),
'template-variable': TextStyle(color: Color(0xff3F6E74)),
'code': TextStyle(color: Color(0xffc41a16)),
'string': TextStyle(color: Color(0xffc41a16)),
'meta-string': TextStyle(color: Color(0xffc41a16)),
'regexp': TextStyle(color: Color(0xff0E0EFF)),
'link': TextStyle(color: Color(0xff0E0EFF)),
'title': TextStyle(color: Color(0xff1c00cf)),
'symbol': TextStyle(color: Color(0xff1c00cf)),
'bullet': TextStyle(color: Color(0xff1c00cf)),
'number': TextStyle(color: Color(0xff1c00cf)),
'section': TextStyle(color: Color(0xff643820)),
'meta': TextStyle(color: Color(0xff643820)),
'type': TextStyle(color: Color(0xff5c2699)),
'built_in': TextStyle(color: Color(0xff5c2699)),
'builtin-name': TextStyle(color: Color(0xff5c2699)),
'params': TextStyle(color: Color(0xff5c2699)),
'attr': TextStyle(color: Color(0xff836C28)),
'subst': TextStyle(color: Color(0xff000000)),
'formula': TextStyle(
backgroundColor: Color(0xffeeeeee), fontStyle: FontStyle.italic,),
'addition': TextStyle(backgroundColor: Color(0xffbaeeba)),
'deletion': TextStyle(backgroundColor: Color(0xffffc8bd)),
'selector-id': TextStyle(color: Color(0xff9b703f)),
'selector-class': TextStyle(color: Color(0xff9b703f)),
'doctag': TextStyle(fontWeight: FontWeight.bold),
'strong': TextStyle(fontWeight: FontWeight.bold),
'emphasis': TextStyle(fontStyle: FontStyle.italic),
};

View File

@ -1,125 +0,0 @@
import 'package:appflowy/plugins/document/presentation/plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
ShortcutEvent enterInCodeBlock = ShortcutEvent(
key: 'Press Enter In Code Block',
command: 'enter',
handler: _enterInCodeBlockHandler,
);
ShortcutEvent ignoreKeysInCodeBlock = ShortcutEvent(
key: 'White space in code block',
command: 'space, slash, shift+underscore',
handler: _ignorekHandler,
);
ShortcutEvent pasteInCodeBlock = ShortcutEvent(
key: 'Paste in code block',
command: 'meta+v',
windowsCommand: 'ctrl+v',
linuxCommand: 'ctrl+v',
handler: _pasteHandler,
);
ShortcutEventHandler _enterInCodeBlockHandler = (editorState, event) {
final selection = editorState.service.selectionService.currentSelection.value;
final nodes = editorState.service.selectionService.currentSelectedNodes;
final codeBlockNode =
nodes.whereType<TextNode>().where((node) => node.id == kCodeBlockType);
if (codeBlockNode.length != 1 ||
selection == null ||
!selection.isCollapsed) {
return KeyEventResult.ignored;
}
final transaction = editorState.transaction
..insertText(
codeBlockNode.first,
selection.end.offset,
'\n',
);
editorState.apply(transaction);
return KeyEventResult.handled;
};
ShortcutEventHandler _ignorekHandler = (editorState, event) {
final nodes = editorState.service.selectionService.currentSelectedNodes;
final codeBlockNodes =
nodes.whereType<TextNode>().where((node) => node.id == kCodeBlockType);
if (codeBlockNodes.length == 1) {
return KeyEventResult.skipRemainingHandlers;
}
return KeyEventResult.ignored;
};
ShortcutEventHandler _pasteHandler = (editorState, event) {
final selection = editorState.service.selectionService.currentSelection.value;
final nodes = editorState.service.selectionService.currentSelectedNodes;
final codeBlockNodes =
nodes.whereType<TextNode>().where((node) => node.id == kCodeBlockType);
if (selection != null &&
selection.isCollapsed &&
codeBlockNodes.length == 1) {
Clipboard.getData(Clipboard.kTextPlain).then((value) {
final text = value?.text;
if (text == null) return;
final transaction = editorState.transaction;
transaction.insertText(
codeBlockNodes.first,
selection.startIndex,
text,
);
editorState.apply(transaction);
});
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
};
SelectionMenuItem codeBlockMenuItem = SelectionMenuItem(
name: 'Code Block',
icon: (editorState, onSelected) => Icon(
Icons.abc,
color: onSelected
? editorState.editorStyle.selectionMenuItemSelectedIconColor
: editorState.editorStyle.selectionMenuItemIconColor,
size: 18.0,
),
keywords: ['code block', 'code snippet'],
handler: (editorState, _, __) {
final selection =
editorState.service.selectionService.currentSelection.value;
final textNodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>();
if (selection == null || textNodes.isEmpty) {
return;
}
final transaction = editorState.transaction;
final textNode = textNodes.first;
if (textNode.toPlainText().isEmpty && textNode.next is TextNode) {
transaction.updateNode(textNodes.first, {
BuiltInAttributeKey.subtype: kCodeBlockSubType,
kCodeBlockAttrTheme: 'vs',
kCodeBlockAttrLanguage: null,
});
transaction.afterSelection = selection;
editorState.apply(transaction);
} else {
transaction.insertNode(
selection.end.path,
TextNode(
attributes: {
BuiltInAttributeKey.subtype: kCodeBlockSubType,
kCodeBlockAttrTheme: 'vs',
kCodeBlockAttrLanguage: null,
},
delta: Delta()..insert('\n'),
),
);
transaction.afterSelection = selection;
}
editorState.apply(transaction);
},
);

View File

@ -1,84 +0,0 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
const String kDividerType = 'divider';
class DividerWidgetBuilder extends NodeWidgetBuilder<Node> {
@override
Widget build(NodeWidgetContext<Node> context) {
return _DividerWidget(
key: context.node.key,
node: context.node,
editorState: context.editorState,
);
}
@override
NodeValidator<Node> get nodeValidator => (node) {
return true;
};
}
class _DividerWidget extends StatefulWidget {
const _DividerWidget({
Key? key,
required this.node,
required this.editorState,
}) : super(key: key);
final Node node;
final EditorState editorState;
@override
State<_DividerWidget> createState() => _DividerWidgetState();
}
class _DividerWidgetState extends State<_DividerWidget> 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);
}

View File

@ -1,72 +0,0 @@
import 'package:appflowy/plugins/document/presentation/plugins/divider/divider_node_widget.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
// insert divider into a document by typing three minuses.
// ---
ShortcutEvent insertDividerEvent = ShortcutEvent(
key: 'Divider',
command: 'Minus',
handler: _insertDividerHandler,
);
ShortcutEventHandler _insertDividerHandler = (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() != '--') {
return KeyEventResult.ignored;
}
final transaction = editorState.transaction
..deleteText(textNode, 0, 2) // remove the existing minuses.
..insertNode(textNode.path, Node(type: kDividerType)) // insert the divder
..afterSelection = Selection.single(
// update selection to the next text node.
path: textNode.path.next,
startOffset: 0,
);
editorState.apply(transaction);
return KeyEventResult.handled;
};
SelectionMenuItem dividerMenuItem = SelectionMenuItem(
name: 'Divider',
icon: (editorState, onSelected) => Icon(
Icons.horizontal_rule,
color: onSelected
? editorState.editorStyle.selectionMenuItemSelectedIconColor
: editorState.editorStyle.selectionMenuItemIconColor,
size: 18.0,
),
keywords: ['horizontal rule', 'divider'],
handler: (editorState, _, __) {
final selection =
editorState.service.selectionService.currentSelection.value;
final textNodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>();
if (textNodes.length != 1 || selection == null) {
return;
}
final textNode = textNodes.first;
// insert the divider at current path if the text node is empty.
if (textNode.toPlainText().isEmpty) {
final transaction = editorState.transaction
..insertNode(textNode.path, Node(type: kDividerType))
..afterSelection = Selection.single(
path: textNode.path.next,
startOffset: 0,
);
editorState.apply(transaction);
} else {
// insert the divider at the path next to current path if the text node is not empty.
final transaction = editorState.transaction
..insertNode(selection.end.path.next, Node(type: kDividerType))
..afterSelection = selection;
editorState.apply(transaction);
}
},
);

View File

@ -1,180 +0,0 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'emoji_picker.dart';
SelectionMenuItem emojiMenuItem = SelectionMenuItem(
name: 'Emoji',
icon: (editorState, onSelected) => Icon(
Icons.emoji_emotions_outlined,
color: onSelected
? editorState.editorStyle.selectionMenuItemSelectedIconColor
: editorState.editorStyle.selectionMenuItemIconColor,
size: 18.0,
),
keywords: ['emoji'],
handler: _showEmojiSelectionMenu,
);
OverlayEntry? _emojiSelectionMenu;
EditorState? _editorState;
void _showEmojiSelectionMenu(
EditorState editorState,
SelectionMenuService menuService,
BuildContext context,
) {
final alignment = menuService.alignment;
final offset = menuService.offset;
menuService.dismiss();
_emojiSelectionMenu?.remove();
_emojiSelectionMenu = OverlayEntry(
builder: (context) {
return Positioned(
top: alignment == Alignment.bottomLeft ? offset.dy : null,
bottom: alignment == Alignment.topLeft ? offset.dy : null,
left: offset.dx,
child: Material(
child: EmojiSelectionMenu(
editorState: editorState,
onSubmitted: (text) {
// insert emoji
editorState.insertEmoji(text);
},
onExit: () {
_dismissEmojiSelectionMenu();
//close emoji panel
},
),
),
);
},
);
Overlay.of(context).insert(_emojiSelectionMenu!);
_editorState = editorState;
editorState.service.selectionService.currentSelection
.addListener(_dismissEmojiSelectionMenu);
}
void _dismissEmojiSelectionMenu() {
_emojiSelectionMenu?.remove();
_emojiSelectionMenu = null;
_editorState?.service.selectionService.currentSelection
.removeListener(_dismissEmojiSelectionMenu);
_editorState?.service.keyboardService?.enable();
_editorState = null;
}
class EmojiSelectionMenu extends StatefulWidget {
const EmojiSelectionMenu({
Key? key,
required this.onSubmitted,
required this.onExit,
required this.editorState,
}) : super(key: key);
final void Function(Emoji emoji) onSubmitted;
final void Function() onExit;
final EditorState editorState;
@override
State<EmojiSelectionMenu> createState() => _EmojiSelectionMenuState();
}
class _EmojiSelectionMenuState extends State<EmojiSelectionMenu> {
EditorStyle get style => widget.editorState.editorStyle;
@override
void initState() {
HardwareKeyboard.instance.addHandler(_handleGlobalKeyEvent);
super.initState();
}
bool _handleGlobalKeyEvent(KeyEvent event) {
if (event.logicalKey == LogicalKeyboardKey.escape &&
event is KeyDownEvent) {
//triggers on esc
widget.onExit();
return true;
} else {
return false;
}
}
@override
void deactivate() {
HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent);
super.deactivate();
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
width: 300,
padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: style.selectionMenuBackgroundColor,
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withOpacity(0.1),
),
],
borderRadius: BorderRadius.circular(6.0),
),
child: _buildEmojiBox(context),
);
}
Widget _buildEmojiBox(BuildContext context) {
return SizedBox(
height: 200,
child: EmojiPicker(
onEmojiSelected: (category, emoji) => widget.onSubmitted(emoji),
config: Config(
columns: 8,
emojiSizeMax: 28,
bgColor:
style.selectionMenuBackgroundColor ?? const Color(0xffF2F2F2),
iconColor: Colors.grey,
iconColorSelected: const Color(0xff333333),
indicatorColor: const Color(0xff333333),
progressIndicatorColor: const Color(0xff333333),
buttonMode: ButtonMode.CUPERTINO,
initCategory: Category.RECENT,
),
),
);
}
}
extension on EditorState {
void insertEmoji(Emoji emoji) {
final selectionService = service.selectionService;
final currentSelection = selectionService.currentSelection.value;
final nodes = selectionService.currentSelectedNodes;
if (currentSelection == null ||
!currentSelection.isCollapsed ||
nodes.first is! TextNode) {
return;
}
final textNode = nodes.first as TextNode;
final tr = transaction;
tr.insertText(
textNode,
currentSelection.endIndex,
emoji.emoji,
);
apply(tr);
}
}

View File

@ -1,54 +0,0 @@
import 'package:appflowy/plugins/database_view/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/document/presentation/plugins/base/built_in_page_widget.dart';
import 'package:appflowy/plugins/document/presentation/plugins/base/insert_page_command.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
const String kGridType = 'grid';
class GridNodeWidgetBuilder extends NodeWidgetBuilder<Node> {
@override
Widget build(NodeWidgetContext<Node> context) {
return _GridWidget(
key: context.node.key,
node: context.node,
editorState: context.editorState,
);
}
@override
NodeValidator<Node> get nodeValidator => (node) {
return node.attributes[kAppID] is String &&
node.attributes[kViewID] is String;
};
}
class _GridWidget extends StatefulWidget {
const _GridWidget({
Key? key,
required this.node,
required this.editorState,
}) : super(key: key);
final Node node;
final EditorState editorState;
@override
State<_GridWidget> createState() => _GridWidgetState();
}
class _GridWidgetState extends State<_GridWidget> {
@override
Widget build(BuildContext context) {
return BuiltInPageWidget(
node: widget.node,
editorState: widget.editorState,
builder: (viewPB) {
return GridPage(
key: ValueKey(viewPB.id),
view: viewPB,
);
},
);
}
}

View File

@ -1,224 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_math_fork/flutter_math.dart';
const String kMathEquationType = 'math_equation';
const String kMathEquationAttr = 'math_equation';
SelectionMenuItem mathEquationMenuItem = SelectionMenuItem(
name: 'Math Equation',
icon: (editorState, onSelected) => Icon(
Icons.text_fields_rounded,
color: onSelected
? editorState.editorStyle.selectionMenuItemSelectedIconColor
: editorState.editorStyle.selectionMenuItemIconColor,
size: 18.0,
),
keywords: ['tex, latex, katex', 'math equation'],
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;
final Path mathEquationNodePath;
if (textNode.toPlainText().isEmpty) {
mathEquationNodePath = selection.end.path;
} else {
mathEquationNodePath = selection.end.path.next;
}
// insert the math equation node
final transaction = editorState.transaction
..insertNode(
mathEquationNodePath,
Node(type: kMathEquationType, attributes: {kMathEquationAttr: ''}),
)
..afterSelection = selection;
editorState.apply(transaction);
// tricy to show the editing dialog.
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
final mathEquationState = editorState.document
.nodeAtPath(mathEquationNodePath)
?.key
.currentState;
if (mathEquationState != null &&
mathEquationState is _MathEquationNodeWidgetState) {
mathEquationState.showEditingDialog();
}
});
},
);
class MathEquationNodeWidgetBuidler extends NodeWidgetBuilder<Node>
with ActionProvider<Node> {
@override
Widget build(NodeWidgetContext<Node> context) {
return _MathEquationNodeWidget(
key: context.node.key,
node: context.node,
editorState: context.editorState,
);
}
@override
NodeValidator<Node> get nodeValidator =>
(node) => node.attributes[kMathEquationAttr] is String;
@override
List<ActionMenuItem> actions(NodeWidgetContext<Node> context) {
return [
ActionMenuItem.svg(
name: "delete",
onPressed: () {
final transaction = context.editorState.transaction
..deleteNode(context.node);
context.editorState.apply(transaction);
},
),
];
}
}
class _MathEquationNodeWidget extends StatefulWidget {
const _MathEquationNodeWidget({
Key? key,
required this.node,
required this.editorState,
}) : super(key: key);
final Node node;
final EditorState editorState;
@override
State<_MathEquationNodeWidget> createState() =>
_MathEquationNodeWidgetState();
}
class _MathEquationNodeWidgetState extends State<_MathEquationNodeWidget> {
String get _mathEquation =>
widget.node.attributes[kMathEquationAttr] as String;
bool _isHover = false;
@override
Widget build(BuildContext context) {
return InkWell(
onHover: (value) {
setState(() {
_isHover = value;
});
},
onTap: () {
showEditingDialog();
},
child: Stack(
children: [
_buildMathEquation(context),
],
),
);
}
Widget _buildMathEquation(BuildContext context) {
return Container(
width: MediaQuery.of(context).size.width,
constraints: const BoxConstraints(minHeight: 50),
padding: const EdgeInsets.symmetric(vertical: 20),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
color: _isHover || _mathEquation.isEmpty
? Theme.of(context).colorScheme.tertiaryContainer
: Colors.transparent,
),
child: Center(
child: _mathEquation.isEmpty
? FlowyText.medium(
LocaleKeys.document_plugins_mathEquation_addMathEquation.tr(),
fontSize: 16,
)
: Math.tex(
_mathEquation,
textStyle: const TextStyle(fontSize: 20),
mathStyle: MathStyle.display,
),
),
);
}
void showEditingDialog() {
showDialog(
context: context,
builder: (context) {
final controller = TextEditingController(text: _mathEquation);
return AlertDialog(
backgroundColor: Theme.of(context).canvasColor,
title: Text(
LocaleKeys.document_plugins_mathEquation_editMathEquation.tr(),
),
content: RawKeyboardListener(
focusNode: FocusNode(),
onKey: (key) {
if (key is! RawKeyDownEvent) return;
if (key.logicalKey == LogicalKeyboardKey.enter &&
!key.isShiftPressed) {
_updateMathEquation(controller.text, context);
} else if (key.logicalKey == LogicalKeyboardKey.escape) {
_dismiss(context);
}
},
child: TextField(
autofocus: true,
controller: controller,
maxLines: null,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'E = MC^2',
),
),
),
actions: [
SecondaryTextButton(
LocaleKeys.button_Cancel.tr(),
onPressed: () => _dismiss(context),
),
PrimaryTextButton(
LocaleKeys.button_Done.tr(),
onPressed: () => _updateMathEquation(controller.text, context),
),
],
actionsPadding: const EdgeInsets.only(bottom: 20),
actionsAlignment: MainAxisAlignment.spaceAround,
);
},
);
}
void _updateMathEquation(String mathEquation, BuildContext context) {
if (mathEquation == _mathEquation) {
_dismiss(context);
return;
}
final transaction = widget.editorState.transaction;
transaction.updateNode(
widget.node,
{
kMathEquationAttr: mathEquation,
},
);
widget.editorState.apply(transaction);
_dismiss(context);
}
void _dismiss(BuildContext context) {
Navigator.of(context).pop();
}
}

View File

@ -1,366 +0,0 @@
import 'dart:convert';
import 'package:appflowy/plugins/document/presentation/plugins/openai/service/openai_client.dart';
import 'package:appflowy/plugins/document/presentation/plugins/openai/util/learn_more_action.dart';
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/discard_dialog.dart';
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/loading.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:http/http.dart' as http;
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import '../util/editor_extension.dart';
const String kAutoCompletionInputType = 'auto_completion_input';
const String kAutoCompletionInputString = 'auto_completion_input_string';
const String kAutoCompletionInputStartSelection =
'auto_completion_input_start_selection';
class AutoCompletionInputBuilder extends NodeWidgetBuilder<Node> {
@override
NodeValidator<Node> get nodeValidator => (node) {
return node.attributes[kAutoCompletionInputString] is String;
};
@override
Widget build(NodeWidgetContext<Node> context) {
return _AutoCompletionInput(
key: context.node.key,
node: context.node,
editorState: context.editorState,
);
}
}
class _AutoCompletionInput extends StatefulWidget {
final Node node;
final EditorState editorState;
const _AutoCompletionInput({
Key? key,
required this.node,
required this.editorState,
});
@override
State<_AutoCompletionInput> createState() => _AutoCompletionInputState();
}
class _AutoCompletionInputState extends State<_AutoCompletionInput> {
String get text => widget.node.attributes[kAutoCompletionInputString];
final controller = TextEditingController();
final focusNode = FocusNode();
final textFieldFocusNode = FocusNode();
final interceptor = SelectionInterceptor();
@override
void initState() {
super.initState();
textFieldFocusNode.addListener(_onFocusChanged);
textFieldFocusNode.requestFocus();
widget.editorState.service.selectionService.register(
interceptor
..canTap = (details) {
final renderBox = context.findRenderObject() as RenderBox?;
if (renderBox != null) {
if (!isTapDownDetailsInRenderBox(details, renderBox)) {
if (text.isNotEmpty || controller.text.isNotEmpty) {
showDialog(
context: context,
builder: (context) {
return DiscardDialog(
onConfirm: () => _onDiscard(),
onCancel: () {},
);
},
);
} else if (controller.text.isEmpty) {
_onExit();
}
}
}
return false;
},
);
}
bool isTapDownDetailsInRenderBox(TapDownDetails details, RenderBox box) {
var result = BoxHitTestResult();
box.hitTest(result, position: box.globalToLocal(details.globalPosition));
return result.path.any((entry) => entry.target == box);
}
@override
void dispose() {
controller.dispose();
textFieldFocusNode.removeListener(_onFocusChanged);
widget.editorState.service.selectionService.currentSelection
.removeListener(_onCancelWhenSelectionChanged);
widget.editorState.service.selectionService.unRegister(interceptor);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Card(
elevation: 5,
color: Theme.of(context).colorScheme.surface,
child: Container(
margin: const EdgeInsets.all(10),
child: _buildAutoGeneratorPanel(context),
),
);
}
Widget _buildAutoGeneratorPanel(BuildContext context) {
if (text.isEmpty) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildHeaderWidget(context),
const Space(0, 10),
_buildInputWidget(context),
const Space(0, 10),
_buildInputFooterWidget(context),
],
);
} else {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildHeaderWidget(context),
const Space(0, 10),
_buildFooterWidget(context),
],
);
}
}
Widget _buildHeaderWidget(BuildContext context) {
return Row(
children: [
FlowyText.medium(
LocaleKeys.document_plugins_autoGeneratorTitleName.tr(),
fontSize: 14,
),
const Spacer(),
FlowyButton(
useIntrinsicWidth: true,
text: FlowyText.regular(
LocaleKeys.document_plugins_autoGeneratorLearnMore.tr(),
),
onTap: () async {
await openLearnMorePage();
},
)
],
);
}
Widget _buildInputWidget(BuildContext context) {
return FlowyTextField(
hintText: LocaleKeys.document_plugins_autoGeneratorHintText.tr(),
controller: controller,
maxLines: 3,
focusNode: textFieldFocusNode,
autoFocus: false,
);
}
Widget _buildInputFooterWidget(BuildContext context) {
return Row(
children: [
PrimaryTextButton(
LocaleKeys.button_generate.tr(),
onPressed: () async => await _onGenerate(),
),
const Space(10, 0),
SecondaryTextButton(
LocaleKeys.button_Cancel.tr(),
onPressed: () async => await _onExit(),
),
Expanded(
child: Container(
alignment: Alignment.centerRight,
child: FlowyText.regular(
LocaleKeys.document_plugins_warning.tr(),
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
),
),
),
],
);
}
Widget _buildFooterWidget(BuildContext context) {
return Row(
children: [
PrimaryTextButton(
LocaleKeys.button_keep.tr(),
onPressed: () => _onExit(),
),
const Space(10, 0),
SecondaryTextButton(
LocaleKeys.button_discard.tr(),
onPressed: () => _onDiscard(),
),
],
);
}
Future<void> _onExit() async {
final transaction = widget.editorState.transaction;
transaction.deleteNode(widget.node);
await widget.editorState.apply(
transaction,
options: const ApplyOptions(
recordRedo: false,
recordUndo: false,
),
);
}
Future<void> _onGenerate() async {
final loading = Loading(context);
loading.start();
await _updateEditingText();
final result = await UserBackendService.getCurrentUserProfile();
result.fold((userProfile) async {
BarrierDialog? barrierDialog;
final openAIRepository = HttpOpenAIRepository(
client: http.Client(),
apiKey: userProfile.openaiKey,
);
await openAIRepository.getStreamedCompletions(
prompt: controller.text,
onStart: () async {
loading.stop();
barrierDialog = BarrierDialog(context);
barrierDialog?.show();
await _makeSurePreviousNodeIsEmptyTextNode();
},
onProcess: (response) async {
if (response.choices.isNotEmpty) {
final text = response.choices.first.text;
await widget.editorState.autoInsertText(
text,
inputType: TextRobotInputType.word,
delay: Duration.zero,
);
}
},
onEnd: () async {
await barrierDialog?.dismiss();
widget.editorState.service.selectionService.currentSelection
.addListener(_onCancelWhenSelectionChanged);
},
onError: (error) async {
loading.stop();
await _showError(error.message);
},
);
}, (error) async {
loading.stop();
await _showError(
LocaleKeys.document_plugins_autoGeneratorCantGetOpenAIKey.tr(),
);
});
}
Future<void> _onDiscard() async {
final selection =
widget.node.attributes[kAutoCompletionInputStartSelection];
if (selection != null) {
final start = Selection.fromJson(json.decode(selection)).start.path;
final end = widget.node.previous?.path;
if (end != null) {
final transaction = widget.editorState.transaction;
transaction.deleteNodesAtPath(
start,
end.last - start.last + 1,
);
await widget.editorState.apply(transaction);
}
}
_onExit();
}
Future<void> _updateEditingText() async {
final transaction = widget.editorState.transaction;
transaction.updateNode(
widget.node,
{
kAutoCompletionInputString: controller.text,
},
);
await widget.editorState.apply(transaction);
}
Future<void> _makeSurePreviousNodeIsEmptyTextNode() async {
// make sure the previous node is a empty text node without any styles.
final transaction = widget.editorState.transaction;
final Selection selection;
if (widget.node.previous is! TextNode ||
(widget.node.previous as TextNode).toPlainText().isNotEmpty ||
(widget.node.previous as TextNode).subtype != null) {
transaction.insertNode(
widget.node.path,
TextNode.empty(),
);
selection = Selection.single(
path: widget.node.path,
startOffset: 0,
);
transaction.afterSelection = selection;
} else {
selection = Selection.single(
path: widget.node.path.previous,
startOffset: 0,
);
transaction.afterSelection = selection;
}
transaction.updateNode(widget.node, {
kAutoCompletionInputStartSelection: jsonEncode(selection.toJson()),
});
await widget.editorState.apply(transaction);
}
Future<void> _showError(String message) async {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
action: SnackBarAction(
label: LocaleKeys.button_Cancel.tr(),
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
},
),
content: FlowyText(message),
),
);
}
void _onFocusChanged() {
if (textFieldFocusNode.hasFocus) {
widget.editorState.service.keyboardService?.disable(
disposition: UnfocusDisposition.previouslyFocusedChild,
);
} else {
widget.editorState.service.keyboardService?.enable();
}
}
void _onCancelWhenSelectionChanged() {}
}

View File

@ -1,23 +0,0 @@
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
SelectionMenuItem autoGeneratorMenuItem = SelectionMenuItem.node(
name: LocaleKeys.document_plugins_autoGeneratorMenuItemName.tr(),
iconData: Icons.generating_tokens,
keywords: ['ai', 'openai' 'writer', 'autogenerator'],
nodeBuilder: (editorState) {
final node = Node(
type: kAutoCompletionInputType,
attributes: {
kAutoCompletionInputString: '',
},
);
return node;
},
replace: (_, textNode) => textNode.toPlainText().isEmpty,
updateSelection: null,
);

View File

@ -1,59 +0,0 @@
import 'package:flutter/material.dart';
class Loading {
Loading(
this.context,
);
late BuildContext loadingContext;
final BuildContext context;
Future<void> start() async {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
loadingContext = context;
return const SimpleDialog(
elevation: 0.0,
backgroundColor:
Colors.transparent, // can change this to your preferred color
children: <Widget>[
Center(
child: CircularProgressIndicator(),
)
],
);
},
);
}
Future<void> stop() async {
return Navigator.of(loadingContext).pop();
}
}
class BarrierDialog {
BarrierDialog(
this.context,
);
late BuildContext loadingContext;
final BuildContext context;
Future<void> show() async {
return showDialog<void>(
context: context,
barrierDismissible: false,
barrierColor: Colors.transparent,
builder: (BuildContext context) {
loadingContext = context;
return Container();
},
);
}
Future<void> dismiss() async {
return Navigator.of(loadingContext).pop();
}
}

View File

@ -4,7 +4,7 @@ import 'package:appflowy/plugins/database_view/application/field/field_controlle
import 'package:appflowy/plugins/database_view/application/field/field_service.dart';
import 'package:appflowy/plugins/database_view/application/setting/property_bloc.dart';
import 'package:appflowy/plugins/database_view/grid/application/grid_header_bloc.dart';
import 'package:appflowy/plugins/document/presentation/plugins/openai/service/openai_client.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart';
import 'package:appflowy/user/application/user_listener.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/util/file_picker/file_picker_impl.dart';
@ -52,13 +52,13 @@ void _resolveCommonService(GetIt getIt) async {
final result = await UserBackendService.getCurrentUserProfile();
return result.fold(
(l) {
return HttpOpenAIRepository(
client: http.Client(),
apiKey: l.openaiKey,
);
throw Exception('Failed to get user profile: ${l.msg}');
},
(r) {
throw Exception('Failed to get user profile: ${r.msg}');
return HttpOpenAIRepository(
client: http.Client(),
apiKey: r.openaiKey,
);
},
);
},

View File

@ -14,8 +14,10 @@ class UserBackendService {
final Int64 userId;
static Future<Either<UserProfilePB, FlowyError>> getCurrentUserProfile() {
return UserEventGetUserProfile().send();
static Future<Either<FlowyError, UserProfilePB>>
getCurrentUserProfile() async {
final result = await UserEventGetUserProfile().send();
return result.swap();
}
Future<Either<Unit, FlowyError>> updateUserProfile({

View File

@ -0,0 +1,5 @@
import 'dart:convert';
extension Base64Encode on String {
String get base64 => base64Encode(utf8.encode(this));
}

View File

@ -0,0 +1,8 @@
import 'dart:convert';
import 'package:appflowy_backend/log.dart';
const JsonEncoder _encoder = JsonEncoder.withIndent(' ');
void prettyPrintJson(Object? object) {
Log.debug(_encoder.convert(object));
}

View File

@ -1,10 +1,10 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:appflowy/core/document_notification.dart';
import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart';
import 'package:dartz/dartz.dart';
import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-document2/notification.pb.dart';
import 'package:appflowy_backend/rust_stream.dart';
class DocumentListener {
@ -17,10 +17,10 @@ class DocumentListener {
StreamSubscription<SubscribeObject>? _subscription;
DocumentNotificationParser? _parser;
Function()? didReceiveUpdate;
Function(DocEventPB docEvent)? didReceiveUpdate;
void start({
void Function()? didReceiveUpdate,
Function(DocEventPB docEvent)? didReceiveUpdate,
}) {
this.didReceiveUpdate = didReceiveUpdate;
@ -39,7 +39,9 @@ class DocumentListener {
) {
switch (ty) {
case DocumentNotification.DidReceiveUpdate:
didReceiveUpdate?.call();
result
.swap()
.map((r) => didReceiveUpdate?.call(DocEventPB.fromBuffer(r)));
break;
default:
break;

View File

@ -1,5 +1,5 @@
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart' hide WidgetBuilder;
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:styled_widget/styled_widget.dart';
@ -11,6 +11,7 @@ class PopoverActionList<T extends PopoverAction> extends StatefulWidget {
final BoxConstraints constraints;
final PopoverDirection direction;
final Widget Function(PopoverController) buildChild;
final VoidCallback? onPopupBuilder;
final VoidCallback? onClosed;
final bool asBarrier;
final Offset offset;
@ -21,6 +22,7 @@ class PopoverActionList<T extends PopoverAction> extends StatefulWidget {
required this.onSelected,
this.mutex,
this.onClosed,
this.onPopupBuilder,
this.direction = PopoverDirection.rightWithTopAligned,
this.asBarrier = false,
this.offset = Offset.zero,
@ -60,6 +62,7 @@ class _PopoverActionListState<T extends PopoverAction>
triggerActions: PopoverTriggerFlags.none,
onClose: widget.onClosed,
popupBuilder: (BuildContext popoverContext) {
widget.onPopupBuilder?.call();
final List<Widget> children = widget.actions.map((action) {
if (action is ActionCell) {
return ActionCellWidget<T>(
@ -69,6 +72,13 @@ class _PopoverActionListState<T extends PopoverAction>
widget.onSelected(action, popoverController);
},
);
} else if (action is PopoverActionCell) {
return PopoverActionCellWidget<T>(
mutex: widget.mutex,
// popoverController: popoverController,
action: action,
itemHeight: ActionListSizes.itemHeight,
);
} else {
final custom = action as CustomActionCell;
return custom.buildWithContext(context);
@ -94,6 +104,15 @@ abstract class ActionCell extends PopoverAction {
String get name;
}
abstract class PopoverActionCell extends PopoverAction {
Widget? leftIcon(Color iconColor) => null;
Widget? rightIcon(Color iconColor) => null;
String get name;
Widget Function(BuildContext context, PopoverController controller)
get builder;
}
abstract class CustomActionCell extends PopoverAction {
Widget buildWithContext(BuildContext context);
}
@ -127,27 +146,95 @@ class ActionCellWidget<T extends PopoverAction> extends StatelessWidget {
final rightIcon =
actionCell.rightIcon(Theme.of(context).colorScheme.onSurface);
return _HoverButton(
itemHeight: itemHeight,
leftIcon: leftIcon,
rightIcon: rightIcon,
name: actionCell.name,
onTap: () => onSelected(action),
);
}
}
class PopoverActionCellWidget<T extends PopoverAction> extends StatelessWidget {
PopoverActionCellWidget({
Key? key,
this.mutex,
// required this.popoverController,
required this.action,
required this.itemHeight,
}) : super(key: key);
final T action;
final double itemHeight;
final PopoverMutex? mutex;
final PopoverController popoverController = PopoverController();
@override
Widget build(BuildContext context) {
final actionCell = action as PopoverActionCell;
final leftIcon =
actionCell.leftIcon(Theme.of(context).colorScheme.onSurface);
final rightIcon =
actionCell.rightIcon(Theme.of(context).colorScheme.onSurface);
return AppFlowyPopover(
// mutex: mutex,
controller: popoverController,
asBarrier: true,
popupBuilder: (context) => actionCell.builder(
context,
popoverController,
),
child: _HoverButton(
itemHeight: itemHeight,
leftIcon: leftIcon,
rightIcon: rightIcon,
name: actionCell.name,
onTap: () => popoverController.show(),
),
);
}
}
class _HoverButton extends StatelessWidget {
const _HoverButton({
required this.onTap,
required this.itemHeight,
required this.leftIcon,
required this.name,
required this.rightIcon,
});
final VoidCallback onTap;
final double itemHeight;
final Widget? leftIcon;
final Widget? rightIcon;
final String name;
@override
Widget build(BuildContext context) {
return FlowyHover(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onSelected(action),
onTap: onTap,
child: SizedBox(
height: itemHeight,
child: Row(
children: [
if (leftIcon != null) ...[
leftIcon,
leftIcon!,
HSpace(ActionListSizes.itemHPadding)
],
Expanded(
child: FlowyText.medium(
actionCell.name,
name,
overflow: TextOverflow.visible,
),
),
if (rightIcon != null) ...[
HSpace(ActionListSizes.itemHPadding),
rightIcon,
rightIcon!,
],
],
),

View File

@ -44,10 +44,11 @@ packages:
appflowy_editor:
dependency: "direct main"
description:
name: appflowy_editor
sha256: "6f7d2b0b54ca1049cb396229549d228b5bbd7ea6d09f1f7325a20db2d7586a5f"
url: "https://pub.dev"
source: hosted
path: "."
ref: "4f66f7"
resolved-ref: "4f66f77debabbc35cf4a56c816f9432a831a40e2"
url: "https://github.com/LucasXu0/appflowy-editor.git"
source: git
version: "0.1.12"
appflowy_popover:
dependency: "direct main"
@ -816,6 +817,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.3.0"
nanoid:
dependency: "direct main"
description:
name: nanoid
sha256: be3f8752d9046c825df2f3914195151eb876f3ad64b9d833dd0b799b77b8759e
url: "https://pub.dev"
source: hosted
version: "1.0.0"
nested:
dependency: transitive
description:

View File

@ -42,7 +42,12 @@ dependencies:
git:
url: https://github.com/AppFlowy-IO/appflowy-board.git
ref: a183c57
appflowy_editor: ^0.1.12
# appflowy_editor: ^0.1.9
appflowy_editor:
# path: /Users/lucas.xu/Desktop/appflowy-editor
git:
url: https://github.com/LucasXu0/appflowy-editor.git
ref: 4f66f7
appflowy_popover:
path: packages/appflowy_popover
@ -101,6 +106,7 @@ dependencies:
mocktail: ^0.3.0
archive: ^3.3.0
flutter_svg: ^2.0.5
nanoid: ^1.0.0
dev_dependencies:
flutter_lints: ^2.0.1

View File

@ -1,8 +1,8 @@
import 'dart:convert';
import 'package:appflowy/plugins/document/presentation/plugins/parsers/code_block_node_parser.dart';
import 'package:appflowy/plugins/document/presentation/plugins/parsers/divider_node_parser.dart';
import 'package:appflowy/plugins/document/presentation/plugins/parsers/math_equation_node_parser.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/code_block_node_parser.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/divider_node_parser.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/math_equation_node_parser.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
@ -12,7 +12,7 @@ void main() {
const text = '''
{
"document":{
"type":"editor",
"type":"document",
"children":[
{
"type":"math_equation",

View File

@ -96,6 +96,21 @@ version = "1.0.70"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7de8ce5e0f9f8d88245311066a578d72b7af3e7088f32783804676302df237e4"
[[package]]
name = "appflowy-integrate"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d074c9#d074c94471f222e2701fe451f13c51aab39d2bf8"
dependencies = [
"collab",
"collab-database",
"collab-document",
"collab-folder",
"collab-persistence",
"collab-plugins",
"serde",
"serde_json",
]
[[package]]
name = "appflowy_tauri"
version = "0.0.0"
@ -193,6 +208,324 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
[[package]]
name = "aws-config"
version = "0.55.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc00553f5f3c06ffd4510a9d576f92143618706c45ea6ff81e84ad9be9588abd"
dependencies = [
"aws-credential-types",
"aws-http",
"aws-sdk-sso",
"aws-sdk-sts",
"aws-smithy-async",
"aws-smithy-client",
"aws-smithy-http",
"aws-smithy-http-tower",
"aws-smithy-json",
"aws-smithy-types",
"aws-types",
"bytes",
"fastrand",
"hex",
"http",
"hyper",
"ring",
"time 0.3.20",
"tokio",
"tower",
"tracing",
"zeroize",
]
[[package]]
name = "aws-credential-types"
version = "0.55.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cb57ac6088805821f78d282c0ba8aec809f11cbee10dda19a97b03ab040ccc2"
dependencies = [
"aws-smithy-async",
"aws-smithy-types",
"fastrand",
"tokio",
"tracing",
"zeroize",
]
[[package]]
name = "aws-endpoint"
version = "0.55.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c5f6f84a4f46f95a9bb71d9300b73cd67eb868bc43ae84f66ad34752299f4ac"
dependencies = [
"aws-smithy-http",
"aws-smithy-types",
"aws-types",
"http",
"regex",
"tracing",
]
[[package]]
name = "aws-http"
version = "0.55.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a754683c322f7dc5167484266489fdebdcd04d26e53c162cad1f3f949f2c5671"
dependencies = [
"aws-credential-types",
"aws-smithy-http",
"aws-smithy-types",
"aws-types",
"bytes",
"http",
"http-body",
"lazy_static",
"percent-encoding",
"pin-project-lite",
"tracing",
]
[[package]]
name = "aws-sdk-dynamodb"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67fb64867fe098cffee7e34352b01bbfa2beb3aa1b2ff0e0a7bf9ff293557852"
dependencies = [
"aws-credential-types",
"aws-endpoint",
"aws-http",
"aws-sig-auth",
"aws-smithy-async",
"aws-smithy-client",
"aws-smithy-http",
"aws-smithy-http-tower",
"aws-smithy-json",
"aws-smithy-types",
"aws-types",
"bytes",
"fastrand",
"http",
"regex",
"tokio-stream",
"tower",
"tracing",
]
[[package]]
name = "aws-sdk-sso"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "babfd626348836a31785775e3c08a4c345a5ab4c6e06dfd9167f2bee0e6295d6"
dependencies = [
"aws-credential-types",
"aws-endpoint",
"aws-http",
"aws-sig-auth",
"aws-smithy-async",
"aws-smithy-client",
"aws-smithy-http",
"aws-smithy-http-tower",
"aws-smithy-json",
"aws-smithy-types",
"aws-types",
"bytes",
"http",
"regex",
"tokio-stream",
"tower",
"tracing",
]
[[package]]
name = "aws-sdk-sts"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d0fbe3c2c342bc8dfea4bb43937405a8ec06f99140a0dcb9c7b59e54dfa93a1"
dependencies = [
"aws-credential-types",
"aws-endpoint",
"aws-http",
"aws-sig-auth",
"aws-smithy-async",
"aws-smithy-client",
"aws-smithy-http",
"aws-smithy-http-tower",
"aws-smithy-json",
"aws-smithy-query",
"aws-smithy-types",
"aws-smithy-xml",
"aws-types",
"bytes",
"http",
"regex",
"tower",
"tracing",
]
[[package]]
name = "aws-sig-auth"
version = "0.55.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84dc92a63ede3c2cbe43529cb87ffa58763520c96c6a46ca1ced80417afba845"
dependencies = [
"aws-credential-types",
"aws-sigv4",
"aws-smithy-http",
"aws-types",
"http",
"tracing",
]
[[package]]
name = "aws-sigv4"
version = "0.55.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "392fefab9d6fcbd76d518eb3b1c040b84728ab50f58df0c3c53ada4bea9d327e"
dependencies = [
"aws-smithy-http",
"form_urlencoded",
"hex",
"hmac",
"http",
"once_cell",
"percent-encoding",
"regex",
"sha2",
"time 0.3.20",
"tracing",
]
[[package]]
name = "aws-smithy-async"
version = "0.55.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae23b9fe7a07d0919000116c4c5c0578303fbce6fc8d32efca1f7759d4c20faf"
dependencies = [
"futures-util",
"pin-project-lite",
"tokio",
"tokio-stream",
]
[[package]]
name = "aws-smithy-client"
version = "0.55.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5230d25d244a51339273b8870f0f77874cd4449fb4f8f629b21188ae10cfc0ba"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
"aws-smithy-http-tower",
"aws-smithy-types",
"bytes",
"fastrand",
"http",
"http-body",
"hyper",
"hyper-rustls",
"lazy_static",
"pin-project-lite",
"rustls",
"tokio",
"tower",
"tracing",
]
[[package]]
name = "aws-smithy-http"
version = "0.55.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b60e2133beb9fe6ffe0b70deca57aaeff0a35ad24a9c6fab2fd3b4f45b99fdb5"
dependencies = [
"aws-smithy-types",
"bytes",
"bytes-utils",
"futures-core",
"http",
"http-body",
"hyper",
"once_cell",
"percent-encoding",
"pin-project-lite",
"pin-utils",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "aws-smithy-http-tower"
version = "0.55.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a4d94f556c86a0dd916a5d7c39747157ea8cb909ca469703e20fee33e448b67"
dependencies = [
"aws-smithy-http",
"aws-smithy-types",
"bytes",
"http",
"http-body",
"pin-project-lite",
"tower",
"tracing",
]
[[package]]
name = "aws-smithy-json"
version = "0.55.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ce3d6e6ebb00b2cce379f079ad5ec508f9bcc3a9510d9b9c1840ed1d6f8af39"
dependencies = [
"aws-smithy-types",
]
[[package]]
name = "aws-smithy-query"
version = "0.55.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d58edfca32ef9bfbc1ca394599e17ea329cb52d6a07359827be74235b64b3298"
dependencies = [
"aws-smithy-types",
"urlencoding",
]
[[package]]
name = "aws-smithy-types"
version = "0.55.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58db46fc1f4f26be01ebdb821751b4e2482cd43aa2b64a0348fb89762defaffa"
dependencies = [
"base64-simd",
"itoa 1.0.6",
"num-integer",
"ryu",
"time 0.3.20",
]
[[package]]
name = "aws-smithy-xml"
version = "0.55.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb557fe4995bd9ec87fb244bbb254666a971dc902a783e9da8b7711610e9664c"
dependencies = [
"xmlparser",
]
[[package]]
name = "aws-types"
version = "0.55.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de0869598bfe46ec44ffe17e063ed33336e59df90356ca8ff0e8da6f7c1d994b"
dependencies = [
"aws-credential-types",
"aws-smithy-async",
"aws-smithy-client",
"aws-smithy-http",
"aws-smithy-types",
"http",
"rustc_version",
"tracing",
]
[[package]]
name = "backtrace"
version = "0.3.67"
@ -220,6 +553,16 @@ version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a"
[[package]]
name = "base64-simd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195"
dependencies = [
"outref",
"vsimd",
]
[[package]]
name = "bincode"
version = "1.3.3"
@ -419,6 +762,16 @@ dependencies = [
"serde",
]
[[package]]
name = "bytes-utils"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e47d3a8076e283f3acd27400535992edb3ba4b5bb72f8891ad8fbe7932a7d4b9"
dependencies = [
"bytes",
"either",
]
[[package]]
name = "bzip2-sys"
version = "0.1.11+1.0.8"
@ -533,6 +886,7 @@ dependencies = [
"js-sys",
"num-integer",
"num-traits",
"serde",
"time 0.1.45",
"wasm-bindgen",
"winapi",
@ -662,7 +1016,7 @@ dependencies = [
[[package]]
name = "collab"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f66084#f66084454c143418cf69155dc8cce77df2d57d0c"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d074c9#d074c94471f222e2701fe451f13c51aab39d2bf8"
dependencies = [
"anyhow",
"bytes",
@ -679,7 +1033,7 @@ dependencies = [
[[package]]
name = "collab-client-ws"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f66084#f66084454c143418cf69155dc8cce77df2d57d0c"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d074c9#d074c94471f222e2701fe451f13c51aab39d2bf8"
dependencies = [
"bytes",
"collab-sync",
@ -697,9 +1051,10 @@ dependencies = [
[[package]]
name = "collab-database"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f66084#f66084454c143418cf69155dc8cce77df2d57d0c"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d074c9#d074c94471f222e2701fe451f13c51aab39d2bf8"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"collab",
"collab-derive",
@ -720,7 +1075,7 @@ dependencies = [
[[package]]
name = "collab-derive"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f66084#f66084454c143418cf69155dc8cce77df2d57d0c"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d074c9#d074c94471f222e2701fe451f13c51aab39d2bf8"
dependencies = [
"proc-macro2",
"quote",
@ -732,7 +1087,7 @@ dependencies = [
[[package]]
name = "collab-document"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f66084#f66084454c143418cf69155dc8cce77df2d57d0c"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d074c9#d074c94471f222e2701fe451f13c51aab39d2bf8"
dependencies = [
"anyhow",
"collab",
@ -749,7 +1104,7 @@ dependencies = [
[[package]]
name = "collab-folder"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f66084#f66084454c143418cf69155dc8cce77df2d57d0c"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d074c9#d074c94471f222e2701fe451f13c51aab39d2bf8"
dependencies = [
"anyhow",
"collab",
@ -767,7 +1122,7 @@ dependencies = [
[[package]]
name = "collab-persistence"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f66084#f66084454c143418cf69155dc8cce77df2d57d0c"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d074c9#d074c94471f222e2701fe451f13c51aab39d2bf8"
dependencies = [
"bincode",
"chrono",
@ -787,12 +1142,24 @@ dependencies = [
[[package]]
name = "collab-plugins"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f66084#f66084454c143418cf69155dc8cce77df2d57d0c"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d074c9#d074c94471f222e2701fe451f13c51aab39d2bf8"
dependencies = [
"anyhow",
"async-trait",
"aws-config",
"aws-credential-types",
"aws-sdk-dynamodb",
"collab",
"collab-client-ws",
"collab-persistence",
"collab-sync",
"futures-util",
"parking_lot 0.12.1",
"rand 0.8.5",
"rusoto_credential",
"thiserror",
"tokio",
"tokio-retry",
"tracing",
"y-sync",
"yrs",
@ -801,7 +1168,7 @@ dependencies = [
[[package]]
name = "collab-sync"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f66084#f66084454c143418cf69155dc8cce77df2d57d0c"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d074c9#d074c94471f222e2701fe451f13c51aab39d2bf8"
dependencies = [
"bytes",
"collab",
@ -1201,6 +1568,7 @@ checksum = "8168378f4e5023e7218c89c891c0fd8ecdb5e5e4f18cb78f38cf245dd021e76f"
dependencies = [
"block-buffer 0.10.4",
"crypto-common",
"subtle",
]
[[package]]
@ -1497,8 +1865,8 @@ dependencies = [
name = "flowy-core"
version = "0.1.0"
dependencies = [
"appflowy-integrate",
"bytes",
"collab-persistence",
"database-model",
"flowy-client-ws",
"flowy-database2",
@ -1531,13 +1899,14 @@ name = "flowy-database2"
version = "0.1.0"
dependencies = [
"anyhow",
"appflowy-integrate",
"async-stream",
"async-trait",
"bytes",
"chrono",
"chrono-tz 0.8.2",
"collab",
"collab-database",
"collab-plugins",
"dashmap",
"database-model",
"fancy-regex 0.10.0",
@ -1624,10 +1993,10 @@ dependencies = [
name = "flowy-document2"
version = "0.1.0"
dependencies = [
"appflowy-integrate",
"bytes",
"collab",
"collab-document",
"collab-plugins",
"flowy-codegen",
"flowy-derive",
"flowy-error",
@ -1651,6 +2020,7 @@ dependencies = [
"anyhow",
"bytes",
"collab-database",
"collab-document",
"flowy-client-sync",
"flowy-client-ws",
"flowy-codegen",
@ -1673,11 +2043,11 @@ dependencies = [
name = "flowy-folder2"
version = "0.1.0"
dependencies = [
"appflowy-integrate",
"bytes",
"chrono",
"collab",
"collab-folder",
"collab-plugins",
"flowy-codegen",
"flowy-derive",
"flowy-document",
@ -1856,8 +2226,8 @@ dependencies = [
name = "flowy-user"
version = "0.1.0"
dependencies = [
"appflowy-integrate",
"bytes",
"collab-persistence",
"diesel",
"diesel_derives",
"flowy-codegen",
@ -2412,6 +2782,21 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest 0.10.6",
]
[[package]]
name = "html5ever"
version = "0.25.2"
@ -2509,6 +2894,21 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1788965e61b367cd03a62950836d5cd41560c3577d90e40e0819373194d1661c"
dependencies = [
"http",
"hyper",
"log",
"rustls",
"rustls-native-certs",
"tokio",
"tokio-rustls",
]
[[package]]
name = "hyper-tls"
version = "0.5.0"
@ -3446,6 +3846,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "outref"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4030760ffd992bef45b0ae3f10ce1aba99e33464c90d14dd7c039884963ddc7a"
[[package]]
name = "overload"
version = "0.1.1"
@ -4252,6 +4658,21 @@ dependencies = [
"serde_json",
]
[[package]]
name = "ring"
version = "0.16.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc"
dependencies = [
"cc",
"libc",
"once_cell",
"spin",
"untrusted",
"web-sys",
"winapi",
]
[[package]]
name = "rkyv"
version = "0.7.41"
@ -4287,6 +4708,24 @@ dependencies = [
"librocksdb-sys",
]
[[package]]
name = "rusoto_credential"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee0a6c13db5aad6047b6a44ef023dbbc21a056b6dab5be3b79ce4283d5c02d05"
dependencies = [
"async-trait",
"chrono",
"dirs-next",
"futures",
"hyper",
"serde",
"serde_json",
"shlex",
"tokio",
"zeroize",
]
[[package]]
name = "rust_decimal"
version = "1.29.1"
@ -4350,6 +4789,39 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "rustls"
version = "0.20.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fff78fc74d175294f4e83b28343315ffcfb114b156f0185e9741cb5570f50e2f"
dependencies = [
"log",
"ring",
"sct",
"webpki",
]
[[package]]
name = "rustls-native-certs"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0167bac7a9f490495f3c33013e7722b53cb087ecbe082fb0c6387c96f634ea50"
dependencies = [
"openssl-probe",
"rustls-pemfile",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pemfile"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d194b56d58803a43635bdc398cd17e383d6f71f9182b9a192c127ca42494a59b"
dependencies = [
"base64 0.21.0",
]
[[package]]
name = "rustversion"
version = "1.0.12"
@ -4423,6 +4895,16 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1792db035ce95be60c3f8853017b3999209281c24e2ba5bc8e59bf97a0c590c1"
[[package]]
name = "sct"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4"
dependencies = [
"ring",
"untrusted",
]
[[package]]
name = "seahash"
version = "4.1.0"
@ -4779,6 +5261,12 @@ dependencies = [
"system-deps 5.0.0",
]
[[package]]
name = "spin"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
[[package]]
name = "stable_deref_trait"
version = "1.2.0"
@ -4850,6 +5338,12 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "subtle"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601"
[[package]]
name = "syn"
version = "1.0.109"
@ -5348,6 +5842,17 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59"
dependencies = [
"rustls",
"tokio",
"webpki",
]
[[package]]
name = "tokio-stream"
version = "0.1.14"
@ -5442,6 +5947,28 @@ dependencies = [
"winnow",
]
[[package]]
name = "tower"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [
"futures-core",
"futures-util",
"pin-project",
"pin-project-lite",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0"
[[package]]
name = "tower-service"
version = "0.3.2"
@ -5728,6 +6255,12 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b"
[[package]]
name = "untrusted"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]]
name = "url"
version = "2.3.1"
@ -5740,6 +6273,12 @@ dependencies = [
"serde",
]
[[package]]
name = "urlencoding"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8db7427f936968176eaa7cdf81b7f98b980b18495ec28f1b5791ac3bfe3eea9"
[[package]]
name = "user-model"
version = "0.1.0"
@ -5820,6 +6359,12 @@ version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]]
name = "vsimd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
[[package]]
name = "walkdir"
version = "2.3.3"
@ -5981,6 +6526,16 @@ dependencies = [
"system-deps 6.0.5",
]
[[package]]
name = "webpki"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd"
dependencies = [
"ring",
"untrusted",
]
[[package]]
name = "webview2-com"
version = "0.19.1"
@ -6399,6 +6954,12 @@ dependencies = [
"libc",
]
[[package]]
name = "xmlparser"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d25c75bf9ea12c4040a97f829154768bbbce366287e2dc044af160cd79a13fd"
[[package]]
name = "y-sync"
version = "0.3.1"
@ -6433,6 +6994,12 @@ dependencies = [
"thiserror",
]
[[package]]
name = "zeroize"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9"
[[package]]
name = "zstd-sys"
version = "2.0.8+zstd.1.5.5"

View File

@ -1920,6 +1920,7 @@ dependencies = [
"anyhow",
"bytes",
"collab-database",
"collab-document",
"flowy-client-sync",
"flowy-client-ws",
"flowy-codegen",

Some files were not shown because too many files have changed in this diff Show More