Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d27f9eb2c1 | ||
|
|
c11904c2e9 | ||
|
|
19b1c84ea3 | ||
|
|
e5f30b000c | ||
|
|
e64462ca9a | ||
|
|
570de3e1a5 | ||
|
|
df82c99755 | ||
|
|
6a02679963 | ||
|
|
8dcd4c51f0 | ||
|
|
3a26570de9 | ||
|
|
76710ea244 | ||
|
|
1bcda3944f | ||
|
|
3d8e6ebd72 |
No files matched your search
@@ -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>
|
||||
+2
-2
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
+57
@@ -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();
|
||||
|
||||
-3
@@ -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();
|
||||
}
|
||||
-370
@@ -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
-2
@@ -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!();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+73
-72
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+60
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
+71
-36
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+11
-18
@@ -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),
|
||||
|
||||
+52
@@ -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 {
|
||||
|
||||
+5
-8
@@ -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);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
+152
-22
@@ -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();
|
||||
}
|
||||
-107
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+40
-27
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+12
-45
@@ -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({
|
||||
|
||||
+13
-40
@@ -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;
|
||||
|
||||
+1
-6
@@ -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();
|
||||
|
||||
+1
-14
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
+9
-88
@@ -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,
|
||||
|
||||
+2
-2
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
+1
-44
@@ -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(
|
||||
|
||||
+4
-1
@@ -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
-3
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
+31
-21
@@ -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);
|
||||
|
||||
-137
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
-35
@@ -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) {
|
||||
|
||||
+2
-6
@@ -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;
|
||||
|
||||
-1
@@ -346,7 +346,6 @@ class FileBlockComponentState extends State<FileBlockComponent>
|
||||
controller.show();
|
||||
dropManagerState?.add(FileBlockKeys.type);
|
||||
} else {
|
||||
editorState.updateSelectionWithReason(null, extraInfo: {});
|
||||
showUploadFileMobileMenu();
|
||||
}
|
||||
}
|
||||
|
||||
-29
@@ -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,
|
||||
|
||||
-238
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
File renamed without changes.
-2
@@ -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,
|
||||
];
|
||||
|
||||
-17
@@ -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);
|
||||
}
|
||||
}
|
||||
-68
@@ -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;
|
||||
}
|
||||
}
|
||||
-404
@@ -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;
|
||||
}
|
||||
}
|
||||
-112
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
-104
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
-112
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+566
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+24
-24
@@ -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;
|
||||
|
||||
+48
-90
@@ -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: '',
|
||||
+141
-34
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+44
-28
@@ -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) {
|
||||
-2
@@ -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';
|
||||
-103
@@ -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)];
|
||||
}
|
||||
}
|
||||
-86
@@ -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();
|
||||
}
|
||||
}
|
||||
+8
-11
@@ -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';
|
||||
-3
@@ -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),
|
||||
),
|
||||
|
||||
|
||||
-736
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
-7
@@ -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';
|
||||
-371
@@ -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);
|
||||
}
|
||||
}
|
||||
-429
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-214
@@ -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);
|
||||
}
|
||||
-690
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-83
@@ -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);
|
||||
}
|
||||
}
|
||||
-101
@@ -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);
|
||||
}
|
||||
}
|
||||
-103
@@ -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);
|
||||
}
|
||||
}
|
||||
-39
@@ -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);
|
||||
}
|
||||
}
|
||||
-195
@@ -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);
|
||||
}
|
||||
}
|
||||
-500
@@ -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;
|
||||
}
|
||||
}
|
||||
-506
@@ -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];
|
||||
}
|
||||
}
|
||||
-8
@@ -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';
|
||||
-236
@@ -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);
|
||||
}
|
||||
}
|
||||
-106
@@ -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;
|
||||
}
|
||||
}
|
||||
-81
@@ -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;
|
||||
}
|
||||
-19
@@ -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;
|
||||
},
|
||||
),
|
||||
);
|
||||
-19
@@ -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;
|
||||
},
|
||||
),
|
||||
);
|
||||
-77
@@ -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;
|
||||
}
|
||||
-33
@@ -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;
|
||||
}
|
||||
-164
@@ -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;
|
||||
}
|
||||
}
|
||||
-18
@@ -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,
|
||||
];
|
||||
-46
@@ -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;
|
||||
}
|
||||
}
|
||||
-35
@@ -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;
|
||||
},
|
||||
),
|
||||
);
|
||||
+9
-14
@@ -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],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+4
-5
@@ -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,
|
||||
|
||||
+1
@@ -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
Reference in new issue
Block a user