import 'dart:async'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/banner.dart'; import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart'; import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/shared/flowy_error_page.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; class DocumentPage extends StatefulWidget { const DocumentPage({ super.key, required this.view, required this.onDeleted, this.initialSelection, this.initialBlockId, this.fixedTitle, }); final ViewPB view; final VoidCallback onDeleted; final Selection? initialSelection; final String? initialBlockId; final String? fixedTitle; @override State createState() => _DocumentPageState(); } class _DocumentPageState extends State with WidgetsBindingObserver { EditorState? editorState; late final documentBloc = DocumentBloc(documentId: widget.view.id) ..add(const DocumentEvent.initial()); StreamSubscription<(TransactionTime, Transaction)>? transactionSubscription; bool isUndoRedo = false; bool isPaste = false; bool isDraggingNode = false; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); EditorNotification.addListener(onEditorNotification); } @override void dispose() { EditorNotification.removeListener(onEditorNotification); WidgetsBinding.instance.removeObserver(this); documentBloc.close(); transactionSubscription?.cancel(); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) { documentBloc.add(const DocumentEvent.clearAwarenessStates()); } else if (state == AppLifecycleState.resumed) { documentBloc.add(const DocumentEvent.syncAwarenessStates()); } } @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ BlocProvider.value(value: getIt()), BlocProvider.value(value: documentBloc), ], child: BlocBuilder( buildWhen: shouldRebuildDocument, builder: (context, state) { if (state.isLoading) { return const Center(child: CircularProgressIndicator.adaptive()); } final editorState = state.editorState; this.editorState = editorState; final error = state.error; if (error != null || editorState == null) { Log.error(error); return Center(child: AppFlowyErrorPage(error: error)); } if (state.forceClose) { widget.onDeleted(); return const SizedBox.shrink(); } editorState.transactionStream.listen(onEditorTransaction); return BlocListener( listenWhen: (_, curr) => curr.action != null, listener: onNotificationAction, child: buildEditorPage(context, state), ); }, ), ); } Widget buildEditorPage( BuildContext context, DocumentState state, ) { final editorState = state.editorState; if (editorState == null) { return const SizedBox.shrink(); } final width = context.read().state.width; final Widget child; if (UniversalPlatform.isMobile) { child = BlocBuilder( builder: (context, styleState) => AppFlowyEditorPage( editorState: editorState, // if the view's name is empty, focus on the title autoFocus: widget.view.name.isEmpty ? false : null, styleCustomizer: EditorStyleCustomizer( context: context, width: width, padding: EditorStyleCustomizer.documentPadding, ), header: buildCoverAndIcon(context, state), initialSelection: widget.initialSelection, ), ); } else { child = EditorDropHandler( viewId: widget.view.id, editorState: editorState, isLocalMode: context.read().isLocalMode, child: AppFlowyEditorPage( editorState: editorState, // if the view's name is empty, focus on the title autoFocus: widget.view.name.isEmpty ? false : null, styleCustomizer: EditorStyleCustomizer( context: context, width: width, padding: EditorStyleCustomizer.documentPadding, ), header: buildCoverAndIcon(context, state), initialSelection: _calculateInitialSelection(editorState), ), ); } return Provider( create: (_) => SharedEditorContext(), child: Column( children: [ if (state.isDeleted) buildBanner(context), Expanded(child: child), ], ), ); } Widget buildBanner(BuildContext context) { return DocumentBanner( viewName: widget.view.name, onRestore: () => context.read().add(const DocumentEvent.restorePage()), onDelete: () => context .read() .add(const DocumentEvent.deletePermanently()), ); } Widget buildCoverAndIcon(BuildContext context, DocumentState state) { final editorState = state.editorState; final userProfilePB = state.userProfilePB; if (editorState == null || userProfilePB == null) { return const SizedBox.shrink(); } if (UniversalPlatform.isMobile) { return DocumentImmersiveCover( fixedTitle: widget.fixedTitle, view: widget.view, userProfilePB: userProfilePB, ); } final page = editorState.document.root; return DocumentCoverWidget( node: page, editorState: editorState, view: widget.view, onIconChanged: (icon) async => ViewBackendService.updateViewIcon( viewId: widget.view.id, viewIcon: icon, ), ); } void onEditorNotification(EditorNotificationType type) { final editorState = this.editorState; if (editorState == null) { return; } if ([EditorNotificationType.undo, EditorNotificationType.redo] .contains(type)) { isUndoRedo = true; } else if (type == EditorNotificationType.paste) { isPaste = true; } else if (type == EditorNotificationType.dragStart) { isDraggingNode = true; } else if (type == EditorNotificationType.dragEnd) { isDraggingNode = false; } if (type == EditorNotificationType.undo) { undoCommand.execute(editorState); } else if (type == EditorNotificationType.redo) { redoCommand.execute(editorState); } else if (type == EditorNotificationType.exitEditing && editorState.selection != null) { editorState.selection = null; } } void onNotificationAction( BuildContext context, ActionNavigationState state, ) { final action = state.action; if (action == null || action.type != ActionType.jumpToBlock || action.objectId != widget.view.id) { return; } final editorState = context.read().state.editorState; if (editorState == null) { return; } final Path? path = _getPathFromAction(action, editorState); if (path != null) { debugPrint('jump to block: $path'); editorState.updateSelectionWithReason( Selection.collapsed(Position(path: path)), ); } } Path? _getPathFromAction(NavigationAction action, EditorState editorState) { Path? path = action.arguments?[ActionArgumentKeys.nodePath]; if (path == null || path.isEmpty) { final blockId = action.arguments?[ActionArgumentKeys.blockId]; if (blockId != null) { path = _findNodePathByBlockId(editorState, blockId); } } return path; } Path? _findNodePathByBlockId(EditorState editorState, String blockId) { final document = editorState.document; final startNode = document.root.children.firstOrNull; if (startNode == null) { return null; } final nodeIterator = NodeIterator(document: document, startNode: startNode); while (nodeIterator.moveNext()) { final node = nodeIterator.current; if (node.id == blockId) { return node.path; } } return null; } bool shouldRebuildDocument(DocumentState previous, DocumentState current) { // only rebuild the document page when the below fields are changed // this is to prevent unnecessary rebuilds // // If you confirm the newly added fields should be rebuilt, please update // this function. if (previous.editorState != current.editorState) { return true; } if (previous.forceClose != current.forceClose || previous.isDeleted != current.isDeleted) { return true; } if (previous.userProfilePB != current.userProfilePB) { return true; } if (previous.isLoading != current.isLoading || previous.error != current.error) { return true; } return false; } List collectMatchingNodes(Node node, String type) { final List matchingNodes = []; if (node.type == type) { matchingNodes.add(node); } for (final child in node.children) { matchingNodes.addAll(collectMatchingNodes(child, type)); } return matchingNodes; } void onEditorTransaction((TransactionTime, Transaction) event) { if (editorState == null || event.$1 == TransactionTime.before) { return; } final Map> addedNodes = { for (final handler in SharedEditorContext.transactionHandlers) handler.blockType: [], }; final Map> removedNodes = { for (final handler in SharedEditorContext.transactionHandlers) handler.blockType: [], }; final transactionHandlerTypes = SharedEditorContext.transactionHandlers .map((h) => h.blockType) .toList(); // Collect all matching nodes in a performant way for each handler type. for (final op in event.$2.operations) { if (op is InsertOperation) { for (final n in op.nodes) { for (final handlerType in transactionHandlerTypes) { if (n.type == handlerType) { addedNodes[handlerType]! .addAll(collectMatchingNodes(n, handlerType)); } } } } else if (op is DeleteOperation) { for (final n in op.nodes) { for (final handlerType in transactionHandlerTypes) { if (n.type == handlerType) { removedNodes[handlerType]! .addAll(collectMatchingNodes(n, handlerType)); } } } } } if (removedNodes.isEmpty && addedNodes.isEmpty) { return; } for (final handler in SharedEditorContext.transactionHandlers) { final added = addedNodes[handler.blockType] ?? []; final removed = removedNodes[handler.blockType] ?? []; if (added.isEmpty && removed.isEmpty) { continue; } handler.onTransaction( context, editorState!, added, removed, isUndoRedo: isUndoRedo, isPaste: isPaste, isDraggingNode: isDraggingNode, parentViewId: widget.view.id, ); isUndoRedo = false; isPaste = false; } } Selection? _calculateInitialSelection(EditorState editorState) { if (widget.initialSelection != null) { return widget.initialSelection; } if (widget.initialBlockId != null) { final path = _findNodePathByBlockId(editorState, widget.initialBlockId!); if (path != null) { editorState.selectionType = SelectionType.block; return Selection.collapsed( Position( path: path, ), ); } } return null; } }