Compare commits

..
13 Commits
Author SHA1 Message Date
Lucas.Xu d27f9eb2c1 fix: unable to scroll down in multiple select options 2024-11-26 10:51:42 +08:00
Lucas.Xu c11904c2e9 feat: optimize text insertion 2024-11-25 21:17:48 +08:00
Lucas.Xu 19b1c84ea3 chore: set the minimum windows size to 640 2024-11-25 18:19:08 +08:00
Lucas.Xu e5f30b000c chore: update changelog 2024-11-25 18:07:58 +08:00
Lucas.Xu e64462ca9a chore: bump version 0.7.5 2024-11-25 18:02:53 +08:00
Lucas 570de3e1a5 feat: support skipping in-memory update transaction (#6856)
* feat: support skipping in-memory update transaction

* fix: flutter analyze

* feat: add sentence mode

* test: support skipping in-memory update transaction

* test: add sentence mode

* test: add sentence mode (2)

* chore: set enableDocumentInternalLog to false

* fix: integration test

* fix: integration test
2024-11-25 17:56:20 +08:00
Richard Shiue df82c99755 fix: loading message hot fix (#6858) 2024-11-25 17:28:00 +08:00
Nathan.fooo 6a02679963 chore: parse chat response (#6843)
* chore: parse chat response

* chore: remove unrelated message

* chore: update readme
2024-11-25 16:43:09 +08:00
Richard Shiue 8dcd4c51f0 chore: bump flutter chat ui version (#6835) 2024-11-25 16:43:00 +08:00
Lucas 3a26570de9 feat: support click to create content inside empty toggle list (#6854)
* feat: support click to create content inside empty toggle list

* test: support click to create content inside empty toggle list

* fix: toggle list rtl issue

* chore: optimize cover title request node logic
2024-11-25 16:29:05 +08:00
Lucas 76710ea244 fix: macOS error 50 on uploading files (#6853) 2024-11-25 16:28:57 +08:00
Nathan.fooo 1bcda3944f chore: only fetch billing info when current user is the workspace owner (#6847) 2024-11-25 16:28:50 +08:00
Nathan.fooo 3d8e6ebd72 chore: delete upload task when upload size exceed limit (#6841)
* chore: delete upload tasks

* test: update

* chore: bump client api

* chore: fix test
2024-11-25 16:27:30 +08:00
211 changed files with 2492 additions and 12803 deletions

No files matched your search

+1 -1
View File
@@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
CARGO_MAKE_CRATE_NAME = "dart-ffi"
LIB_NAME = "dart_ffi"
APPFLOWY_VERSION = "0.7.6"
APPFLOWY_VERSION = "0.7.5"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -65,5 +65,4 @@
-->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.CAMERA" />
</manifest>
@@ -33,7 +33,7 @@ void main() {
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_aiWriter.tr(),
);
expect(find.byType(AIWriterBlockComponent), findsOneWidget);
expect(find.byType(AutoCompletionBlockComponent), findsOneWidget);
// switch to another page
await tester.openPage(Constants.gettingStartedPageName);
@@ -41,7 +41,7 @@ void main() {
await tester.openPage(pageName);
// expect the ai writer block is not in the document
expect(find.byType(AIWriterBlockComponent), findsNothing);
expect(find.byType(AutoCompletionBlockComponent), findsNothing);
});
});
}
@@ -4,10 +4,12 @@ import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/widgets/card/card.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart';
import 'package:appflowy/plugins/database/widgets/row/row_banner.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/shared/af_image.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
@@ -24,6 +26,61 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('database row cover', () {
testWidgets('add image to media field and check if cover is set (grid)',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
// Invoke the field editor
await tester.tapGridFieldWithName('Type');
await tester.tapEditFieldButton();
// Change to media type
await tester.tapSwitchFieldTypeButton();
await tester.selectFieldType(FieldType.Media);
await tester.dismissFieldEditor();
// Prepare file for upload from local
final image = await rootBundle.load('assets/test/images/sample.jpeg');
final tempDirectory = await getTemporaryDirectory();
final imagePath = p.join(tempDirectory.path, 'sample.jpeg');
final file = File(imagePath)
..writeAsBytesSync(image.buffer.asUint8List());
mockPickFilePaths(paths: [imagePath]);
await getIt<KeyValueStorage>().set(KVKeys.kCloudType, '0');
// Open media cell editor
await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.Media);
await tester.findMediaCellEditor(findsOneWidget);
// Click on add file button in the Media Cell Editor
await tester.tap(find.text(LocaleKeys.grid_media_addFileOrImage.tr()));
await tester.pumpAndSettle();
// Tap on the upload interaction
await tester.tapFileUploadHint();
// Expect one file
expect(find.byType(RenderMedia), findsOneWidget);
// Close cell editor
await tester.dismissCellEditor();
// Open first row in row detail view
await tester.openFirstRowDetailPage();
await tester.pumpAndSettle();
// Expect a cover to be shown
expect(find.byType(RowCover), findsOneWidget);
// Remove the temp file
await Future.wait([file.delete()]);
});
testWidgets('add and remove cover from Row Detail Card', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
@@ -11,8 +11,6 @@ import 'document_with_file_test.dart' as document_with_file_test;
import 'document_with_image_block_test.dart' as document_with_image_block_test;
import 'document_with_multi_image_block_test.dart'
as document_with_multi_image_block_test;
import 'document_with_simple_table_test.dart'
as document_with_simple_table_test;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@@ -27,5 +25,4 @@ void main() {
document_block_option_test.main();
document_find_menu_test.main();
document_toolbar_test.main();
document_with_simple_table_test.main();
}
@@ -1,370 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/_shared_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:universal_platform/universal_platform.dart';
import '../../shared/util.dart';
const String heading1 = "Heading 1";
const String heading2 = "Heading 2";
const String heading3 = "Heading 3";
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('simple table block test:', () {
testWidgets('insert a simple table block', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
// validate the table is inserted
expect(find.byType(SimpleTableBlockWidget), findsOneWidget);
final editorState = tester.editor.getCurrentEditorState();
expect(
editorState.selection,
// table -> row -> cell -> paragraph
Selection.collapsed(Position(path: [0, 0, 0, 0])),
);
final firstCell = find.byType(SimpleTableCellBlockWidget).first;
expect(
tester
.state<SimpleTableCellBlockWidgetState>(firstCell)
.isEditingCellNotifier
.value,
isTrue,
);
});
testWidgets('select all in table cell', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
const cell1Content = 'Cell 1';
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText('New Table');
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
await tester.editor.tapLineOfEditorAt(1);
await tester.insertTableInDocument();
await tester.ime.insertText(cell1Content);
await tester.pumpAndSettle();
// Select all in the cell
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyA,
isControlPressed: !UniversalPlatform.isMacOS,
isMetaPressed: UniversalPlatform.isMacOS,
);
expect(
tester.editor.getCurrentEditorState().selection,
Selection(
start: Position(path: [1, 0, 0, 0]),
end: Position(path: [1, 0, 0, 0], offset: cell1Content.length),
),
);
// Press select all again, the selection should be the entire document
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyA,
isControlPressed: !UniversalPlatform.isMacOS,
isMetaPressed: UniversalPlatform.isMacOS,
);
expect(
tester.editor.getCurrentEditorState().selection,
Selection(
start: Position(path: [0]),
end: Position(path: [1, 1, 1, 0]),
),
);
});
testWidgets('''
1. hover on the table
1.1 click the add row button
1.2 click the add column button
1.3 click the add row and column button
2. validate the table is updated
3. delete the last column
4. delete the last row
5. validate the table is updated
''', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
// hover on the table
final tableBlock = find.byType(SimpleTableBlockWidget).first;
await tester.hoverOnWidget(
tableBlock,
onHover: () async {
// click the add row button
final addRowButton = find.byType(SimpleTableAddRowButton).first;
await tester.tap(addRowButton);
// click the add column button
final addColumnButton = find.byType(SimpleTableAddColumnButton).first;
await tester.tap(addColumnButton);
// click the add row and column button
final addRowAndColumnButton =
find.byType(SimpleTableAddColumnAndRowButton).first;
await tester.tap(addRowAndColumnButton);
},
);
await tester.pumpAndSettle();
final tableNode =
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
expect(tableNode.columnLength, 4);
expect(tableNode.rowLength, 4);
// delete the last row
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.row,
index: tableNode.rowLength - 1,
action: SimpleTableMoreAction.delete,
);
await tester.pumpAndSettle();
expect(tableNode.rowLength, 3);
expect(tableNode.columnLength, 4);
// delete the last column
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.column,
index: tableNode.columnLength - 1,
action: SimpleTableMoreAction.delete,
);
await tester.pumpAndSettle();
expect(tableNode.columnLength, 3);
expect(tableNode.rowLength, 3);
});
testWidgets('enable header column and header row', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
// enable the header row
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.row,
index: 0,
action: SimpleTableMoreAction.enableHeaderRow,
);
await tester.pumpAndSettle();
// enable the header column
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.column,
index: 0,
action: SimpleTableMoreAction.enableHeaderColumn,
);
await tester.pumpAndSettle();
final tableNode =
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
expect(tableNode.isHeaderColumnEnabled, isTrue);
expect(tableNode.isHeaderRowEnabled, isTrue);
// disable the header row
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.row,
index: 0,
action: SimpleTableMoreAction.enableHeaderRow,
);
await tester.pumpAndSettle();
expect(tableNode.isHeaderColumnEnabled, isTrue);
expect(tableNode.isHeaderRowEnabled, isFalse);
// disable the header column
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.column,
index: 0,
action: SimpleTableMoreAction.enableHeaderColumn,
);
await tester.pumpAndSettle();
expect(tableNode.isHeaderColumnEnabled, isFalse);
expect(tableNode.isHeaderRowEnabled, isFalse);
});
testWidgets('duplicate a column / row', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
// duplicate the row
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.row,
index: 0,
action: SimpleTableMoreAction.duplicate,
);
await tester.pumpAndSettle();
// duplicate the column
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.column,
index: 0,
action: SimpleTableMoreAction.duplicate,
);
await tester.pumpAndSettle();
final tableNode =
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
expect(tableNode.columnLength, 3);
expect(tableNode.rowLength, 3);
});
testWidgets('insert left / insert right', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
// insert left
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.column,
index: 0,
action: SimpleTableMoreAction.insertLeft,
);
await tester.pumpAndSettle();
// insert right
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.column,
index: 0,
action: SimpleTableMoreAction.insertRight,
);
await tester.pumpAndSettle();
final tableNode =
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
expect(tableNode.columnLength, 4);
expect(tableNode.rowLength, 2);
});
testWidgets('insert above / insert below', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
// insert above
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.row,
index: 0,
action: SimpleTableMoreAction.insertAbove,
);
await tester.pumpAndSettle();
// insert below
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.row,
index: 0,
action: SimpleTableMoreAction.insertBelow,
);
await tester.pumpAndSettle();
final tableNode =
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
expect(tableNode.rowLength, 4);
expect(tableNode.columnLength, 2);
});
});
}
extension on WidgetTester {
/// Insert a table in the document
Future<void> insertTableInDocument() async {
// open the actions menu and insert the outline block
await editor.showSlashMenu();
await editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_table.tr(),
);
await pumpAndSettle();
}
Future<void> clickMoreActionItemInTableMenu({
required SimpleTableMoreActionType type,
required int index,
required SimpleTableMoreAction action,
}) async {
if (type == SimpleTableMoreActionType.row) {
final row = find.byWidgetPredicate((w) {
return w is SimpleTableRowBlockWidget && w.node.rowIndex == index;
});
await hoverOnWidget(
row,
onHover: () async {
final moreActionButton = find.byWidgetPredicate((w) {
return w is SimpleTableMoreActionMenu &&
w.type == SimpleTableMoreActionType.row &&
w.index == index;
});
await tapButton(moreActionButton);
await tapButton(find.text(action.name));
},
);
await pumpAndSettle();
} else if (type == SimpleTableMoreActionType.column) {
final column = find.byWidgetPredicate((w) {
return w is SimpleTableCellBlockWidget && w.node.columnIndex == index;
}).first;
await hoverOnWidget(
column,
onHover: () async {
final moreActionButton = find.byWidgetPredicate((w) {
return w is SimpleTableMoreActionMenu &&
w.type == SimpleTableMoreActionType.column &&
w.index == index;
});
await tapButton(moreActionButton);
await tapButton(find.text(action.name));
},
);
await pumpAndSettle();
}
await tapAt(Offset.zero);
}
}
@@ -1,6 +1,5 @@
import 'dart:io';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -88,7 +87,7 @@ void main() {
);
expect(
importedPageEditorState.getNodeAtPath([2])!.type,
SimpleTableBlockKeys.type,
TableBlockKeys.type,
);
});
});
@@ -3,7 +3,6 @@ import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/home/tabs/flowy_tab.dart';
import 'package:appflowy/workspace/presentation/home/tabs/tabs_manager.dart';
import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/gestures.dart';
@@ -146,189 +145,5 @@ void main() {
// and in this case view name in sidebar)
expect(find.text(gettingStarted), findsNWidgets(3));
});
testWidgets('cannot close pinned tabs', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
expect(
find.descendant(
of: find.byType(TabsManager),
matching: find.byType(TabBar),
),
findsNothing,
);
await tester.createNewPageWithNameUnderParent(name: _documentName);
await tester.createNewPageWithNameUnderParent(name: _documentTwoName);
// Open second menu item in a new tab
await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document);
// Open third menu item in a new tab
await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document);
expect(
find.descendant(
of: find.byType(TabsManager),
matching: find.byType(FlowyTab),
),
findsNWidgets(3),
);
const firstTab = _documentTwoName;
const secondTab = gettingStarted;
const thirdTab = _documentName;
expect(tester.isTabAtIndex(firstTab, 0), isTrue);
expect(tester.isTabAtIndex(secondTab, 1), isTrue);
expect(tester.isTabAtIndex(thirdTab, 2), isTrue);
expect(tester.isTabPinned(gettingStarted), isFalse);
// Right click on second tab
await tester.openTabMenu(gettingStarted);
expect(find.byType(TabMenu), findsOneWidget);
// Pin second tab
await tester.tap(find.text(LocaleKeys.tabMenu_pinTab.tr()));
await tester.pumpAndSettle();
expect(tester.isTabPinned(gettingStarted), isTrue);
/// Right click on first unpinned tab (second tab)
await tester.openTabMenu(_documentTwoName);
// Close others
await tester.tap(find.text(LocaleKeys.tabMenu_closeOthers.tr()));
await tester.pumpAndSettle();
// We expect to find 2 tabs, the first pinned tab and the second tab
expect(find.byType(FlowyTab), findsNWidgets(2));
expect(tester.isTabAtIndex(gettingStarted, 0), isTrue);
expect(tester.isTabAtIndex(_documentTwoName, 1), isTrue);
});
testWidgets('pin/unpin tabs proper order', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
expect(
find.descendant(
of: find.byType(TabsManager),
matching: find.byType(TabBar),
),
findsNothing,
);
await tester.createNewPageWithNameUnderParent(name: _documentName);
await tester.createNewPageWithNameUnderParent(name: _documentTwoName);
// Open second menu item in a new tab
await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document);
// Open third menu item in a new tab
await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document);
expect(
find.descendant(
of: find.byType(TabsManager),
matching: find.byType(FlowyTab),
),
findsNWidgets(3),
);
const firstTabName = _documentTwoName;
const secondTabName = gettingStarted;
const thirdTabName = _documentName;
// Expect correct order
expect(tester.isTabAtIndex(firstTabName, 0), isTrue);
expect(tester.isTabAtIndex(secondTabName, 1), isTrue);
expect(tester.isTabAtIndex(thirdTabName, 2), isTrue);
// Pin second tab
await tester.openTabMenu(secondTabName);
await tester.tap(find.text(LocaleKeys.tabMenu_pinTab.tr()));
await tester.pumpAndSettle();
expect(tester.isTabPinned(secondTabName), isTrue);
// Expect correct order
expect(tester.isTabAtIndex(secondTabName, 0), isTrue);
expect(tester.isTabAtIndex(firstTabName, 1), isTrue);
expect(tester.isTabAtIndex(thirdTabName, 2), isTrue);
// Pin new second tab (first tab)
await tester.openTabMenu(firstTabName);
await tester.tap(find.text(LocaleKeys.tabMenu_pinTab.tr()));
await tester.pumpAndSettle();
expect(tester.isTabPinned(firstTabName), isTrue);
expect(tester.isTabPinned(secondTabName), isTrue);
expect(tester.isTabPinned(thirdTabName), isFalse);
expect(tester.isTabAtIndex(secondTabName, 0), isTrue);
expect(tester.isTabAtIndex(firstTabName, 1), isTrue);
expect(tester.isTabAtIndex(thirdTabName, 2), isTrue);
// Unpin second tab
await tester.openTabMenu(secondTabName);
await tester.tap(find.text(LocaleKeys.tabMenu_unpinTab.tr()));
await tester.pumpAndSettle();
expect(tester.isTabPinned(firstTabName), isTrue);
expect(tester.isTabPinned(secondTabName), isFalse);
expect(tester.isTabPinned(thirdTabName), isFalse);
expect(tester.isTabAtIndex(firstTabName, 0), isTrue);
expect(tester.isTabAtIndex(secondTabName, 1), isTrue);
expect(tester.isTabAtIndex(thirdTabName, 2), isTrue);
});
});
}
extension _TabsTester on WidgetTester {
bool isTabPinned(String tabName) {
final tabFinder = find.ancestor(
of: find.byWidgetPredicate(
(w) => w is ViewTabBarItem && w.view.name == tabName,
),
matching: find.byType(FlowyTab),
);
final FlowyTab tabWidget = widget(tabFinder);
return tabWidget.pageManager.isPinned;
}
bool isTabAtIndex(String tabName, int index) {
final tabFinder = find.ancestor(
of: find.byWidgetPredicate(
(w) => w is ViewTabBarItem && w.view.name == tabName,
),
matching: find.byType(FlowyTab),
);
final pluginId = (widget(tabFinder) as FlowyTab).pageManager.plugin.id;
final pluginIds = find
.byType(FlowyTab)
.evaluate()
.map((e) => (e.widget as FlowyTab).pageManager.plugin.id);
return pluginIds.elementAt(index) == pluginId;
}
Future<void> openTabMenu(String tabName) async {
await tap(
buttons: kSecondaryButton,
find.ancestor(
of: find.byWidgetPredicate(
(w) => w is ViewTabBarItem && w.view.name == tabName,
),
matching: find.byType(FlowyTab),
),
);
await pumpAndSettle();
}
}
@@ -50,8 +50,6 @@
<string>AppFlowy needs access to your photos to let you add images to your documents</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>AppFlowy needs access to your camera to let you add images to your documents from camera</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
@@ -67,9 +65,9 @@
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UISupportsDocumentBrowser</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>UISupportsDocumentBrowser</key>
<true/>
</dict>
</plist>
@@ -49,7 +49,6 @@ class KVKeys {
/// {'SidebarFolderCategoryType.value': true}
static const String expandedFolders = 'expandedFolders';
/// @deprecated in version 0.7.6
/// The key for saving if showing the rename dialog when creating a new file
///
/// The value is a boolean string.
@@ -28,10 +28,11 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
if (state.stream != null) {
_startListening();
if (state.stream!.error != null) {
add(ChatAIMessageEvent.receiveError(state.stream!.error!));
}
if (state.stream!.aiLimitReached) {
add(const ChatAIMessageEvent.onAIResponseLimit());
} else if (state.stream!.error != null) {
add(ChatAIMessageEvent.receiveError(state.stream!.error!));
}
}
}
@@ -119,19 +119,15 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
return;
}
final metadata = {
onetimeShotType: OnetimeShotType.relatedQuestion,
'questions': questions,
};
final createdAt = DateTime.now();
final metatdata = OnetimeShotType.relatedQuestion.toMap();
metatdata['questions'] = questions;
final message = TextMessage(
id: "related_question_$createdAt",
text: '',
metadata: metadata,
metadata: metatdata,
author: const User(id: systemUserId),
createdAt: createdAt,
id: systemUserId,
createdAt: DateTime.now(),
);
chatController.insert(message);
@@ -151,13 +147,12 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
) {
numSendMessage += 1;
final relatedQuestionMessages = chatController.messages
.where(
(message) =>
onetimeMessageTypeFromMeta(message.metadata) ==
OnetimeShotType.relatedQuestion,
)
.toList();
final relatedQuestionMessages = chatController.messages.where(
(message) {
return onetimeMessageTypeFromMeta(message.metadata) ==
OnetimeShotType.relatedQuestion;
},
).toList();
for (final message in relatedQuestionMessages) {
chatController.remove(message);
@@ -383,23 +378,20 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
await AIEventStreamMessage(payload).send().fold(
(question) {
if (!isClosed) {
final streamAnswer = _createAnswerStreamMessage(
answerStream!,
question.messageId,
);
add(ChatEvent.finishSending(question));
final streamAnswer =
_createAnswerStreamMessage(answerStream!, question.messageId);
add(ChatEvent.startAnswerStreaming(streamAnswer));
}
},
(err) {
if (!isClosed) {
Log.error("Failed to send message: ${err.msg}");
final metadata = {
onetimeShotType: OnetimeShotType.error,
if (err.code != ErrorCode.Internal) errorMessageTextKey: err.msg,
};
final metadata = OnetimeShotType.invalidSendMesssage.toMap();
if (err.code != ErrorCode.Internal) {
metadata[sendMessageErrorKey] = err.msg;
}
final error = TextMessage(
text: '',
@@ -420,18 +412,19 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
AnswerStream stream,
Int64 questionMessageId,
) {
answerStreamMessageId = (questionMessageId + 1).toString();
final streamMessageId = (questionMessageId + 1).toString();
answerStreamMessageId = streamMessageId;
return TextMessage(
id: answerStreamMessageId,
text: '',
author: User(id: "streamId:${nanoid()}"),
metadata: {
"$AnswerStream": stream,
messageQuestionIdKey: questionMessageId,
"chatId": chatId,
},
id: streamMessageId,
createdAt: DateTime.now(),
text: '',
);
}
@@ -440,16 +433,17 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
Map<String, dynamic>? sentMetadata,
) {
final now = DateTime.now();
questionStreamMessageId = (now.millisecondsSinceEpoch ~/ 1000).toString();
final Map<String, dynamic> metadata = {
"$QuestionStream": stream,
"chatId": chatId,
messageChatFileListKey: chatFilesFromMessageMetadata(sentMetadata),
};
return TextMessage(
author: User(id: userId),
metadata: {
"$QuestionStream": stream,
"chatId": chatId,
messageChatFileListKey: chatFilesFromMessageMetadata(sentMetadata),
},
metadata: metadata,
id: questionStreamMessageId,
createdAt: now,
text: '',
@@ -487,28 +481,27 @@ class ChatEvent with _$ChatEvent {
_FinishSendMessage;
const factory ChatEvent.failedSending() = _FailSendMessage;
// streaming answer
// receive message
const factory ChatEvent.startAnswerStreaming(Message message) =
_StartAnswerStreaming;
const factory ChatEvent.stopStream() = _StopStream;
const factory ChatEvent.receiveMessage(Message message) = _ReceiveMessage;
const factory ChatEvent.didFinishAnswerStream() = _DidFinishAnswerStream;
// receive message
const factory ChatEvent.receiveMessage(Message message) = _ReceiveMessage;
// loading messages
const factory ChatEvent.didLoadLatestMessages(List<Message> messages) =
_DidLoadMessages;
const factory ChatEvent.loadPreviousMessages() = _LoadPreviousMessages;
const factory ChatEvent.didLoadPreviousMessages(
List<Message> messages,
bool hasMore,
) = _DidLoadPreviousMessages;
const factory ChatEvent.didLoadLatestMessages(List<Message> messages) =
_DidLoadMessages;
// related questions
const factory ChatEvent.didReceiveRelatedQuestions(
List<String> questions,
) = _DidReceiveRelatedQueston;
const factory ChatEvent.stopStream() = _StopStream;
}
@freezed
@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:equatable/equatable.dart';
@@ -9,7 +10,7 @@ import 'package:path/path.dart' as path;
part 'chat_entity.g.dart';
part 'chat_entity.freezed.dart';
const errorMessageTextKey = "errorMessageText";
const sendMessageErrorKey = "sendMessageError";
const systemUserId = "system";
const aiResponseUserId = "0";
@@ -120,13 +121,45 @@ extension ChatLoadingStateExtension on ChatLoadingState {
}
enum OnetimeShotType {
unknown,
sendingMessage,
relatedQuestion,
error,
invalidSendMesssage,
}
const onetimeShotType = "OnetimeShotType";
extension OnetimeMessageTypeExtension on OnetimeShotType {
static OnetimeShotType fromString(String value) {
switch (value) {
case 'OnetimeShotType.sendingMessage':
return OnetimeShotType.sendingMessage;
case 'OnetimeShotType.relatedQuestion':
return OnetimeShotType.relatedQuestion;
case 'OnetimeShotType.invalidSendMesssage':
return OnetimeShotType.invalidSendMesssage;
default:
Log.error('Unknown OnetimeShotType: $value');
return OnetimeShotType.unknown;
}
}
Map<String, dynamic> toMap() {
return {
onetimeShotType: toString(),
};
}
}
OnetimeShotType? onetimeMessageTypeFromMeta(Map<String, dynamic>? metadata) {
return metadata?[onetimeShotType];
if (metadata == null) {
return null;
}
for (final entry in metadata.entries) {
if (entry.key == onetimeShotType) {
return OnetimeMessageTypeExtension.fromString(entry.value as String);
}
}
return null;
}
@@ -13,10 +13,14 @@ class AnswerStream {
_hasStarted = true;
final newText = event.substring(5);
_text += newText;
_onData?.call(_text);
if (_onData != null) {
_onData!(_text);
}
} else if (event.startsWith("error:")) {
_error = event.substring(5);
_onError?.call(_error!);
if (_onError != null) {
_onError!(_error!);
}
} else if (event.startsWith("metadata:")) {
if (_onMetadata != null) {
final s = event.substring(9);
@@ -24,14 +28,20 @@ class AnswerStream {
}
} else if (event == "AI_RESPONSE_LIMIT") {
_aiLimitReached = true;
_onAIResponseLimit?.call();
if (_onAIResponseLimit != null) {
_onAIResponseLimit!();
}
}
},
onDone: () {
_onEnd?.call();
if (_onEnd != null) {
_onEnd!();
}
},
onError: (error) {
_onError?.call(error.toString());
if (_onError != null) {
_onError!(error.toString());
}
},
);
}
@@ -54,8 +64,8 @@ class AnswerStream {
int get nativePort => _port.sendPort.nativePort;
bool get hasStarted => _hasStarted;
bool get aiLimitReached => _aiLimitReached;
String? get error => _error;
bool get aiLimitReached => _aiLimitReached;
String get text => _text;
Future<void> dispose() async {
@@ -79,7 +89,9 @@ class AnswerStream {
_onAIResponseLimit = onAIResponseLimit;
_onMetadata = onMetadata;
_onStart?.call();
if (_onStart != null) {
_onStart!();
}
}
}
@@ -8,19 +8,72 @@ part 'chat_user_message_bloc.freezed.dart';
class ChatUserMessageBloc
extends Bloc<ChatUserMessageEvent, ChatUserMessageState> {
ChatUserMessageBloc({
required this.questionStream,
required String text,
}) : super(ChatUserMessageState.initial(text)) {
_dispatch();
_startListening();
}
final QuestionStream? questionStream;
void _dispatch() {
required dynamic message,
}) : super(ChatUserMessageState.initial(message)) {
on<ChatUserMessageEvent>(
(event, emit) {
event.when(
initial: () {
if (state.stream != null) {
if (!isClosed) {
add(ChatUserMessageEvent.updateText(state.stream!.text));
}
}
state.stream?.listen(
onData: (text) {
if (!isClosed) {
add(ChatUserMessageEvent.updateText(text));
}
},
onMessageId: (messageId) {
if (!isClosed) {
add(ChatUserMessageEvent.updateMessageId(messageId));
}
},
onError: (error) {
if (!isClosed) {
add(ChatUserMessageEvent.receiveError(error.toString()));
}
},
onFileIndexStart: (indexName) {
Log.debug("index start: $indexName");
},
onFileIndexEnd: (indexName) {
Log.info("index end: $indexName");
},
onFileIndexFail: (indexName) {
Log.debug("index fail: $indexName");
},
onIndexStart: () {
if (!isClosed) {
add(
const ChatUserMessageEvent.updateQuestionState(
QuestionMessageState.indexStart(),
),
);
}
},
onIndexEnd: () {
if (!isClosed) {
add(
const ChatUserMessageEvent.updateQuestionState(
QuestionMessageState.indexEnd(),
),
);
}
},
onDone: () {
if (!isClosed) {
add(
const ChatUserMessageEvent.updateQuestionState(
QuestionMessageState.finish(),
),
);
}
},
);
},
updateText: (String text) {
emit(state.copyWith(text: text));
},
@@ -35,66 +88,11 @@ class ChatUserMessageBloc
},
);
}
void _startListening() {
questionStream?.listen(
onData: (text) {
if (!isClosed) {
add(ChatUserMessageEvent.updateText(text));
}
},
onMessageId: (messageId) {
if (!isClosed) {
add(ChatUserMessageEvent.updateMessageId(messageId));
}
},
onError: (error) {
if (!isClosed) {
add(ChatUserMessageEvent.receiveError(error.toString()));
}
},
onFileIndexStart: (indexName) {
Log.debug("index start: $indexName");
},
onFileIndexEnd: (indexName) {
Log.info("index end: $indexName");
},
onFileIndexFail: (indexName) {
Log.debug("index fail: $indexName");
},
onIndexStart: () {
if (!isClosed) {
add(
const ChatUserMessageEvent.updateQuestionState(
QuestionMessageState.indexStart(),
),
);
}
},
onIndexEnd: () {
if (!isClosed) {
add(
const ChatUserMessageEvent.updateQuestionState(
QuestionMessageState.indexEnd(),
),
);
}
},
onDone: () {
if (!isClosed) {
add(
const ChatUserMessageEvent.updateQuestionState(
QuestionMessageState.finish(),
),
);
}
},
);
}
}
@freezed
class ChatUserMessageEvent with _$ChatUserMessageEvent {
const factory ChatUserMessageEvent.initial() = Initial;
const factory ChatUserMessageEvent.updateText(String text) = _UpdateText;
const factory ChatUserMessageEvent.updateQuestionState(
QuestionMessageState newState,
@@ -108,14 +106,17 @@ class ChatUserMessageEvent with _$ChatUserMessageEvent {
class ChatUserMessageState with _$ChatUserMessageState {
const factory ChatUserMessageState({
required String text,
required String? messageId,
required QuestionMessageState messageState,
QuestionStream? stream,
String? messageId,
@Default(QuestionMessageState.finish()) QuestionMessageState messageState,
}) = _ChatUserMessageState;
factory ChatUserMessageState.initial(String message) => ChatUserMessageState(
text: message,
messageId: null,
messageState: const QuestionMessageState.finish(),
factory ChatUserMessageState.initial(
dynamic message,
) =>
ChatUserMessageState(
text: message is String ? message : "",
stream: message is QuestionStream ? message : null,
);
}
@@ -0,0 +1,60 @@
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'chat_message_service.dart';
part 'chat_user_message_bubble_bloc.freezed.dart';
class ChatUserMessageBubbleBloc
extends Bloc<ChatUserMessageBubbleEvent, ChatUserMessageBubbleState> {
ChatUserMessageBubbleBloc({
required Message message,
}) : super(
ChatUserMessageBubbleState.initial(
message,
_getFiles(message.metadata),
),
) {
on<ChatUserMessageBubbleEvent>(
(event, emit) async {
event.when(
initial: () {},
);
},
);
}
}
List<ChatFile> _getFiles(Map<String, dynamic>? metadata) {
if (metadata == null) {
return [];
}
final refSourceMetadata = metadata[messageRefSourceJsonStringKey] as String?;
final files = metadata[messageChatFileListKey] as List<ChatFile>?;
if (refSourceMetadata != null) {
return chatFilesFromMetadataString(refSourceMetadata);
}
return files ?? [];
}
@freezed
class ChatUserMessageBubbleEvent with _$ChatUserMessageBubbleEvent {
const factory ChatUserMessageBubbleEvent.initial() = Initial;
}
@freezed
class ChatUserMessageBubbleState with _$ChatUserMessageBubbleState {
const factory ChatUserMessageBubbleState({
required Message message,
required List<ChatFile> files,
}) = _ChatUserMessageBubbleState;
factory ChatUserMessageBubbleState.initial(
Message message,
List<ChatFile> files,
) =>
ChatUserMessageBubbleState(message: message, files: files);
}
@@ -2,7 +2,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/ai_chat/chat_page.dart';
import 'package:appflowy/plugins/util.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart';
@@ -86,16 +85,12 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder
final ViewPluginNotifier notifier;
int? deletedViewIndex;
@override
String? get viewName => notifier.view.nameOrDefault;
@override
Widget get leftBarItem =>
ViewTitleBar(key: ValueKey(notifier.view.id), view: notifier.view);
@override
Widget tabBarItem(String pluginId, [bool shortForm = false]) =>
ViewTabBarItem(view: notifier.view, shortForm: shortForm);
Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view);
@override
Widget buildWidget({
@@ -4,6 +4,7 @@ import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/user_message_bubble.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:desktop_drop/desktop_drop.dart';
@@ -23,10 +24,10 @@ import 'presentation/animated_chat_list.dart';
import 'presentation/chat_input/desktop_ai_prompt_input.dart';
import 'presentation/chat_input/mobile_ai_prompt_input.dart';
import 'presentation/chat_side_panel.dart';
import 'presentation/chat_user_invalid_message.dart';
import 'presentation/chat_welcome_page.dart';
import 'presentation/layout_define.dart';
import 'presentation/message/ai_text_message.dart';
import 'presentation/message/error_text_message.dart';
import 'presentation/message/user_text_message.dart';
import 'presentation/scroll_to_bottom.dart';
@@ -68,24 +69,20 @@ class AIChatPage extends StatelessWidget {
BlocProvider(create: (_) => ChatSidePanelBloc(chatId: view.id)),
BlocProvider(create: (_) => ChatMemberBloc()),
],
child: Builder(
builder: (context) {
return DropTarget(
onDragDone: (DropDoneDetails detail) async {
if (context.read<AIPromptInputBloc>().state.supportChatWithFile) {
for (final file in detail.files) {
context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.newFile(file.path, file.name));
}
}
},
child: _ChatContentPage(
view: view,
userProfile: userProfile,
),
);
child: DropTarget(
onDragDone: (DropDoneDetails detail) async {
if (context.read<AIPromptInputBloc>().state.supportChatWithFile) {
for (final file in detail.files) {
context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.newFile(file.path, file.name));
}
}
},
child: _ChatContentPage(
view: view,
userProfile: userProfile,
),
),
);
}
@@ -120,7 +117,7 @@ class _ChatContentPage extends StatelessWidget {
.constrained(
maxWidth: 784,
)
.padding(horizontal: 60)
.padding(horizontal: 32)
.animate(
const Duration(milliseconds: 200),
Curves.easeOut,
@@ -218,9 +215,9 @@ class _ChatContentPage extends StatelessWidget {
message.metadata,
);
if (messageType == OnetimeShotType.error) {
return ChatErrorMessageWidget(
errorMessage: message.metadata?[errorMessageTextKey] ?? "",
if (messageType == OnetimeShotType.invalidSendMesssage) {
return ChatInvalidUserMessage(
message: message,
);
}
@@ -236,18 +233,27 @@ class _ChatContentPage extends StatelessWidget {
}
if (message.author.id == userProfile.id.toString()) {
return ChatUserMessageWidget(
user: message.author,
final stream = message.metadata?["$QuestionStream"];
return ChatUserMessageBubble(
key: ValueKey(message.id),
message: message,
isCurrentUser: true,
child: ChatUserMessageWidget(
user: message.author,
message: stream is QuestionStream ? stream : message.text,
),
);
}
if (isOtherUserMessage(message)) {
return ChatUserMessageWidget(
user: message.author,
final stream = message.metadata?["$QuestionStream"];
return ChatUserMessageBubble(
key: ValueKey(message.id),
message: message,
isCurrentUser: false,
child: ChatUserMessageWidget(
user: message.author,
message: stream is QuestionStream ? stream : message.text,
),
);
}
@@ -257,10 +263,22 @@ class _ChatContentPage extends StatelessWidget {
message.metadata?[messageRefSourceJsonStringKey] as String?;
return BlocSelector<ChatBloc, ChatState, bool>(
key: ValueKey(message.id),
selector: (state) {
final chatController = context.read<ChatBloc>().chatController;
final messages = chatController.messages
.where((e) => onetimeMessageTypeFromMeta(e.metadata) == null);
final messages = chatController.messages.where((e) {
final oneTimeMessageType = onetimeMessageTypeFromMeta(e.metadata);
if (oneTimeMessageType == null) {
return true;
}
if (oneTimeMessageType
case OnetimeShotType.relatedQuestion ||
OnetimeShotType.sendingMessage ||
OnetimeShotType.invalidSendMesssage) {
return false;
}
return true;
});
return messages.isEmpty ? false : messages.last.id == message.id;
},
builder: (context, isLastMessage) {
@@ -292,8 +310,7 @@ class _ChatContentPage extends StatelessWidget {
return ChatMessage(
message: message,
animation: animation,
padding: const EdgeInsets.symmetric(vertical: 12.0),
receivedMessageScaleAnimationAlignment: Alignment.center,
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: child,
);
}
@@ -10,6 +10,7 @@ import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:provider/provider.dart';
import 'package:flutter_chat_ui/src/scroll_to_bottom.dart';
import 'package:flutter_chat_ui/src/utils/chat_input_height_notifier.dart';
import 'package:flutter_chat_ui/src/utils/message_list_diff.dart';
class ChatAnimatedListReversed extends StatefulWidget {
@@ -46,8 +47,8 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
late List<Message> _oldList;
late StreamSubscription<ChatOperation> _operationsSubscription;
late final AnimationController _scrollToBottomController;
late final Animation<double> _scrollToBottomAnimation;
late AnimationController _scrollToBottomController;
late Animation<double> _scrollToBottomAnimation;
Timer? _scrollToBottomShowTimer;
bool _userHasScrolled = false;
@@ -71,7 +72,7 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
event.message != null,
'Message must be provided when inserting a message.',
);
_onInserted(0, event.message!);
_onInserted(event.index!, event.message!);
_oldList = List.from(_chatController.messages);
break;
case ChatOperationType.remove:
@@ -138,12 +139,12 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
onNotification: (notification) {
if (notification is UserScrollNotification) {
// When user scrolls up, save it to `_userHasScrolled`
if (notification.direction == ScrollDirection.reverse) {
if (notification.direction == ScrollDirection.forward) {
_userHasScrolled = true;
} else {
// When user overscolls to the bottom or stays idle at the bottom, set `_userHasScrolled` to false
if (notification.metrics.pixels ==
notification.metrics.minScrollExtent) {
notification.metrics.maxScrollExtent) {
_userHasScrolled = false;
}
}
@@ -162,10 +163,14 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
reverse: true,
controller: widget.scrollController,
slivers: <Widget>[
SliverPadding(
padding: EdgeInsets.only(
top: widget.bottomPadding ?? 0,
),
Consumer<ChatInputHeightNotifier>(
builder: (context, heightNotifier, child) {
return SliverPadding(
padding: EdgeInsets.only(
top: heightNotifier.height + (widget.bottomPadding ?? 0),
),
);
},
),
SliverAnimatedList(
key: _listKey,
@@ -200,10 +205,32 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
);
}
void _initialScrollToEnd() async {
// Delay the scroll to the end animation so new message is painted, otherwise
// maxScrollExtent is not yet updated and the animation might not work.
await Future.delayed(widget.insertAnimationDuration);
if (!widget.scrollController.hasClients || !mounted) return;
if (widget.scrollController.offset >
widget.scrollController.position.minScrollExtent) {
if (widget.scrollToEndAnimationDuration == Duration.zero) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
} else {
await widget.scrollController.animateTo(
widget.scrollController.position.minScrollExtent,
duration: widget.scrollToEndAnimationDuration,
curve: Curves.linearToEaseOut,
);
}
}
}
void _subsequentScrollToEnd(Message data) async {
final user = Provider.of<User>(context, listen: false);
// We only want to scroll to the bottom if user has not scrolled up
// In this case we only want to scroll to the bottom if user has not scrolled up
// or if the message is sent by the current user.
if (data.id == _lastInsertedMessageId &&
widget.scrollController.offset >
@@ -246,7 +273,15 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
(_) {
if (!widget.scrollController.hasClients || !mounted) return;
_subsequentScrollToEnd(data);
// We need this condition because if scroll view is not yet scrollable,
// we want to wait for the insert animation to finish before scrolling to the end.
if (widget.scrollController.position.maxScrollExtent == 0) {
// Scroll view is not yet scrollable, scroll to the end if
// new message makes it scrollable.
_initialScrollToEnd();
} else {
_subsequentScrollToEnd(data);
}
},
);
}
@@ -283,37 +318,37 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
}
void _handleToggleScrollToBottom() {
if (_isScrollingToBottom) {
return;
}
_scrollToBottomShowTimer?.cancel();
if (widget.scrollController.offset >
widget.scrollController.position.minScrollExtent) {
_scrollToBottomShowTimer =
Timer(widget.scrollToBottomAppearanceDelay, () {
if (mounted) {
_scrollToBottomController.forward();
if (!_isScrollingToBottom) {
_scrollToBottomShowTimer?.cancel();
if (widget.scrollController.offset >
widget.scrollController.position.minScrollExtent) {
_scrollToBottomShowTimer =
Timer(widget.scrollToBottomAppearanceDelay, () {
if (mounted) {
_scrollToBottomController.forward();
}
});
} else {
if (_scrollToBottomController.status == AnimationStatus.completed) {
_scrollToBottomController.reverse();
}
});
} else {
if (_scrollToBottomController.status != AnimationStatus.completed) {
_scrollToBottomController.stop();
}
_scrollToBottomController.reverse();
}
}
void _onInserted(final int position, final Message data) {
// There is a scroll notification listener the controls the
// `_userHasScrolled` variable.
final user = Provider.of<User>(context, listen: false);
// There is a scroll notification listener the controls the `_userHasScrolled` variable.
// However, when a new message is sent by the current user we want to
// set `_userHasScrolled` to false so that the scroll animation is triggered.
//
// If for some reason `_userHasScrolled` is true and the user is not at the
// bottom of the list, set `_userHasScrolled` to false so that the scroll
// animation is triggered.
if (_userHasScrolled &&
widget.scrollController.offset >=
widget.scrollController.position.minScrollExtent) {
// Also, if for some reason `_userHasScrolled` is true and the user is not at the bottom of the list,
// set `_userHasScrolled` to false so that the scroll animation is triggered.
if (user.id == data.author.id ||
(_userHasScrolled == true &&
widget.scrollController.offset >=
widget.scrollController.position.maxScrollExtent)) {
_userHasScrolled = false;
}
@@ -325,7 +360,7 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
// Used later to trigger scroll to end only for the last inserted message.
_lastInsertedMessageId = data.id;
if (position == _oldList.length) {
if (user.id == data.author.id && position == _oldList.length) {
_scrollToEnd(data);
}
}
@@ -18,56 +18,53 @@ class ChatAILoading extends StatelessWidget {
@override
Widget build(BuildContext context) {
final slice = Duration(milliseconds: duration.inMilliseconds ~/ 5);
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: SizedBox(
height: 20,
child: SeparatedRow(
separatorBuilder: () => const HSpace(4),
children: [
Padding(
padding: const EdgeInsetsDirectional.only(end: 4.0),
child: BlocBuilder<ChatAIMessageBloc, ChatAIMessageState>(
builder: (context, state) {
return FlowyText(
state.progress?.step ??
LocaleKeys.chat_generatingResponse.tr(),
color: Theme.of(context).hintColor,
);
},
),
return SizedBox(
height: 20,
child: SeparatedRow(
separatorBuilder: () => const HSpace(4),
children: [
Padding(
padding: const EdgeInsetsDirectional.only(end: 4.0),
child: BlocBuilder<ChatAIMessageBloc, ChatAIMessageState>(
builder: (context, state) {
return FlowyText(
state.progress?.step ??
LocaleKeys.chat_generatingResponse.tr(),
color: Theme.of(context).hintColor,
);
},
),
buildDot(const Color(0xFF9327FF))
.animate(onPlay: (controller) => controller.repeat())
.slideY(duration: slice, begin: 0, end: -1)
.then()
.slideY(begin: -1, end: 1)
.then()
.slideY(begin: 1, end: 0)
.then()
.slideY(duration: slice * 2, begin: 0, end: 0),
buildDot(const Color(0xFFFB006D))
.animate(onPlay: (controller) => controller.repeat())
.slideY(duration: slice, begin: 0, end: 0)
.then()
.slideY(begin: 0, end: -1)
.then()
.slideY(begin: -1, end: 1)
.then()
.slideY(begin: 1, end: 0)
.then()
.slideY(begin: 0, end: 0),
buildDot(const Color(0xFFFFCE00))
.animate(onPlay: (controller) => controller.repeat())
.slideY(duration: slice * 2, begin: 0, end: 0)
.then()
.slideY(duration: slice, begin: 0, end: -1)
.then()
.slideY(begin: -1, end: 1)
.then()
.slideY(begin: 1, end: 0),
],
),
),
buildDot(const Color(0xFF9327FF))
.animate(onPlay: (controller) => controller.repeat())
.slideY(duration: slice, begin: 0, end: -1)
.then()
.slideY(begin: -1, end: 1)
.then()
.slideY(begin: 1, end: 0)
.then()
.slideY(duration: slice * 2, begin: 0, end: 0),
buildDot(const Color(0xFFFB006D))
.animate(onPlay: (controller) => controller.repeat())
.slideY(duration: slice, begin: 0, end: 0)
.then()
.slideY(begin: 0, end: -1)
.then()
.slideY(begin: -1, end: 1)
.then()
.slideY(begin: 1, end: 0)
.then()
.slideY(begin: 0, end: 0),
buildDot(const Color(0xFFFFCE00))
.animate(onPlay: (controller) => controller.repeat())
.slideY(duration: slice * 2, begin: 0, end: 0)
.then()
.slideY(duration: slice, begin: 0, end: -1)
.then()
.slideY(begin: -1, end: 1)
.then()
.slideY(begin: 1, end: 0),
],
),
);
}
@@ -7,8 +7,6 @@ import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:universal_platform/universal_platform.dart';
import 'layout_define.dart';
class RelatedQuestionList extends StatelessWidget {
const RelatedQuestionList({
super.key,
@@ -25,8 +23,10 @@ class RelatedQuestionList extends StatelessWidget {
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: relatedQuestions.length + 1,
padding:
const EdgeInsets.only(bottom: 8.0) + AIChatUILayout.messageMargin,
padding: const EdgeInsets.only(bottom: 8.0) +
(UniversalPlatform.isMobile
? const EdgeInsets.symmetric(horizontal: 16)
: EdgeInsets.zero),
separatorBuilder: (context, index) => const VSpace(4.0),
itemBuilder: (context, index) {
if (index == 0) {
@@ -39,12 +39,9 @@ class RelatedQuestionList extends StatelessWidget {
),
);
} else {
return Align(
alignment: AlignmentDirectional.centerStart,
child: RelatedQuestionItem(
question: relatedQuestions[index - 1],
onQuestionSelected: onQuestionSelected,
),
return RelatedQuestionItem(
question: relatedQuestions[index - 1],
onQuestionSelected: onQuestionSelected,
);
}
},
@@ -65,15 +62,11 @@ class RelatedQuestionItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FlowyButton(
mainAxisAlignment: MainAxisAlignment.start,
text: Flexible(
child: FlowyText(
question,
lineHeight: 1.4,
overflow: TextOverflow.ellipsis,
),
text: FlowyText(
question,
lineHeight: 1.4,
overflow: TextOverflow.ellipsis,
),
expandText: false,
margin: UniversalPlatform.isMobile
? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0)
: const EdgeInsets.all(8.0),
@@ -0,0 +1,52 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:universal_platform/universal_platform.dart';
class ChatInvalidUserMessage extends StatelessWidget {
const ChatInvalidUserMessage({
required this.message,
super.key,
});
final Message message;
@override
Widget build(BuildContext context) {
final errorMessage = message.metadata?[sendMessageErrorKey] ?? "";
return Center(
child: Container(
margin: const EdgeInsets.only(top: 16.0, bottom: 24.0),
constraints: UniversalPlatform.isDesktop
? const BoxConstraints(maxWidth: 480)
: const BoxConstraints(),
padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: Theme.of(context).isLightMode
? const Color(0x80FFE7EE)
: const Color(0x80591734),
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FlowySvg(
FlowySvgs.warning_filled_s,
blendMode: null,
),
const HSpace(8.0),
Flexible(
child: FlowyText(
errorMessage,
lineHeight: 1.4,
maxLines: null,
),
),
],
),
),
);
}
}
@@ -180,7 +180,7 @@ class WelcomeSampleQuestion extends StatelessWidget {
}
}
class _AutoScrollingSampleQuestions extends StatefulWidget {
class _AutoScrollingSampleQuestions extends StatelessWidget {
const _AutoScrollingSampleQuestions({
super.key,
required this.questions,
@@ -194,29 +194,17 @@ class _AutoScrollingSampleQuestions extends StatefulWidget {
final double offset;
final bool reverse;
@override
State<_AutoScrollingSampleQuestions> createState() =>
_AutoScrollingSampleQuestionsState();
}
class _AutoScrollingSampleQuestionsState
extends State<_AutoScrollingSampleQuestions> {
late final scrollController = ScrollController(
initialScrollOffset: widget.offset,
);
@override
Widget build(BuildContext context) {
return SizedBox(
height: 36,
child: InfiniteScrollView(
scrollController: scrollController,
centerKey: UniqueKey(),
itemCount: widget.questions.length,
itemCount: questions.length,
itemBuilder: (context, index) {
return WelcomeSampleQuestion(
question: widget.questions[index],
onSelected: widget.onSelected,
question: questions[index],
onSelected: onSelected,
);
},
separatorBuilder: (context, index) => const HSpace(8),
@@ -13,10 +13,6 @@ class AIChatUILayout {
)
: const EdgeInsets.only(bottom: 16);
}
static EdgeInsets get messageMargin => UniversalPlatform.isMobile
? const EdgeInsets.symmetric(horizontal: 16)
: EdgeInsets.zero;
}
class DesktopAIPromptSizes {
@@ -4,6 +4,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy/startup/startup.dart';
@@ -16,13 +17,13 @@ import 'package:flutter/material.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:universal_platform/universal_platform.dart';
import '../chat_avatar.dart';
import '../layout_define.dart';
/// Wraps an AI response message with the avatar and actions. On desktop,
/// the actions will be displayed below the response if the response is the
/// last message in the chat. For the others, the actions will be shown on hover
/// On mobile, the actions will be displayed in a bottom sheet on long press.
/// last message in the chat. For other AI responses, the actions will be shown
/// on hover. On mobile, the actions will be displayed in a bottom sheet on
/// long press.
class ChatAIMessageBubble extends StatelessWidget {
const ChatAIMessageBubble({
super.key,
@@ -292,11 +293,7 @@ class AIResponseActionBar extends StatelessWidget {
padding: const EdgeInsets.all(2.0),
decoration: BoxDecoration(
borderRadius: DesktopAIConvoSizes.actionBarIconRadius,
border: Border.all(
color: isLightMode
? const Color(0x1F1F2329)
: Theme.of(context).dividerColor,
),
border: Border.all(color: Theme.of(context).dividerColor),
color: Theme.of(context).cardColor,
boxShadow: [
BoxShadow(
@@ -17,7 +17,7 @@ class AIMessageMetadata extends StatefulWidget {
});
final List<ChatMessageRefSource> sources;
final void Function(ChatMessageRefSource metadata)? onSelectedMetadata;
final void Function(ChatMessageRefSource metadata) onSelectedMetadata;
@override
State<AIMessageMetadata> createState() => _AIMessageMetadataState();
@@ -92,7 +92,7 @@ class _AIMessageMetadataState extends State<AIMessageMetadata> {
if (m.source != appflowySource) {
return;
}
widget.onSelectedMetadata?.call(m);
widget.onSelectedMetadata(m);
},
),
);
@@ -1,20 +1,23 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_loading.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:universal_platform/universal_platform.dart';
import '../chat_loading.dart';
import '../layout_define.dart';
import 'ai_markdown_text.dart';
import 'ai_message_bubble.dart';
import 'ai_metadata.dart';
import 'error_text_message.dart';
/// [ChatAIMessageWidget] includes both the text of the AI response as well as
/// the avatar, decorations and hover effects that are also rendered. This is
@@ -31,7 +34,7 @@ class ChatAIMessageWidget extends StatelessWidget {
required this.questionId,
required this.chatId,
required this.refSourceJsonString,
this.onSelectedMetadata,
required this.onSelectedMetadata,
this.isLastMessage = false,
});
@@ -43,7 +46,7 @@ class ChatAIMessageWidget extends StatelessWidget {
final Int64? questionId;
final String chatId;
final String? refSourceJsonString;
final void Function(ChatMessageRefSource metadata)? onSelectedMetadata;
final void Function(ChatMessageRefSource metadata) onSelectedMetadata;
final bool isLastMessage;
@override
@@ -58,13 +61,20 @@ class ChatAIMessageWidget extends StatelessWidget {
child: BlocBuilder<ChatAIMessageBloc, ChatAIMessageState>(
builder: (context, state) {
return Padding(
padding: AIChatUILayout.messageMargin,
padding: UniversalPlatform.isMobile
? const EdgeInsets.symmetric(horizontal: 16)
: EdgeInsets.zero,
child: state.messageState.when(
loading: () => ChatAIMessageBubble(
message: message,
showActions: false,
child: const ChatAILoading(),
),
loading: () {
return ChatAIMessageBubble(
message: message,
showActions: false,
child: const Padding(
padding: EdgeInsets.only(top: 8.0),
child: ChatAILoading(),
),
);
},
ready: () {
return state.text.isEmpty
? ChatAIMessageBubble(
@@ -85,15 +95,12 @@ class ChatAIMessageWidget extends StatelessWidget {
sources: state.sources,
onSelectedMetadata: onSelectedMetadata,
),
if (state.sources.isNotEmpty && !isLastMessage)
const VSpace(16.0),
],
),
);
},
onError: (error) {
return ChatErrorMessageWidget(
errorMessage: LocaleKeys.chat_aiServerUnavailable.tr(),
onError: (err) {
return StreamingError(
onRetry: () {
context
.read<ChatAIMessageBloc>()
@@ -102,10 +109,7 @@ class ChatAIMessageWidget extends StatelessWidget {
);
},
onAIResponseLimit: () {
return ChatErrorMessageWidget(
errorMessage:
LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(),
);
return const AIResponseLimitReachedError();
},
),
);
@@ -114,3 +118,129 @@ class ChatAIMessageWidget extends StatelessWidget {
);
}
}
class StreamingError extends StatefulWidget {
const StreamingError({
required this.onRetry,
super.key,
});
final VoidCallback onRetry;
@override
State<StreamingError> createState() => _StreamingErrorState();
}
class _StreamingErrorState extends State<StreamingError> {
late final TapGestureRecognizer recognizer;
@override
void initState() {
super.initState();
recognizer = TapGestureRecognizer()..onTap = widget.onRetry;
}
@override
void dispose() {
recognizer.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Center(
child: Container(
margin: const EdgeInsets.only(top: 16.0, bottom: 24.0),
padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: Theme.of(context).isLightMode
? const Color(0x80FFE7EE)
: const Color(0x80591734),
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
),
constraints: _errorConstraints(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FlowySvg(
FlowySvgs.warning_filled_s,
blendMode: null,
),
const HSpace(8.0),
Flexible(
child: RichText(
text: TextSpan(
children: [
TextSpan(
text: LocaleKeys.chat_aiServerUnavailable.tr(),
style: Theme.of(context).textTheme.bodyMedium,
),
TextSpan(
text: " ",
style: Theme.of(context).textTheme.bodyMedium,
),
TextSpan(
text: LocaleKeys.chat_retry.tr(),
recognizer: recognizer,
mouseCursor: SystemMouseCursors.click,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
),
),
],
),
),
),
],
),
),
);
}
}
class AIResponseLimitReachedError extends StatelessWidget {
const AIResponseLimitReachedError({
super.key,
});
@override
Widget build(BuildContext context) {
return Center(
child: Container(
margin: const EdgeInsets.only(top: 16.0, bottom: 24.0),
constraints: _errorConstraints(),
padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: Theme.of(context).isLightMode
? const Color(0x80FFE7EE)
: const Color(0x80591734),
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FlowySvg(
FlowySvgs.warning_filled_s,
blendMode: null,
),
const HSpace(8.0),
Flexible(
child: FlowyText(
LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(),
lineHeight: 1.4,
maxLines: null,
),
),
],
),
),
);
}
}
BoxConstraints _errorConstraints() {
return UniversalPlatform.isDesktop
? const BoxConstraints(maxWidth: 480)
: const BoxConstraints();
}
@@ -1,107 +0,0 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:universal_platform/universal_platform.dart';
class ChatErrorMessageWidget extends StatefulWidget {
const ChatErrorMessageWidget({
super.key,
required this.errorMessage,
this.onRetry,
});
final String errorMessage;
final VoidCallback? onRetry;
@override
State<ChatErrorMessageWidget> createState() => _ChatErrorMessageWidgetState();
}
class _ChatErrorMessageWidgetState extends State<ChatErrorMessageWidget> {
late final TapGestureRecognizer recognizer;
@override
void initState() {
super.initState();
recognizer = TapGestureRecognizer()..onTap = widget.onRetry;
}
@override
void dispose() {
recognizer.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Center(
child: Container(
margin: const EdgeInsets.only(top: 16.0, bottom: 24.0) +
(UniversalPlatform.isMobile
? const EdgeInsets.symmetric(horizontal: 16)
: EdgeInsets.zero),
padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: Theme.of(context).isLightMode
? const Color(0x80FFE7EE)
: const Color(0x80591734),
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
),
constraints: UniversalPlatform.isDesktop
? const BoxConstraints(maxWidth: 480)
: null,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FlowySvg(
FlowySvgs.warning_filled_s,
blendMode: null,
),
const HSpace(8.0),
Flexible(
child: _buildText(),
),
],
),
),
);
}
Widget _buildText() {
final errorMessage = widget.errorMessage;
return widget.onRetry != null
? RichText(
text: TextSpan(
children: [
TextSpan(
text: errorMessage,
style: Theme.of(context).textTheme.bodyMedium,
),
TextSpan(
text: ' ',
style: Theme.of(context).textTheme.bodyMedium,
),
TextSpan(
text: LocaleKeys.chat_retry.tr(),
recognizer: recognizer,
mouseCursor: SystemMouseCursors.click,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
),
),
],
),
)
: FlowyText(
errorMessage,
lineHeight: 1.4,
maxLines: null,
);
}
}
@@ -1,54 +1,67 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_member_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_user_message_bubble_bloc.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.dart';
import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import '../chat_avatar.dart';
import '../layout_define.dart';
import 'package:universal_platform/universal_platform.dart';
class ChatUserMessageBubble extends StatelessWidget {
const ChatUserMessageBubble({
super.key,
required this.message,
required this.child,
required this.isCurrentUser,
this.files = const [],
this.isCurrentUser = true,
});
final Message message;
final Widget child;
final bool isCurrentUser;
final List<ChatFile> files;
@override
Widget build(BuildContext context) {
context
.read<ChatMemberBloc>()
.add(ChatMemberEvent.getMemberInfo(message.author.id));
if (context.read<ChatMemberBloc>().state.members[message.author.id] ==
null) {
context
.read<ChatMemberBloc>()
.add(ChatMemberEvent.getMemberInfo(message.author.id));
}
return Padding(
padding: AIChatUILayout.messageMargin,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (files.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(right: 32),
child: _MessageFileList(files: files),
return BlocProvider(
create: (context) => ChatUserMessageBubbleBloc(
message: message,
),
child: BlocBuilder<ChatUserMessageBubbleBloc, ChatUserMessageBubbleState>(
builder: (context, state) {
return Padding(
padding: UniversalPlatform.isMobile
? const EdgeInsets.symmetric(horizontal: 16)
: EdgeInsets.zero,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (state.files.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(right: 32),
child: _MessageFileList(files: state.files),
),
const VSpace(6),
],
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: getChildren(context),
),
],
),
const VSpace(6),
],
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: getChildren(context),
),
],
);
},
),
);
}
@@ -1,6 +1,3 @@
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_user_message_bloc.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -8,63 +5,33 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'user_message_bubble.dart';
class ChatUserMessageWidget extends StatelessWidget {
const ChatUserMessageWidget({
super.key,
required this.user,
required this.message,
required this.isCurrentUser,
});
final User user;
final TextMessage message;
final bool isCurrentUser;
final dynamic message;
@override
Widget build(BuildContext context) {
final stream = message.metadata?["$QuestionStream"];
final messageText = stream is QuestionStream ? stream.text : message.text;
return BlocProvider(
create: (context) => ChatUserMessageBloc(
text: messageText,
questionStream: stream,
),
child: ChatUserMessageBubble(
message: message,
isCurrentUser: isCurrentUser,
files: _getFiles(),
child: BlocBuilder<ChatUserMessageBloc, ChatUserMessageState>(
builder: (context, state) {
return Opacity(
opacity: state.messageState.isFinish ? 1.0 : 0.8,
child: TextMessageText(
text: state.text,
),
);
},
),
create: (context) => ChatUserMessageBloc(message: message)
..add(const ChatUserMessageEvent.initial()),
child: BlocBuilder<ChatUserMessageBloc, ChatUserMessageState>(
builder: (context, state) {
return Opacity(
opacity: state.messageState.isFinish ? 1.0 : 0.8,
child: TextMessageText(
text: state.text,
),
);
},
),
);
}
List<ChatFile> _getFiles() {
if (message.metadata == null) {
return const [];
}
final refSourceMetadata =
message.metadata?[messageRefSourceJsonStringKey] as String?;
if (refSourceMetadata != null) {
return chatFilesFromMetadataString(refSourceMetadata);
}
final chatFileList =
message.metadata![messageChatFileListKey] as List<ChatFile>?;
return chatFileList ?? [];
}
}
/// Widget to reuse the markdown capabilities, e.g., for previews.
@@ -44,14 +44,11 @@ class BlankPagePlugin extends Plugin {
class BlankPagePluginWidgetBuilder extends PluginWidgetBuilder
with NavigationItem {
@override
String? get viewName => LocaleKeys.blankPageTitle.tr();
@override
Widget get leftBarItem => FlowyText.medium(LocaleKeys.blankPageTitle.tr());
@override
Widget tabBarItem(String pluginId, [bool shortForm = false]) => leftBarItem;
Widget tabBarItem(String pluginId) => leftBarItem;
@override
Widget buildWidget({
@@ -293,6 +293,7 @@ class DatabaseTabBarController {
OnViewChildViewChanged? onViewChildViewChanged;
Future<void> dispose() async {
await Future.wait([viewListener.stop(), controller.dispose()]);
await viewListener.stop();
await controller.dispose();
}
}
@@ -179,7 +179,9 @@ class _DesktopBoardPageState extends State<DesktopBoardPage> {
_focusScope.dispose();
_boardBloc.close();
_boardActionsCubit.close();
_didCreateRow.dispose();
_didCreateRow
..removeListener(_handleDidCreateRow)
..dispose();
super.dispose();
}
@@ -187,7 +189,7 @@ class _DesktopBoardPageState extends State<DesktopBoardPage> {
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider.value(
BlocProvider<BoardBloc>.value(
value: _boardBloc,
),
BlocProvider.value(
@@ -5,7 +5,6 @@ import 'package:appflowy/plugins/shared/share/share_button.dart';
import 'package:appflowy/plugins/util.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/widgets/favorite_button.dart';
@@ -89,11 +88,11 @@ class _DatabaseTabBarViewState extends State<DatabaseTabBarView> {
return MultiBlocProvider(
providers: [
BlocProvider<DatabaseTabBarBloc>(
create: (_) => DatabaseTabBarBloc(view: widget.view)
create: (context) => DatabaseTabBarBloc(view: widget.view)
..add(const DatabaseTabBarEvent.initial()),
),
BlocProvider<ViewBloc>(
create: (_) =>
create: (context) =>
ViewBloc(view: widget.view)..add(const ViewEvent.initial()),
),
],
@@ -101,7 +100,7 @@ class _DatabaseTabBarViewState extends State<DatabaseTabBarView> {
listeners: [
BlocListener<DatabaseTabBarBloc, DatabaseTabBarState>(
listenWhen: (p, c) => p.selectedIndex != c.selectedIndex,
listener: (_, state) {
listener: (context, state) {
_initialRowId = null;
_pageController.jumpToPage(state.selectedIndex);
},
@@ -133,22 +132,15 @@ class _DatabaseTabBarViewState extends State<DatabaseTabBarView> {
builder: (context, state) =>
pageSettingBarExtensionFromState(state),
),
BlocBuilder<DatabaseTabBarBloc, DatabaseTabBarState>(
builder: (context, state) {
final content = PageView(
controller: _pageController,
Expanded(
child: BlocBuilder<DatabaseTabBarBloc, DatabaseTabBarState>(
builder: (context, state) => PageView(
pageSnapping: false,
physics: const NeverScrollableScrollPhysics(),
controller: _pageController,
children: pageContentFromState(state),
);
if (widget.shrinkWrap) {
final layout = state.tabBars[state.selectedIndex].layout;
return SizedBox(height: layout.pluginHeight, child: content);
}
return Expanded(child: content);
},
),
),
),
],
),
@@ -257,16 +249,12 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
///
final String? initialRowId;
@override
String? get viewName => notifier.view.nameOrDefault;
@override
Widget get leftBarItem =>
ViewTitleBar(key: ValueKey(notifier.view.id), view: notifier.view);
@override
Widget tabBarItem(String pluginId, [bool shortForm = false]) =>
ViewTabBarItem(view: notifier.view, shortForm: shortForm);
Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view);
@override
Widget buildWidget({
@@ -27,7 +27,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:open_filex/open_filex.dart';
import 'package:reorderables/reorderables.dart';
const _dropFileKey = 'files_media';
@@ -152,10 +151,9 @@ class DekstopRowDetailMediaCellSkin extends IEditableMediaCellSkin {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowySvg(
const FlowySvg(
FlowySvgs.add_thin_s,
size: const Size.square(12),
color: Theme.of(context).hintColor,
size: Size.square(12),
),
const HSpace(6),
FlowyText.medium(
@@ -221,9 +219,7 @@ class _FilePreviewFeedback extends StatelessWidget {
}
}
const _menuWidth = 350.0;
class _AddFileButton extends StatefulWidget {
class _AddFileButton extends StatelessWidget {
const _AddFileButton({
this.mutex,
required this.controller,
@@ -236,22 +232,15 @@ class _AddFileButton extends StatefulWidget {
final PopoverDirection direction;
final Widget child;
@override
State<_AddFileButton> createState() => _AddFileButtonState();
}
class _AddFileButtonState extends State<_AddFileButton> {
Offset? position;
@override
Widget build(BuildContext context) {
return AppFlowyPopover(
triggerActions: PopoverTriggerFlags.none,
controller: widget.controller,
mutex: widget.mutex,
controller: controller,
mutex: mutex,
offset: const Offset(0, 10),
direction: widget.direction,
constraints: const BoxConstraints(maxWidth: _menuWidth),
direction: direction,
constraints: const BoxConstraints(maxWidth: 350),
margin: EdgeInsets.zero,
onClose: () =>
context.read<EditorDropManagerState>().remove(_dropFileKey),
@@ -284,7 +273,7 @@ class _AddFileButtonState extends State<_AddFileButton> {
),
);
widget.controller.close();
controller.close();
},
),
onInsertNetworkFile: (url) {
@@ -317,25 +306,14 @@ class _AddFileButtonState extends State<_AddFileButton> {
),
);
widget.controller.close();
controller.close();
},
);
},
child: MouseRegion(
onEnter: (event) => position = event.position,
onExit: (_) => position = null,
onHover: (event) => position = event.position,
child: GestureDetector(
onTap: () {
if (position != null) {
widget.controller.showAt(
position! - const Offset(_menuWidth / 2, 0),
);
}
},
behavior: HitTestBehavior.translucent,
child: FlowyHover(resetHoverOnRebuild: false, child: widget.child),
),
child: GestureDetector(
onTap: controller.show,
behavior: HitTestBehavior.translucent,
child: FlowyHover(resetHoverOnRebuild: false, child: child),
),
);
}
@@ -478,11 +456,6 @@ class _FilePreviewRenderState extends State<_FilePreviewRender> {
onTap: widget.foregroundText != null
? null
: () {
if (file.uploadType == FileUploadTypePB.LocalFile) {
OpenFilex.open(file.url);
return;
}
if (file.fileType != MediaFileTypePB.Image) {
afLaunchUrlString(widget.file.url);
return;
@@ -4,7 +4,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@@ -98,9 +97,6 @@ class DatabaseDocumentPluginWidgetBuilder extends PluginWidgetBuilder
final String documentId;
final Selection? initialSelection;
@override
String? get viewName => view.nameOrDefault;
@override
EdgeInsets get contentPadding => EdgeInsets.zero;
@@ -127,8 +123,7 @@ class DatabaseDocumentPluginWidgetBuilder extends PluginWidgetBuilder
ViewTitleBarWithRow(view: view, databaseId: databaseId, rowId: rowId);
@override
Widget tabBarItem(String pluginId, [bool shortForm = false]) =>
const SizedBox.shrink();
Widget tabBarItem(String pluginId) => const SizedBox.shrink();
@override
Widget? get rightBarItem => const SizedBox.shrink();
@@ -204,19 +204,6 @@ extension NodeToBlock on Node {
}
String _dataAdapter(String type, Attributes attributes) {
try {
return jsonEncode(
attributes,
toEncodable: (value) {
if (value is Map) {
return jsonEncode(value);
}
return value;
},
);
} catch (e) {
Log.error('encode attributes error: $e');
return '{}';
}
return jsonEncode(attributes);
}
}
@@ -4,7 +4,7 @@ import 'dart:convert';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
import 'package:appflowy/plugins/document/application/document_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_node_widget.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart'
@@ -176,7 +176,7 @@ extension on InsertOperation {
Path currentPath = path;
final List<BlockActionWrapper> actions = [];
for (final node in nodes) {
if (node.type == AskAIBlockKeys.type) {
if (node.type == SmartEditBlockKeys.type) {
continue;
}
@@ -10,7 +10,6 @@ import 'package:appflowy/plugins/shared/share/share_button.dart';
import 'package:appflowy/plugins/util.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/widgets/favorite_button.dart';
@@ -146,15 +145,11 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
);
}
@override
String? get viewName => notifier.view.nameOrDefault;
@override
Widget get leftBarItem => ViewTitleBar(key: ValueKey(view.id), view: view);
@override
Widget tabBarItem(String pluginId, [bool shortForm = false]) =>
ViewTabBarItem(view: notifier.view, shortForm: shortForm);
Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view);
@override
Widget? get rightBarItem {
@@ -125,9 +125,8 @@ void _customBlockOptionActions(
final actions = _buildOptionActions(context, entry.key);
if (UniversalPlatform.isDesktop) {
builder.showActions = (node) =>
node.parent?.type != TableCellBlockKeys.type &&
node.parent?.type != SimpleTableCellBlockKeys.type;
builder.showActions =
(node) => node.parent?.type != TableCellBlockKeys.type;
builder.configuration = builder.configuration.copyWith(
blockSelectionAreaMargin: (_) => const EdgeInsets.symmetric(
vertical: 1,
@@ -143,8 +142,6 @@ void _customBlockOptionActions(
level > 0) {
final offset = [14.0, 11.0, 8.0, 6.0, 4.0, 2.0];
top += offset[level - 1];
} else if (type == SimpleTableBlockKeys.type) {
top += 8.0;
} else {
top += 2.0;
}
@@ -160,7 +157,6 @@ void _customBlockOptionActions(
? () => customSlashCommand(
slashMenuItems,
shouldInsertSlash: false,
deleteKeywordsByDefault: true,
style: styleCustomizer.selectionMenuStyleBuilder(),
).handler.call(editorState)
: () {},
@@ -256,11 +252,11 @@ Map<String, BlockComponentBuilder> _buildBlockComponentBuilderMap(
configuration,
styleCustomizer,
),
AIWriterBlockKeys.type: _buildAIWriterBlockComponentBuilder(
AutoCompletionBlockKeys.type: _buildAutoCompletionBlockComponentBuilder(
context,
configuration,
),
AskAIBlockKeys.type: _buildAskAIBlockComponentBuilder(
SmartEditBlockKeys.type: _buildSmartEditBlockComponentBuilder(
context,
configuration,
),
@@ -290,18 +286,6 @@ Map<String, BlockComponentBuilder> _buildBlockComponentBuilderMap(
errorBlockComponentBuilderKey: ErrorBlockComponentBuilder(
configuration: configuration,
),
SimpleTableBlockKeys.type: _buildSimpleTableBlockComponentBuilder(
context,
configuration,
),
SimpleTableRowBlockKeys.type: _buildSimpleTableRowBlockComponentBuilder(
context,
configuration,
),
SimpleTableCellBlockKeys.type: _buildSimpleTableCellBlockComponentBuilder(
context,
configuration,
),
};
final builders = {
@@ -312,27 +296,6 @@ Map<String, BlockComponentBuilder> _buildBlockComponentBuilderMap(
return builders;
}
SimpleTableBlockComponentBuilder _buildSimpleTableBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return SimpleTableBlockComponentBuilder(configuration: configuration);
}
SimpleTableRowBlockComponentBuilder _buildSimpleTableRowBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return SimpleTableRowBlockComponentBuilder(configuration: configuration);
}
SimpleTableCellBlockComponentBuilder _buildSimpleTableCellBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return SimpleTableCellBlockComponentBuilder(configuration: configuration);
}
ParagraphBlockComponentBuilder _buildParagraphBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
@@ -340,17 +303,7 @@ ParagraphBlockComponentBuilder _buildParagraphBlockComponentBuilder(
String Function(Node)? placeholderText,
) {
return ParagraphBlockComponentBuilder(
configuration: configuration.copyWith(
placeholderText: placeholderText,
textStyle: (node) {
if (node.isInHeaderColumn || node.isInHeaderRow) {
return configuration.textStyle(node).copyWith(
fontWeight: FontWeight.bold,
);
}
return configuration.textStyle(node);
},
),
configuration: configuration.copyWith(placeholderText: placeholderText),
showPlaceholder: showParagraphPlaceholder,
);
}
@@ -362,14 +315,6 @@ TodoListBlockComponentBuilder _buildTodoListBlockComponentBuilder(
return TodoListBlockComponentBuilder(
configuration: configuration.copyWith(
placeholderText: (_) => LocaleKeys.blockPlaceholders_todoList.tr(),
textStyle: (node) {
if (node.isInHeaderColumn || node.isInHeaderRow) {
return configuration.textStyle(node).copyWith(
fontWeight: FontWeight.bold,
);
}
return configuration.textStyle(node);
},
),
iconBuilder: (_, node, onCheck) => TodoListIcon(
node: node,
@@ -390,14 +335,6 @@ BulletedListBlockComponentBuilder _buildBulletedListBlockComponentBuilder(
return BulletedListBlockComponentBuilder(
configuration: configuration.copyWith(
placeholderText: (_) => LocaleKeys.blockPlaceholders_bulletList.tr(),
textStyle: (node) {
if (node.isInHeaderColumn || node.isInHeaderRow) {
return configuration.textStyle(node).copyWith(
fontWeight: FontWeight.bold,
);
}
return configuration.textStyle(node);
},
),
iconBuilder: (_, node) => BulletedListIcon(node: node),
);
@@ -410,14 +347,6 @@ NumberedListBlockComponentBuilder _buildNumberedListBlockComponentBuilder(
return NumberedListBlockComponentBuilder(
configuration: configuration.copyWith(
placeholderText: (_) => LocaleKeys.blockPlaceholders_numberList.tr(),
textStyle: (node) {
if (node.isInHeaderColumn || node.isInHeaderRow) {
return configuration.textStyle(node).copyWith(
fontWeight: FontWeight.bold,
);
}
return configuration.textStyle(node);
},
),
iconBuilder: (_, node, textDirection) => NumberedListIcon(
node: node,
@@ -433,14 +362,6 @@ QuoteBlockComponentBuilder _buildQuoteBlockComponentBuilder(
return QuoteBlockComponentBuilder(
configuration: configuration.copyWith(
placeholderText: (_) => LocaleKeys.blockPlaceholders_quote.tr(),
textStyle: (node) {
if (node.isInHeaderColumn || node.isInHeaderRow) {
return configuration.textStyle(node).copyWith(
fontWeight: FontWeight.bold,
);
}
return configuration.textStyle(node);
},
),
);
}
@@ -632,18 +553,18 @@ CodeBlockComponentBuilder _buildCodeBlockComponentBuilder(
);
}
AIWriterBlockComponentBuilder _buildAIWriterBlockComponentBuilder(
AutoCompletionBlockComponentBuilder _buildAutoCompletionBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return AIWriterBlockComponentBuilder();
return AutoCompletionBlockComponentBuilder();
}
AskAIBlockComponentBuilder _buildAskAIBlockComponentBuilder(
SmartEditBlockComponentBuilder _buildSmartEditBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return AskAIBlockComponentBuilder();
return SmartEditBlockComponentBuilder();
}
ToggleListBlockComponentBuilder _buildToggleListBlockComponentBuilder(
@@ -79,7 +79,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
];
final List<ToolbarItem> toolbarItems = [
askAIItem..isActive = onlyShowInTextType,
smartEditItem..isActive = onlyShowInTextType,
paragraphItem..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable,
headingsToolbarItem
..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable,
@@ -34,7 +34,7 @@ class BlockActionList extends StatelessWidget {
editorState: editorState,
showSlashMenu: showSlashMenu,
),
const HSpace(2.0),
const HSpace(4.0),
BlockOptionButton(
blockComponentContext: blockComponentContext,
blockComponentState: blockComponentState,
@@ -42,7 +42,7 @@ class BlockActionList extends StatelessWidget {
editorState: editorState,
blockComponentBuilder: blockComponentBuilder,
),
const HSpace(8.0),
const HSpace(4.0),
],
);
}
@@ -86,11 +86,6 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
Log.error('Block type $type is not valid');
if (node.type == TableBlockKeys.type) {
copiedNode = _fixTableBlock(node);
copiedNode = _convertTableToSimpleTable(copiedNode);
}
} else {
if (node.type == TableBlockKeys.type) {
copiedNode = _convertTableToSimpleTable(node);
}
}
}
@@ -143,44 +138,6 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
);
}
Node _convertTableToSimpleTable(Node node) {
if (node.type != TableBlockKeys.type) {
return node;
}
// the table node should contains colsLen and rowsLen
final colsLen = node.attributes[TableBlockKeys.colsLen];
final rowsLen = node.attributes[TableBlockKeys.rowsLen];
if (colsLen == null || rowsLen == null) {
return node;
}
final rows = <List<Node>>[];
final children = node.children;
for (var i = 0; i < rowsLen; i++) {
final row = <Node>[];
for (var j = 0; j < colsLen; j++) {
final cell = children
.where(
(n) =>
n.attributes[TableCellBlockKeys.rowPosition] == i &&
n.attributes[TableCellBlockKeys.colPosition] == j,
)
.firstOrNull;
row.add(
simpleTableCellBlockNode(
children: [cell?.children.first.copyWith() ?? paragraphNode()],
),
);
}
rows.add(row);
}
return simpleTableBlockNode(
children: rows.map((e) => simpleTableRowBlockNode(children: e)).toList(),
);
}
Future<void> _copyLinkToBlock(Node node) async {
final context = editorState.document.root.context;
final viewId = context?.read<DocumentBloc>().documentId;
@@ -490,7 +447,7 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
// We move views after applying transaction to avoid performing side-effects on the views
final viewIdsToMove = _extractChildViewIds(selectedNodes);
for (final viewId in viewIdsToMove) {
// Attempt to put back from trash if necessary
// Attempt to put back from trash if neccessary
await TrashService.putback(viewId);
await ViewBackendService.moveViewV2(
@@ -2,6 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -110,7 +111,9 @@ class _OptionButtonState extends State<OptionButton> {
widget.blockComponentContext.node,
beforeSelection,
);
Log.info(
'update block selection, beforeSelection: $beforeSelection, afterSelection: $selection',
);
widget.editorState.updateSelectionWithReason(
selection,
customSelectionType: SelectionType.block,
@@ -1,5 +1,3 @@
import 'dart:math';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
@@ -48,7 +46,7 @@ class VisualDragArea extends StatelessWidget {
Widget child = Container(
height: 2,
width: max(width, 0.0),
width: width,
color: Theme.of(context).colorScheme.primary,
);
@@ -1,6 +1,7 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
@@ -61,35 +62,41 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
if (snapshot.hasData && page != null) {
return _build(context, page);
}
if (snapshot.connectionState == ConnectionState.done) {
// Delete the page if not found
WidgetsBinding.instance.addPostFrameCallback((_) => _deletePage());
return const Center(child: FlowyText('Cannot load the page'));
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
// just delete the page if it is not found
_deletePage();
});
return const Center(
child: FlowyText('Cannot load the page'),
);
}
return const Center(child: CircularProgressIndicator());
return const Center(
child: CircularProgressIndicator(),
);
},
future: future,
);
}
Widget _build(BuildContext context, ViewPB view) {
Widget _build(BuildContext context, ViewPB viewPB) {
return MouseRegion(
onEnter: (_) => widget.editorState.service.scrollService?.disable(),
onExit: (_) => widget.editorState.service.scrollService?.enable(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildMenu(context, view),
_buildPage(context, view),
],
child: SizedBox(
height: viewPB.pluginType == PluginType.calendar ? 700 : 400,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildMenu(context, viewPB),
Expanded(child: _buildPage(context, viewPB)),
],
),
),
);
}
Widget _buildPage(BuildContext context, ViewPB view) {
Widget _buildPage(BuildContext context, ViewPB viewPB) {
return Focus(
focusNode: focusNode,
onFocusChange: (value) {
@@ -97,11 +104,11 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
widget.editorState.service.selectionService.clearSelection();
}
},
child: widget.builder(view),
child: widget.builder(viewPB),
);
}
Widget _buildMenu(BuildContext context, ViewPB view) {
Widget _buildMenu(BuildContext context, ViewPB viewPB) {
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
@@ -109,7 +116,7 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
// information
FlowyIconButton(
tooltipText: LocaleKeys.tooltip_referencePage.tr(
namedArgs: {'name': view.layout.name},
namedArgs: {'name': viewPB.layout.name},
),
width: 24,
height: 24,
@@ -140,8 +147,8 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
case _ActionType.viewDatabase:
getIt<TabsBloc>().add(
TabsEvent.openPlugin(
plugin: view.plugin(),
view: view,
plugin: viewPB.plugin(),
view: viewPB,
),
);
break;
@@ -165,7 +172,10 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
}
}
enum _ActionType { viewDatabase, delete }
enum _ActionType {
viewDatabase,
delete,
}
class _ActionWrapper extends ActionCell {
_ActionWrapper(this.inner);
@@ -1,137 +0,0 @@
import 'dart:convert';
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/foundation.dart';
import 'package:synchronized/synchronized.dart';
class MarkdownTextRobot {
MarkdownTextRobot({
required this.editorState,
this.enableDebug = true,
});
final EditorState editorState;
final bool enableDebug;
final Lock lock = Lock();
// The selection before the text robot is ready.
Selection? _startSelection;
// The markdown text to be inserted.
String _markdownText = '';
// Only for debug. Enable by [enableDebug].
@visibleForTesting
final List<String> debugMarkdownTexts = [];
// The nodes inserted in the previous refresh.
Iterable<Node> _previousInsertedNodes = [];
/// Start the text robot.
///
/// Must call this function before using the text robot.
void start() {
_startSelection = editorState.selection;
if (enableDebug) {
Log.info(
'MarkdownTextRobot prepare, current selection: $_startSelection',
);
}
}
/// Append the markdown text to the text robot.
///
/// The text will be inserted into document but not persisted until the text
/// robot is stopped.
Future<void> appendMarkdownText(String text) async {
_markdownText += text;
await lock.synchronized(() async {
await _refresh();
});
if (enableDebug) {
debugMarkdownTexts.add(text);
Log.info('debug markdown texts: ${jsonEncode(debugMarkdownTexts)}');
}
}
/// Stop the text robot.
///
/// The text will be persisted into document.
Future<void> stop() async {
// persist the markdown text
await lock.synchronized(() async {
await _refresh(inMemoryUpdate: false);
});
_markdownText = '';
if (enableDebug) {
Log.info(
'debug markdown texts: ${jsonEncode(debugMarkdownTexts)}',
);
debugMarkdownTexts.clear();
}
}
/// Refreshes the editor state with the current markdown text by:
///
/// 1. Converting markdown to document nodes
/// 2. Replacing previously inserted nodes with new nodes
/// 3. Updating selection position
Future<void> _refresh({bool inMemoryUpdate = true}) async {
final start = _startSelection?.start;
if (start == null) {
return;
}
final transaction = editorState.transaction;
// Convert markdown and deep copy nodes
final nodes = customMarkdownToDocument(_markdownText).root.children.map(
(node) => node.copyWith(),
); // deep copy the nodes to avoid the linked entities being changed.
// Insert new nodes at selection start
transaction.insertNodes(start.path, nodes);
// Remove previously inserted nodes if they exist
if (_previousInsertedNodes.isNotEmpty) {
// fallback to the calculated position if the selection is null.
final end = editorState.selection?.end ??
Position(
path: start.path.nextNPath(_previousInsertedNodes.length - 1),
);
final deletedNodes = editorState.getNodesInSelection(
Selection(start: start, end: end),
);
transaction.deleteNodes(deletedNodes);
}
// Update selection to end of inserted content if it contains text
final lastDelta = nodes.lastOrNull?.delta;
if (lastDelta != null) {
transaction.afterSelection = Selection.collapsed(
Position(
path: start.path.nextNPath(nodes.length - 1),
offset: lastDelta.length,
),
);
}
await editorState.apply(
transaction,
options: ApplyOptions(
inMemoryUpdate: inMemoryUpdate,
recordUndo: false,
),
);
_previousInsertedNodes = nodes;
}
}
@@ -1,11 +1,11 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
/// Copy.
///
@@ -59,12 +59,11 @@ KeyEventResult handleCopyCommand(
// plain text.
text = editorState.getTextInSelection(selection).join('\n');
final document = _buildCopiedDocument(
editorState,
selection,
isCut: isCut,
);
final selectedNodes = editorState.getSelectedNodes(selection: selection);
final nodes = _handleSubPageNodes(selectedNodes, isCut);
final document = Document.blank()..insert([0], nodes);
// in app json
inAppJson = jsonEncode(document.toJson());
// html
@@ -84,34 +83,6 @@ KeyEventResult handleCopyCommand(
return KeyEventResult.handled;
}
Document _buildCopiedDocument(
EditorState editorState,
Selection selection, {
bool isCut = false,
}) {
// filter the table nodes
final filteredNodes = <Node>[];
final selectedNodes = editorState.getSelectedNodes(selection: selection);
final nodes = _handleSubPageNodes(selectedNodes, isCut);
for (final node in nodes) {
if (node.type == SimpleTableCellBlockKeys.type) {
// if the node is a table cell, we will fetch its children instead.
filteredNodes.addAll(node.children);
} else if (node.type == SimpleTableRowBlockKeys.type) {
// if the node is a table row, we will fetch its children instead.
filteredNodes.addAll(node.children.expand((e) => e.children));
} else {
filteredNodes.add(node);
}
}
final document = Document.blank()
..insert(
[0],
filteredNodes.map((e) => e.copyWith()),
);
return document;
}
List<Node> _handleSubPageNodes(List<Node> nodes, [bool isCut = false]) {
final handled = <Node>[];
for (final node in nodes) {
@@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/shared/clipboard_state.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// cut.
@@ -41,11 +42,6 @@ CommandShortcutEventHandler _cutCommandHandler = (editorState) {
if (node == null) {
return KeyEventResult.handled;
}
// prevent to cut the node that is selecting the table.
if (node.parentTableNode != null) {
return KeyEventResult.skipRemainingHandlers;
}
final transaction = editorState.transaction;
transaction.deleteNode(node);
final nextNode = node.next;
@@ -346,7 +346,6 @@ class FileBlockComponentState extends State<FileBlockComponent>
controller.show();
dropManagerState?.add(FileBlockKeys.type);
} else {
editorState.updateSelectionWithReason(null, extraInfo: {});
showUploadFileMobileMenu();
}
}
@@ -1,4 +1,3 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:flutter/services.dart';
@@ -16,34 +15,6 @@ class EditorKeyboardInterceptor extends AppFlowyKeyboardServiceInterceptor {
);
}
@override
Future<bool> interceptDelete(
TextEditingDeltaDeletion deletion,
EditorState editorState,
) async {
// check if the current selection is in a code block
final (isInTableCell, selection, tableCellNode, node) =
editorState.isCurrentSelectionInTableCell();
if (!isInTableCell ||
selection == null ||
tableCellNode == null ||
node == null) {
return false;
}
final onlyContainsOneChild = tableCellNode.children.length == 1;
final isParagraphNode =
tableCellNode.children.first.type == ParagraphBlockKeys.type;
if (onlyContainsOneChild &&
selection.isCollapsed &&
selection.end.offset == 0 &&
isParagraphNode) {
return true;
}
return false;
}
/// Check if the backtick pressed event should be handled
Future<bool> _checkIfBacktickPressed(
EditorState editorState,
@@ -1,238 +0,0 @@
import 'dart:async';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/shared/permission/permission_checker.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/startup/tasks/app_widget.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/file_picker/file_picker_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
@visibleForTesting
const addAttachmentToolbarItemKey = ValueKey('add_attachment_toolbar_item');
final addAttachmentItem = AppFlowyMobileToolbarItem(
itemBuilder: (context, editorState, service, _, onAction) {
return AppFlowyMobileToolbarIconItem(
key: addAttachmentToolbarItemKey,
editorState: editorState,
icon: FlowySvgs.media_s,
onTap: () {
final documentId = context.read<DocumentBloc>().documentId;
final isLocalMode = context.read<DocumentBloc>().isLocalMode;
final selection = editorState.selection;
service.closeKeyboard();
// delay to wait the keyboard closed.
Future.delayed(const Duration(milliseconds: 100), () async {
unawaited(
editorState.updateSelectionWithReason(
selection,
extraInfo: {
selectionExtraInfoDisableMobileToolbarKey: true,
selectionExtraInfoDisableFloatingToolbar: true,
selectionExtraInfoDoNotAttachTextService: true,
},
),
);
keepEditorFocusNotifier.increase();
final didAddAttachment = await showAddAttachmentMenu(
AppGlobals.rootNavKey.currentContext!,
documentId: documentId,
isLocalMode: isLocalMode,
editorState: editorState,
selection: selection!,
);
if (didAddAttachment != true) {
unawaited(editorState.updateSelectionWithReason(selection));
}
});
},
);
},
);
Future<bool?> showAddAttachmentMenu(
BuildContext context, {
required String documentId,
required bool isLocalMode,
required EditorState editorState,
required Selection selection,
}) async =>
showMobileBottomSheet<bool>(
context,
showDragHandle: true,
barrierColor: Colors.transparent,
backgroundColor:
ToolbarColorExtension.of(context).toolbarMenuBackgroundColor,
elevation: 20,
isScrollControlled: false,
enableDraggableScrollable: true,
builder: (_) => _AddAttachmentMenu(
documentId: documentId,
isLocalMode: isLocalMode,
editorState: editorState,
selection: selection,
),
);
class _AddAttachmentMenu extends StatelessWidget {
const _AddAttachmentMenu({
required this.documentId,
required this.isLocalMode,
required this.editorState,
required this.selection,
});
final String documentId;
final bool isLocalMode;
final EditorState editorState;
final Selection selection;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
MobileQuickActionButton(
text: LocaleKeys.document_attachmentMenu_choosePhoto.tr(),
icon: FlowySvgs.image_rounded_s,
iconSize: const Size.square(20),
onTap: () async => selectPhoto(context),
),
const Divider(height: 8.5, thickness: 0.5),
MobileQuickActionButton(
text: LocaleKeys.document_attachmentMenu_takePicture.tr(),
icon: FlowySvgs.camera_s,
iconSize: const Size.square(20),
onTap: () async => selectCamera(context),
),
const Divider(height: 8.5, thickness: 0.5),
MobileQuickActionButton(
text: LocaleKeys.document_attachmentMenu_chooseFile.tr(),
icon: FlowySvgs.file_s,
iconSize: const Size.square(20),
onTap: () async => selectFile(context),
),
const Divider(height: 8.5, thickness: 0.5),
],
),
);
}
Future<void> _insertNode(Node node) async {
Future.delayed(
const Duration(milliseconds: 100),
() async {
// if current selected block is a empty paragraph block, replace it with the new block.
if (selection.isCollapsed) {
final path = selection.end.path;
final currentNode = editorState.getNodeAtPath(path);
final text = currentNode?.delta?.toPlainText();
if (currentNode != null &&
currentNode.type == ParagraphBlockKeys.type &&
text != null &&
text.isEmpty) {
final transaction = editorState.transaction;
transaction.insertNode(path.next, node);
transaction.deleteNode(currentNode);
transaction.afterSelection =
Selection.collapsed(Position(path: path));
transaction.selectionExtraInfo = {};
return editorState.apply(transaction);
}
}
await editorState.insertBlockAfterCurrentSelection(selection, node);
},
);
}
Future<void> insertImage(BuildContext context, XFile image) async {
CustomImageType type = CustomImageType.local;
String? path;
if (isLocalMode) {
path = await saveImageToLocalStorage(image.path);
} else {
(path, _) = await saveImageToCloudStorage(image.path, documentId);
type = CustomImageType.internal;
}
if (path != null) {
final node = customImageNode(url: path, type: type);
await _insertNode(node);
}
}
Future<void> selectPhoto(BuildContext context) async {
final image = await ImagePicker().pickImage(source: ImageSource.gallery);
if (image != null && context.mounted) {
await insertImage(context, image);
}
if (context.mounted) {
Navigator.pop(context);
}
}
Future<void> selectCamera(BuildContext context) async {
final cameraPermission =
await PermissionChecker.checkCameraPermission(context);
if (!cameraPermission) {
Log.error('Has no permission to access the camera');
return;
}
final image = await ImagePicker().pickImage(source: ImageSource.camera);
if (image != null && context.mounted) {
await insertImage(context, image);
}
if (context.mounted) {
Navigator.pop(context);
}
}
Future<void> selectFile(BuildContext context) async {
final result = await getIt<FilePickerService>().pickFiles();
final file = result?.files.first.xFile;
if (file != null) {
FileUrlType type = FileUrlType.local;
String? path;
if (isLocalMode) {
path = await saveFileToLocalStorage(file.path);
} else {
(path, _) = await saveFileToCloudStorage(file.path, documentId);
type = FileUrlType.cloud;
}
if (path != null) {
final node = fileNode(url: path, type: type, name: file.name);
await _insertNode(node);
}
}
if (context.mounted) {
Navigator.pop(context);
}
}
}
@@ -392,3 +392,22 @@ class AddBlockMenu extends StatelessWidget {
};
}
}
extension on EditorState {
Future<void> insertBlockAfterCurrentSelection(
Selection selection,
Node node,
) async {
final path = selection.end.path.next;
final transaction = this.transaction;
transaction.insertNode(
path,
node,
);
transaction.afterSelection = Selection.collapsed(
Position(path: path),
);
transaction.selectionExtraInfo = {};
await apply(transaction);
}
}
@@ -12,7 +12,6 @@ final _defaultToolbarItems = [
aaToolbarItem,
todoListToolbarItem,
bulletedListToolbarItem,
addAttachmentItem,
numberedListToolbarItem,
boldToolbarItem,
italicToolbarItem,
@@ -36,7 +35,6 @@ final _listToolbarItems = [
underlineToolbarItem,
strikethroughToolbarItem,
colorToolbarItem,
addAttachmentItem,
undoToolbarItem,
redoToolbarItem,
];
@@ -339,21 +339,4 @@ extension MobileToolbarEditorState on EditorState {
await apply(transaction);
}
Future<void> insertBlockAfterCurrentSelection(
Selection selection,
Node node,
) async {
final path = selection.end.path.next;
final transaction = this.transaction;
transaction.insertNode(
path,
node,
);
transaction.afterSelection = Selection.collapsed(
Position(path: path),
);
transaction.selectionExtraInfo = {};
await apply(transaction);
}
}
@@ -1,68 +0,0 @@
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
extension AskAINodeExtension on EditorState {
String getMarkdownInSelection(Selection? selection) {
selection ??= this.selection?.normalized;
if (selection == null || selection.isCollapsed) {
return '';
}
// if the selected nodes are not entirely selected, slice the nodes
final slicedNodes = <Node>[];
final nodes = getNodesInSelection(selection);
for (final node in nodes) {
final delta = node.delta;
if (delta == null) {
continue;
}
final slicedDelta = delta.slice(
node == nodes.first ? selection.startIndex : 0,
node == nodes.last ? selection.endIndex : delta.length,
);
final copiedNode = node.copyWith(
attributes: {
...node.attributes,
blockComponentDelta: slicedDelta.toJson(),
},
);
slicedNodes.add(copiedNode);
}
final markdown = customDocumentToMarkdown(
Document.blank()..insert([0], slicedNodes),
);
return markdown;
}
List<String> getPlainTextInSelection(Selection? selection) {
selection ??= this.selection?.normalized;
if (selection == null || selection.isCollapsed) {
return [];
}
final res = <String>[];
if (selection.isCollapsed) {
return res;
}
final nodes = getNodesInSelection(selection);
for (final node in nodes) {
final delta = node.delta;
if (delta == null) {
continue;
}
final startIndex = node == nodes.first ? selection.startIndex : 0;
final endIndex = node == nodes.last ? selection.endIndex : delta.length;
res.add(delta.slice(startIndex, endIndex).toPlainText());
}
return res;
}
}
@@ -1,404 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/build_context_extension.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ai_writer_block_operations.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ai_writer_block_widgets.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/ai_service.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:universal_platform/universal_platform.dart';
import 'ai_limit_dialog.dart';
class AIWriterBlockKeys {
const AIWriterBlockKeys._();
static const String type = 'ai_writer';
static const String prompt = 'prompt';
static const String startSelection = 'start_selection';
static const String generationCount = 'generation_count';
static String getRewritePrompt(String previousOutput, String prompt) {
return 'I am not satisfied with your previous response ($previousOutput) to the query ($prompt). Please provide an alternative response.';
}
}
Node aiWriterNode({
String prompt = '',
required Selection start,
}) {
return Node(
type: AIWriterBlockKeys.type,
attributes: {
AIWriterBlockKeys.prompt: prompt,
AIWriterBlockKeys.startSelection: start.toJson(),
AIWriterBlockKeys.generationCount: 0,
},
);
}
class AIWriterBlockComponentBuilder extends BlockComponentBuilder {
AIWriterBlockComponentBuilder();
@override
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return AIWriterBlockComponent(
key: node.key,
node: node,
showActions: showActions(node),
actionBuilder: (context, state) => actionBuilder(
blockComponentContext,
state,
),
);
}
@override
BlockComponentValidate get validate => (node) =>
node.children.isEmpty &&
node.attributes[AIWriterBlockKeys.prompt] is String &&
node.attributes[AIWriterBlockKeys.startSelection] is Map;
}
class AIWriterBlockComponent extends BlockComponentStatefulWidget {
const AIWriterBlockComponent({
super.key,
required super.node,
super.showActions,
super.actionBuilder,
super.configuration = const BlockComponentConfiguration(),
});
@override
State<AIWriterBlockComponent> createState() => _AIWriterBlockComponentState();
}
class _AIWriterBlockComponentState extends State<AIWriterBlockComponent> {
final controller = TextEditingController();
final textFieldFocusNode = FocusNode();
late final editorState = context.read<EditorState>();
late final SelectionGestureInterceptor interceptor;
late final AIWriterBlockOperations aiWriterOperations =
AIWriterBlockOperations(
editorState: editorState,
aiWriterNode: widget.node,
);
String get prompt => widget.node.attributes[AIWriterBlockKeys.prompt];
int get generationCount =>
widget.node.attributes[AIWriterBlockKeys.generationCount] ?? 0;
Selection? get startSelection {
final selection = widget.node.attributes[AIWriterBlockKeys.startSelection];
if (selection != null) {
return Selection.fromJson(selection);
}
return null;
}
bool isGenerating = false;
@override
void initState() {
super.initState();
_subscribeSelectionGesture();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
editorState.selection = null;
textFieldFocusNode.requestFocus();
});
}
@override
void dispose() {
_onExit();
_unsubscribeSelectionGesture();
controller.dispose();
textFieldFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (UniversalPlatform.isMobile) {
return const SizedBox.shrink();
}
final child = Focus(
onKeyEvent: (node, event) {
if (event is! KeyDownEvent) {
return KeyEventResult.ignored;
}
if (event.logicalKey == LogicalKeyboardKey.enter) {
if (!isGenerating) {
_onGenerate();
}
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Card(
elevation: 5,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
color: Theme.of(context).colorScheme.surface,
child: Container(
margin: const EdgeInsets.all(10),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const AIWriterBlockHeader(),
const Space(0, 10),
if (prompt.isEmpty && generationCount < 1) ...[
_buildInputWidget(context),
const Space(0, 10),
AIWriterBlockInputField(
onGenerate: _onGenerate,
onExit: _onExit,
),
] else ...[
AIWriterBlockFooter(
onKeep: _onExit,
onRewrite: _onRewrite,
onDiscard: _onDiscard,
),
],
],
),
),
),
);
return Padding(
padding: const EdgeInsets.only(left: 40),
child: child,
);
}
Widget _buildInputWidget(BuildContext context) {
return FlowyTextField(
hintText: LocaleKeys.document_plugins_autoGeneratorHintText.tr(),
controller: controller,
maxLines: 5,
focusNode: textFieldFocusNode,
autoFocus: false,
hintTextConstraints: const BoxConstraints(),
);
}
Future<void> _onExit() async {
await aiWriterOperations.removeAIWriterNode(widget.node);
}
Future<void> _onGenerate() async {
if (isGenerating) {
return;
}
isGenerating = true;
await aiWriterOperations.updatePromptText(controller.text);
if (!_isAIWriterEnabled) {
Log.error('AI Writer is not enabled');
return;
}
final markdownTextRobot = MarkdownTextRobot(
editorState: editorState,
);
BarrierDialog? barrierDialog;
final aiRepository = AppFlowyAIService();
await aiRepository.streamCompletion(
text: controller.text,
completionType: CompletionTypePB.ContinueWriting,
onStart: () async {
if (mounted) {
barrierDialog = BarrierDialog(context);
barrierDialog?.show();
await aiWriterOperations.ensurePreviousNodeIsEmptyParagraphNode();
markdownTextRobot.start();
}
},
onProcess: (text) async {
await markdownTextRobot.appendMarkdownText(text);
},
onEnd: () async {
barrierDialog?.dismiss();
await markdownTextRobot.stop();
editorState.service.keyboardService?.enable();
},
onError: (error) async {
barrierDialog?.dismiss();
_showAIWriterError(error);
},
);
await aiWriterOperations.updateGenerationCount(generationCount + 1);
isGenerating = false;
}
Future<void> _onDiscard() async {
await aiWriterOperations.discardCurrentResponse(
aiWriterNode: widget.node,
selection: startSelection,
);
return _onExit();
}
Future<void> _onRewrite() async {
if (isGenerating) {
return;
}
isGenerating = true;
final previousOutput = _getPreviousOutput();
if (previousOutput == null) {
return;
}
// discard the current response
await aiWriterOperations.discardCurrentResponse(
aiWriterNode: widget.node,
selection: startSelection,
);
if (!_isAIWriterEnabled) {
return;
}
final markdownTextRobot = MarkdownTextRobot(
editorState: editorState,
);
final aiService = AppFlowyAIService();
await aiService.streamCompletion(
text: AIWriterBlockKeys.getRewritePrompt(previousOutput, prompt),
completionType: CompletionTypePB.ContinueWriting,
onStart: () async {
await aiWriterOperations.ensurePreviousNodeIsEmptyParagraphNode();
markdownTextRobot.start();
},
onProcess: (text) async {
await markdownTextRobot.appendMarkdownText(text);
},
onEnd: () async {
await markdownTextRobot.stop();
},
onError: (error) {
_showAIWriterError(error);
},
);
await aiWriterOperations.updateGenerationCount(generationCount + 1);
isGenerating = false;
}
String? _getPreviousOutput() {
final startSelection = this.startSelection;
if (startSelection != null) {
final end = widget.node.previous?.path;
if (end != null) {
final result = editorState
.getNodesInSelection(
startSelection.copyWith(end: Position(path: end)),
)
.fold(
'',
(previousValue, element) {
final delta = element.delta;
if (delta != null) {
return "$previousValue\n${delta.toPlainText()}";
} else {
return previousValue;
}
},
);
return result.trim();
}
}
return null;
}
void _subscribeSelectionGesture() {
interceptor = SelectionGestureInterceptor(
key: AIWriterBlockKeys.type,
canTap: (details) {
if (!context.isOffsetInside(details.globalPosition)) {
if (prompt.isNotEmpty || controller.text.isNotEmpty) {
// show dialog
showDialog(
context: context,
builder: (_) => 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(
AIWriterBlockKeys.type,
);
}
void _showAIWriterError(AIError error) {
if (mounted) {
if (error.isLimitExceeded) {
showAILimitDialog(context, error.message);
} else {
showToastNotification(
context,
message: error.message,
type: ToastificationType.error,
);
}
}
}
bool get _isAIWriterEnabled {
final userProfile = context.read<DocumentBloc>().state.userProfilePB;
final isAIWriterEnabled = userProfile != null;
if (!isAIWriterEnabled) {
showToastNotification(
context,
message: LocaleKeys.document_plugins_autoGeneratorCantGetOpenAIKey.tr(),
type: ToastificationType.error,
);
}
return isAIWriterEnabled;
}
}
@@ -1,112 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ai_writer_block_component.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
/// Notes: All the operation related to the AI writer block will be applied
/// in memory.
class AIWriterBlockOperations {
AIWriterBlockOperations({
required this.editorState,
required this.aiWriterNode,
}) : assert(aiWriterNode.type == AIWriterBlockKeys.type);
final EditorState editorState;
final Node aiWriterNode;
/// Update the prompt text in the node.
Future<void> updatePromptText(String prompt) async {
final transaction = editorState.transaction;
transaction.updateNode(
aiWriterNode,
{AIWriterBlockKeys.prompt: prompt},
);
await editorState.apply(
transaction,
options: const ApplyOptions(
inMemoryUpdate: true,
recordUndo: false,
),
);
}
/// Update the generation count in the node.
Future<void> updateGenerationCount(int count) async {
final transaction = editorState.transaction;
transaction.updateNode(
aiWriterNode,
{AIWriterBlockKeys.generationCount: count},
);
await editorState.apply(
transaction,
options: const ApplyOptions(inMemoryUpdate: true),
);
}
/// Ensure the previous node is a empty paragraph node without any styles.
Future<void> ensurePreviousNodeIsEmptyParagraphNode() async {
final previous = aiWriterNode.previous;
final Selection selection;
// 1. previous node is null or
// 2. previous node is not a paragraph node or
// 3. previous node is a paragraph node but not empty
final isNotEmptyParagraphNode = previous == null ||
previous.type != ParagraphBlockKeys.type ||
(previous.delta?.toPlainText().isNotEmpty ?? false);
if (isNotEmptyParagraphNode) {
final path = aiWriterNode.path;
final transaction = editorState.transaction;
selection = Selection.collapsed(Position(path: path));
transaction
..insertNode(
path,
paragraphNode(),
)
..afterSelection = selection;
await editorState.apply(transaction);
} else {
selection = Selection.collapsed(Position(path: previous.path));
}
final transaction = editorState.transaction;
transaction.updateNode(aiWriterNode, {
AIWriterBlockKeys.startSelection: selection.toJson(),
});
transaction.afterSelection = selection;
await editorState.apply(
transaction,
options: const ApplyOptions(inMemoryUpdate: true),
);
}
/// Discard the current response and delete the previous node.
Future<void> discardCurrentResponse({
required Node aiWriterNode,
Selection? selection,
}) async {
if (selection != null) {
final start = selection.start.path;
final end = aiWriterNode.previous?.path;
if (end != null) {
final transaction = editorState.transaction;
transaction.deleteNodesAtPath(
start,
end.last - start.last + 1,
);
await editorState.apply(transaction);
await ensurePreviousNodeIsEmptyParagraphNode();
}
}
}
/// Remove the ai writer node from the editor.
Future<void> removeAIWriterNode(Node aiWriterNode) async {
final transaction = editorState.transaction;
transaction.deleteNode(aiWriterNode);
await editorState.apply(
transaction,
options: const ApplyOptions(inMemoryUpdate: true, recordUndo: false),
withUpdateSelection: false,
);
}
}
@@ -1,104 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class AIWriterBlockHeader extends StatelessWidget {
const AIWriterBlockHeader({super.key});
@override
Widget build(BuildContext context) {
return FlowyText.medium(
LocaleKeys.document_plugins_autoGeneratorTitleName.tr(),
fontSize: 14,
);
}
}
class AIWriterBlockInputField extends StatelessWidget {
const AIWriterBlockInputField({
super.key,
required this.onGenerate,
required this.onExit,
});
final VoidCallback onGenerate;
final VoidCallback onExit;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
PrimaryRoundedButton(
text: LocaleKeys.button_generate.tr(),
margin: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 10.0,
),
radius: 8.0,
onTap: onGenerate,
),
const Space(10, 0),
OutlinedRoundedButton(
text: LocaleKeys.button_cancel.tr(),
margin: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 10.0,
),
onTap: onExit,
),
Flexible(
child: Container(
alignment: Alignment.centerRight,
child: FlowyText.regular(
LocaleKeys.document_plugins_warning.tr(),
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
fontSize: 12,
),
),
),
],
);
}
}
class AIWriterBlockFooter extends StatelessWidget {
const AIWriterBlockFooter({
super.key,
required this.onKeep,
required this.onRewrite,
required this.onDiscard,
});
final VoidCallback onKeep;
final VoidCallback onRewrite;
final VoidCallback onDiscard;
@override
Widget build(BuildContext context) {
return Row(
children: [
PrimaryRoundedButton(
text: LocaleKeys.button_keep.tr(),
margin: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 9.0,
),
onTap: onKeep,
),
const HSpace(10),
OutlinedRoundedButton(
text: LocaleKeys.document_plugins_autoGeneratorRewrite.tr(),
onTap: onRewrite,
),
const HSpace(10),
OutlinedRoundedButton(
text: LocaleKeys.button_discard.tr(),
onTap: onDiscard,
),
],
);
}
}
@@ -1,112 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class AskAiInputContent extends StatelessWidget {
const AskAiInputContent({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<AskAIActionBloc, AskAIState>(
builder: (context, state) {
return Card(
elevation: 5,
color: Theme.of(context).colorScheme.surface,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
child: Container(
margin: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText.medium(
state.action.name,
fontSize: 14,
),
const VSpace(16),
state.loading
? _buildLoadingWidget(context)
: _buildResultWidget(context, state),
const VSpace(16),
const AskAIFooter(),
],
),
),
);
},
);
}
Widget _buildResultWidget(BuildContext context, AskAIState state) {
return Flexible(
child: AIMarkdownText(
markdown: state.result,
),
);
}
Widget _buildLoadingWidget(BuildContext context) {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 4.0),
child: SizedBox.square(
dimension: 14,
child: CircularProgressIndicator(
strokeWidth: 2.0,
),
),
);
}
}
class AskAIFooter extends StatelessWidget {
const AskAIFooter({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
OutlinedRoundedButton(
text: LocaleKeys.document_plugins_autoGeneratorRewrite.tr(),
onTap: () =>
context.read<AskAIActionBloc>().add(const AskAIEvent.rewrite()),
),
const HSpace(10),
OutlinedRoundedButton(
text: LocaleKeys.button_replace.tr(),
onTap: () =>
context.read<AskAIActionBloc>().add(const AskAIEvent.replace()),
),
const HSpace(10),
OutlinedRoundedButton(
text: LocaleKeys.button_insertBelow.tr(),
onTap: () => context
.read<AskAIActionBloc>()
.add(const AskAIEvent.insertBelow()),
),
const HSpace(10),
OutlinedRoundedButton(
text: LocaleKeys.button_cancel.tr(),
onTap: () =>
context.read<AskAIActionBloc>().add(const AskAIEvent.cancel()),
),
Expanded(
child: Container(
alignment: Alignment.centerRight,
child: Text(
LocaleKeys.document_plugins_warning.tr(),
style: TextStyle(color: Theme.of(context).hintColor),
overflow: TextOverflow.ellipsis,
),
),
),
],
);
}
}
@@ -0,0 +1,566 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/build_context_extension.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/text_robot.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.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/ai_service.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:universal_platform/universal_platform.dart';
import 'ai_limit_dialog.dart';
class AutoCompletionBlockKeys {
const AutoCompletionBlockKeys._();
static const String type = 'auto_completion';
static const String prompt = 'prompt';
static const String startSelection = 'start_selection';
static const String generationCount = 'generation_count';
}
Node autoCompletionNode({
String prompt = '',
required Selection start,
}) {
return Node(
type: AutoCompletionBlockKeys.type,
attributes: {
AutoCompletionBlockKeys.prompt: prompt,
AutoCompletionBlockKeys.startSelection: start.toJson(),
AutoCompletionBlockKeys.generationCount: 0,
},
);
}
SelectionMenuItem autoGeneratorMenuItem = SelectionMenuItem.node(
getName: LocaleKeys.document_plugins_autoGeneratorMenuItemName.tr,
iconBuilder: (editorState, onSelected, style) => SelectableSvgWidget(
data: FlowySvgs.menu_item_ai_writer_s,
isSelected: onSelected,
style: style,
),
keywords: ['ai', 'openai', 'writer', 'ai writer', 'autogenerator'],
nodeBuilder: (editorState, _) {
final node = autoCompletionNode(start: editorState.selection!);
return node;
},
replace: (_, node) => false,
);
class AutoCompletionBlockComponentBuilder extends BlockComponentBuilder {
AutoCompletionBlockComponentBuilder();
@override
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return AutoCompletionBlockComponent(
key: node.key,
node: node,
showActions: showActions(node),
actionBuilder: (context, state) => actionBuilder(
blockComponentContext,
state,
),
);
}
@override
BlockComponentValidate get validate => (node) =>
node.children.isEmpty &&
node.attributes[AutoCompletionBlockKeys.prompt] is String &&
node.attributes[AutoCompletionBlockKeys.startSelection] is Map;
}
class AutoCompletionBlockComponent extends BlockComponentStatefulWidget {
const AutoCompletionBlockComponent({
super.key,
required super.node,
super.showActions,
super.actionBuilder,
super.configuration = const BlockComponentConfiguration(),
});
@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];
int get generationCount =>
widget.node.attributes[AutoCompletionBlockKeys.generationCount] ?? 0;
Selection? get startSelection {
final selection =
widget.node.attributes[AutoCompletionBlockKeys.startSelection];
if (selection != null) {
return Selection.fromJson(selection);
}
return null;
}
@override
void initState() {
super.initState();
_subscribeSelectionGesture();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
editorState.selection = null;
textFieldFocusNode.requestFocus();
});
}
@override
void dispose() {
_onExit();
_unsubscribeSelectionGesture();
controller.dispose();
textFieldFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (UniversalPlatform.isMobile) {
return const SizedBox.shrink();
}
final child = Card(
elevation: 5,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
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 && generationCount < 1) ...[
_buildInputWidget(context),
const Space(0, 10),
AutoCompletionInputFooter(
onGenerate: _onGenerate,
onExit: _onExit,
),
] else ...[
AutoCompletionFooter(
onKeep: _onExit,
onRewrite: _onRewrite,
onDiscard: _onDiscard,
),
],
],
),
),
);
return Padding(
padding: const EdgeInsets.only(left: 40),
child: child,
);
}
Widget _buildInputWidget(BuildContext context) {
return FlowyTextField(
hintText: LocaleKeys.document_plugins_autoGeneratorHintText.tr(),
controller: controller,
maxLines: 5,
focusNode: textFieldFocusNode,
autoFocus: false,
hintTextConstraints: const BoxConstraints(),
);
}
Future<void> _onExit() async {
final transaction = editorState.transaction..deleteNode(widget.node);
await editorState.apply(
transaction,
options: const ApplyOptions(recordUndo: false, inMemoryUpdate: true),
withUpdateSelection: false,
);
}
Future<void> _onGenerate() async {
await _updateEditingText();
final userProfile = await UserBackendService.getCurrentUserProfile()
.then((value) => value.toNullable());
if (userProfile == null) {
if (mounted) {
showSnackBarMessage(
context,
LocaleKeys.document_plugins_autoGeneratorCantGetOpenAIKey.tr(),
showCancel: true,
);
}
return;
}
final textRobot = TextRobot(editorState: editorState);
BarrierDialog? barrierDialog;
final aiRepository = AppFlowyAIService();
await aiRepository.streamCompletion(
text: controller.text,
completionType: CompletionTypePB.ContinueWriting,
onStart: () async {
if (mounted) {
barrierDialog = BarrierDialog(context);
barrierDialog?.show();
await _makeSurePreviousNodeIsEmptyParagraphNode();
}
},
onProcess: (text) async {
await textRobot.autoInsertTextSync(
text,
separator: r'\n\n',
inputType: TextRobotInputType.sentence,
delay: Duration.zero,
);
},
onEnd: () async {
barrierDialog?.dismiss();
},
onError: (error) async {
barrierDialog?.dismiss();
if (mounted) {
if (error.isLimitExceeded) {
showAILimitDialog(context, error.message);
await _onDiscard();
} else {
showSnackBarMessage(
context,
error.message,
showCancel: true,
);
}
}
},
);
await _updateGenerationCount();
}
Future<void> _onDiscard() async {
final selection = startSelection;
if (selection != null) {
final start = 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);
await _makeSurePreviousNodeIsEmptyParagraphNode();
}
}
return _onExit();
}
Future<void> _onRewrite() async {
final previousOutput = _getPreviousOutput();
if (previousOutput == null) {
return;
}
// clear previous response
final selection = startSelection;
if (selection != null) {
final start = 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);
}
}
// generate new response
final userProfile = await UserBackendService.getCurrentUserProfile()
.then((value) => value.toNullable());
if (userProfile == null) {
if (mounted) {
showSnackBarMessage(
context,
LocaleKeys.document_plugins_autoGeneratorCantGetOpenAIKey.tr(),
showCancel: true,
);
}
return;
}
final textRobot = TextRobot(editorState: editorState);
final aiService = AppFlowyAIService();
await aiService.streamCompletion(
text: _rewritePrompt(previousOutput),
completionType: CompletionTypePB.ContinueWriting,
onStart: () async {
await _makeSurePreviousNodeIsEmptyParagraphNode();
},
onProcess: (text) async {
await textRobot.autoInsertTextSync(
text,
inputType: TextRobotInputType.sentence,
separator: r'\n\n',
delay: Duration.zero,
);
},
onEnd: () async {},
onError: (error) async {
if (mounted) {
if (error.isLimitExceeded) {
showAILimitDialog(context, error.message);
} else {
showSnackBarMessage(
context,
error.message,
showCancel: true,
);
}
}
},
);
await _updateGenerationCount();
}
String? _getPreviousOutput() {
final startSelection = this.startSelection;
if (startSelection != null) {
final end = widget.node.previous?.path;
if (end != null) {
final result = editorState
.getNodesInSelection(
startSelection.copyWith(end: Position(path: end)),
)
.fold(
'',
(previousValue, element) {
final delta = element.delta;
if (delta != null) {
return "$previousValue\n${delta.toPlainText()}";
} else {
return previousValue;
}
},
);
return result.trim();
}
}
return null;
}
Future<void> _updateEditingText() async {
final transaction = editorState.transaction;
transaction.updateNode(
widget.node,
{
AutoCompletionBlockKeys.prompt: controller.text,
},
);
await editorState.apply(transaction);
}
Future<void> _updateGenerationCount() async {
final transaction = editorState.transaction;
transaction.updateNode(
widget.node,
{
AutoCompletionBlockKeys.generationCount: generationCount + 1,
},
);
await editorState.apply(transaction);
}
String _rewritePrompt(String previousOutput) {
return 'I am not satisfied with your previous response ($previousOutput) to the query ($prompt). Please provide an alternative response.';
}
Future<void> _makeSurePreviousNodeIsEmptyParagraphNode() async {
// make sure the previous node is a empty paragraph node without any styles.
final previous = widget.node.previous;
final Selection selection;
if (previous == null ||
previous.type != ParagraphBlockKeys.type ||
(previous.delta?.toPlainText().isNotEmpty ?? false)) {
final transaction = editorState.transaction;
selection = Selection.single(
path: widget.node.path,
startOffset: 0,
);
transaction.insertNode(
widget.node.path,
paragraphNode(),
);
await editorState.apply(transaction);
} else {
selection = Selection.single(
path: previous.path,
startOffset: 0,
);
}
final transaction = editorState.transaction;
transaction.updateNode(widget.node, {
AutoCompletionBlockKeys.startSelection: selection.toJson(),
});
transaction.afterSelection = selection;
await editorState.apply(
transaction,
options: const ApplyOptions(inMemoryUpdate: true),
);
}
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: (_) => 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 FlowyText.medium(
LocaleKeys.document_plugins_autoGeneratorTitleName.tr(),
fontSize: 14,
);
}
}
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(
mainAxisSize: MainAxisSize.min,
children: [
PrimaryRoundedButton(
text: LocaleKeys.button_generate.tr(),
margin: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 10.0,
),
radius: 8.0,
onTap: onGenerate,
),
const Space(10, 0),
OutlinedRoundedButton(
text: LocaleKeys.button_cancel.tr(),
margin: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 10.0,
),
onTap: onExit,
),
Flexible(
child: Container(
alignment: Alignment.centerRight,
child: FlowyText.regular(
LocaleKeys.document_plugins_warning.tr(),
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
fontSize: 12,
),
),
),
],
);
}
}
class AutoCompletionFooter extends StatelessWidget {
const AutoCompletionFooter({
super.key,
required this.onKeep,
required this.onRewrite,
required this.onDiscard,
});
final VoidCallback onKeep;
final VoidCallback onRewrite;
final VoidCallback onDiscard;
@override
Widget build(BuildContext context) {
return Row(
children: [
PrimaryRoundedButton(
text: LocaleKeys.button_keep.tr(),
margin: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 9.0,
),
onTap: onKeep,
),
const HSpace(10),
OutlinedRoundedButton(
text: LocaleKeys.document_plugins_autoGeneratorRewrite.tr(),
onTap: onRewrite,
),
const HSpace(10),
OutlinedRoundedButton(
text: LocaleKeys.button_discard.tr(),
onTap: onDiscard,
),
],
);
}
}
@@ -1,9 +1,9 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
enum AskAIAction {
enum SmartEditAction {
summarize,
fixSpelling,
improveWriting,
@@ -11,62 +11,62 @@ enum AskAIAction {
String get toInstruction {
switch (this) {
case AskAIAction.summarize:
case SmartEditAction.summarize:
return 'Tl;dr';
case AskAIAction.fixSpelling:
case SmartEditAction.fixSpelling:
return 'Correct this to standard English:';
case AskAIAction.improveWriting:
case SmartEditAction.improveWriting:
return 'Rewrite this in your own words:';
case AskAIAction.makeItLonger:
case SmartEditAction.makeItLonger:
return 'Make this text longer:';
}
}
String prompt(String input) {
switch (this) {
case AskAIAction.summarize:
case SmartEditAction.summarize:
return '$input\n\nTl;dr';
case AskAIAction.fixSpelling:
case SmartEditAction.fixSpelling:
return 'Correct this to standard English:\n\n$input';
case AskAIAction.improveWriting:
case SmartEditAction.improveWriting:
return 'Rewrite this:\n\n$input';
case AskAIAction.makeItLonger:
case SmartEditAction.makeItLonger:
return 'Make this text longer:\n\n$input';
}
}
static AskAIAction from(int index) {
static SmartEditAction from(int index) {
switch (index) {
case 0:
return AskAIAction.summarize;
return SmartEditAction.summarize;
case 1:
return AskAIAction.fixSpelling;
return SmartEditAction.fixSpelling;
case 2:
return AskAIAction.improveWriting;
return SmartEditAction.improveWriting;
case 3:
return AskAIAction.makeItLonger;
return SmartEditAction.makeItLonger;
}
return AskAIAction.fixSpelling;
return SmartEditAction.fixSpelling;
}
String get name {
switch (this) {
case AskAIAction.summarize:
case SmartEditAction.summarize:
return LocaleKeys.document_plugins_smartEditSummarize.tr();
case AskAIAction.fixSpelling:
case SmartEditAction.fixSpelling:
return LocaleKeys.document_plugins_smartEditFixSpelling.tr();
case AskAIAction.improveWriting:
case SmartEditAction.improveWriting:
return LocaleKeys.document_plugins_smartEditImproveWriting.tr();
case AskAIAction.makeItLonger:
case SmartEditAction.makeItLonger:
return LocaleKeys.document_plugins_smartEditMakeLonger.tr();
}
}
}
class AskAIActionWrapper extends ActionCell {
AskAIActionWrapper(this.inner);
class SmartEditActionWrapper extends ActionCell {
SmartEditActionWrapper(this.inner);
final AskAIAction inner;
final SmartEditAction inner;
Widget? icon(Color iconColor) => null;
@@ -2,34 +2,27 @@ import 'dart:async';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action.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/shared/markdown_to_document.dart';
import 'package:appflowy/user/application/ai_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'ask_ai_action_bloc.freezed.dart';
part 'smart_edit_bloc.freezed.dart';
enum AskAIReplacementType {
markdown,
plainText,
}
const _defaultReplacementType = AskAIReplacementType.markdown;
class AskAIActionBloc extends Bloc<AskAIEvent, AskAIState> {
AskAIActionBloc({
class SmartEditBloc extends Bloc<SmartEditEvent, SmartEditState> {
SmartEditBloc({
required this.node,
required this.editorState,
required this.action,
this.enableLogging = true,
}) : super(
AskAIState.initial(action),
SmartEditState.initial(action),
) {
on<AskAIEvent>((event, emit) async {
on<SmartEditEvent>((event, emit) async {
await event.when(
initial: (aiRepositoryProvider) async {
aiRepository = await aiRepositoryProvider;
@@ -68,7 +61,7 @@ class AskAIActionBloc extends Bloc<AskAIEvent, AskAIState> {
final Node node;
final EditorState editorState;
final AskAIAction action;
final SmartEditAction action;
final bool enableLogging;
// used to wait for the aiRepository to be initialized
final aiRepositoryCompleter = Completer();
@@ -82,14 +75,14 @@ class AskAIActionBloc extends Bloc<AskAIEvent, AskAIState> {
await aiRepositoryCompleter.future;
if (rewrite) {
add(const AskAIEvent.update('', true, null));
add(const SmartEditEvent.update('', true, null));
}
if (enableLogging) {
Log.info('[smart_edit] request completions');
}
final content = node.attributes[AskAIBlockKeys.content] as String;
final content = node.attributes[SmartEditBlockKeys.content] as String;
await aiRepository.streamCompletion(
text: content,
completionType: completionTypeFromInt(state.action),
@@ -100,7 +93,7 @@ class AskAIActionBloc extends Bloc<AskAIEvent, AskAIState> {
if (enableLogging) {
Log.info('[smart_edit] start generating');
}
add(const AskAIEvent.update('', true, null));
add(const SmartEditEvent.update('', true, null));
},
onProcess: (text) async {
if (isCanceled) {
@@ -111,7 +104,7 @@ class AskAIActionBloc extends Bloc<AskAIEvent, AskAIState> {
Log.debug('[smart_edit] onProcess: $text');
}
final newResult = state.result + text;
add(AskAIEvent.update(newResult, false, null));
add(SmartEditEvent.update(newResult, false, null));
},
onEnd: () async {
if (isCanceled) {
@@ -120,7 +113,7 @@ class AskAIActionBloc extends Bloc<AskAIEvent, AskAIState> {
if (enableLogging) {
Log.info('[smart_edit] end generating');
}
add(AskAIEvent.update('${state.result}\n', false, null));
add(SmartEditEvent.update('${state.result}\n', false, null));
},
onError: (error) async {
if (isCanceled) {
@@ -129,7 +122,7 @@ class AskAIActionBloc extends Bloc<AskAIEvent, AskAIState> {
if (enableLogging) {
Log.info('[smart_edit] onError: $error');
}
add(AskAIEvent.update('', false, error));
add(SmartEditEvent.update('', false, error));
await _exit();
await _clearSelection();
},
@@ -142,70 +135,35 @@ class AskAIActionBloc extends Bloc<AskAIEvent, AskAIState> {
if (selection == null) {
return;
}
final nodes = customMarkdownToDocument(state.result)
.root
.children
.map((e) => e.copyWith())
.toList();
final insertedPath = selection.end.path.next;
// return if the result is empty
final result = state.result.trim();
if (result.isEmpty) {
return;
}
final insertedText = result.split('\n')
..removeWhere((element) => element.isEmpty);
final transaction = editorState.transaction;
// todo: keep the style of the current node
transaction.insertNodes(
insertedPath,
nodes,
);
final lastDeltaLength = nodes.lastOrNull?.delta?.length ?? 0;
transaction.afterSelection = Selection(
start: Position(path: insertedPath),
end: Position(
path: insertedPath.nextNPath(nodes.length - 1),
offset: lastDeltaLength,
selection.end.path.next,
insertedText.map(
(e) => paragraphNode(
text: e,
),
),
);
final start = Position(path: selection.end.path.next);
final end = Position(
path: [selection.end.path.next.first + insertedText.length],
);
transaction.afterSelection = Selection(
start: start,
end: end,
);
await editorState.apply(transaction);
}
Future<void> _replace() async {
switch (_defaultReplacementType) {
case AskAIReplacementType.markdown:
await _replaceWithMarkdown();
case AskAIReplacementType.plainText:
await _replaceWithPlainText();
}
}
Future<void> _replaceWithMarkdown() async {
final selection = editorState.selection?.normalized;
if (selection == null) {
return;
}
final nodes = customMarkdownToDocument(state.result)
.root
.children
.map((e) => e.copyWith())
.toList();
if (nodes.isEmpty) {
return;
}
final nodesInSelection = editorState.getNodesInSelection(selection);
final transaction = editorState.transaction;
transaction.insertNodes(
selection.start.path,
nodes,
);
transaction.deleteNodes(nodesInSelection);
transaction.afterSelection = Selection(
start: selection.start,
end: Position(
path: selection.start.path.nextNPath(nodes.length - 1),
offset: nodes.lastOrNull?.delta?.length ?? 0,
),
);
await editorState.apply(transaction);
}
Future<void> _replaceWithPlainText() async {
final result = state.result.trim();
if (result.isEmpty) {
return;
@@ -264,16 +222,16 @@ class AskAIActionBloc extends Bloc<AskAIEvent, AskAIState> {
}
@freezed
class AskAIEvent with _$AskAIEvent {
const factory AskAIEvent.initial(
class SmartEditEvent with _$SmartEditEvent {
const factory SmartEditEvent.initial(
Future<AIRepository> aiRepositoryProvider,
) = _Initial;
const factory AskAIEvent.started() = _Started;
const factory AskAIEvent.rewrite() = _Rewrite;
const factory AskAIEvent.replace() = _Replace;
const factory AskAIEvent.insertBelow() = _InsertBelow;
const factory AskAIEvent.cancel() = _Cancel;
const factory AskAIEvent.update(
const factory SmartEditEvent.started() = _Started;
const factory SmartEditEvent.rewrite() = _Rewrite;
const factory SmartEditEvent.replace() = _Replace;
const factory SmartEditEvent.insertBelow() = _InsertBelow;
const factory SmartEditEvent.cancel() = _Cancel;
const factory SmartEditEvent.update(
String result,
bool isLoading,
AIError? error,
@@ -281,15 +239,15 @@ class AskAIEvent with _$AskAIEvent {
}
@freezed
class AskAIState with _$AskAIState {
const factory AskAIState({
class SmartEditState with _$SmartEditState {
const factory SmartEditState({
required bool loading,
required String result,
required AskAIAction action,
required SmartEditAction action,
@Default(null) AIError? requestError,
}) = _AskAIState;
}) = _SmartEditState;
factory AskAIState.initial(AskAIAction action) => AskAIState(
factory SmartEditState.initial(SmartEditAction action) => SmartEditState(
loading: true,
action: action,
result: '',
@@ -4,9 +4,8 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ai_limit_dialog.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_block_widgets.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_action.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_bloc.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@@ -16,14 +15,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
class AskAIBlockKeys {
const AskAIBlockKeys._();
class SmartEditBlockKeys {
const SmartEditBlockKeys._();
static const type = 'ask_ai';
static const type = 'smart_edit';
/// The instruction of the smart edit.
///
/// It is a [AskAIAction] value.
/// It is a [SmartEditAction] value.
static const action = 'action';
/// The input of the smart edit.
@@ -32,26 +31,26 @@ class AskAIBlockKeys {
static const content = 'content';
}
Node askAINode({
required AskAIAction action,
Node smartEditNode({
required SmartEditAction action,
required String content,
}) {
return Node(
type: AskAIBlockKeys.type,
type: SmartEditBlockKeys.type,
attributes: {
AskAIBlockKeys.action: action.index,
AskAIBlockKeys.content: content,
SmartEditBlockKeys.action: action.index,
SmartEditBlockKeys.content: content,
},
);
}
class AskAIBlockComponentBuilder extends BlockComponentBuilder {
AskAIBlockComponentBuilder();
class SmartEditBlockComponentBuilder extends BlockComponentBuilder {
SmartEditBlockComponentBuilder();
@override
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return AskAIBlockComponentWidget(
return SmartEditBlockComponentWidget(
key: node.key,
node: node,
showActions: showActions(node),
@@ -64,12 +63,12 @@ class AskAIBlockComponentBuilder extends BlockComponentBuilder {
@override
BlockComponentValidate get validate => (node) =>
node.attributes[AskAIBlockKeys.action] is int &&
node.attributes[AskAIBlockKeys.content] is String;
node.attributes[SmartEditBlockKeys.action] is int &&
node.attributes[SmartEditBlockKeys.content] is String;
}
class AskAIBlockComponentWidget extends BlockComponentStatefulWidget {
const AskAIBlockComponentWidget({
class SmartEditBlockComponentWidget extends BlockComponentStatefulWidget {
const SmartEditBlockComponentWidget({
super.key,
required super.node,
super.showActions,
@@ -78,17 +77,18 @@ class AskAIBlockComponentWidget extends BlockComponentStatefulWidget {
});
@override
State<AskAIBlockComponentWidget> createState() =>
_AskAIBlockComponentWidgetState();
State<SmartEditBlockComponentWidget> createState() =>
_SmartEditBlockComponentWidgetState();
}
class _AskAIBlockComponentWidgetState extends State<AskAIBlockComponentWidget> {
class _SmartEditBlockComponentWidgetState
extends State<SmartEditBlockComponentWidget> {
final popoverController = PopoverController();
late final editorState = context.read<EditorState>();
late final action =
AskAIAction.values[widget.node.attributes[AskAIBlockKeys.action] as int];
late AskAIActionBloc askAIBloc;
late final action = SmartEditAction
.values[widget.node.attributes[SmartEditBlockKeys.action] as int];
late SmartEditBloc smartEditBloc;
@override
void initState() {
@@ -98,16 +98,16 @@ class _AskAIBlockComponentWidgetState extends State<AskAIBlockComponentWidget> {
popoverController.show();
});
askAIBloc = AskAIActionBloc(
smartEditBloc = SmartEditBloc(
node: widget.node,
editorState: editorState,
action: action,
)..add(AskAIEvent.initial(getIt.getAsync<AIRepository>()));
)..add(SmartEditEvent.initial(getIt.getAsync<AIRepository>()));
}
@override
void dispose() {
askAIBloc.close();
smartEditBloc.close();
super.dispose();
}
@@ -128,8 +128,8 @@ class _AskAIBlockComponentWidgetState extends State<AskAIBlockComponentWidget> {
final width = _getEditorWidth();
return BlocProvider.value(
value: askAIBloc,
child: BlocListener<AskAIActionBloc, AskAIState>(
value: smartEditBloc,
child: BlocListener<SmartEditBloc, SmartEditState>(
listener: _onListen,
child: AppFlowyPopover(
controller: popoverController,
@@ -141,7 +141,7 @@ class _AskAIBlockComponentWidgetState extends State<AskAIBlockComponentWidget> {
constraints: BoxConstraints(maxWidth: width),
canClose: () async {
final completer = Completer<bool>();
final state = askAIBloc.state;
final state = smartEditBloc.state;
if (state.result.isEmpty) {
completer.complete(true);
} else {
@@ -160,8 +160,8 @@ class _AskAIBlockComponentWidgetState extends State<AskAIBlockComponentWidget> {
popupBuilder: (BuildContext popoverContext) {
return BlocProvider.value(
// request the result when opening the popover
value: askAIBloc..add(const AskAIEvent.started()),
child: const AskAiInputContent(),
value: smartEditBloc..add(const SmartEditEvent.started()),
child: const SmartEditInputContent(),
);
},
child: const SizedBox(
@@ -191,7 +191,7 @@ class _AskAIBlockComponentWidgetState extends State<AskAIBlockComponentWidget> {
editorState.apply(transaction);
}
void _onListen(BuildContext context, AskAIState state) {
void _onListen(BuildContext context, SmartEditState state) {
final error = state.requestError;
if (error != null) {
if (error.isLimitExceeded) {
@@ -206,3 +206,110 @@ class _AskAIBlockComponentWidgetState extends State<AskAIBlockComponentWidget> {
}
}
}
class SmartEditInputContent extends StatelessWidget {
const SmartEditInputContent({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<SmartEditBloc, SmartEditState>(
builder: (context, state) {
return Card(
elevation: 5,
color: Theme.of(context).colorScheme.surface,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
child: Container(
margin: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText.medium(
state.action.name,
fontSize: 14,
),
const VSpace(16),
state.loading
? _buildLoadingWidget(context)
: _buildResultWidget(context, state),
const VSpace(16),
const _SmartEditFooterWidget(),
],
),
),
);
},
);
}
Widget _buildResultWidget(BuildContext context, SmartEditState state) {
// todo: replace it with appflowy_editor
return Flexible(
child: FlowyText.regular(
state.result,
maxLines: null,
),
);
}
Widget _buildLoadingWidget(BuildContext context) {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 4.0),
child: SizedBox.square(
dimension: 14,
child: CircularProgressIndicator(
strokeWidth: 2.0,
),
),
);
}
}
class _SmartEditFooterWidget extends StatelessWidget {
const _SmartEditFooterWidget();
@override
Widget build(BuildContext context) {
return Row(
children: [
OutlinedRoundedButton(
text: LocaleKeys.document_plugins_autoGeneratorRewrite.tr(),
onTap: () =>
context.read<SmartEditBloc>().add(const SmartEditEvent.rewrite()),
),
const HSpace(10),
OutlinedRoundedButton(
text: LocaleKeys.button_replace.tr(),
onTap: () =>
context.read<SmartEditBloc>().add(const SmartEditEvent.replace()),
),
const HSpace(10),
OutlinedRoundedButton(
text: LocaleKeys.button_insertBelow.tr(),
onTap: () => context
.read<SmartEditBloc>()
.add(const SmartEditEvent.insertBelow()),
),
const HSpace(10),
OutlinedRoundedButton(
text: LocaleKeys.button_cancel.tr(),
onTap: () =>
context.read<SmartEditBloc>().add(const SmartEditEvent.cancel()),
),
Expanded(
child: Container(
alignment: Alignment.centerRight,
child: Text(
LocaleKeys.document_plugins_warning.tr(),
style: TextStyle(color: Theme.of(context).hintColor),
overflow: TextOverflow.ellipsis,
),
),
),
],
);
}
}
@@ -1,9 +1,8 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/util/ask_ai_node_extension.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_block_component.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/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@@ -12,20 +11,20 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
const _kAskAIToolbarItemId = 'appflowy.editor.ask_ai';
const _kSmartEditToolbarItemId = 'appflowy.editor.smart_edit';
final ToolbarItem askAIItem = ToolbarItem(
id: _kAskAIToolbarItemId,
final ToolbarItem smartEditItem = ToolbarItem(
id: _kSmartEditToolbarItemId,
group: 0,
isActive: onlyShowInSingleSelectionAndTextType,
builder: (context, editorState, _, __, tooltipBuilder) => AskAIActionList(
builder: (context, editorState, _, __, tooltipBuilder) => SmartEditActionList(
editorState: editorState,
tooltipBuilder: tooltipBuilder,
),
);
class AskAIActionList extends StatefulWidget {
const AskAIActionList({
class SmartEditActionList extends StatefulWidget {
const SmartEditActionList({
super.key,
required this.editorState,
this.tooltipBuilder,
@@ -35,28 +34,25 @@ class AskAIActionList extends StatefulWidget {
final ToolbarTooltipBuilder? tooltipBuilder;
@override
State<AskAIActionList> createState() => _AskAIActionListState();
State<SmartEditActionList> createState() => _SmartEditActionListState();
}
class _AskAIActionListState extends State<AskAIActionList> {
class _SmartEditActionListState extends State<SmartEditActionList> {
bool isAIEnabled = true;
EditorState get editorState => widget.editorState;
@override
void initState() {
super.initState();
isAIEnabled = _isAIEnabled();
}
@override
Widget build(BuildContext context) {
return PopoverActionList<AskAIActionWrapper>(
return PopoverActionList<SmartEditActionWrapper>(
offset: const Offset(-5, 5),
direction: PopoverDirection.bottomWithLeftAligned,
actions: AskAIAction.values
.map((action) => AskAIActionWrapper(action))
actions: SmartEditAction.values
.map((action) => SmartEditActionWrapper(action))
.toList(),
onClosed: () => keepEditorFocusNotifier.decrease(),
buildChild: (controller) {
@@ -92,7 +88,7 @@ class _AskAIActionListState extends State<AskAIActionList> {
if (widget.tooltipBuilder != null) {
return widget.tooltipBuilder!(
context,
_kAskAIToolbarItemId,
_kSmartEditToolbarItemId,
isAIEnabled
? LocaleKeys.document_plugins_smartEdit.tr()
: LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(),
@@ -104,39 +100,59 @@ class _AskAIActionListState extends State<AskAIActionList> {
},
onSelected: (action, controller) {
controller.close();
_insertAskAINode(action);
_insertSmartEditNode(action);
},
);
}
Future<void> _insertAskAINode(
AskAIActionWrapper actionWrapper,
Future<void> _insertSmartEditNode(
SmartEditActionWrapper actionWrapper,
) async {
final selection = editorState.selection?.normalized;
final selection = widget.editorState.selection?.normalized;
if (selection == null) {
return;
}
final markdown = editorState.getMarkdownInSelection(selection);
// support multiple paragraphs
final input = _getTextInSelection(selection);
final transaction = editorState.transaction;
final transaction = widget.editorState.transaction;
transaction.insertNode(
selection.normalized.end.path.next,
askAINode(
smartEditNode(
action: actionWrapper.inner,
content: markdown,
content: input.join('\n\n'),
),
);
await editorState.apply(
await widget.editorState.apply(
transaction,
options: const ApplyOptions(
recordUndo: false,
inMemoryUpdate: true,
),
withUpdateSelection: false,
);
}
List<String> _getTextInSelection(
Selection selection,
) {
final res = <String>[];
if (selection.isCollapsed) {
return res;
}
final nodes = widget.editorState.getNodesInSelection(selection);
for (final node in nodes) {
final delta = node.delta;
if (delta == null) {
continue;
}
final startIndex = node == nodes.first ? selection.startIndex : 0;
final endIndex = node == nodes.last ? selection.endIndex : delta.length;
res.add(delta.slice(startIndex, endIndex).toPlainText());
}
return res;
}
bool _isAIEnabled() {
final documentContext = widget.editorState.document.root.context;
if (documentContext == null) {
@@ -1,6 +1,4 @@
export 'callout_node_parser.dart';
export 'custom_image_node_parser.dart';
export 'markdown_code_parser.dart';
export 'math_equation_node_parser.dart';
export 'toggle_list_node_parser.dart';
export 'simple_table_parser.dart';
@@ -1,103 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:markdown/markdown.dart' as md;
class MarkdownSimpleTableParser extends CustomMarkdownParser {
const MarkdownSimpleTableParser();
@override
List<Node> transform(
md.Node element,
List<CustomMarkdownParser> parsers, {
MarkdownListType listType = MarkdownListType.unknown,
int? startNumber,
}) {
if (element is! md.Element) {
return [];
}
if (element.tag != 'table') {
return [];
}
final ec = element.children;
if (ec == null || ec.isEmpty) {
return [];
}
final th = ec
.whereType<md.Element>()
.where((e) => e.tag == 'thead')
.firstOrNull
?.children
?.whereType<md.Element>()
.where((e) => e.tag == 'tr')
.expand((e) => e.children?.whereType<md.Element>().toList() ?? [])
.where((e) => e.tag == 'th')
.toList();
final tr = ec
.whereType<md.Element>()
.where((e) => e.tag == 'tbody')
.firstOrNull
?.children
?.whereType<md.Element>()
.where((e) => e.tag == 'tr')
.toList();
if (th == null || tr == null || th.isEmpty || tr.isEmpty) {
return [];
}
final rows = <Node>[];
// Add header cells
rows.add(
simpleTableRowBlockNode(
children: th
.map(
(e) => simpleTableCellBlockNode(
children: [
paragraphNode(
delta: DeltaMarkdownDecoder().convertNodes(e.children),
),
],
),
)
.toList(),
),
);
// Add body cells
for (var i = 0; i < tr.length; i++) {
final td = tr[i]
.children
?.whereType<md.Element>()
.where((e) => e.tag == 'td')
.toList();
if (td == null || td.isEmpty) {
continue;
}
rows.add(
simpleTableRowBlockNode(
children: td
.map(
(e) => simpleTableCellBlockNode(
children: [
paragraphNode(
delta: DeltaMarkdownDecoder().convertNodes(e.children),
),
],
),
)
.toList(),
),
);
}
return [simpleTableBlockNode(children: rows)];
}
}
@@ -1,86 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
/// Parser for converting SimpleTable nodes to markdown format
class SimpleTableNodeParser extends NodeParser {
const SimpleTableNodeParser();
@override
String get id => SimpleTableBlockKeys.type;
@override
String transform(Node node, DocumentMarkdownEncoder? encoder) {
try {
final tableData = _extractTableData(node);
if (tableData.isEmpty) {
return '';
}
return _buildMarkdownTable(tableData);
} catch (e) {
return '';
}
}
/// Extracts table data from the node structure into a 2D list of strings
/// Each inner list represents a row, and each string represents a cell's content
List<List<String>> _extractTableData(Node node) {
final tableData = <List<String>>[];
final rows = node.children;
for (final row in rows) {
final rowData = _extractRowData(row);
tableData.add(rowData);
}
return tableData;
}
/// Extracts data from a single table row
List<String> _extractRowData(Node row) {
final rowData = <String>[];
final cells = row.children;
for (final cell in cells) {
final content = _extractCellContent(cell);
rowData.add(content);
}
return rowData;
}
/// Extracts and formats content from a single table cell
String _extractCellContent(Node cell) {
final contentBuffer = StringBuffer();
for (final child in cell.children) {
final delta = child.delta;
if (delta == null) continue;
final content = DeltaMarkdownEncoder().convert(delta);
// Escape pipe characters to prevent breaking markdown table structure
contentBuffer.write(content.replaceAll('|', '\\|'));
}
return contentBuffer.toString();
}
/// Builds a markdown table string from the extracted table data
/// First row is treated as header, followed by separator row and data rows
String _buildMarkdownTable(List<List<String>> tableData) {
final markdown = StringBuffer();
final columnCount = tableData[0].length;
// Add header row
markdown.writeln('|${tableData[0].join('|')}|');
// Add separator row
markdown.writeln('|${List.filled(columnCount, '---').join('|')}|');
// Add data rows (skip header row)
for (int i = 1; i < tableData.length; i++) {
markdown.writeln('|${tableData[i].join('|')}|');
}
return markdown.toString();
}
}
@@ -24,11 +24,9 @@ export 'header/cover_editor_bloc.dart';
export 'header/custom_cover_picker.dart';
export 'header/document_cover_widget.dart';
export 'heading/heading_toolbar_item.dart';
export 'image/custom_image_block_component/custom_image_block_component.dart';
export 'image/custom_image_block_component/image_menu.dart';
export 'image/image_selection_menu.dart';
export 'image/mobile_image_toolbar_item.dart';
export 'image/multi_image_block_component/multi_image_block_component.dart';
export 'image/multi_image_block_component/multi_image_menu.dart';
export 'inline_math_equation/inline_math_equation.dart';
export 'inline_math_equation/inline_math_equation_toolbar_item.dart';
@@ -38,14 +36,12 @@ export 'link_preview/link_preview_cache.dart';
export 'link_preview/link_preview_menu.dart';
export 'math_equation/math_equation_block_component.dart';
export 'math_equation/mobile_math_equation_toolbar_item.dart';
export 'mention/mention_block.dart';
export 'mobile_floating_toolbar/custom_mobile_floating_toolbar.dart';
export 'mobile_toolbar_v3/aa_toolbar_item.dart';
export 'mobile_toolbar_v3/add_attachment_item.dart';
export 'mobile_toolbar_v3/add_block_toolbar_item.dart';
export 'mobile_toolbar_v3/appflowy_mobile_toolbar.dart';
export 'mobile_toolbar_v3/appflowy_mobile_toolbar_item.dart';
export 'mobile_toolbar_v3/basic_toolbar_item.dart';
export 'mobile_toolbar_v3/biusc_toolbar_item.dart';
export 'mobile_toolbar_v3/indent_outdent_toolbar_item.dart';
export 'mobile_toolbar_v3/list_toolbar_item.dart';
export 'mobile_toolbar_v3/more_toolbar_item.dart';
@@ -53,20 +49,21 @@ export 'mobile_toolbar_v3/toolbar_item_builder.dart';
export 'mobile_toolbar_v3/undo_redo_toolbar_item.dart';
export 'mobile_toolbar_v3/util.dart';
export 'numbered_list/numbered_list_icon.dart';
export 'openai/widgets/ai_writer_block_component.dart';
export 'openai/widgets/ask_ai_block_component.dart';
export 'openai/widgets/ask_ai_toolbar_item.dart';
export 'openai/widgets/auto_completion_node_widget.dart';
export 'openai/widgets/smart_edit_node_widget.dart';
export 'openai/widgets/smart_edit_toolbar_item.dart';
export 'outline/outline_block_component.dart';
export 'parsers/markdown_parsers.dart';
export 'parsers/markdown_simple_table_parser.dart';
export 'quote/quote_block_shortcuts.dart';
export 'shortcuts/character_shortcuts.dart';
export 'shortcuts/command_shortcuts.dart';
export 'simple_table/simple_table.dart';
export 'slash_menu/slash_menu_items.dart';
export 'sub_page/sub_page_block_component.dart';
export 'table/table_menu.dart';
export 'table/table_option_action.dart';
export 'todo_list/todo_list_icon.dart';
export 'toggle/toggle_block_component.dart';
export 'toggle/toggle_block_shortcuts.dart';
export 'sub_page/sub_page_block_component.dart';
export 'mention/mention_block.dart';
export 'image/custom_image_block_component/custom_image_block_component.dart';
export 'image/multi_image_block_component/multi_image_block_component.dart';
@@ -15,8 +15,6 @@ final List<CommandShortcutEvent> defaultCommandShortcutEvents = [
// Command shortcuts are order-sensitive. Verify order when modifying.
List<CommandShortcutEvent> commandShortcutEvents = [
...simpleTableCommands,
customExitEditingCommand,
backspaceToTitle,
removeToggleHeadingStyle,
@@ -47,7 +45,6 @@ List<CommandShortcutEvent> commandShortcutEvents = [
undoCommand,
redoCommand,
exitEditingCommand,
// ...tableCommands,
].contains(shortcut),
),
@@ -1,736 +0,0 @@
import 'dart:ui';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.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/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// Only displaying the add row / add column / add column and row button
/// when hovering on the last row / last column / last cell.
bool _enableHoveringLogicV2 = true;
class SimpleTableReorderButton extends StatelessWidget {
const SimpleTableReorderButton({
super.key,
required this.isShowingMenu,
required this.type,
});
final ValueNotifier<bool> isShowingMenu;
final SimpleTableMoreActionType type;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: isShowingMenu,
builder: (context, isShowingMenu, child) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: Container(
decoration: BoxDecoration(
color: isShowingMenu
? context.simpleTableMoreActionHoverColor
: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(8.0),
border: Border.all(
color: context.simpleTableMoreActionBorderColor,
),
),
height: 16.0,
width: 16.0,
child: FlowySvg(
type.reorderIconSvg,
color: isShowingMenu ? Colors.white : null,
size: const Size.square(16.0),
),
),
);
},
);
}
}
class SimpleTableAddRowHoverButton extends StatefulWidget {
const SimpleTableAddRowHoverButton({
super.key,
required this.editorState,
required this.tableNode,
});
final EditorState editorState;
final Node tableNode;
@override
State<SimpleTableAddRowHoverButton> createState() =>
_SimpleTableAddRowHoverButtonState();
}
class _SimpleTableAddRowHoverButtonState
extends State<SimpleTableAddRowHoverButton> {
late final interceptorKey =
'simple_table_add_row_hover_button_${widget.tableNode.id}';
SelectionGestureInterceptor? interceptor;
@override
void initState() {
super.initState();
interceptor = SelectionGestureInterceptor(
key: interceptorKey,
canTap: (details) => !_isTapInBounds(details.globalPosition),
);
widget.editorState.service.selectionService
.registerGestureInterceptor(interceptor!);
}
@override
void dispose() {
widget.editorState.service.selectionService.unregisterGestureInterceptor(
interceptorKey,
);
super.dispose();
}
@override
Widget build(BuildContext context) {
assert(widget.tableNode.type == SimpleTableBlockKeys.type);
if (widget.tableNode.type != SimpleTableBlockKeys.type) {
return const SizedBox.shrink();
}
final simpleTableContext = context.read<SimpleTableContext>();
return ValueListenableBuilder(
valueListenable: simpleTableContext.isHoveringOnTableArea,
builder: (context, isHoveringOnTableArea, child) {
return ValueListenableBuilder(
valueListenable: simpleTableContext.hoveringTableCell,
builder: (context, hoveringTableCell, child) {
bool shouldShow = isHoveringOnTableArea;
if (hoveringTableCell != null && _enableHoveringLogicV2) {
shouldShow =
hoveringTableCell.rowIndex + 1 == hoveringTableCell.rowLength;
}
return shouldShow
? Positioned(
bottom: 2 * SimpleTableConstants.addRowButtonPadding,
left: SimpleTableConstants.tableLeftPadding -
SimpleTableConstants.cellBorderWidth,
right: SimpleTableConstants.addRowButtonRightPadding,
child: SimpleTableAddRowButton(
onTap: () => widget.editorState.addRowInTable(
widget.tableNode,
),
),
)
: const SizedBox.shrink();
},
);
},
);
}
bool _isTapInBounds(Offset offset) {
final renderBox = context.findRenderObject() as RenderBox?;
if (renderBox == null) {
return false;
}
final localPosition = renderBox.globalToLocal(offset);
final result = renderBox.paintBounds.contains(localPosition);
return result;
}
}
class SimpleTableAddRowButton extends StatelessWidget {
const SimpleTableAddRowButton({
super.key,
this.onTap,
});
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.document_plugins_simpleTable_clickToAddNewRow.tr(),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Container(
height: SimpleTableConstants.addRowButtonHeight,
margin: const EdgeInsets.symmetric(
vertical: SimpleTableConstants.addColumnButtonPadding,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
SimpleTableConstants.addRowButtonRadius,
),
color: context.simpleTableMoreActionBackgroundColor,
),
child: const FlowySvg(
FlowySvgs.add_s,
),
),
),
),
);
}
}
class SimpleTableAddColumnHoverButton extends StatefulWidget {
const SimpleTableAddColumnHoverButton({
super.key,
required this.editorState,
required this.node,
});
final EditorState editorState;
final Node node;
@override
State<SimpleTableAddColumnHoverButton> createState() =>
_SimpleTableAddColumnHoverButtonState();
}
class _SimpleTableAddColumnHoverButtonState
extends State<SimpleTableAddColumnHoverButton> {
late final interceptorKey =
'simple_table_add_column_hover_button_${widget.node.id}';
SelectionGestureInterceptor? interceptor;
@override
void initState() {
super.initState();
interceptor = SelectionGestureInterceptor(
key: interceptorKey,
canTap: (details) => !_isTapInBounds(details.globalPosition),
);
widget.editorState.service.selectionService
.registerGestureInterceptor(interceptor!);
}
@override
void dispose() {
widget.editorState.service.selectionService.unregisterGestureInterceptor(
interceptorKey,
);
super.dispose();
}
@override
Widget build(BuildContext context) {
assert(widget.node.type == SimpleTableBlockKeys.type);
if (widget.node.type != SimpleTableBlockKeys.type) {
return const SizedBox.shrink();
}
return ValueListenableBuilder(
valueListenable: context.read<SimpleTableContext>().isHoveringOnTableArea,
builder: (context, isHoveringOnTableArea, _) {
return ValueListenableBuilder(
valueListenable: context.read<SimpleTableContext>().hoveringTableCell,
builder: (context, hoveringTableCell, _) {
bool shouldShow = isHoveringOnTableArea;
if (hoveringTableCell != null && _enableHoveringLogicV2) {
shouldShow = hoveringTableCell.columnIndex + 1 ==
hoveringTableCell.columnLength;
}
return Positioned(
top: SimpleTableConstants.tableTopPadding -
SimpleTableConstants.cellBorderWidth,
bottom: SimpleTableConstants.addColumnButtonBottomPadding,
right: 0,
child: Opacity(
opacity: shouldShow ? 1.0 : 0.0,
child: SimpleTableAddColumnButton(
onTap: () {
widget.editorState.addColumnInTable(widget.node);
},
),
),
);
},
);
},
);
}
bool _isTapInBounds(Offset offset) {
final renderBox = context.findRenderObject() as RenderBox?;
if (renderBox == null) {
return false;
}
final localPosition = renderBox.globalToLocal(offset);
final result = renderBox.paintBounds.contains(localPosition);
return result;
}
}
class SimpleTableAddColumnButton extends StatelessWidget {
const SimpleTableAddColumnButton({
super.key,
this.onTap,
});
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.document_plugins_simpleTable_clickToAddNewColumn.tr(),
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onTap,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Container(
width: SimpleTableConstants.addColumnButtonWidth,
margin: const EdgeInsets.symmetric(
horizontal: SimpleTableConstants.addColumnButtonPadding,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
SimpleTableConstants.addColumnButtonRadius,
),
color: context.simpleTableMoreActionBackgroundColor,
),
child: const FlowySvg(
FlowySvgs.add_s,
),
),
),
),
);
}
}
class SimpleTableAddColumnAndRowHoverButton extends StatelessWidget {
const SimpleTableAddColumnAndRowHoverButton({
super.key,
required this.editorState,
required this.node,
});
final EditorState editorState;
final Node node;
@override
Widget build(BuildContext context) {
assert(node.type == SimpleTableBlockKeys.type);
if (node.type != SimpleTableBlockKeys.type) {
return const SizedBox.shrink();
}
return ValueListenableBuilder(
valueListenable: context.read<SimpleTableContext>().isHoveringOnTableArea,
builder: (context, isHoveringOnTableArea, child) {
return ValueListenableBuilder(
valueListenable: context.read<SimpleTableContext>().hoveringTableCell,
builder: (context, hoveringTableCell, child) {
bool shouldShow = isHoveringOnTableArea;
if (hoveringTableCell != null && _enableHoveringLogicV2) {
shouldShow = hoveringTableCell.isLastCellInTable;
}
return shouldShow
? Positioned(
bottom:
SimpleTableConstants.addColumnAndRowButtonBottomPadding,
right: SimpleTableConstants.addColumnButtonPadding,
child: SimpleTableAddColumnAndRowButton(
onTap: () => editorState.addColumnAndRowInTable(node),
),
)
: const SizedBox.shrink();
},
);
},
);
}
}
class SimpleTableAddColumnAndRowButton extends StatelessWidget {
const SimpleTableAddColumnAndRowButton({
super.key,
this.onTap,
});
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.document_plugins_simpleTable_clickToAddNewRowAndColumn
.tr(),
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onTap,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Container(
width: SimpleTableConstants.addColumnAndRowButtonWidth,
height: SimpleTableConstants.addColumnAndRowButtonHeight,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
SimpleTableConstants.addColumnAndRowButtonCornerRadius,
),
color: context.simpleTableMoreActionBackgroundColor,
),
child: const FlowySvg(
FlowySvgs.add_s,
),
),
),
),
);
}
}
class SimpleTableRowDivider extends StatelessWidget {
const SimpleTableRowDivider({
super.key,
});
@override
Widget build(BuildContext context) {
return VerticalDivider(
color: context.simpleTableBorderColor,
width: 1.0,
);
}
}
class SimpleTableColumnDivider extends StatelessWidget {
const SimpleTableColumnDivider({super.key});
@override
Widget build(BuildContext context) {
return Divider(
color: context.simpleTableBorderColor,
height: 1.0,
);
}
}
class SimpleTableAlignMenu extends StatefulWidget {
const SimpleTableAlignMenu({
super.key,
required this.type,
required this.tableCellNode,
this.mutex,
});
final SimpleTableMoreActionType type;
final Node tableCellNode;
final PopoverMutex? mutex;
@override
State<SimpleTableAlignMenu> createState() => _SimpleTableAlignMenuState();
}
class _SimpleTableAlignMenuState extends State<SimpleTableAlignMenu> {
@override
Widget build(BuildContext context) {
final align = switch (widget.type) {
SimpleTableMoreActionType.column => widget.tableCellNode.columnAlign,
SimpleTableMoreActionType.row => widget.tableCellNode.rowAlign,
};
return AppFlowyPopover(
mutex: widget.mutex,
child: SimpleTableBasicButton(
leftIconSvg: align.leftIconSvg,
text: LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(),
onTap: () {},
),
popupBuilder: (popoverContext) {
void onClose() => PopoverContainer.of(popoverContext).closeAll();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildAlignButton(context, TableAlign.left, onClose),
_buildAlignButton(context, TableAlign.center, onClose),
_buildAlignButton(context, TableAlign.right, onClose),
],
);
},
);
}
Widget _buildAlignButton(
BuildContext context,
TableAlign align,
VoidCallback onClose,
) {
return SimpleTableBasicButton(
leftIconSvg: align.leftIconSvg,
text: align.name,
onTap: () {
switch (widget.type) {
case SimpleTableMoreActionType.column:
context.read<EditorState>().updateColumnAlign(
tableCellNode: widget.tableCellNode,
align: align,
);
break;
case SimpleTableMoreActionType.row:
context.read<EditorState>().updateRowAlign(
tableCellNode: widget.tableCellNode,
align: align,
);
break;
}
onClose();
},
);
}
}
class SimpleTableBasicButton extends StatelessWidget {
const SimpleTableBasicButton({
super.key,
required this.text,
required this.onTap,
this.leftIconSvg,
this.leftIconBuilder,
this.rightIcon,
});
final FlowySvgData? leftIconSvg;
final String text;
final VoidCallback onTap;
final Widget Function(bool onHover)? leftIconBuilder;
final Widget? rightIcon;
@override
Widget build(BuildContext context) {
return Container(
height: SimpleTableConstants.moreActionHeight,
padding: SimpleTableConstants.moreActionPadding,
child: FlowyIconTextButton(
margin: SimpleTableConstants.moreActionHorizontalMargin,
leftIconBuilder: _buildLeftIcon,
iconPadding: 10.0,
textBuilder: (onHover) => FlowyText.regular(
text,
fontSize: 14.0,
figmaLineHeight: 18.0,
),
onTap: onTap,
rightIconBuilder: (onHover) => rightIcon ?? const SizedBox.shrink(),
),
);
}
Widget _buildLeftIcon(bool onHover) {
if (leftIconBuilder != null) {
return leftIconBuilder!(onHover);
}
return leftIconSvg != null
? FlowySvg(leftIconSvg!)
: const SizedBox.shrink();
}
}
class SimpleTableColumnResizeHandle extends StatefulWidget {
const SimpleTableColumnResizeHandle({
super.key,
required this.node,
});
final Node node;
@override
State<SimpleTableColumnResizeHandle> createState() =>
_SimpleTableColumnResizeHandleState();
}
class _SimpleTableColumnResizeHandleState
extends State<SimpleTableColumnResizeHandle> {
bool isStartDragging = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.resizeColumn,
onEnter: (event) => context
.read<SimpleTableContext>()
.hoveringOnResizeHandle
.value = widget.node,
onExit: (event) {
Future.delayed(const Duration(milliseconds: 100), () {
// the onExit event will be triggered before dragging started.
// delay the hiding of the resize handle to avoid flickering.
if (!isStartDragging) {
context.read<SimpleTableContext>().hoveringOnResizeHandle.value =
null;
}
});
},
child: GestureDetector(
onHorizontalDragStart: (details) {
// disable the two-finger drag on trackpad
if (details.kind == PointerDeviceKind.trackpad) {
return;
}
isStartDragging = true;
},
onHorizontalDragUpdate: (details) {
if (!isStartDragging) {
return;
}
context.read<EditorState>().updateColumnWidthInMemory(
tableCellNode: widget.node,
deltaX: details.delta.dx,
);
},
onHorizontalDragEnd: (details) {
if (!isStartDragging) {
return;
}
context.read<SimpleTableContext>().hoveringOnResizeHandle.value =
null;
isStartDragging = false;
context.read<EditorState>().updateColumnWidth(
tableCellNode: widget.node,
width: widget.node.columnWidth,
);
},
child: ValueListenableBuilder<Node?>(
valueListenable: context.read<SimpleTableContext>().hoveringTableCell,
builder: (context, hoveringCell, child) {
return ValueListenableBuilder(
valueListenable:
context.read<SimpleTableContext>().hoveringOnResizeHandle,
builder: (context, hoveringOnResizeHandle, child) {
final isSameRowIndex = hoveringOnResizeHandle?.columnIndex ==
widget.node.columnIndex;
return Opacity(
opacity: isSameRowIndex ? 1.0 : 0.0,
child: Container(
height: double.infinity,
width: SimpleTableConstants.resizeHandleWidth,
color: Theme.of(context).colorScheme.primary,
),
);
},
);
},
),
),
);
}
}
class SimpleTableBackgroundColorMenu extends StatefulWidget {
const SimpleTableBackgroundColorMenu({
super.key,
required this.type,
required this.tableCellNode,
this.mutex,
});
final SimpleTableMoreActionType type;
final Node tableCellNode;
final PopoverMutex? mutex;
@override
State<SimpleTableBackgroundColorMenu> createState() =>
_SimpleTableBackgroundColorMenuState();
}
class _SimpleTableBackgroundColorMenuState
extends State<SimpleTableBackgroundColorMenu> {
@override
Widget build(BuildContext context) {
final theme = AFThemeExtension.of(context);
final backgroundColor = switch (widget.type) {
SimpleTableMoreActionType.row =>
widget.tableCellNode.buildRowColor(context),
SimpleTableMoreActionType.column =>
widget.tableCellNode.buildColumnColor(context),
};
return AppFlowyPopover(
mutex: widget.mutex,
popupBuilder: (popoverContext) {
return _buildColorOptionMenu(
context,
theme: theme,
onClose: () => PopoverContainer.of(popoverContext).closeAll(),
);
},
direction: PopoverDirection.rightWithCenterAligned,
child: SimpleTableBasicButton(
leftIconBuilder: (onHover) => ColorOptionIcon(
color: backgroundColor ?? Colors.transparent,
),
text: LocaleKeys.document_plugins_simpleTable_moreActions_color.tr(),
onTap: () {},
),
);
}
Widget _buildColorOptionMenu(
BuildContext context, {
required AFThemeExtension theme,
required VoidCallback onClose,
}) {
final colors = [
// reset to default background color
FlowyColorOption(
color: Colors.transparent,
i18n: LocaleKeys.document_plugins_optionAction_defaultColor.tr(),
id: optionActionColorDefaultColor,
),
...FlowyTint.values.map(
(e) => FlowyColorOption(
color: e.color(context, theme: theme),
i18n: e.tintName(AppFlowyEditorL10n.current),
id: e.id,
),
),
];
return FlowyColorPicker(
colors: colors,
border: Border.all(
color: theme.onBackground,
),
onTap: (option, index) {
switch (widget.type) {
case SimpleTableMoreActionType.column:
context.read<EditorState>().updateColumnBackgroundColor(
tableCellNode: widget.tableCellNode,
color: option.id,
);
break;
case SimpleTableMoreActionType.row:
context.read<EditorState>().updateRowBackgroundColor(
tableCellNode: widget.tableCellNode,
color: option.id,
);
break;
}
onClose();
},
);
}
}
@@ -1,7 +0,0 @@
export 'simple_table_block_component.dart';
export 'simple_table_cell_block_component.dart';
export 'simple_table_constants.dart';
export 'simple_table_more_action.dart';
export 'simple_table_operations/simple_table_operations.dart';
export 'simple_table_row_block_component.dart';
export 'simple_table_shortcuts/simple_table_commands.dart';
@@ -1,371 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/_shared_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_row_block_component.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
typedef SimpleTableColumnWidthMap = Map<String, double>;
typedef SimpleTableRowAlignMap = Map<String, String>;
typedef SimpleTableColumnAlignMap = Map<String, String>;
typedef SimpleTableColorMap = Map<String, String>;
class SimpleTableBlockKeys {
const SimpleTableBlockKeys._();
static const String type = 'simple_table';
// enable header row
// it's a bool value, default is false
static const String enableHeaderRow = 'enable_header_row';
// enable column header
// it's a bool value, default is false
static const String enableHeaderColumn = 'enable_header_column';
// column colors
// it's a `SimpleTableColorMap` value, {column_index: color, ...}
// the number of colors should be the same as the number of columns
static const String columnColors = 'column_colors';
// row colors
// it's a `SimpleTableColorMap` value, {row_index: color, ...}
// the number of colors should be the same as the number of rows
static const String rowColors = 'row_colors';
// column alignments
// it's a `SimpleTableColumnAlignMap` value, {column_index: align, ...}
// the value should be one of the following: 'left', 'center', 'right'
static const String columnAligns = 'column_aligns';
// row alignments
// it's a `SimpleTableRowAlignMap` value, {row_index: align, ...}
// the value should be one of the following: 'top', 'center', 'bottom'
static const String rowAligns = 'row_aligns';
// column widths
// it's a `SimpleTableColumnWidthMap` value, {column_index: width, ...}
static const String columnWidths = 'column_widths';
}
Node simpleTableBlockNode({
bool enableHeaderRow = false,
bool enableHeaderColumn = false,
SimpleTableColorMap? columnColors,
SimpleTableColorMap? rowColors,
SimpleTableColumnAlignMap? columnAligns,
SimpleTableRowAlignMap? rowAligns,
SimpleTableColumnWidthMap? columnWidths,
required List<Node> children,
}) {
assert(children.every((e) => e.type == SimpleTableRowBlockKeys.type));
return Node(
type: SimpleTableBlockKeys.type,
attributes: {
SimpleTableBlockKeys.enableHeaderRow: enableHeaderRow,
SimpleTableBlockKeys.enableHeaderColumn: enableHeaderColumn,
SimpleTableBlockKeys.columnColors: columnColors,
SimpleTableBlockKeys.rowColors: rowColors,
SimpleTableBlockKeys.columnAligns: columnAligns,
SimpleTableBlockKeys.rowAligns: rowAligns,
SimpleTableBlockKeys.columnWidths: columnWidths,
},
children: children,
);
}
/// Create a simple table block node with the given column and row count.
///
/// The table will have cells filled with paragraph nodes.
///
/// For example, if you want to create a table with 2 columns and 3 rows, you can use:
/// ```dart
/// final table = createSimpleTableBlockNode(columnCount: 2, rowCount: 3);
/// ```
///
/// | cell 1 | cell 2 |
/// | cell 3 | cell 4 |
/// | cell 5 | cell 6 |
Node createSimpleTableBlockNode({
required int columnCount,
required int rowCount,
String? defaultContent,
String Function(int rowIndex, int columnIndex)? contentBuilder,
}) {
final rows = List.generate(rowCount, (rowIndex) {
final cells = List.generate(
columnCount,
(columnIndex) => simpleTableCellBlockNode(
children: [
paragraphNode(
text: defaultContent ?? contentBuilder?.call(rowIndex, columnIndex),
),
],
),
);
return simpleTableRowBlockNode(children: cells);
});
return simpleTableBlockNode(children: rows);
}
class SimpleTableBlockComponentBuilder extends BlockComponentBuilder {
SimpleTableBlockComponentBuilder({
super.configuration,
});
@override
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return SimpleTableBlockWidget(
key: node.key,
node: node,
configuration: configuration,
showActions: showActions(node),
actionBuilder: (context, state) => actionBuilder(
blockComponentContext,
state,
),
);
}
@override
BlockComponentValidate get validate => (node) => node.children.isNotEmpty;
}
class SimpleTableBlockWidget extends BlockComponentStatefulWidget {
const SimpleTableBlockWidget({
super.key,
required super.node,
super.showActions,
super.actionBuilder,
super.configuration = const BlockComponentConfiguration(),
});
@override
State<SimpleTableBlockWidget> createState() => _SimpleTableBlockWidgetState();
}
class _SimpleTableBlockWidgetState extends State<SimpleTableBlockWidget>
with
SelectableMixin,
BlockComponentConfigurable,
BlockComponentTextDirectionMixin,
BlockComponentBackgroundColorMixin {
@override
BlockComponentConfiguration get configuration => widget.configuration;
@override
Node get node => widget.node;
@override
late EditorState editorState = context.read<EditorState>();
final tableKey = GlobalKey();
final simpleTableContext = SimpleTableContext();
final scrollController = ScrollController();
@override
void initState() {
super.initState();
editorState.selectionNotifier.addListener(_onSelectionChanged);
}
@override
void dispose() {
simpleTableContext.dispose();
editorState.selectionNotifier.removeListener(_onSelectionChanged);
scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
Widget child = Transform.translate(
offset: const Offset(
-SimpleTableConstants.tableLeftPadding,
0,
),
child: _buildTable(),
);
child = Provider.value(
value: simpleTableContext,
child: MouseRegion(
onEnter: (event) =>
simpleTableContext.isHoveringOnTableBlock.value = true,
onExit: (event) {
simpleTableContext.isHoveringOnTableBlock.value = false;
},
child: child,
),
);
if (widget.showActions && widget.actionBuilder != null) {
child = BlockComponentActionWrapper(
node: node,
actionBuilder: widget.actionBuilder!,
child: child,
);
}
return child;
}
Widget _buildTable() {
// IntrinsicWidth and IntrinsicHeight are used to make the table size fit the content.
return MouseRegion(
onEnter: (event) => simpleTableContext.isHoveringOnTableArea.value = true,
onExit: (event) {
simpleTableContext.isHoveringOnTableArea.value = false;
},
child: Provider.value(
value: simpleTableContext,
child: Stack(
children: [
MouseRegion(
hitTestBehavior: HitTestBehavior.opaque,
onEnter: (event) =>
simpleTableContext.isHoveringOnColumnsAndRows.value = true,
onExit: (event) {
simpleTableContext.isHoveringOnColumnsAndRows.value = false;
simpleTableContext.hoveringTableCell.value = null;
},
child: Scrollbar(
controller: scrollController,
child: SingleChildScrollView(
controller: scrollController,
scrollDirection: Axis.horizontal,
child: Padding(
padding: SimpleTableConstants.tablePadding,
child: IntrinsicWidth(
child: IntrinsicHeight(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildRows(),
),
),
),
),
),
),
),
SimpleTableAddColumnHoverButton(
editorState: editorState,
node: node,
),
SimpleTableAddRowHoverButton(
editorState: editorState,
tableNode: node,
),
SimpleTableAddColumnAndRowHoverButton(
editorState: editorState,
node: node,
),
],
),
),
);
}
List<Widget> _buildRows() {
final List<Widget> rows = [];
if (SimpleTableConstants.borderType == SimpleTableBorderRenderType.table) {
rows.add(const SimpleTableColumnDivider());
}
for (final child in node.children) {
rows.add(editorState.renderer.build(context, child));
if (SimpleTableConstants.borderType ==
SimpleTableBorderRenderType.table) {
rows.add(const SimpleTableColumnDivider());
}
}
return rows;
}
void _onSelectionChanged() {
final selection = editorState.selectionNotifier.value;
final selectionType = editorState.selectionType;
if (selectionType == SelectionType.block &&
widget.node.path.inSelection(selection)) {
simpleTableContext.isSelectingTable.value = true;
} else {
simpleTableContext.isSelectingTable.value = false;
}
}
RenderBox get _renderBox => context.findRenderObject() as RenderBox;
@override
Position start() => Position(path: widget.node.path);
@override
Position end() => Position(path: widget.node.path, offset: 1);
@override
Position getPositionInOffset(Offset start) => end();
@override
List<Rect> getRectsInSelection(
Selection selection, {
bool shiftWithBaseOffset = false,
}) {
final parentBox = context.findRenderObject();
final tableBox = tableKey.currentContext?.findRenderObject();
if (parentBox is RenderBox && tableBox is RenderBox) {
return [
(shiftWithBaseOffset
? tableBox.localToGlobal(Offset.zero, ancestor: parentBox)
: Offset.zero) &
tableBox.size,
];
}
return [Offset.zero & _renderBox.size];
}
@override
Selection getSelectionInRange(Offset start, Offset end) => Selection.single(
path: widget.node.path,
startOffset: 0,
endOffset: 1,
);
@override
bool get shouldCursorBlink => false;
@override
CursorStyle get cursorStyle => CursorStyle.cover;
@override
Offset localToGlobal(
Offset offset, {
bool shiftWithBaseOffset = false,
}) =>
_renderBox.localToGlobal(offset);
@override
Rect getBlockRect({
bool shiftWithBaseOffset = false,
}) {
return getRectsInSelection(Selection.invalid()).first;
}
@override
Rect? getCursorRectInPosition(
Position position, {
bool shiftWithBaseOffset = false,
}) {
final size = _renderBox.size;
return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height);
}
}
@@ -1,429 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/_shared_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class SimpleTableCellBlockKeys {
const SimpleTableCellBlockKeys._();
static const String type = 'simple_table_cell';
}
Node simpleTableCellBlockNode({
List<Node>? children,
}) {
// Default children is a paragraph node.
children ??= [
paragraphNode(),
];
return Node(
type: SimpleTableCellBlockKeys.type,
children: children,
);
}
class SimpleTableCellBlockComponentBuilder extends BlockComponentBuilder {
SimpleTableCellBlockComponentBuilder({
super.configuration,
});
@override
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return SimpleTableCellBlockWidget(
key: node.key,
node: node,
configuration: configuration,
showActions: showActions(node),
actionBuilder: (context, state) => actionBuilder(
blockComponentContext,
state,
),
);
}
@override
BlockComponentValidate get validate => (node) => true;
}
class SimpleTableCellBlockWidget extends BlockComponentStatefulWidget {
const SimpleTableCellBlockWidget({
super.key,
required super.node,
super.showActions,
super.actionBuilder,
super.configuration = const BlockComponentConfiguration(),
});
@override
State<SimpleTableCellBlockWidget> createState() =>
SimpleTableCellBlockWidgetState();
}
@visibleForTesting
class SimpleTableCellBlockWidgetState extends State<SimpleTableCellBlockWidget>
with
BlockComponentConfigurable,
BlockComponentTextDirectionMixin,
BlockComponentBackgroundColorMixin {
@override
BlockComponentConfiguration get configuration => widget.configuration;
@override
Node get node => widget.node;
@override
late EditorState editorState = context.read<EditorState>();
late SimpleTableContext? simpleTableContext =
context.read<SimpleTableContext?>();
ValueNotifier<bool> isEditingCellNotifier = ValueNotifier(false);
@override
void initState() {
super.initState();
simpleTableContext?.isSelectingTable.addListener(_onSelectingTableChanged);
node.parentTableNode?.addListener(_onSelectingTableChanged);
editorState.selectionNotifier.addListener(_onSelectionChanged);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_onSelectionChanged();
});
}
@override
void dispose() {
simpleTableContext?.isSelectingTable.removeListener(
_onSelectingTableChanged,
);
node.parentTableNode?.removeListener(_onSelectingTableChanged);
editorState.selectionNotifier.removeListener(_onSelectionChanged);
isEditingCellNotifier.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (simpleTableContext == null) {
return const SizedBox.shrink();
}
return MouseRegion(
hitTestBehavior: HitTestBehavior.opaque,
onEnter: (event) => simpleTableContext!.hoveringTableCell.value = node,
child: Stack(
clipBehavior: Clip.none,
children: [
_buildCell(),
Positioned(
top: 0,
bottom: 0,
left: -SimpleTableConstants.tableLeftPadding,
child: _buildRowMoreActionButton(),
),
Positioned(
left: 0,
right: 0,
child: _buildColumnMoreActionButton(),
),
Positioned(
right: 0,
top: node.rowIndex == 0 ? SimpleTableConstants.tableTopPadding : 0,
bottom: 0,
child: SimpleTableColumnResizeHandle(
node: node,
),
),
],
),
);
}
Widget _buildCell() {
if (simpleTableContext == null) {
return const SizedBox.shrink();
}
return Padding(
// add padding to the top of the cell if it is the first row, otherwise the
// column action button is not clickable.
// issue: https://github.com/flutter/flutter/issues/75747
padding: EdgeInsets.only(
top: node.rowIndex == 0 ? SimpleTableConstants.tableTopPadding : 0,
),
child: ValueListenableBuilder<bool>(
valueListenable: isEditingCellNotifier,
builder: (context, isEditingCell, child) {
return ValueListenableBuilder(
valueListenable: simpleTableContext!.selectingColumn,
builder: (context, selectingColumn, child) {
return ValueListenableBuilder(
valueListenable: simpleTableContext!.selectingRow,
builder: (context, selectingRow, _) {
return DecoratedBox(
decoration: _buildDecoration(),
child: child!,
);
},
);
},
child: Column(
children: node.children.map(_buildCellContent).toList(),
),
);
},
),
);
}
Widget _buildCellContent(Node childNode) {
final alignment = _buildAlignment();
return Container(
padding: SimpleTableConstants.cellEdgePadding,
constraints: const BoxConstraints(
minWidth: SimpleTableConstants.minimumColumnWidth,
),
width: node.columnWidth,
alignment: alignment,
child: IntrinsicWidth(
child: IntrinsicHeight(
child: editorState.renderer.build(context, childNode),
),
),
);
}
Widget _buildRowMoreActionButton() {
final columnIndex = node.columnIndex;
final rowIndex = node.rowIndex;
if (columnIndex != 0) {
return const SizedBox.shrink();
}
return SimpleTableMoreActionMenu(
index: rowIndex,
type: SimpleTableMoreActionType.row,
);
}
Widget _buildColumnMoreActionButton() {
final columnIndex = node.columnIndex;
final rowIndex = node.rowIndex;
if (rowIndex != 0) {
return const SizedBox.shrink();
}
return SimpleTableMoreActionMenu(
index: columnIndex,
type: SimpleTableMoreActionType.column,
);
}
Alignment _buildAlignment() {
Alignment alignment = Alignment.topLeft;
if (node.columnAlign != TableAlign.left) {
alignment = node.columnAlign.alignment;
} else if (node.rowAlign != TableAlign.left) {
alignment = node.rowAlign.alignment;
}
return alignment;
}
Decoration _buildDecoration() {
final backgroundColor = _buildBackgroundColor();
final border = _buildBorder();
return BoxDecoration(
border: border,
color: backgroundColor,
);
}
Color? _buildBackgroundColor() {
// Priority: column color > row color > header color > default color
final columnColor = node.buildColumnColor(context);
if (columnColor != null && columnColor != Colors.transparent) {
return columnColor;
}
final rowColor = node.buildRowColor(context);
if (rowColor != null && rowColor != Colors.transparent) {
return rowColor;
}
// Check if the cell is in the header.
// If the cell is in the header, set the background color to the default header color.
// Otherwise, set the background color to null.
if (_isInHeader()) {
return context.simpleTableDefaultHeaderColor;
}
return Theme.of(context).colorScheme.surface;
}
Border? _buildBorder() {
if (SimpleTableConstants.borderType != SimpleTableBorderRenderType.cell) {
return null;
}
final tableContext = context.watch<SimpleTableContext>();
final isCellInSelectedColumn =
node.columnIndex == tableContext.selectingColumn.value;
final isCellInSelectedRow =
node.rowIndex == tableContext.selectingRow.value;
if (tableContext.isSelectingTable.value) {
return _buildSelectingTableBorder();
} else if (isCellInSelectedColumn) {
return _buildColumnBorder();
} else if (isCellInSelectedRow) {
return _buildRowBorder();
} else if (isEditingCellNotifier.value) {
return _buildEditingBorder();
} else {
return _buildCellBorder();
}
}
bool _isInHeader() {
final isHeaderColumnEnabled = node.isHeaderColumnEnabled;
final isHeaderRowEnabled = node.isHeaderRowEnabled;
final cellPosition = node.cellPosition;
final isFirstColumn = cellPosition.$1 == 0;
final isFirstRow = cellPosition.$2 == 0;
return isHeaderColumnEnabled && isFirstRow ||
isHeaderRowEnabled && isFirstColumn;
}
/// the column border means the `VERTICAL` border of the cell
///
/// ____
/// | 1 | 2 |
/// | 3 | 4 |
/// |___|
///
/// the border wrapping the cell 2 and cell 4 is the column border
Border _buildColumnBorder() {
return Border(
left: _buildHighlightBorderSide(),
right: _buildHighlightBorderSide(),
top: node.rowIndex == 0
? _buildHighlightBorderSide()
: _buildDefaultBorderSide(),
bottom: node.rowIndex + 1 == node.parentTableNode?.rowLength
? _buildHighlightBorderSide()
: _buildDefaultBorderSide(),
);
}
/// the row border means the `HORIZONTAL` border of the cell
///
/// ________
/// | 1 | 2 |
/// |_______|
/// | 3 | 4 |
///
/// the border wrapping the cell 1 and cell 2 is the row border
Border _buildRowBorder() {
return Border(
top: _buildHighlightBorderSide(),
bottom: _buildHighlightBorderSide(),
left: node.columnIndex == 0
? _buildHighlightBorderSide()
: _buildDefaultBorderSide(),
right: node.columnIndex + 1 == node.parentTableNode?.columnLength
? _buildHighlightBorderSide()
: _buildDefaultBorderSide(),
);
}
Border _buildCellBorder() {
return Border(
top: node.rowIndex == 0
? _buildDefaultBorderSide()
: _buildLightBorderSide(),
bottom: node.rowIndex + 1 == node.parentTableNode?.rowLength
? _buildDefaultBorderSide()
: _buildLightBorderSide(),
left: node.columnIndex == 0
? _buildDefaultBorderSide()
: _buildLightBorderSide(),
right: node.columnIndex + 1 == node.parentTableNode?.columnLength
? _buildDefaultBorderSide()
: _buildLightBorderSide(),
);
}
Border _buildEditingBorder() {
return Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
);
}
Border _buildSelectingTableBorder() {
final rowIndex = node.rowIndex;
final columnIndex = node.columnIndex;
return Border(
top:
rowIndex == 0 ? _buildHighlightBorderSide() : _buildLightBorderSide(),
bottom: rowIndex + 1 == node.parentTableNode?.rowLength
? _buildHighlightBorderSide()
: _buildLightBorderSide(),
left: columnIndex == 0
? _buildHighlightBorderSide()
: _buildLightBorderSide(),
right: columnIndex + 1 == node.parentTableNode?.columnLength
? _buildHighlightBorderSide()
: _buildLightBorderSide(),
);
}
BorderSide _buildHighlightBorderSide() {
return BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
);
}
BorderSide _buildLightBorderSide() {
return BorderSide(
color: context.simpleTableBorderColor,
width: 0.5,
);
}
BorderSide _buildDefaultBorderSide() {
return BorderSide(
color: context.simpleTableBorderColor,
);
}
void _onSelectingTableChanged() {
if (mounted) {
setState(() {});
}
}
void _onSelectionChanged() {
final selection = editorState.selection;
// check if the selection is in the cell
if (selection != null &&
node.path.isAncestorOf(selection.start.path) &&
node.path.isAncestorOf(selection.end.path)) {
isEditingCellNotifier.value = true;
} else {
isEditingCellNotifier.value = false;
}
}
}
@@ -1,214 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
const enableTableDebugLog = false;
class SimpleTableContext {
SimpleTableContext() {
if (enableTableDebugLog) {
isHoveringOnColumnsAndRows.addListener(
_onHoveringOnColumnsAndRowsChanged,
);
isHoveringOnTableArea.addListener(
_onHoveringOnTableAreaChanged,
);
hoveringTableCell.addListener(_onHoveringTableNodeChanged);
selectingColumn.addListener(_onSelectingColumnChanged);
selectingRow.addListener(_onSelectingRowChanged);
isSelectingTable.addListener(_onSelectingTableChanged);
isHoveringOnTableBlock.addListener(_onHoveringOnTableBlockChanged);
}
}
// the area only contains the columns and rows,
// the add row button, add column button, and add column and row button are not part of the table area
final ValueNotifier<bool> isHoveringOnColumnsAndRows = ValueNotifier(false);
// the table area contains the columns and rows,
// the add row button, add column button, and add column and row button are not part of the table area,
// not including the selection area and padding
final ValueNotifier<bool> isHoveringOnTableArea = ValueNotifier(false);
// the table block area contains the table area and the add row button, add column button, and add column and row button
// also, the table block area contains the selection area and padding
final ValueNotifier<bool> isHoveringOnTableBlock = ValueNotifier(false);
// the hovering table cell is the cell that the mouse is hovering on
final ValueNotifier<Node?> hoveringTableCell = ValueNotifier(null);
// the hovering on resize handle is the resize handle that the mouse is hovering on
final ValueNotifier<Node?> hoveringOnResizeHandle = ValueNotifier(null);
// the selecting column is the column that the user is selecting
final ValueNotifier<int?> selectingColumn = ValueNotifier(null);
// the selecting row is the row that the user is selecting
final ValueNotifier<int?> selectingRow = ValueNotifier(null);
// the is selecting table is the table that the user is selecting
final ValueNotifier<bool> isSelectingTable = ValueNotifier(false);
void _onHoveringOnColumnsAndRowsChanged() {
if (!enableTableDebugLog) {
return;
}
Log.debug('isHoveringOnTable: ${isHoveringOnColumnsAndRows.value}');
}
void _onHoveringTableNodeChanged() {
if (!enableTableDebugLog) {
return;
}
final node = hoveringTableCell.value;
if (node == null) {
return;
}
Log.debug('hoveringTableNode: $node, ${node.cellPosition}');
}
void _onSelectingColumnChanged() {
if (!enableTableDebugLog) {
return;
}
Log.debug('selectingColumn: ${selectingColumn.value}');
}
void _onSelectingRowChanged() {
if (!enableTableDebugLog) {
return;
}
Log.debug('selectingRow: ${selectingRow.value}');
}
void _onSelectingTableChanged() {
if (!enableTableDebugLog) {
return;
}
Log.debug('isSelectingTable: ${isSelectingTable.value}');
}
void _onHoveringOnTableBlockChanged() {
if (!enableTableDebugLog) {
return;
}
Log.debug('isHoveringOnTableBlock: ${isHoveringOnTableBlock.value}');
}
void _onHoveringOnTableAreaChanged() {
if (!enableTableDebugLog) {
return;
}
Log.debug('isHoveringOnTableArea: ${isHoveringOnTableArea.value}');
}
void dispose() {
isHoveringOnColumnsAndRows.dispose();
isHoveringOnTableBlock.dispose();
isHoveringOnTableArea.dispose();
hoveringTableCell.dispose();
hoveringOnResizeHandle.dispose();
selectingColumn.dispose();
selectingRow.dispose();
isSelectingTable.dispose();
}
}
class SimpleTableConstants {
// Table
static const defaultColumnWidth = 120.0;
static const minimumColumnWidth = 36.0;
static const tableTopPadding = 8.0;
static const tableLeftPadding = 8.0;
static const tableBottomPadding =
addRowButtonHeight + 3 * addRowButtonPadding;
static const tableRightPadding =
addColumnButtonWidth + 2 * SimpleTableConstants.addColumnButtonPadding;
static const tablePadding = EdgeInsets.only(
// don't add padding to the top of the table, the first row will have padding
// to make the column action button clickable.
bottom: tableBottomPadding,
left: tableLeftPadding,
right: tableRightPadding,
);
// Add row button
static const addRowButtonHeight = 16.0;
static const addRowButtonPadding = 4.0;
static const addRowButtonRadius = 4.0;
static const addRowButtonRightPadding =
addColumnButtonWidth + addColumnButtonPadding * 2;
// Add column button
static const addColumnButtonWidth = 16.0;
static const addColumnButtonPadding = 2.0;
static const addColumnButtonRadius = 4.0;
static const addColumnButtonBottomPadding =
addRowButtonHeight + 3 * addRowButtonPadding;
// Add column and row button
static const addColumnAndRowButtonWidth = addColumnButtonWidth;
static const addColumnAndRowButtonHeight = addRowButtonHeight;
static const addColumnAndRowButtonCornerRadius = addColumnButtonWidth / 2.0;
static const addColumnAndRowButtonBottomPadding = 2.5 * addRowButtonPadding;
// Table cell
static const cellEdgePadding = EdgeInsets.symmetric(
horizontal: 9.0,
vertical: 2.0,
);
static const cellBorderWidth = 1.0;
static const resizeHandleWidth = 3.0;
static const borderType = SimpleTableBorderRenderType.cell;
// Table more action
static const moreActionHeight = 34.0;
static const moreActionPadding = EdgeInsets.symmetric(vertical: 2.0);
static const moreActionHorizontalMargin =
EdgeInsets.symmetric(horizontal: 6.0);
}
enum SimpleTableBorderRenderType {
cell,
table,
}
extension SimpleTableColors on BuildContext {
Color get simpleTableBorderColor => Theme.of(this).isLightMode
? const Color(0xFFE4E5E5)
: const Color(0xFF3A3F49);
Color get simpleTableDividerColor => Theme.of(this).isLightMode
? const Color(0x141F2329)
: const Color(0xFF23262B).withOpacity(0.5);
Color get simpleTableMoreActionBackgroundColor => Theme.of(this).isLightMode
? const Color(0xFFF2F3F5)
: const Color(0xFF2D3036);
Color get simpleTableMoreActionBorderColor => Theme.of(this).isLightMode
? const Color(0xFFCFD3D9)
: const Color(0xFF44484E);
Color get simpleTableMoreActionHoverColor => Theme.of(this).isLightMode
? const Color(0xFF00C8FF)
: const Color(0xFF00C8FF);
Color get simpleTableDefaultHeaderColor => Theme.of(this).isLightMode
? const Color(0xFFF2F2F2)
: const Color(0x08FFFFFF);
}
@@ -1,690 +0,0 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/_shared_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_operations.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
enum SimpleTableMoreActionType {
column,
row;
List<SimpleTableMoreAction> get actions {
switch (this) {
case SimpleTableMoreActionType.row:
return [
SimpleTableMoreAction.insertAbove,
SimpleTableMoreAction.insertBelow,
SimpleTableMoreAction.duplicate,
SimpleTableMoreAction.clearContents,
SimpleTableMoreAction.delete,
SimpleTableMoreAction.divider,
SimpleTableMoreAction.align,
SimpleTableMoreAction.backgroundColor,
];
case SimpleTableMoreActionType.column:
return [
SimpleTableMoreAction.insertLeft,
SimpleTableMoreAction.insertRight,
SimpleTableMoreAction.duplicate,
SimpleTableMoreAction.clearContents,
SimpleTableMoreAction.delete,
SimpleTableMoreAction.divider,
SimpleTableMoreAction.align,
SimpleTableMoreAction.backgroundColor,
];
}
}
FlowySvgData get reorderIconSvg {
switch (this) {
case SimpleTableMoreActionType.column:
return FlowySvgs.table_reorder_column_s;
case SimpleTableMoreActionType.row:
return FlowySvgs.table_reorder_row_s;
}
}
}
enum SimpleTableMoreAction {
insertLeft,
insertRight,
insertAbove,
insertBelow,
duplicate,
clearContents,
delete,
align,
backgroundColor,
enableHeaderColumn,
enableHeaderRow,
divider;
String get name {
return switch (this) {
SimpleTableMoreAction.align =>
LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(),
SimpleTableMoreAction.backgroundColor =>
LocaleKeys.document_plugins_simpleTable_moreActions_color.tr(),
SimpleTableMoreAction.enableHeaderColumn =>
LocaleKeys.document_plugins_simpleTable_moreActions_headerColumn.tr(),
SimpleTableMoreAction.enableHeaderRow =>
LocaleKeys.document_plugins_simpleTable_moreActions_headerRow.tr(),
SimpleTableMoreAction.insertLeft =>
LocaleKeys.document_plugins_simpleTable_moreActions_insertLeft.tr(),
SimpleTableMoreAction.insertRight =>
LocaleKeys.document_plugins_simpleTable_moreActions_insertRight.tr(),
SimpleTableMoreAction.insertBelow =>
LocaleKeys.document_plugins_simpleTable_moreActions_insertBelow.tr(),
SimpleTableMoreAction.insertAbove =>
LocaleKeys.document_plugins_simpleTable_moreActions_insertAbove.tr(),
SimpleTableMoreAction.clearContents =>
LocaleKeys.document_plugins_simpleTable_moreActions_clearContents.tr(),
SimpleTableMoreAction.delete =>
LocaleKeys.document_plugins_simpleTable_moreActions_delete.tr(),
SimpleTableMoreAction.duplicate =>
LocaleKeys.document_plugins_simpleTable_moreActions_duplicate.tr(),
SimpleTableMoreAction.divider => throw UnimplementedError(),
};
}
FlowySvgData get leftIconSvg {
return switch (this) {
SimpleTableMoreAction.insertLeft => FlowySvgs.table_insert_left_s,
SimpleTableMoreAction.insertRight => FlowySvgs.table_insert_right_s,
SimpleTableMoreAction.insertAbove => FlowySvgs.table_insert_above_s,
SimpleTableMoreAction.insertBelow => FlowySvgs.table_insert_below_s,
SimpleTableMoreAction.duplicate => FlowySvgs.duplicate_s,
SimpleTableMoreAction.clearContents => FlowySvgs.table_clear_content_s,
SimpleTableMoreAction.delete => FlowySvgs.trash_s,
SimpleTableMoreAction.enableHeaderColumn =>
FlowySvgs.table_header_column_s,
SimpleTableMoreAction.enableHeaderRow => FlowySvgs.table_header_row_s,
SimpleTableMoreAction.divider =>
throw UnsupportedError('divider icon is not supported'),
SimpleTableMoreAction.align =>
throw UnsupportedError('align icon is not supported'),
SimpleTableMoreAction.backgroundColor =>
throw UnsupportedError('background color icon is not supported'),
};
}
}
class SimpleTableMoreActionMenu extends StatefulWidget {
const SimpleTableMoreActionMenu({
super.key,
required this.index,
required this.type,
});
final int index;
final SimpleTableMoreActionType type;
@override
State<SimpleTableMoreActionMenu> createState() =>
_SimpleTableMoreActionMenuState();
}
class _SimpleTableMoreActionMenuState extends State<SimpleTableMoreActionMenu> {
ValueNotifier<bool> isShowingMenu = ValueNotifier(false);
@override
void dispose() {
isShowingMenu.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Align(
alignment: widget.type == SimpleTableMoreActionType.row
? Alignment.centerLeft
: Alignment.topCenter,
child: ValueListenableBuilder<bool>(
valueListenable: isShowingMenu,
builder: (context, isShowingMenu, child) {
return ValueListenableBuilder(
valueListenable:
context.read<SimpleTableContext>().hoveringTableCell,
builder: (context, hoveringTableNode, child) {
final hoveringIndex =
widget.type == SimpleTableMoreActionType.column
? hoveringTableNode?.columnIndex
: hoveringTableNode?.rowIndex;
if (hoveringIndex != widget.index && !isShowingMenu) {
return const SizedBox.shrink();
}
return child!;
},
child: SimpleTableMoreActionPopup(
key: ValueKey(widget.type.name + widget.index.toString()),
index: widget.index,
isShowingMenu: this.isShowingMenu,
type: widget.type,
),
);
},
),
);
}
}
class SimpleTableMoreActionPopup extends StatefulWidget {
const SimpleTableMoreActionPopup({
super.key,
required this.index,
required this.isShowingMenu,
required this.type,
});
final int index;
final ValueNotifier<bool> isShowingMenu;
final SimpleTableMoreActionType type;
@override
State<SimpleTableMoreActionPopup> createState() =>
_SimpleTableMoreActionPopupState();
}
class _SimpleTableMoreActionPopupState
extends State<SimpleTableMoreActionPopup> {
late final editorState = context.read<EditorState>();
SelectionGestureInterceptor? gestureInterceptor;
RenderBox? get renderBox => context.findRenderObject() as RenderBox?;
@override
void initState() {
super.initState();
final tableCellNode =
context.read<SimpleTableContext>().hoveringTableCell.value;
gestureInterceptor = SelectionGestureInterceptor(
key: 'simple_table_more_action_popup_interceptor_${tableCellNode?.id}',
canTap: (details) => !_isTapInBounds(details.globalPosition),
);
editorState.service.selectionService.registerGestureInterceptor(
gestureInterceptor!,
);
}
@override
void dispose() {
if (gestureInterceptor != null) {
editorState.service.selectionService.unregisterGestureInterceptor(
gestureInterceptor!.key,
);
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final tableCellNode =
context.read<SimpleTableContext>().hoveringTableCell.value;
return AppFlowyPopover(
onOpen: () {
widget.isShowingMenu.value = true;
switch (widget.type) {
case SimpleTableMoreActionType.column:
context.read<SimpleTableContext>().selectingColumn.value =
tableCellNode?.columnIndex;
case SimpleTableMoreActionType.row:
context.read<SimpleTableContext>().selectingRow.value =
tableCellNode?.rowIndex;
}
// Workaround to clear the selection after the menu is opened.
Future.delayed(Durations.short3, () {
if (!editorState.isDisposed) {
editorState.selection = null;
}
});
},
onClose: () {
widget.isShowingMenu.value = false;
// clear the selecting index
context.read<SimpleTableContext>().selectingColumn.value = null;
context.read<SimpleTableContext>().selectingRow.value = null;
},
direction: widget.type == SimpleTableMoreActionType.row
? PopoverDirection.bottomWithCenterAligned
: PopoverDirection.bottomWithLeftAligned,
offset: widget.type == SimpleTableMoreActionType.row
? const Offset(24, 14)
: const Offset(-14, 8),
popupBuilder: (_) {
if (tableCellNode == null) {
return const SizedBox.shrink();
}
return MultiProvider(
providers: [
Provider.value(
value: context.read<SimpleTableContext>(),
),
Provider.value(
value: context.read<EditorState>(),
),
],
child: SimpleTableMoreActionList(
type: widget.type,
index: widget.index,
tableCellNode: tableCellNode,
),
);
},
child: SimpleTableReorderButton(
isShowingMenu: widget.isShowingMenu,
type: widget.type,
),
);
}
bool _isTapInBounds(Offset offset) {
if (renderBox == null) {
return false;
}
final localPosition = renderBox!.globalToLocal(offset);
final result = renderBox!.paintBounds.contains(localPosition);
if (result) {
editorState.selection = null;
}
return result;
}
}
class SimpleTableMoreActionList extends StatefulWidget {
const SimpleTableMoreActionList({
super.key,
required this.type,
required this.index,
required this.tableCellNode,
this.mutex,
});
final SimpleTableMoreActionType type;
final int index;
final Node tableCellNode;
final PopoverMutex? mutex;
@override
State<SimpleTableMoreActionList> createState() =>
_SimpleTableMoreActionListState();
}
class _SimpleTableMoreActionListState extends State<SimpleTableMoreActionList> {
// ensure the background color menu and align menu exclusive
final mutex = PopoverMutex();
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: _buildActions()
.map(
(action) => SimpleTableMoreActionItem(
type: widget.type,
action: action,
tableCellNode: widget.tableCellNode,
popoverMutex: mutex,
),
)
.toList(),
);
}
List<SimpleTableMoreAction> _buildActions() {
final actions = widget.type.actions;
// if the index is 0, add the divider and enable header action
if (widget.index == 0) {
actions.addAll([
SimpleTableMoreAction.divider,
if (widget.type == SimpleTableMoreActionType.column)
SimpleTableMoreAction.enableHeaderColumn,
if (widget.type == SimpleTableMoreActionType.row)
SimpleTableMoreAction.enableHeaderRow,
]);
}
// if the table only contains one row or one column, remove the delete action
if (widget.tableCellNode.rowLength == 1 &&
widget.type == SimpleTableMoreActionType.row) {
actions.remove(SimpleTableMoreAction.delete);
}
if (widget.tableCellNode.columnLength == 1 &&
widget.type == SimpleTableMoreActionType.column) {
actions.remove(SimpleTableMoreAction.delete);
}
return actions;
}
}
class SimpleTableMoreActionItem extends StatefulWidget {
const SimpleTableMoreActionItem({
super.key,
required this.type,
required this.action,
required this.tableCellNode,
required this.popoverMutex,
});
final SimpleTableMoreActionType type;
final SimpleTableMoreAction action;
final Node tableCellNode;
final PopoverMutex popoverMutex;
@override
State<SimpleTableMoreActionItem> createState() =>
_SimpleTableMoreActionItemState();
}
class _SimpleTableMoreActionItemState extends State<SimpleTableMoreActionItem> {
final isEnableHeader = ValueNotifier(false);
@override
void initState() {
super.initState();
_initEnableHeader();
}
@override
void dispose() {
isEnableHeader.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.action == SimpleTableMoreAction.divider) {
return _buildDivider(context);
} else if (widget.action == SimpleTableMoreAction.align) {
return _buildAlignMenu(context);
} else if (widget.action == SimpleTableMoreAction.backgroundColor) {
return _buildBackgroundColorMenu(context);
} else if (widget.action == SimpleTableMoreAction.enableHeaderColumn) {
return _buildEnableHeaderButton(context);
} else if (widget.action == SimpleTableMoreAction.enableHeaderRow) {
return _buildEnableHeaderButton(context);
}
return _buildActionButton(context);
}
Widget _buildDivider(BuildContext context) {
return const FlowyDivider(
padding: EdgeInsets.symmetric(
vertical: 4.0,
),
);
}
Widget _buildAlignMenu(BuildContext context) {
return SimpleTableAlignMenu(
type: widget.type,
tableCellNode: widget.tableCellNode,
mutex: widget.popoverMutex,
);
}
Widget _buildBackgroundColorMenu(BuildContext context) {
return SimpleTableBackgroundColorMenu(
type: widget.type,
tableCellNode: widget.tableCellNode,
mutex: widget.popoverMutex,
);
}
Widget _buildEnableHeaderButton(BuildContext context) {
return SimpleTableBasicButton(
text: widget.action.name,
leftIconSvg: widget.action.leftIconSvg,
rightIcon: ValueListenableBuilder(
valueListenable: isEnableHeader,
builder: (context, isEnableHeader, child) {
return Toggle(
value: isEnableHeader,
onChanged: (value) => _toggleEnableHeader(),
padding: EdgeInsets.zero,
);
},
),
onTap: _toggleEnableHeader,
);
}
Widget _buildActionButton(BuildContext context) {
return Container(
height: SimpleTableConstants.moreActionHeight,
padding: SimpleTableConstants.moreActionPadding,
child: FlowyIconTextButton(
margin: SimpleTableConstants.moreActionHorizontalMargin,
leftIconBuilder: (onHover) => FlowySvg(
widget.action.leftIconSvg,
color: widget.action == SimpleTableMoreAction.delete && onHover
? Theme.of(context).colorScheme.error
: null,
),
iconPadding: 10.0,
textBuilder: (onHover) => FlowyText.regular(
widget.action.name,
fontSize: 14.0,
figmaLineHeight: 18.0,
color: widget.action == SimpleTableMoreAction.delete && onHover
? Theme.of(context).colorScheme.error
: null,
),
onTap: _onAction,
),
);
}
void _onAction() {
switch (widget.action) {
case SimpleTableMoreAction.delete:
switch (widget.type) {
case SimpleTableMoreActionType.column:
_deleteColumn();
break;
case SimpleTableMoreActionType.row:
_deleteRow();
break;
}
break;
case SimpleTableMoreAction.insertLeft:
_insertColumnLeft();
break;
case SimpleTableMoreAction.insertRight:
_insertColumnRight();
break;
case SimpleTableMoreAction.insertAbove:
_insertRowAbove();
break;
case SimpleTableMoreAction.insertBelow:
_insertRowBelow();
break;
case SimpleTableMoreAction.clearContents:
_clearContent();
break;
case SimpleTableMoreAction.duplicate:
switch (widget.type) {
case SimpleTableMoreActionType.column:
_duplicateColumn();
break;
case SimpleTableMoreActionType.row:
_duplicateRow();
break;
}
break;
default:
break;
}
PopoverContainer.of(context).close();
}
void _duplicateRow() {
final value = _getTableAndTableCellAndCellPosition();
if (value == null) {
return;
}
final (table, node, _) = value;
final editorState = context.read<EditorState>();
editorState.duplicateRowInTable(table, node.rowIndex);
}
void _duplicateColumn() {
final value = _getTableAndTableCellAndCellPosition();
if (value == null) {
return;
}
final (table, node, _) = value;
final editorState = context.read<EditorState>();
editorState.duplicateColumnInTable(table, node.columnIndex);
}
void _toggleEnableHeader() {
final value = _getTableAndTableCellAndCellPosition();
if (value == null) {
return;
}
isEnableHeader.value = !isEnableHeader.value;
final (table, _, _) = value;
final editorState = context.read<EditorState>();
switch (widget.type) {
case SimpleTableMoreActionType.column:
editorState.toggleEnableHeaderColumn(
table,
isEnableHeader.value,
);
case SimpleTableMoreActionType.row:
editorState.toggleEnableHeaderRow(
table,
isEnableHeader.value,
);
}
}
void _clearContent() {
final value = _getTableAndTableCellAndCellPosition();
if (value == null) {
return;
}
final (table, node, _) = value;
final editorState = context.read<EditorState>();
if (widget.type == SimpleTableMoreActionType.column) {
editorState.clearContentAtColumnIndex(table, node.columnIndex);
} else if (widget.type == SimpleTableMoreActionType.row) {
editorState.clearContentAtRowIndex(table, node.rowIndex);
}
}
void _insertColumnLeft() {
final value = _getTableAndTableCellAndCellPosition();
if (value == null) {
return;
}
final (table, node, _) = value;
final columnIndex = node.columnIndex;
final editorState = context.read<EditorState>();
editorState.insertColumnInTable(table, columnIndex);
}
void _insertColumnRight() {
final value = _getTableAndTableCellAndCellPosition();
if (value == null) {
return;
}
final (table, node, _) = value;
final columnIndex = node.columnIndex;
final editorState = context.read<EditorState>();
editorState.insertColumnInTable(table, columnIndex + 1);
}
void _insertRowAbove() {
final value = _getTableAndTableCellAndCellPosition();
if (value == null) {
return;
}
final (table, node, _) = value;
final rowIndex = node.rowIndex;
final editorState = context.read<EditorState>();
editorState.insertRowInTable(table, rowIndex);
}
void _insertRowBelow() {
final value = _getTableAndTableCellAndCellPosition();
if (value == null) {
return;
}
final (table, node, _) = value;
final rowIndex = node.rowIndex;
final editorState = context.read<EditorState>();
editorState.insertRowInTable(table, rowIndex + 1);
}
void _deleteRow() {
final value = _getTableAndTableCellAndCellPosition();
if (value == null) {
return;
}
final (table, node, _) = value;
final rowIndex = node.rowIndex;
final editorState = context.read<EditorState>();
editorState.deleteRowInTable(table, rowIndex);
}
void _deleteColumn() {
final value = _getTableAndTableCellAndCellPosition();
if (value == null) {
return;
}
final (table, node, _) = value;
final columnIndex = node.columnIndex;
final editorState = context.read<EditorState>();
editorState.deleteColumnInTable(table, columnIndex);
}
(Node, Node, TableCellPosition)? _getTableAndTableCellAndCellPosition() {
final cell = widget.tableCellNode;
final table = cell.parent?.parent;
if (table == null || table.type != SimpleTableBlockKeys.type) {
return null;
}
return (table, cell, cell.cellPosition);
}
void _initEnableHeader() {
final value = _getTableAndTableCellAndCellPosition();
if (value != null) {
final (table, _, _) = value;
if (widget.type == SimpleTableMoreActionType.column) {
isEnableHeader.value = table
.attributes[SimpleTableBlockKeys.enableHeaderColumn] as bool? ??
false;
} else if (widget.type == SimpleTableMoreActionType.row) {
isEnableHeader.value =
table.attributes[SimpleTableBlockKeys.enableHeaderRow] as bool? ??
false;
}
}
}
}
@@ -1,83 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
extension TableContentOperation on EditorState {
/// Clear the content of the column at the given index.
///
/// Before:
/// Given column index: 0
/// Row 1: | 0 | 1 | ← The content of these cells will be cleared
/// Row 2: | 2 | 3 |
///
/// Call this function with column index 0 will clear the first column of the table.
///
/// After:
/// Row 1: | | |
/// Row 2: | 2 | 3 |
Future<void> clearContentAtRowIndex(Node node, int index) async {
assert(node.type == SimpleTableBlockKeys.type);
if (node.type != SimpleTableBlockKeys.type) {
return;
}
if (index < 0 || index >= node.columnLength) {
Log.warn('clear content in column: index out of range: $index');
return;
}
Log.info('clear content in column: $index in table ${node.id}');
final transaction = this.transaction;
final row = node.children[index];
for (var i = 0; i < row.children.length; i++) {
final cell = row.children[i];
transaction.insertNode(cell.path.next, simpleTableCellBlockNode());
transaction.deleteNode(cell);
}
await apply(transaction);
}
/// Clear the content of the row at the given index.
///
/// Before:
/// Given row index: 1
/// ↓ The content of these cells will be cleared
/// Row 1: | 0 | 1 |
/// Row 2: | 2 | 3 |
///
/// Call this function with row index 1 will clear the second row of the table.
///
/// After:
/// Row 1: | 0 | |
/// Row 2: | 2 | |
Future<void> clearContentAtColumnIndex(Node node, int index) async {
assert(node.type == SimpleTableBlockKeys.type);
if (node.type != SimpleTableBlockKeys.type) {
return;
}
if (index < 0 || index >= node.columnLength) {
Log.warn('clear content in column: index out of range: $index');
return;
}
Log.info('clear content in column: $index in table ${node.id}');
final transaction = this.transaction;
for (var i = 0; i < node.rowLength; i++) {
final row = node.children[i];
final cell = index >= row.children.length
? row.children.last
: row.children[index];
transaction.insertNode(cell.path.next, simpleTableCellBlockNode());
transaction.deleteNode(cell);
}
await apply(transaction);
}
}
@@ -1,101 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_map_operation.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
extension TableDeletionOperations on EditorState {
/// Delete a row at the given index.
///
/// Before:
/// Given index: 0
/// Row 1: | | | | ← This row will be deleted
/// Row 2: | | | |
///
/// Call this function with index 0 will delete the first row of the table.
///
/// After:
/// Row 1: | | | |
Future<void> deleteRowInTable(
Node node,
int index,
) async {
assert(node.type == SimpleTableBlockKeys.type);
if (node.type != SimpleTableBlockKeys.type) {
return;
}
final rowLength = node.rowLength;
if (index < 0 || index >= rowLength) {
Log.warn(
'delete row: index out of range: $index, row length: $rowLength',
);
return;
}
Log.info('delete row: $index in table ${node.id}');
final attributes = node.mapTableAttributes(
node,
type: TableMapOperationType.deleteRow,
index: index,
);
final row = node.children[index];
final transaction = this.transaction;
transaction.deleteNode(row);
if (attributes != null) {
transaction.updateNode(node, attributes);
}
await apply(transaction);
}
/// Delete a column at the given index.
///
/// Before:
/// Given index: 2
/// ↓ This column will be deleted
/// Row 1: | 0 | 1 | 2 |
/// Row 2: | | | |
///
/// Call this function with index 2 will delete the third column of the table.
///
/// After:
/// Row 1: | 0 | 1 |
/// Row 2: | | |
Future<void> deleteColumnInTable(Node node, int index) async {
assert(node.type == SimpleTableBlockKeys.type);
if (node.type != SimpleTableBlockKeys.type) {
return;
}
final rowLength = node.rowLength;
final columnLength = node.columnLength;
if (index < 0 || index >= columnLength) {
Log.warn(
'delete column: index out of range: $index, column length: $columnLength',
);
return;
}
Log.info('delete column: $index in table ${node.id}');
final attributes = node.mapTableAttributes(
node,
type: TableMapOperationType.deleteColumn,
index: index,
);
final transaction = this.transaction;
for (var i = 0; i < rowLength; i++) {
final row = node.children[i];
transaction.deleteNode(row.children[index]);
}
if (attributes != null) {
transaction.updateNode(node, attributes);
}
await apply(transaction);
}
}
@@ -1,103 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_map_operation.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
extension TableDuplicationOperations on EditorState {
/// Duplicate a row at the given index.
///
/// Before:
/// | 0 | 1 | 2 |
/// | 3 | 4 | 5 | ← This row will be duplicated
///
/// Call this function with index 1 will duplicate the second row of the table.
///
/// After:
/// | 0 | 1 | 2 |
/// | 3 | 4 | 5 |
/// | 3 | 4 | 5 | ← New row
Future<void> duplicateRowInTable(Node node, int index) async {
assert(node.type == SimpleTableBlockKeys.type);
if (node.type != SimpleTableBlockKeys.type) {
return;
}
final columnLength = node.columnLength;
final rowLength = node.rowLength;
if (index < 0 || index >= rowLength) {
Log.warn(
'duplicate row: index out of range: $index, row length: $rowLength',
);
return;
}
Log.info(
'duplicate row in table ${node.id} at index: $index, column length: $columnLength, row length: $rowLength',
);
final attributes = node.mapTableAttributes(
node,
type: TableMapOperationType.duplicateRow,
index: index,
);
final newRow = node.children[index].copyWith();
final transaction = this.transaction;
final path = index >= columnLength
? node.children.last.path.next
: node.children[index].path;
transaction.insertNode(path, newRow);
if (attributes != null) {
transaction.updateNode(node, attributes);
}
await apply(transaction);
}
Future<void> duplicateColumnInTable(Node node, int index) async {
assert(node.type == SimpleTableBlockKeys.type);
if (node.type != SimpleTableBlockKeys.type) {
return;
}
final columnLength = node.columnLength;
final rowLength = node.rowLength;
if (index < 0 || index >= columnLength) {
Log.warn(
'duplicate column: index out of range: $index, column length: $columnLength',
);
return;
}
Log.info(
'duplicate column in table ${node.id} at index: $index, column length: $columnLength, row length: $rowLength',
);
final attributes = node.mapTableAttributes(
node,
type: TableMapOperationType.duplicateColumn,
index: index,
);
final transaction = this.transaction;
for (var i = 0; i < rowLength; i++) {
final row = node.children[i];
final path = index >= rowLength
? row.children.last.path.next
: row.children[index].path;
final newCell = row.children[index].copyWith();
transaction.insertNode(
path,
newCell,
);
}
if (attributes != null) {
transaction.updateNode(node, attributes);
}
await apply(transaction);
}
}
@@ -1,39 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
extension TableHeaderOperation on EditorState {
/// Toggle the enable header column of the table.
Future<void> toggleEnableHeaderColumn(Node node, bool enable) async {
assert(node.type == SimpleTableBlockKeys.type);
if (node.type != SimpleTableBlockKeys.type) {
return;
}
Log.info('toggle enable header column: $enable in table ${node.id}');
final transaction = this.transaction;
transaction.updateNode(node, {
SimpleTableBlockKeys.enableHeaderColumn: enable,
});
await apply(transaction);
}
/// Toggle the enable header row of the table.
Future<void> toggleEnableHeaderRow(Node node, bool enable) async {
assert(node.type == SimpleTableBlockKeys.type);
if (node.type != SimpleTableBlockKeys.type) {
return;
}
Log.info('toggle enable header row: $enable in table ${node.id}');
final transaction = this.transaction;
transaction.updateNode(node, {
SimpleTableBlockKeys.enableHeaderRow: enable,
});
await apply(transaction);
}
}
@@ -1,195 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_row_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_map_operation.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
extension TableInsertionOperations on EditorState {
/// Add a row at the end of the table.
///
/// Before:
/// Row 1: | | | |
/// Row 2: | | | |
///
/// Call this function will add a row at the end of the table.
///
/// After:
/// Row 1: | | | |
/// Row 2: | | | |
/// Row 3: | | | | ← New row
///
Future<void> addRowInTable(Node tableNode) async {
assert(tableNode.type == SimpleTableBlockKeys.type);
if (tableNode.type != SimpleTableBlockKeys.type) {
Log.warn('node is not a table node: ${tableNode.type}');
return;
}
await insertRowInTable(tableNode, tableNode.rowLength);
}
/// Add a column at the end of the table.
///
/// Before:
/// Row 1: | | | |
/// Row 2: | | | |
///
/// Call this function will add a column at the end of the table.
///
/// After:
/// ↓ New column
/// Row 1: | | | | |
/// Row 2: | | | | |
Future<void> addColumnInTable(Node node) async {
assert(node.type == SimpleTableBlockKeys.type);
if (node.type != SimpleTableBlockKeys.type) {
Log.warn('node is not a table node: ${node.type}');
return;
}
await insertColumnInTable(node, node.columnLength);
}
/// Add a column and a row at the end of the table.
///
/// Before:
/// Row 1: | | | |
/// Row 2: | | | |
///
/// Call this function will add a column and a row at the end of the table.
///
/// After:
/// ↓ New column
/// Row 1: | | | | |
/// Row 2: | | | | |
/// Row 3: | | | | | ← New row
Future<void> addColumnAndRowInTable(Node node) async {
assert(node.type == SimpleTableBlockKeys.type);
if (node.type != SimpleTableBlockKeys.type) {
return;
}
await addColumnInTable(node);
await addRowInTable(node);
}
/// Add a column at the given index.
///
/// Before:
/// Given index: 1
/// Row 1: | 0 | 1 |
/// Row 2: | | |
///
/// Call this function with index 1 will add a column at the second position of the table.
///
/// After: ↓ New column
/// Row 1: | 0 | | 1 |
/// Row 2: | | | |
Future<void> insertColumnInTable(Node node, int index) async {
assert(node.type == SimpleTableBlockKeys.type);
if (node.type != SimpleTableBlockKeys.type) {
Log.warn('node is not a table node: ${node.type}');
return;
}
final columnLength = node.rowLength;
final rowLength = node.columnLength;
Log.info(
'add column in table ${node.id} at index: $index, column length: $columnLength, row length: $rowLength',
);
if (index < 0) {
Log.warn(
'insert column: index out of range: $index, column length: $columnLength',
);
return;
}
final attributes = node.mapTableAttributes(
node,
type: TableMapOperationType.insertColumn,
index: index,
);
final transaction = this.transaction;
for (var i = 0; i < columnLength; i++) {
final row = node.children[i];
// if the index is greater than the row length, we add the new column at the end of the row.
final path = index >= rowLength
? row.children.last.path.next
: row.children[index].path;
transaction.insertNode(
path,
simpleTableCellBlockNode(),
);
}
if (attributes != null) {
transaction.updateNode(node, attributes);
}
await apply(transaction);
}
/// Add a row at the given index.
///
/// Before:
/// Given index: 1
/// Row 1: | | |
/// Row 2: | | |
///
/// Call this function with index 1 will add a row at the second position of the table.
///
/// After:
/// Row 1: | | |
/// Row 2: | | |
/// Row 3: | | | ← New row
Future<void> insertRowInTable(Node node, int index) async {
assert(node.type == SimpleTableBlockKeys.type);
if (node.type != SimpleTableBlockKeys.type) {
return;
}
if (index < 0) {
Log.warn(
'insert row: index out of range: $index',
);
return;
}
final columnLength = node.rowLength;
final rowLength = node.columnLength;
Log.info(
'insert row in table ${node.id} at index: $index, column length: $columnLength, row length: $rowLength',
);
final newRow = simpleTableRowBlockNode(
children: [
for (var i = 0; i < rowLength; i++) simpleTableCellBlockNode(),
],
);
final attributes = node.mapTableAttributes(
node,
type: TableMapOperationType.insertRow,
index: index,
);
final transaction = this.transaction;
final path = index >= columnLength
? node.children.last.path.next
: node.children[index].path;
transaction.insertNode(path, newRow);
if (attributes != null) {
transaction.updateNode(node, attributes);
}
await apply(transaction);
}
}
@@ -1,500 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
enum TableMapOperationType {
insertRow,
deleteRow,
insertColumn,
deleteColumn,
duplicateRow,
duplicateColumn,
}
extension TableMapOperation on Node {
Attributes? mapTableAttributes(
Node node, {
required TableMapOperationType type,
required int index,
}) {
assert(this.type == SimpleTableBlockKeys.type);
if (this.type != SimpleTableBlockKeys.type) {
return null;
}
Attributes? attributes;
switch (type) {
case TableMapOperationType.insertRow:
attributes = _mapRowInsertionAttributes(index);
case TableMapOperationType.insertColumn:
attributes = _mapColumnInsertionAttributes(index);
case TableMapOperationType.duplicateRow:
attributes = _mapRowDuplicationAttributes(index);
case TableMapOperationType.duplicateColumn:
attributes = _mapColumnDuplicationAttributes(index);
case TableMapOperationType.deleteRow:
attributes = _mapRowDeletionAttributes(index);
case TableMapOperationType.deleteColumn:
attributes = _mapColumnDeletionAttributes(index);
}
// clear the attributes that are null
attributes?.removeWhere((key, value) => value == null);
return attributes;
}
/// Map the attributes of a row insertion operation.
///
/// When inserting a row, the attributes of the table after the index should be updated
/// For example:
/// Before:
/// | 0 | 1 | 2 |
/// | 3 | 4 | 5 | ← insert a new row here
///
/// The original attributes of the table:
/// {
/// "rowColors": {
/// 0: "#FF0000",
/// 1: "#00FF00",
/// }
/// }
///
/// Insert a row at index 1:
/// | 0 | 1 | 2 |
/// | | | | ← new row
/// | 3 | 4 | 5 |
///
/// The new attributes of the table:
/// {
/// "rowColors": {
/// 0: "#FF0000",
/// 2: "#00FF00", ← The attributes of the original second row
/// }
/// }
Attributes? _mapRowInsertionAttributes(int index) {
final attributes = this.attributes;
try {
final rowColors = _remapSource(
this.rowColors,
index,
comparator: (iKey, index) => iKey >= index,
);
final rowAligns = _remapSource(
this.rowAligns,
index,
comparator: (iKey, index) => iKey >= index,
);
return attributes
.mergeValues(
SimpleTableBlockKeys.rowColors,
rowColors,
)
.mergeValues(
SimpleTableBlockKeys.rowAligns,
rowAligns,
);
} catch (e) {
Log.warn('Failed to map row insertion attributes: $e');
return attributes;
}
}
/// Map the attributes of a column insertion operation.
///
/// When inserting a column, the attributes of the table after the index should be updated
/// For example:
/// Before:
/// | 0 | 1 |
/// | 2 | 3 |
///
/// The original attributes of the table:
/// {
/// "columnColors": {
/// 0: "#FF0000",
/// 1: "#00FF00",
/// }
/// }
///
/// Insert a column at index 1:
/// | 0 | | 1 |
/// | 2 | | 3 |
///
/// The new attributes of the table:
/// {
/// "columnColors": {
/// 0: "#FF0000",
/// 2: "#00FF00", ← The attributes of the original second column
/// }
/// }
Attributes? _mapColumnInsertionAttributes(int index) {
final attributes = this.attributes;
try {
final columnColors = _remapSource(
this.columnColors,
index,
comparator: (iKey, index) => iKey >= index,
);
final columnAligns = _remapSource(
this.columnAligns,
index,
comparator: (iKey, index) => iKey >= index,
);
final columnWidths = _remapSource(
this.columnWidths,
index,
comparator: (iKey, index) => iKey >= index,
);
return attributes
.mergeValues(
SimpleTableBlockKeys.columnColors,
columnColors,
)
.mergeValues(
SimpleTableBlockKeys.columnAligns,
columnAligns,
)
.mergeValues(
SimpleTableBlockKeys.columnWidths,
columnWidths,
);
} catch (e) {
Log.warn('Failed to map row insertion attributes: $e');
return attributes;
}
}
/// Map the attributes of a row duplication operation.
///
/// When duplicating a row, the attributes of the table after the index should be updated
/// For example:
/// Before:
/// | 0 | 1 | 2 |
/// | 3 | 4 | 5 |
///
/// The original attributes of the table:
/// {
/// "rowColors": {
/// 0: "#FF0000",
/// 1: "#00FF00",
/// }
/// }
///
/// Duplicate the row at index 1:
/// | 0 | 1 | 2 |
/// | 3 | 4 | 5 |
/// | 3 | 4 | 5 | ← duplicated row
///
/// The new attributes of the table:
/// {
/// "rowColors": {
/// 0: "#FF0000",
/// 1: "#00FF00",
/// 2: "#00FF00", ← The attributes of the original second row
/// }
/// }
Attributes? _mapRowDuplicationAttributes(int index) {
final attributes = this.attributes;
try {
final (rowColors, duplicatedRowColor) = _findDuplicatedEntryAndRemap(
this.rowColors,
index,
);
final (rowAligns, duplicatedRowAlign) = _findDuplicatedEntryAndRemap(
this.rowAligns,
index,
);
return attributes
.mergeValues(
SimpleTableBlockKeys.rowColors,
rowColors,
duplicatedEntry: duplicatedRowColor,
)
.mergeValues(
SimpleTableBlockKeys.rowAligns,
rowAligns,
duplicatedEntry: duplicatedRowAlign,
);
} catch (e) {
Log.warn('Failed to map row insertion attributes: $e');
return attributes;
}
}
/// Map the attributes of a column duplication operation.
///
/// When duplicating a column, the attributes of the table after the index should be updated
/// For example:
/// Before:
/// | 0 | 1 |
/// | 2 | 3 |
///
/// The original attributes of the table:
/// {
/// "columnColors": {
/// 0: "#FF0000",
/// 1: "#00FF00",
/// }
/// }
///
/// Duplicate the column at index 1:
/// | 0 | 1 | 1 | ← duplicated column
/// | 2 | 3 | 2 | ← duplicated column
///
/// The new attributes of the table:
/// {
/// "columnColors": {
/// 0: "#FF0000",
/// 1: "#00FF00",
/// 2: "#00FF00", ← The attributes of the original second column
/// }
/// }
Attributes? _mapColumnDuplicationAttributes(int index) {
final attributes = this.attributes;
try {
final (columnColors, duplicatedColumnColor) =
_findDuplicatedEntryAndRemap(
this.columnColors,
index,
);
final (columnAligns, duplicatedColumnAlign) =
_findDuplicatedEntryAndRemap(
this.columnAligns,
index,
);
final (columnWidths, duplicatedColumnWidth) =
_findDuplicatedEntryAndRemap(
this.columnWidths,
index,
);
return attributes
.mergeValues(
SimpleTableBlockKeys.columnColors,
columnColors,
duplicatedEntry: duplicatedColumnColor,
)
.mergeValues(
SimpleTableBlockKeys.columnAligns,
columnAligns,
duplicatedEntry: duplicatedColumnAlign,
)
.mergeValues(
SimpleTableBlockKeys.columnWidths,
columnWidths,
duplicatedEntry: duplicatedColumnWidth,
);
} catch (e) {
Log.warn('Failed to map column duplication attributes: $e');
return attributes;
}
}
/// Map the attributes of a column deletion operation.
///
/// When deleting a column, the attributes of the table after the index should be updated
///
/// For example:
/// Before:
/// | 0 | 1 | 2 |
/// | 3 | 4 | 5 |
///
/// The original attributes of the table:
/// {
/// "columnColors": {
/// 0: "#FF0000",
/// 2: "#00FF00",
/// }
/// }
///
/// Delete the column at index 1:
/// | 0 | 2 |
/// | 3 | 5 |
///
/// The new attributes of the table:
/// {
/// "columnColors": {
/// 0: "#FF0000",
/// 1: "#00FF00", ← The attributes of the original second column
/// }
/// }
Attributes? _mapColumnDeletionAttributes(int index) {
final attributes = this.attributes;
try {
final columnColors = _remapSource(
this.columnColors,
index,
increment: false,
comparator: (iKey, index) => iKey > index,
filterIndex: index,
);
final columnAligns = _remapSource(
this.columnAligns,
index,
increment: false,
comparator: (iKey, index) => iKey > index,
filterIndex: index,
);
final columnWidths = _remapSource(
this.columnWidths,
index,
increment: false,
comparator: (iKey, index) => iKey > index,
filterIndex: index,
);
return attributes
.mergeValues(
SimpleTableBlockKeys.columnColors,
columnColors,
)
.mergeValues(
SimpleTableBlockKeys.columnAligns,
columnAligns,
)
.mergeValues(
SimpleTableBlockKeys.columnWidths,
columnWidths,
);
} catch (e) {
Log.warn('Failed to map column deletion attributes: $e');
return attributes;
}
}
/// Map the attributes of a row deletion operation.
///
/// When deleting a row, the attributes of the table after the index should be updated
///
/// For example:
/// Before:
/// | 0 | 1 | 2 | ← delete this row
/// | 3 | 4 | 5 |
///
/// The original attributes of the table:
/// {
/// "rowColors": {
/// 0: "#FF0000",
/// 1: "#00FF00",
/// }
/// }
///
/// Delete the row at index 0:
/// | 3 | 4 | 5 |
///
/// The new attributes of the table:
/// {
/// "rowColors": {
/// 0: "#00FF00",
/// }
/// }
Attributes? _mapRowDeletionAttributes(int index) {
final attributes = this.attributes;
try {
final rowColors = _remapSource(
this.rowColors,
index,
increment: false,
comparator: (iKey, index) => iKey > index,
filterIndex: index,
);
final rowAligns = _remapSource(
this.rowAligns,
index,
increment: false,
comparator: (iKey, index) => iKey > index,
filterIndex: index,
);
return attributes
.mergeValues(
SimpleTableBlockKeys.rowColors,
rowColors,
)
.mergeValues(
SimpleTableBlockKeys.rowAligns,
rowAligns,
);
} catch (e) {
Log.warn('Failed to map row deletion attributes: $e');
return attributes;
}
}
}
/// Find the duplicated entry and remap the source.
///
/// All the entries after the index will be remapped to the new index.
(Map<String, dynamic> newSource, MapEntry? duplicatedEntry)
_findDuplicatedEntryAndRemap(
Map<String, dynamic> source,
int index, {
bool increment = true,
}) {
MapEntry? duplicatedEntry;
final newSource = source.map((key, value) {
final iKey = int.parse(key);
if (iKey == index) {
duplicatedEntry = MapEntry(key, value);
}
if (iKey >= index) {
return MapEntry((iKey + (increment ? 1 : -1)).toString(), value);
}
return MapEntry(key, value);
});
return (newSource, duplicatedEntry);
}
/// Remap the source to the new index.
///
/// All the entries after the index will be remapped to the new index.
Map<String, dynamic> _remapSource(
Map<String, dynamic> source,
int index, {
bool increment = true,
required bool Function(int iKey, int index) comparator,
int? filterIndex,
}) {
var newSource = {...source};
if (filterIndex != null) {
newSource.remove(filterIndex.toString());
}
newSource = newSource.map((key, value) {
final iKey = int.parse(key);
if (comparator(iKey, index)) {
return MapEntry((iKey + (increment ? 1 : -1)).toString(), value);
}
return MapEntry(key, value);
});
return newSource;
}
extension TableMapOperationAttributes on Attributes {
Attributes mergeValues(
String key,
Map<String, dynamic> newSource, {
MapEntry? duplicatedEntry,
}) {
final result = {...this};
if (duplicatedEntry != null) {
newSource[duplicatedEntry.key] = duplicatedEntry.value;
}
result[key] = newSource;
return result;
}
}
@@ -1,506 +0,0 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_row_block_component.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
typedef TableCellPosition = (int, int);
enum TableAlign {
left,
center,
right;
String get name => switch (this) {
TableAlign.left => 'left',
TableAlign.center => 'center',
TableAlign.right => 'right',
};
FlowySvgData get leftIconSvg => switch (this) {
TableAlign.left => FlowySvgs.table_align_left_s,
TableAlign.center => FlowySvgs.table_align_center_s,
TableAlign.right => FlowySvgs.table_align_right_s,
};
Alignment get alignment => switch (this) {
TableAlign.left => Alignment.topLeft,
TableAlign.center => Alignment.topCenter,
TableAlign.right => Alignment.topRight,
};
}
extension TableNodeExtension on Node {
/// The number of rows in the table.
///
/// The acceptable node is a table node, table row node or table cell node.
///
/// Example:
///
/// Row 1: | | | |
/// Row 2: | | | |
///
/// The row length is 2.
int get rowLength {
final parentTableNode = this.parentTableNode;
if (parentTableNode == null ||
parentTableNode.type != SimpleTableBlockKeys.type) {
return -1;
}
return parentTableNode.children.length;
}
/// The number of rows in the table.
///
/// The acceptable node is a table node, table row node or table cell node.
///
/// Example:
///
/// Row 1: | | | |
/// Row 2: | | | |
///
/// The column length is 3.
int get columnLength {
final parentTableNode = this.parentTableNode;
if (parentTableNode == null ||
parentTableNode.type != SimpleTableBlockKeys.type) {
return -1;
}
return parentTableNode.children.firstOrNull?.children.length ?? 0;
}
TableCellPosition get cellPosition {
assert(type == SimpleTableCellBlockKeys.type);
return (rowIndex, columnIndex);
}
int get rowIndex {
if (type == SimpleTableCellBlockKeys.type) {
return path.parent.last;
} else if (type == SimpleTableRowBlockKeys.type) {
return path.last;
}
return -1;
}
int get columnIndex {
assert(type == SimpleTableCellBlockKeys.type);
return path.last;
}
bool get isHeaderColumnEnabled {
try {
return parentTableNode
?.attributes[SimpleTableBlockKeys.enableHeaderColumn] ??
false;
} catch (e) {
Log.warn('get is header column enabled: $e');
return false;
}
}
bool get isHeaderRowEnabled {
try {
return parentTableNode
?.attributes[SimpleTableBlockKeys.enableHeaderRow] ??
false;
} catch (e) {
Log.warn('get is header row enabled: $e');
return false;
}
}
TableAlign get rowAlign {
final parentTableNode = this.parentTableNode;
if (parentTableNode == null) {
return TableAlign.left;
}
try {
final rowAligns =
parentTableNode.attributes[SimpleTableBlockKeys.rowAligns];
final align = rowAligns?[rowIndex.toString()];
return TableAlign.values.firstWhere(
(e) => e.name == align,
orElse: () => TableAlign.left,
);
} catch (e) {
Log.warn('get row align: $e');
return TableAlign.left;
}
}
TableAlign get columnAlign {
final parentTableNode = this.parentTableNode;
if (parentTableNode == null) {
return TableAlign.left;
}
try {
final columnAligns =
parentTableNode.attributes[SimpleTableBlockKeys.columnAligns];
final align = columnAligns?[columnIndex.toString()];
return TableAlign.values.firstWhere(
(e) => e.name == align,
orElse: () => TableAlign.left,
);
} catch (e) {
Log.warn('get column align: $e');
return TableAlign.left;
}
}
Node? get parentTableNode {
Node? tableNode;
if (type == SimpleTableBlockKeys.type) {
tableNode = this;
} else if (type == SimpleTableRowBlockKeys.type) {
tableNode = parent;
} else if (type == SimpleTableCellBlockKeys.type) {
tableNode = parent?.parent;
} else {
return parent?.parentTableNode;
}
if (tableNode == null || tableNode.type != SimpleTableBlockKeys.type) {
return null;
}
return tableNode;
}
double get columnWidth {
final parentTableNode = this.parentTableNode;
if (parentTableNode == null) {
return SimpleTableConstants.defaultColumnWidth;
}
try {
final columnWidths =
parentTableNode.attributes[SimpleTableBlockKeys.columnWidths];
final width = columnWidths?[columnIndex.toString()];
return width ?? SimpleTableConstants.defaultColumnWidth;
} catch (e) {
Log.warn('get column width: $e');
return SimpleTableConstants.defaultColumnWidth;
}
}
/// Build the row color.
///
/// Default is null.
Color? buildRowColor(BuildContext context) {
try {
final rawRowColors =
parentTableNode?.attributes[SimpleTableBlockKeys.rowColors];
if (rawRowColors == null) {
return null;
}
final color = rawRowColors[rowIndex.toString()];
if (color == null) {
return null;
}
return buildEditorCustomizedColor(context, this, color);
} catch (e) {
Log.warn('get row color: $e');
return null;
}
}
/// Build the column color.
///
/// Default is null.
Color? buildColumnColor(BuildContext context) {
try {
final columnColors =
parentTableNode?.attributes[SimpleTableBlockKeys.columnColors];
if (columnColors == null) {
return null;
}
final color = columnColors[columnIndex.toString()];
if (color == null) {
return null;
}
return buildEditorCustomizedColor(context, this, color);
} catch (e) {
Log.warn('get column color: $e');
return null;
}
}
/// Whether the current node is in the header column.
///
/// Default is false.
bool get isInHeaderColumn {
final parentTableNode = parent?.parentTableNode;
if (parentTableNode == null ||
parentTableNode.type != SimpleTableBlockKeys.type) {
return false;
}
return parentTableNode.isHeaderColumnEnabled && parent?.columnIndex == 0;
}
/// Whether the current node is in the header row.
///
/// Default is false.
bool get isInHeaderRow {
final parentTableNode = parent?.parentTableNode;
if (parentTableNode == null ||
parentTableNode.type != SimpleTableBlockKeys.type) {
return false;
}
return parentTableNode.isHeaderRowEnabled && parent?.rowIndex == 0;
}
SimpleTableRowAlignMap get rowAligns {
final rawRowAligns =
parentTableNode?.attributes[SimpleTableBlockKeys.rowAligns];
if (rawRowAligns == null) {
return SimpleTableRowAlignMap();
}
try {
return SimpleTableRowAlignMap.from(rawRowAligns);
} catch (e) {
Log.warn('get row aligns: $e');
return SimpleTableRowAlignMap();
}
}
SimpleTableColorMap get rowColors {
final rawRowColors =
parentTableNode?.attributes[SimpleTableBlockKeys.rowColors];
if (rawRowColors == null) {
return SimpleTableColorMap();
}
try {
return SimpleTableColorMap.from(rawRowColors);
} catch (e) {
Log.warn('get row colors: $e');
return SimpleTableColorMap();
}
}
SimpleTableColorMap get columnColors {
final rawColumnColors =
parentTableNode?.attributes[SimpleTableBlockKeys.columnColors];
if (rawColumnColors == null) {
return SimpleTableColorMap();
}
try {
return SimpleTableColorMap.from(rawColumnColors);
} catch (e) {
Log.warn('get column colors: $e');
return SimpleTableColorMap();
}
}
SimpleTableRowAlignMap get columnAligns {
final rawColumnAligns =
parentTableNode?.attributes[SimpleTableBlockKeys.columnAligns];
if (rawColumnAligns == null) {
return SimpleTableRowAlignMap();
}
try {
return SimpleTableRowAlignMap.from(rawColumnAligns);
} catch (e) {
Log.warn('get column aligns: $e');
return SimpleTableRowAlignMap();
}
}
SimpleTableColumnWidthMap get columnWidths {
final rawColumnWidths =
parentTableNode?.attributes[SimpleTableBlockKeys.columnWidths];
if (rawColumnWidths == null) {
return SimpleTableColumnWidthMap();
}
try {
return SimpleTableColumnWidthMap.from(rawColumnWidths);
} catch (e) {
Log.warn('get column widths: $e');
return SimpleTableColumnWidthMap();
}
}
/// Get the previous cell in the same column. If the row index is 0, it will return the same cell.
Node? getPreviousCellInSameColumn() {
assert(type == SimpleTableCellBlockKeys.type);
final parentTableNode = this.parentTableNode;
if (parentTableNode == null) {
return null;
}
final columnIndex = this.columnIndex;
final rowIndex = this.rowIndex;
if (rowIndex == 0) {
return this;
}
final previousColumn = parentTableNode.children[rowIndex - 1];
final previousCell = previousColumn.children[columnIndex];
return previousCell;
}
/// Get the next cell in the same column. If the row index is the last row, it will return the same cell.
Node? getNextCellInSameColumn() {
assert(type == SimpleTableCellBlockKeys.type);
final parentTableNode = this.parentTableNode;
if (parentTableNode == null) {
return null;
}
final columnIndex = this.columnIndex;
final rowIndex = this.rowIndex;
if (rowIndex == parentTableNode.rowLength - 1) {
return this;
}
final nextColumn = parentTableNode.children[rowIndex + 1];
final nextCell = nextColumn.children[columnIndex];
return nextCell;
}
/// Get the right cell in the same row. If the column index is the last column, it will return the same cell.
Node? getNextCellInSameRow() {
assert(type == SimpleTableCellBlockKeys.type);
final parentTableNode = this.parentTableNode;
if (parentTableNode == null) {
return null;
}
final columnIndex = this.columnIndex;
final rowIndex = this.rowIndex;
// the last cell
if (columnIndex == parentTableNode.columnLength - 1 &&
rowIndex == parentTableNode.rowLength - 1) {
return this;
}
if (columnIndex == parentTableNode.columnLength - 1) {
final nextRow = parentTableNode.children[rowIndex + 1];
final nextCell = nextRow.children.first;
return nextCell;
}
final nextColumn = parentTableNode.children[rowIndex];
final nextCell = nextColumn.children[columnIndex + 1];
return nextCell;
}
/// Get the previous cell in the same row. If the column index is 0, it will return the same cell.
Node? getPreviousCellInSameRow() {
assert(type == SimpleTableCellBlockKeys.type);
final parentTableNode = this.parentTableNode;
if (parentTableNode == null) {
return null;
}
final columnIndex = this.columnIndex;
final rowIndex = this.rowIndex;
if (columnIndex == 0 && rowIndex == 0) {
return this;
}
if (columnIndex == 0) {
final previousRow = parentTableNode.children[rowIndex - 1];
final previousCell = previousRow.children.last;
return previousCell;
}
final previousColumn = parentTableNode.children[rowIndex];
final previousCell = previousColumn.children[columnIndex - 1];
return previousCell;
}
/// Get the previous focusable sibling.
///
/// If the current node is the first child of its parent, it will return itself.
Node? getPreviousFocusableSibling() {
final parent = this.parent;
if (parent == null) {
return null;
}
final parentTableNode = this.parentTableNode;
if (parentTableNode == null) {
return null;
}
if (parentTableNode.path == [0]) {
return this;
}
final previous = parentTableNode.previous;
if (previous == null) {
return null;
}
var children = previous.children;
if (children.isEmpty) {
return previous;
}
while (children.isNotEmpty) {
children = children.last.children;
}
return children.lastWhere((c) => c.delta != null);
}
/// Get the next focusable sibling.
///
/// If the current node is the last child of its parent, it will return itself.
Node? getNextFocusableSibling() {
final next = this.next;
if (next == null) {
return null;
}
return next;
}
/// Is the last cell in the table.
bool get isLastCellInTable {
return columnIndex + 1 == parentTableNode?.columnLength &&
rowIndex + 1 == parentTableNode?.rowLength;
}
/// Is the first cell in the table.
bool get isFirstCellInTable {
return columnIndex == 0 && rowIndex == 0;
}
/// Get the table cell node by the row index and column index.
///
/// If the current node is not a table cell node, it will return null.
/// Or if the row index or column index is out of range, it will return null.
Node? getTableCellNode({
required int rowIndex,
required int columnIndex,
}) {
assert(type == SimpleTableBlockKeys.type);
if (type != SimpleTableBlockKeys.type) {
return null;
}
if (rowIndex < 0 || rowIndex >= rowLength) {
return null;
}
if (columnIndex < 0 || columnIndex >= columnLength) {
return null;
}
return children[rowIndex].children[columnIndex];
}
}
@@ -1,8 +0,0 @@
export 'simple_table_content_operation.dart';
export 'simple_table_delete_operation.dart';
export 'simple_table_duplicate_operation.dart';
export 'simple_table_header_operation.dart';
export 'simple_table_insert_operation.dart';
export 'simple_table_map_operation.dart';
export 'simple_table_node_extension.dart';
export 'simple_table_style_operation.dart';
@@ -1,236 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_map_operation.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:universal_platform/universal_platform.dart';
extension TableOptionOperation on EditorState {
/// Update the column width of the table in memory. Call this function when dragging the table column.
///
/// The deltaX is the change of the column width.
Future<void> updateColumnWidthInMemory({
required Node tableCellNode,
required double deltaX,
}) async {
// Disable in mobile
if (UniversalPlatform.isMobile) {
return;
}
assert(tableCellNode.type == SimpleTableCellBlockKeys.type);
if (tableCellNode.type != SimpleTableCellBlockKeys.type) {
return;
}
// when dragging the table column, we need to update the column width in memory.
// so that the table can render the column with the new width.
// but don't need to persist to the database immediately.
// only persist to the database when the drag is completed.
final columnIndex = tableCellNode.columnIndex;
final parentTableNode = tableCellNode.parentTableNode;
if (parentTableNode == null) {
Log.warn('parent table node is null');
return;
}
final width = tableCellNode.columnWidth + deltaX;
try {
final columnWidths =
parentTableNode.attributes[SimpleTableBlockKeys.columnWidths] ??
SimpleTableColumnWidthMap();
final newAttributes = {
...parentTableNode.attributes,
SimpleTableBlockKeys.columnWidths: {
...columnWidths,
columnIndex.toString(): width.clamp(
SimpleTableConstants.minimumColumnWidth,
double.infinity,
),
},
};
parentTableNode.updateAttributes(newAttributes);
} catch (e) {
Log.warn('update column width in memory: $e');
}
}
/// Update the column width of the table. Call this function after the drag is completed.
Future<void> updateColumnWidth({
required Node tableCellNode,
required double width,
}) async {
// Disable in mobile
if (UniversalPlatform.isMobile) {
return;
}
assert(tableCellNode.type == SimpleTableCellBlockKeys.type);
if (tableCellNode.type != SimpleTableCellBlockKeys.type) {
return;
}
final columnIndex = tableCellNode.columnIndex;
final parentTableNode = tableCellNode.parentTableNode;
if (parentTableNode == null) {
Log.warn('parent table node is null');
return;
}
final transaction = this.transaction;
final columnWidths =
parentTableNode.attributes[SimpleTableBlockKeys.columnWidths] ??
SimpleTableColumnWidthMap();
transaction.updateNode(parentTableNode, {
SimpleTableBlockKeys.columnWidths: {
...columnWidths,
columnIndex.toString(): width.clamp(
SimpleTableConstants.minimumColumnWidth,
double.infinity,
),
},
});
await apply(transaction);
}
/// Update the align of the column at the index where the table cell node is located.
///
/// Before:
/// Given table cell node:
/// Row 1: | 0 | 1 |
/// Row 2: |2 |3 | ← This column will be updated
///
/// Call this function will update the align of the column where the table cell node is located.
///
/// After:
/// Row 1: | 0 | 1 |
/// Row 2: | 2 | 3 | ← This column is updated, texts are aligned to the center
Future<void> updateColumnAlign({
required Node tableCellNode,
required TableAlign align,
}) async {
assert(tableCellNode.type == SimpleTableCellBlockKeys.type);
final parentTableNode = tableCellNode.parentTableNode;
if (parentTableNode == null) {
Log.warn('parent table node is null');
return;
}
final transaction = this.transaction;
final columnIndex = tableCellNode.columnIndex;
final attributes = parentTableNode.attributes.mergeValues(
SimpleTableBlockKeys.columnAligns,
parentTableNode.columnAligns,
duplicatedEntry: MapEntry(columnIndex.toString(), align.name),
);
transaction.updateNode(parentTableNode, attributes);
await apply(transaction);
}
/// Update the align of the row at the index where the table cell node is located.
///
/// Before:
/// Given table cell node:
/// ↓ This row will be updated
/// Row 1: | 0 |1 |
/// Row 2: | 2 |3 |
///
/// Call this function will update the align of the row where the table cell node is located.
///
/// After:
/// ↓ This row is updated, texts are aligned to the center
/// Row 1: | 0 | 1 |
/// Row 2: | 2 | 3 |
Future<void> updateRowAlign({
required Node tableCellNode,
required TableAlign align,
}) async {
assert(tableCellNode.type == SimpleTableCellBlockKeys.type);
final parentTableNode = tableCellNode.parentTableNode;
if (parentTableNode == null) {
Log.warn('parent table node is null');
return;
}
final transaction = this.transaction;
final rowIndex = tableCellNode.rowIndex;
final attributes = parentTableNode.attributes.mergeValues(
SimpleTableBlockKeys.rowAligns,
parentTableNode.rowAligns,
duplicatedEntry: MapEntry(rowIndex.toString(), align.name),
);
transaction.updateNode(parentTableNode, attributes);
await apply(transaction);
}
/// Update the background color of the column at the index where the table cell node is located.
Future<void> updateColumnBackgroundColor({
required Node tableCellNode,
required String color,
}) async {
assert(tableCellNode.type == SimpleTableCellBlockKeys.type);
final parentTableNode = tableCellNode.parentTableNode;
if (parentTableNode == null) {
Log.warn('parent table node is null');
return;
}
final columnIndex = tableCellNode.columnIndex;
Log.info(
'update column background color: $color at column $columnIndex in table ${parentTableNode.id}',
);
final transaction = this.transaction;
final attributes = parentTableNode.attributes.mergeValues(
SimpleTableBlockKeys.columnColors,
parentTableNode.columnColors,
duplicatedEntry: MapEntry(columnIndex.toString(), color),
);
transaction.updateNode(parentTableNode, attributes);
await apply(transaction);
}
/// Update the background color of the row at the index where the table cell node is located.
Future<void> updateRowBackgroundColor({
required Node tableCellNode,
required String color,
}) async {
assert(tableCellNode.type == SimpleTableCellBlockKeys.type);
final parentTableNode = tableCellNode.parentTableNode;
if (parentTableNode == null) {
Log.warn('parent table node is null');
return;
}
final rowIndex = tableCellNode.rowIndex;
Log.info(
'update row background color: $color at row $rowIndex in table ${parentTableNode.id}',
);
final transaction = this.transaction;
final attributes = parentTableNode.attributes.mergeValues(
SimpleTableBlockKeys.rowColors,
parentTableNode.rowColors,
duplicatedEntry: MapEntry(rowIndex.toString(), color),
);
transaction.updateNode(parentTableNode, attributes);
await apply(transaction);
}
}
@@ -1,106 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/_shared_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class SimpleTableRowBlockKeys {
const SimpleTableRowBlockKeys._();
static const String type = 'simple_table_row';
}
Node simpleTableRowBlockNode({
List<Node> children = const [],
}) {
return Node(
type: SimpleTableRowBlockKeys.type,
children: children,
);
}
class SimpleTableRowBlockComponentBuilder extends BlockComponentBuilder {
SimpleTableRowBlockComponentBuilder({
super.configuration,
});
@override
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return SimpleTableRowBlockWidget(
key: node.key,
node: node,
configuration: configuration,
showActions: showActions(node),
actionBuilder: (context, state) => actionBuilder(
blockComponentContext,
state,
),
);
}
@override
BlockComponentValidate get validate => (node) => node.children.isNotEmpty;
}
class SimpleTableRowBlockWidget extends BlockComponentStatefulWidget {
const SimpleTableRowBlockWidget({
super.key,
required super.node,
super.showActions,
super.actionBuilder,
super.configuration = const BlockComponentConfiguration(),
});
@override
State<SimpleTableRowBlockWidget> createState() =>
_SimpleTableRowBlockWidgetState();
}
class _SimpleTableRowBlockWidgetState extends State<SimpleTableRowBlockWidget>
with
BlockComponentConfigurable,
BlockComponentTextDirectionMixin,
BlockComponentBackgroundColorMixin {
@override
BlockComponentConfiguration get configuration => widget.configuration;
@override
Node get node => widget.node;
@override
late EditorState editorState = context.read<EditorState>();
@override
Widget build(BuildContext context) {
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: _buildCells(),
),
);
}
List<Widget> _buildCells() {
final List<Widget> cells = [];
for (var i = 0; i < node.children.length; i++) {
// border
if (i == 0 &&
SimpleTableConstants.borderType ==
SimpleTableBorderRenderType.table) {
cells.add(const SimpleTableRowDivider());
}
cells.add(editorState.renderer.build(context, node.children[i]));
// border
if (SimpleTableConstants.borderType ==
SimpleTableBorderRenderType.table) {
cells.add(const SimpleTableRowDivider());
}
}
return cells;
}
}
@@ -1,81 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_operations.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
final CommandShortcutEvent arrowDownInTableCell = CommandShortcutEvent(
key: 'Press arrow down in table cell',
getDescription: () =>
AppFlowyEditorL10n.current.cmdTableMoveToDownCellAtSameOffset,
command: 'arrow down',
handler: _arrowDownInTableCellHandler,
);
/// Move the selection to the next cell in the same column.
///
/// Only handle the case when the selection is in the first line of the cell.
KeyEventResult _arrowDownInTableCellHandler(EditorState editorState) {
final (isInTableCell, selection, tableCellNode, node) =
editorState.isCurrentSelectionInTableCell();
if (!isInTableCell ||
selection == null ||
tableCellNode == null ||
node == null) {
return KeyEventResult.ignored;
}
final isInLastLine = node.path.last + 1 == node.parent?.children.length;
if (!isInLastLine) {
return KeyEventResult.ignored;
}
Selection? newSelection = editorState.selection;
final rowIndex = tableCellNode.rowIndex;
final parentTableNode = tableCellNode.parentTableNode;
if (parentTableNode == null) {
return KeyEventResult.ignored;
}
if (rowIndex == parentTableNode.rowLength - 1) {
// focus on the next block
final nextNode = tableCellNode.next;
if (nextNode != null) {
final nextFocusableSibling = parentTableNode.getNextFocusableSibling();
if (nextFocusableSibling != null) {
final length = nextFocusableSibling.delta?.length ?? 0;
newSelection = Selection.collapsed(
Position(
path: nextFocusableSibling.path,
offset: length,
),
);
}
}
} else {
// focus on next cell in the same column
final nextCell = tableCellNode.getNextCellInSameColumn();
if (nextCell != null) {
final offset = selection.end.offset;
// get the first children of the next cell
final firstChild = nextCell.children.firstWhereOrNull(
(c) => c.delta != null,
);
if (firstChild != null) {
final length = firstChild.delta?.length ?? 0;
newSelection = Selection.collapsed(
Position(
path: firstChild.path,
offset: offset.clamp(0, length),
),
);
}
}
}
if (newSelection != null) {
editorState.updateSelectionWithReason(newSelection);
}
return KeyEventResult.handled;
}
@@ -1,19 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
final CommandShortcutEvent arrowLeftInTableCell = CommandShortcutEvent(
key: 'Press arrow left in table cell',
getDescription: () => AppFlowyEditorL10n
.current.cmdTableMoveToRightCellIfItsAtTheEndOfCurrentCell,
command: 'arrow left',
handler: (editorState) => editorState.moveToPreviousCell(
editorState,
(result) {
// only handle the case when the selection is at the beginning of the cell
if (0 != result.$2?.end.offset) {
return false;
}
return true;
},
),
);
@@ -1,19 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
final CommandShortcutEvent arrowRightInTableCell = CommandShortcutEvent(
key: 'Press arrow right in table cell',
getDescription: () => AppFlowyEditorL10n
.current.cmdTableMoveToRightCellIfItsAtTheEndOfCurrentCell,
command: 'arrow right',
handler: (editorState) => editorState.moveToNextCell(
editorState,
(result) {
// only handle the case when the selection is at the end of the cell
final node = result.$4;
final length = node?.delta?.length ?? 0;
final selection = result.$2;
return selection?.end.offset == length;
},
),
);
@@ -1,77 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_operations.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
final CommandShortcutEvent arrowUpInTableCell = CommandShortcutEvent(
key: 'Press arrow up in table cell',
getDescription: () =>
AppFlowyEditorL10n.current.cmdTableMoveToUpCellAtSameOffset,
command: 'arrow up',
handler: _arrowUpInTableCellHandler,
);
/// Move the selection to the previous cell in the same column.
///
/// Only handle the case when the selection is in the first line of the cell.
KeyEventResult _arrowUpInTableCellHandler(EditorState editorState) {
final (isInTableCell, selection, tableCellNode, node) =
editorState.isCurrentSelectionInTableCell();
if (!isInTableCell ||
selection == null ||
tableCellNode == null ||
node == null) {
return KeyEventResult.ignored;
}
final isInFirstLine = node.path.last == 0;
if (!isInFirstLine) {
return KeyEventResult.ignored;
}
Selection? newSelection = editorState.selection;
final rowIndex = tableCellNode.rowIndex;
if (rowIndex == 0) {
// focus on the previous block
final previousNode = tableCellNode.parentTableNode;
if (previousNode != null) {
final previousFocusableSibling =
previousNode.getPreviousFocusableSibling();
if (previousFocusableSibling != null) {
final length = previousFocusableSibling.delta?.length ?? 0;
newSelection = Selection.collapsed(
Position(
path: previousFocusableSibling.path,
offset: length,
),
);
}
}
} else {
// focus on previous cell in the same column
final previousCell = tableCellNode.getPreviousCellInSameColumn();
if (previousCell != null) {
final offset = selection.end.offset;
// get the last children of the previous cell
final lastChild = previousCell.children.lastWhereOrNull(
(c) => c.delta != null,
);
if (lastChild != null) {
final length = lastChild.delta?.length ?? 0;
newSelection = Selection.collapsed(
Position(
path: lastChild.path,
offset: offset.clamp(0, length),
),
);
}
}
}
if (newSelection != null) {
editorState.updateSelectionWithReason(newSelection);
}
return KeyEventResult.handled;
}
@@ -1,33 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
final CommandShortcutEvent backspaceInTableCell = CommandShortcutEvent(
key: 'Press backspace in table cell',
getDescription: () => 'Ignore the backspace key in table cell',
command: 'backspace',
handler: _backspaceInTableCellHandler,
);
KeyEventResult _backspaceInTableCellHandler(EditorState editorState) {
final (isInTableCell, selection, tableCellNode, node) =
editorState.isCurrentSelectionInTableCell();
if (!isInTableCell ||
selection == null ||
tableCellNode == null ||
node == null) {
return KeyEventResult.ignored;
}
final onlyContainsOneChild = tableCellNode.children.length == 1;
final isParagraphNode =
tableCellNode.children.first.type == ParagraphBlockKeys.type;
if (onlyContainsOneChild &&
selection.isCollapsed &&
selection.end.offset == 0 &&
isParagraphNode) {
return KeyEventResult.skipRemainingHandlers;
}
return KeyEventResult.ignored;
}
@@ -1,164 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_operations.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
typedef IsInTableCellResult = (
bool isInTableCell,
Selection? selection,
Node? tableCellNode,
Node? node,
);
extension TableCommandExtension on EditorState {
/// Return a tuple, the first element is a boolean indicating whether the current selection is in a table cell,
/// the second element is the node that is the parent of the table cell if the current selection is in a table cell,
/// otherwise it is null.
/// The third element is the node that is the current selection.
IsInTableCellResult isCurrentSelectionInTableCell() {
final selection = this.selection;
if (selection == null) {
return (false, null, null, null);
}
if (selection.isCollapsed) {
// if the selection is collapsed, check if the node is in a table cell
final node = document.nodeAtPath(selection.end.path);
final tableCellParent = node?.findParent(
(node) => node.type == SimpleTableCellBlockKeys.type,
);
final isInTableCell = tableCellParent != null;
return (isInTableCell, selection, tableCellParent, node);
} else {
// if the selection is not collapsed, check if the start and end nodes are in a table cell
final startNode = document.nodeAtPath(selection.start.path);
final endNode = document.nodeAtPath(selection.end.path);
final startNodeInTableCell = startNode?.findParent(
(node) => node.type == SimpleTableCellBlockKeys.type,
);
final endNodeInTableCell = endNode?.findParent(
(node) => node.type == SimpleTableCellBlockKeys.type,
);
final isInSameTableCell = startNodeInTableCell != null &&
endNodeInTableCell != null &&
startNodeInTableCell.path.equals(endNodeInTableCell.path);
return (isInSameTableCell, selection, startNodeInTableCell, endNode);
}
}
/// Move the selection to the previous cell
KeyEventResult moveToPreviousCell(
EditorState editorState,
bool Function(IsInTableCellResult result) shouldHandle,
) {
final (isInTableCell, selection, tableCellNode, node) =
editorState.isCurrentSelectionInTableCell();
if (!isInTableCell ||
selection == null ||
tableCellNode == null ||
node == null) {
return KeyEventResult.ignored;
}
if (!shouldHandle((isInTableCell, selection, tableCellNode, node))) {
return KeyEventResult.ignored;
}
Selection? newSelection;
final previousCell = tableCellNode.getPreviousCellInSameRow();
if (previousCell != null && !previousCell.path.equals(tableCellNode.path)) {
// get the last children of the previous cell
final lastChild = previousCell.children.lastWhereOrNull(
(c) => c.delta != null,
);
if (lastChild != null) {
newSelection = Selection.collapsed(
Position(
path: lastChild.path,
offset: lastChild.delta?.length ?? 0,
),
);
}
} else {
// focus on the previous block
final previousNode = tableCellNode.parentTableNode;
if (previousNode != null) {
final previousFocusableSibling =
previousNode.getPreviousFocusableSibling();
if (previousFocusableSibling != null) {
final length = previousFocusableSibling.delta?.length ?? 0;
newSelection = Selection.collapsed(
Position(
path: previousFocusableSibling.path,
offset: length,
),
);
}
}
}
if (newSelection != null) {
editorState.updateSelectionWithReason(newSelection);
}
return KeyEventResult.handled;
}
/// Move the selection to the next cell
KeyEventResult moveToNextCell(
EditorState editorState,
bool Function(IsInTableCellResult result) shouldHandle,
) {
final (isInTableCell, selection, tableCellNode, node) =
editorState.isCurrentSelectionInTableCell();
if (!isInTableCell ||
selection == null ||
tableCellNode == null ||
node == null) {
return KeyEventResult.ignored;
}
if (!shouldHandle((isInTableCell, selection, tableCellNode, node))) {
return KeyEventResult.ignored;
}
Selection? newSelection;
final nextCell = tableCellNode.getNextCellInSameRow();
if (nextCell != null && !nextCell.path.equals(tableCellNode.path)) {
// get the first children of the next cell
final firstChild = nextCell.children.firstWhereOrNull(
(c) => c.delta != null,
);
if (firstChild != null) {
newSelection = Selection.collapsed(
Position(
path: firstChild.path,
),
);
}
} else {
// focus on the previous block
final nextNode = tableCellNode.parentTableNode;
if (nextNode != null) {
final nextFocusableSibling = nextNode.getNextFocusableSibling();
nextNode.getNextFocusableSibling();
if (nextFocusableSibling != null) {
newSelection = Selection.collapsed(
Position(
path: nextFocusableSibling.path,
),
);
}
}
}
if (newSelection != null) {
editorState.updateSelectionWithReason(newSelection);
}
return KeyEventResult.handled;
}
}
@@ -1,18 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_down_command.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_left_command.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_right_command.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_up_command.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_backspace_command.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_select_all_command.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_tab_command.dart';
final simpleTableCommands = [
arrowUpInTableCell,
arrowDownInTableCell,
arrowLeftInTableCell,
arrowRightInTableCell,
tabInTableCell,
shiftTabInTableCell,
backspaceInTableCell,
selectAllInTableCellCommand,
];
@@ -1,46 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
final CommandShortcutEvent selectAllInTableCellCommand = CommandShortcutEvent(
key: 'Select all contents in table cell',
getDescription: () => 'Select all contents in table cell',
command: 'ctrl+a',
macOSCommand: 'cmd+a',
handler: _selectAllInTableCellHandler,
);
KeyEventResult _selectAllInTableCellHandler(EditorState editorState) {
final (isInTableCell, selection, tableCellNode, _) =
editorState.isCurrentSelectionInTableCell();
if (!isInTableCell || selection == null || tableCellNode == null) {
return KeyEventResult.ignored;
}
final firstFocusableChild = tableCellNode.children.firstWhereOrNull(
(e) => e.delta != null,
);
final lastFocusableChild = tableCellNode.lastChildWhere(
(e) => e.delta != null,
);
if (firstFocusableChild == null || lastFocusableChild == null) {
return KeyEventResult.ignored;
}
final afterSelection = Selection(
start: Position(path: firstFocusableChild.path),
end: Position(
path: lastFocusableChild.path,
offset: lastFocusableChild.delta?.length ?? 0,
),
);
if (afterSelection == editorState.selection) {
// Focus on the cell already
return KeyEventResult.ignored;
} else {
editorState.selection = afterSelection;
return KeyEventResult.handled;
}
}
@@ -1,35 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_operations.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
final CommandShortcutEvent tabInTableCell = CommandShortcutEvent(
key: 'Press tab in table cell',
getDescription: () => 'Move the selection to the next cell',
command: 'tab',
handler: (editorState) => editorState.moveToNextCell(
editorState,
(result) {
final tableCellNode = result.$3;
if (tableCellNode?.isLastCellInTable ?? false) {
return false;
}
return true;
},
),
);
final CommandShortcutEvent shiftTabInTableCell = CommandShortcutEvent(
key: 'Press shift + tab in table cell',
getDescription: () => 'Move the selection to the previous cell',
command: 'shift+tab',
handler: (editorState) => editorState.moveToPreviousCell(
editorState,
(result) {
final tableCellNode = result.$3;
if (tableCellNode?.isFirstCellInTable ?? false) {
return false;
}
return true;
},
),
);
@@ -560,7 +560,7 @@ SelectionMenuItem aiWriterSlashMenuItem = SelectionMenuItem(
if (node == null || delta == null) {
return;
}
final newNode = aiWriterNode(start: selection);
final newNode = autoCompletionNode(start: selection);
final transaction = editorState.transaction;
//default insert after
@@ -596,32 +596,27 @@ SelectionMenuItem tableSlashMenuItem = SelectionMenuItem(
return;
}
// create a simple table with 2 columns and 2 rows
final tableNode = createSimpleTableBlockNode(
columnCount: 2,
rowCount: 2,
);
final tableNode = TableNode.fromList([
['', ''],
['', ''],
]);
final transaction = editorState.transaction;
final delta = currentNode.delta;
if (delta != null && delta.isEmpty) {
final path = selection.end.path;
transaction
..insertNode(path, tableNode)
..insertNode(selection.end.path, tableNode.node)
..deleteNode(currentNode);
transaction.afterSelection = Selection.collapsed(
Position(
// table -> row -> cell -> paragraph
path: path + [0, 0, 0],
path: selection.end.path + [0, 0],
),
);
} else {
final path = selection.end.path.next;
transaction.insertNode(path, tableNode);
transaction.insertNode(selection.end.path.next, tableNode.node);
transaction.afterSelection = Selection.collapsed(
Position(
// table -> row -> cell -> paragraph
path: path + [0, 0, 0],
path: selection.end.path.next + [0, 0],
),
);
}
@@ -1,11 +1,10 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/table/table_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:flutter/material.dart';
import 'dart:math' as math;
const tableActions = <TableOptionAction>[
TableOptionAction.addAfter,
@@ -125,6 +125,7 @@ CharacterShortcutEvent insertChildNodeInsideToggleList = CharacterShortcutEvent(
selection.start.path.next,
[
toggleListBlockNode(collapsed: true, delta: slicedDelta),
paragraphNode(),
],
)
..afterSelection = Selection.collapsed(
@@ -53,14 +53,11 @@ class TrashPlugin extends Plugin {
}
class TrashPluginDisplay extends PluginWidgetBuilder {
@override
String? get viewName => LocaleKeys.trash_text.tr();
@override
Widget get leftBarItem => FlowyText.medium(LocaleKeys.trash_text.tr());
@override
Widget tabBarItem(String pluginId, [bool shortForm = false]) => leftBarItem;
Widget tabBarItem(String pluginId) => leftBarItem;
@override
Widget? get rightBarItem => null;
@@ -71,7 +68,9 @@ class TrashPluginDisplay extends PluginWidgetBuilder {
required bool shrinkWrap,
Map<String, dynamic>? data,
}) =>
const TrashPage(key: ValueKey('TrashPage'));
const TrashPage(
key: ValueKey('TrashPage'),
);
@override
List<NavigationItem> get navigationItems => [this];
Loaded 100 of 211 files, more files were not shown because too many files have changed in this diff. Show more