Merge branch 'main' into #692

This commit is contained in:
Nathan.fooo
2022-08-29 09:46:15 +08:00
committed by GitHub
181 changed files with 6414 additions and 2485 deletions

View File

@ -1,6 +1,17 @@
# Release Notes # Release Notes
## Version 0.0.4 - 2022-06-06 ## Version 0.0.5 - beta.1 - 08/25/2022
New features
- Board-view database
- Group by single select
- drag and drop cards
- insert / delete cards
![Aug-25-2022 16-22-38](https://user-images.githubusercontent.com/86001920/186614248-23186dfe-410e-427a-8cc6-865b1f79e074.gif)
## Version 0.0.4 - 06/06/2022
- Drag to adjust the width of a column - Drag to adjust the width of a column
- Upgrade to Flutter 3.0 - Upgrade to Flutter 3.0
- Native support for M1 chip - Native support for M1 chip
@ -12,12 +23,12 @@
- Fixed some bugs - Fixed some bugs
## Version 0.0.4 - beta.3 - 2022-05-02 ## Version 0.0.4 - beta.3 - 05/02/2022
- Drag to reorder app/ view/ field - Drag to reorder app/ view/ field
- Row record open as a page - Row record open as a page
- Auto resize the height of the row in the grid - Auto resize the height of the row in the grid
- Support more number formats - Support more number formats
- Search column options, supporting Single select, Multi-select, and number format - Search column options, supporting Single-select, Multi-select, and number format
![May-03-2022 10-03-00](https://user-images.githubusercontent.com/86001920/166394640-a8f1f3bc-5f20-4033-93e9-16bc308d7005.gif) ![May-03-2022 10-03-00](https://user-images.githubusercontent.com/86001920/166394640-a8f1f3bc-5f20-4033-93e9-16bc308d7005.gif)
@ -27,7 +38,7 @@
- Fixed some bugs - Fixed some bugs
## Version 0.0.4 - beta.2 - 2022-04-11 ## Version 0.0.4 - beta.2 - 04/11/2022
- Support properties: Text, Number, Date, Checkbox, Select, Multi-select - Support properties: Text, Number, Date, Checkbox, Select, Multi-select
- Insert / delete rows - Insert / delete rows
@ -35,16 +46,16 @@
- Edit property - Edit property
![](https://user-images.githubusercontent.com/12026239/162753644-bf2f4e7a-2367-4d48-87e6-35e244e83a5b.png) ![](https://user-images.githubusercontent.com/12026239/162753644-bf2f4e7a-2367-4d48-87e6-35e244e83a5b.png)
## Version 0.0.4 - beta.1 - 2022-04-08 ## Version 0.0.4 - beta.1 - 04/08/2022
v0.0.4 - beta.1 is pre-release v0.0.4 - beta.1 is pre-release
New features New features
- Table-view database - Table-view database
- supported column types: Text, Checbox, Single-select, Multi-select, Numbers - supported column types: Text, Checkbox, Single-select, Multi-select, Numbers
- hide / delete columns - hide / delete columns
- insert rows - insert rows
## Version 0.0.3 - 2022-02-23 ## Version 0.0.3 - 02/23/2022
v0.0.3 is production ready, available on Linux, macOS, and Windows v0.0.3 is production ready, available on Linux, macOS, and Windows
New features New features

View File

@ -56,7 +56,12 @@ Please see the [changelog](https://www.appflowy.io/whatsnew) for more details ab
## Contributing ## Contributing
Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**. Please look at [CONTRIBUTING.md](https://github.com/AppFlowy-IO/appflowy/blob/main/doc/CONTRIBUTING.md) for details. Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**. Please look at [Contributing to AppFlowy](https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/contributing-to-appflowy) for details.
If your Pull Request is accepted as it fixes a bug, adds functionality, or makes AppFlowy's codebase significantly easier to use or understand, congratulations! If your administrative and managerial work behind the scenes that sustains the community as a whole, congratulations! You are now an official contributor to AppFlowy. Get in touch with us ([link](https://tally.so/r/mKP5z3)) to receive the very special Contributor T-shirt!
Proudly wear your T-shirt and show it to us by tagging [@appflowy](https://twitter.com/appflowy) on Twitter.
![DSCF3560](https://user-images.githubusercontent.com/12026239/186106423-a923f6fe-b169-477b-87e4-ffb2e375e0f6.jpg)
## Why Are We Building This? ## Why Are We Building This?

View File

@ -2,6 +2,6 @@
# Contributing to AppFlowy # Contributing to AppFlowy
Hello, and welcome! Whether you are trying to report a bug, proposing a feature request, or want to work on the code you should go visit [our documentation](https://appflowy.gitbook.io) Hello, and welcome! Whether you are trying to report a bug, proposing a feature request, or want to work on the code you should go visit [Contributing to AppFlowy](https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/contributing-to-appflowy)
We look forward to hearing from you! We look forward to hearing from you!

View File

@ -29,7 +29,7 @@
"program": "./lib/main.dart", "program": "./lib/main.dart",
"type": "dart", "type": "dart",
"env": { "env": {
"RUST_LOG": "trace" "RUST_LOG": "debug"
}, },
"cwd": "${workspaceRoot}/app_flowy" "cwd": "${workspaceRoot}/app_flowy"
}, },

View File

@ -22,7 +22,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi" CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
CARGO_MAKE_CRATE_NAME = "dart-ffi" CARGO_MAKE_CRATE_NAME = "dart-ffi"
LIB_NAME = "dart_ffi" LIB_NAME = "dart_ffi"
CURRENT_APP_VERSION = "0.0.4" CURRENT_APP_VERSION = "0.0.5"
FEATURES = "flutter" FEATURES = "flutter"
PRODUCT_NAME = "AppFlowy" PRODUCT_NAME = "AppFlowy"
# CRATE_TYPE: https://doc.rust-lang.org/reference/linkage.html # CRATE_TYPE: https://doc.rust-lang.org/reference/linkage.html

View File

@ -145,5 +145,71 @@
"sideBar": { "sideBar": {
"openSidebar": "Open sidebar", "openSidebar": "Open sidebar",
"closeSidebar": "Close sidebar" "closeSidebar": "Close sidebar"
},
"grid": {
"settings": {
"filter": "Filtrer",
"sortBy": "Trier par",
"Properties": "Propriétés"
},
"field": {
"hide": "Cacher",
"insertLeft": "Insérer à gauche",
"insertRight": "Insérer à droite",
"duplicate": "Dupliquer",
"delete": "Supprimer",
"textFieldName": "Texte",
"checkboxFieldName": "Case à cocher",
"dateFieldName": "Date",
"numberFieldName": "Nombre",
"singleSelectFieldName": "Sélectionner",
"multiSelectFieldName": "Multisélection",
"urlFieldName": "URL",
"numberFormat": " Format du nombre",
"dateFormat": " Format de la date",
"includeTime": " Inclure l'heure",
"dateFormatFriendly": "Mois Jour, Année",
"dateFormatISO": "Année-Mois-Jour",
"dateFormatLocal": "Année/Mois/Jour",
"dateFormatUS": "Année/Mois/Jour",
"timeFormat": " Format du temps",
"invalidTimeFormat": "Format invalide",
"timeFormatTwelveHour": "12 heures",
"timeFormatTwentyFourHour": "24 heures",
"addSelectOption": "Ajouter une option",
"optionTitle": "Options",
"addOption": "Ajouter une option",
"editProperty": "Modifier la propriété"
},
"row": {
"duplicate": "Dupliquer",
"delete": "Supprimer",
"textPlaceholder": "Vide",
"copyProperty": "Copie de la propriété dans le presse-papiers"
},
"selectOption": {
"create": "Créer",
"purpleColor": "Violet",
"pinkColor": "Rose",
"lightPinkColor": "Rose clair",
"orangeColor": "Orange",
"yellowColor": "Jaune",
"limeColor": "Citron vert",
"greenColor": "Vert",
"aquaColor": "Aqua",
"blueColor": "Bleu",
"deleteTag": "Supprimer l'étiquette",
"colorPannelTitle": "Couleurs",
"pannelTitle": "Sélectionnez une option ou créez-en une",
"searchOption": "Rechercher une option"
},
"menuName": "Grille"
},
"document": {
"menuName": "Doc",
"date": {
"timeHintTextInTwelveHour": "12:00 AM",
"timeHintTextInTwentyFourHour": "12:00"
}
} }
} }

View File

@ -93,8 +93,14 @@
"highlight": "高亮" "highlight": "高亮"
}, },
"tooltip": { "tooltip": {
"lightMode": "切换到灯光模式", "lightMode": "切换到亮色模式",
"darkMode": "切换到暗模式" "darkMode": "切换到暗模式"
},
"notifications": {
"export": {
"markdown": "导出笔记为Markdown文档",
"path": "Documents/flowy"
}
}, },
"contactsPage": { "contactsPage": {
"title": "联系人", "title": "联系人",
@ -135,6 +141,7 @@
"menu": { "menu": {
"appearance": "外观", "appearance": "外观",
"language": "语言", "language": "语言",
"user": "用户",
"open": "打开设置" "open": "打开设置"
}, },
"appearance": { "appearance": {
@ -145,5 +152,71 @@
"sideBar": { "sideBar": {
"openSidebar": "打开侧边栏", "openSidebar": "打开侧边栏",
"closeSidebar": "关闭侧边栏" "closeSidebar": "关闭侧边栏"
},
"grid": {
"settings": {
"filter": "过滤器",
"sortBy": "排序",
"Properties": "属性"
},
"field": {
"hide": "隐藏",
"insertLeft": "左侧插入",
"insertRight": "右侧插入",
"duplicate": "拷贝",
"delete": "删除",
"textFieldName": "文本",
"checkboxFieldName": "勾选框",
"dateFieldName": "日期",
"numberFieldName": "数字",
"singleSelectFieldName": "单项选择器",
"multiSelectFieldName": "多项选择器",
"urlFieldName": "链接",
"numberFormat": " 数字格式",
"dateFormat": " 日期格式",
"includeTime": " 包含时间",
"dateFormatFriendly": "月 日,年",
"dateFormatISO": "年-月-日",
"dateFormatLocal": "年/月/日",
"dateFormatUS": "年/月/日",
"timeFormat": " 时间格式",
"invalidTimeFormat": "时间格式错误",
"timeFormatTwelveHour": "12小时制",
"timeFormatTwentyFourHour": "24小时制",
"addSelectOption": "添加一个标签",
"optionTitle": "标签",
"addOption": "添加标签",
"editProperty": "编辑列属性"
},
"row": {
"duplicate": "复制",
"delete": "删除",
"textPlaceholder": "空",
"copyProperty": "复制列"
},
"selectOption": {
"create": "新建",
"purpleColor": "紫色",
"pinkColor": "粉色",
"lightPinkColor": "浅粉色",
"orangeColor": "橙色",
"yellowColor": "黄色",
"limeColor": "鲜绿色",
"greenColor": "绿色",
"aquaColor": "水蓝色",
"blueColor": "蓝色",
"deleteTag": "删除标签",
"colorPannelTitle": "颜色",
"pannelTitle": "选择或新建一个标签",
"searchOption": "搜索标签"
},
"menuName": "网格"
},
"document": {
"menuName": "文档",
"date": {
"timeHintTextInTwelveHour": "12:00 AM",
"timeHintTextInTwentyFourHour": "12:00"
}
} }
} }

View File

@ -20,23 +20,27 @@ import 'group_controller.dart';
part 'board_bloc.freezed.dart'; part 'board_bloc.freezed.dart';
class BoardBloc extends Bloc<BoardEvent, BoardState> { class BoardBloc extends Bloc<BoardEvent, BoardState> {
final BoardDataController _dataController; final BoardDataController _gridDataController;
late final AFBoardDataController afBoardDataController; late final AFBoardDataController boardController;
final MoveRowFFIService _rowService; final MoveRowFFIService _rowService;
Map<String, GroupController> groupControllers = {}; LinkedHashMap<String, GroupController> groupControllers = LinkedHashMap.new();
GridFieldCache get fieldCache => _dataController.fieldCache; GridFieldCache get fieldCache => _gridDataController.fieldCache;
String get gridId => _dataController.gridId; String get gridId => _gridDataController.gridId;
BoardBloc({required ViewPB view}) BoardBloc({required ViewPB view})
: _rowService = MoveRowFFIService(gridId: view.id), : _rowService = MoveRowFFIService(gridId: view.id),
_dataController = BoardDataController(view: view), _gridDataController = BoardDataController(view: view),
super(BoardState.initial(view.id)) { super(BoardState.initial(view.id)) {
afBoardDataController = AFBoardDataController( boardController = AFBoardDataController(
onMoveColumn: ( onMoveColumn: (
fromColumnId,
fromIndex, fromIndex,
toColumnId,
toIndex, toIndex,
) {}, ) {
_moveGroup(fromColumnId, toColumnId);
},
onMoveColumnItem: ( onMoveColumnItem: (
columnId, columnId,
fromIndex, fromIndex,
@ -44,7 +48,7 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
) { ) {
final fromRow = groupControllers[columnId]?.rowAtIndex(fromIndex); final fromRow = groupControllers[columnId]?.rowAtIndex(fromIndex);
final toRow = groupControllers[columnId]?.rowAtIndex(toIndex); final toRow = groupControllers[columnId]?.rowAtIndex(toIndex);
_moveRow(fromRow, toRow); _moveRow(fromRow, columnId, toRow);
}, },
onMoveColumnItemToColumn: ( onMoveColumnItemToColumn: (
fromColumnId, fromColumnId,
@ -54,7 +58,7 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
) { ) {
final fromRow = groupControllers[fromColumnId]?.rowAtIndex(fromIndex); final fromRow = groupControllers[fromColumnId]?.rowAtIndex(fromIndex);
final toRow = groupControllers[toColumnId]?.rowAtIndex(toIndex); final toRow = groupControllers[toColumnId]?.rowAtIndex(toIndex);
_moveRow(fromRow, toRow); _moveRow(fromRow, toColumnId, toRow);
}, },
); );
@ -66,7 +70,7 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
await _loadGrid(emit); await _loadGrid(emit);
}, },
createRow: (groupId) async { createRow: (groupId) async {
final result = await _dataController.createBoardCard(groupId); final result = await _gridDataController.createBoardCard(groupId);
result.fold( result.fold(
(rowPB) { (rowPB) {
emit(state.copyWith(editingRow: some(rowPB))); emit(state.copyWith(editingRow: some(rowPB)));
@ -95,12 +99,13 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
); );
} }
void _moveRow(RowPB? fromRow, RowPB? toRow) { void _moveRow(RowPB? fromRow, String columnId, RowPB? toRow) {
if (fromRow != null && toRow != null) { if (fromRow != null) {
_rowService _rowService
.moveRow( .moveGroupRow(
fromRowId: fromRow.id, fromRowId: fromRow.id,
toRowId: toRow.id, toGroupId: columnId,
toRowId: toRow?.id,
) )
.then((result) { .then((result) {
result.fold((l) => null, (r) => add(BoardEvent.didReceiveError(r))); result.fold((l) => null, (r) => add(BoardEvent.didReceiveError(r)));
@ -108,9 +113,20 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
} }
} }
void _moveGroup(String fromColumnId, String toColumnId) {
_rowService
.moveGroup(
fromGroupId: fromColumnId,
toGroupId: toColumnId,
)
.then((result) {
result.fold((l) => null, (r) => add(BoardEvent.didReceiveError(r)));
});
}
@override @override
Future<void> close() async { Future<void> close() async {
await _dataController.dispose(); await _gridDataController.dispose();
for (final controller in groupControllers.values) { for (final controller in groupControllers.values) {
controller.dispose(); controller.dispose();
} }
@ -119,7 +135,7 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
void initializeGroups(List<GroupPB> groups) { void initializeGroups(List<GroupPB> groups) {
for (final group in groups) { for (final group in groups) {
final delegate = GroupControllerDelegateImpl(afBoardDataController); final delegate = GroupControllerDelegateImpl(boardController);
final controller = GroupController( final controller = GroupController(
gridId: state.gridId, gridId: state.gridId,
group: group, group: group,
@ -131,12 +147,12 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
} }
GridRowCache? getRowCache(String blockId) { GridRowCache? getRowCache(String blockId) {
final GridBlockCache? blockCache = _dataController.blocks[blockId]; final GridBlockCache? blockCache = _gridDataController.blocks[blockId];
return blockCache?.rowCache; return blockCache?.rowCache;
} }
void _startListening() { void _startListening() {
_dataController.addListener( _gridDataController.addListener(
onGridChanged: (grid) { onGridChanged: (grid) {
if (!isClosed) { if (!isClosed) {
add(BoardEvent.didReceiveGridUpdate(grid)); add(BoardEvent.didReceiveGridUpdate(grid));
@ -146,18 +162,34 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
List<AFBoardColumnData> columns = groups.map((group) { List<AFBoardColumnData> columns = groups.map((group) {
return AFBoardColumnData( return AFBoardColumnData(
id: group.groupId, id: group.groupId,
desc: group.desc, name: group.desc,
items: _buildRows(group.rows), items: _buildRows(group.rows),
customData: group, customData: group,
); );
}).toList(); }).toList();
afBoardDataController.addColumns(columns); boardController.addColumns(columns);
initializeGroups(groups); initializeGroups(groups);
}, },
onRowsChanged: (List<RowInfo> rowInfos, RowsChangedReason reason) { onRowsChanged: (List<RowInfo> rowInfos, RowsChangedReason reason) {
add(BoardEvent.didReceiveRows(rowInfos)); add(BoardEvent.didReceiveRows(rowInfos));
}, },
onDeletedGroup: (groupIds) {
//
},
onInsertedGroup: (insertedGroups) {
//
},
onUpdatedGroup: (updatedGroups) {
//
for (final group in updatedGroups) {
final columnController =
boardController.getColumnController(group.groupId);
if (columnController != null) {
columnController.updateColumnName(group.desc);
}
}
},
onError: (err) { onError: (err) {
Log.error(err); Log.error(err);
}, },
@ -173,7 +205,7 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
} }
Future<void> _loadGrid(Emitter<BoardState> emit) async { Future<void> _loadGrid(Emitter<BoardState> emit) async {
final result = await _dataController.loadData(); final result = await _gridDataController.loadData();
result.fold( result.fold(
(grid) => emit( (grid) => emit(
state.copyWith(loadingState: GridLoadingState.finish(left(unit))), state.copyWith(loadingState: GridLoadingState.finish(left(unit))),
@ -285,6 +317,6 @@ class GroupControllerDelegateImpl extends GroupControllerDelegate {
@override @override
void updateRow(String groupId, RowPB row) { void updateRow(String groupId, RowPB row) {
// controller.updateColumnItem(groupId, BoardColumnItem(row: row));
} }
} }

View File

@ -10,9 +10,15 @@ import 'dart:async';
import 'package:dartz/dartz.dart'; import 'package:dartz/dartz.dart';
import 'package:flowy_sdk/protobuf/flowy-grid/protobuf.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/protobuf.dart';
import 'board_listener.dart';
typedef OnFieldsChanged = void Function(UnmodifiableListView<FieldPB>); typedef OnFieldsChanged = void Function(UnmodifiableListView<FieldPB>);
typedef OnGridChanged = void Function(GridPB); typedef OnGridChanged = void Function(GridPB);
typedef DidLoadGroups = void Function(List<GroupPB>); typedef DidLoadGroups = void Function(List<GroupPB>);
typedef OnUpdatedGroup = void Function(List<GroupPB>);
typedef OnDeletedGroup = void Function(List<String>);
typedef OnInsertedGroup = void Function(List<InsertedGroupPB>);
typedef OnRowsChanged = void Function( typedef OnRowsChanged = void Function(
List<RowInfo>, List<RowInfo>,
RowsChangedReason, RowsChangedReason,
@ -23,6 +29,7 @@ class BoardDataController {
final String gridId; final String gridId;
final GridFFIService _gridFFIService; final GridFFIService _gridFFIService;
final GridFieldCache fieldCache; final GridFieldCache fieldCache;
final BoardListener _listener;
// key: the block id // key: the block id
final LinkedHashMap<String, GridBlockCache> _blocks; final LinkedHashMap<String, GridBlockCache> _blocks;
@ -44,16 +51,20 @@ class BoardDataController {
BoardDataController({required ViewPB view}) BoardDataController({required ViewPB view})
: gridId = view.id, : gridId = view.id,
_listener = BoardListener(view.id),
_blocks = LinkedHashMap.new(), _blocks = LinkedHashMap.new(),
_gridFFIService = GridFFIService(gridId: view.id), _gridFFIService = GridFFIService(gridId: view.id),
fieldCache = GridFieldCache(gridId: view.id); fieldCache = GridFieldCache(gridId: view.id);
void addListener({ void addListener({
OnGridChanged? onGridChanged, required OnGridChanged onGridChanged,
OnFieldsChanged? onFieldsChanged, OnFieldsChanged? onFieldsChanged,
DidLoadGroups? didLoadGroups, required DidLoadGroups didLoadGroups,
OnRowsChanged? onRowsChanged, required OnRowsChanged onRowsChanged,
OnError? onError, required OnUpdatedGroup onUpdatedGroup,
required OnDeletedGroup onDeletedGroup,
required OnInsertedGroup onInsertedGroup,
required OnError? onError,
}) { }) {
_onGridChanged = onGridChanged; _onGridChanged = onGridChanged;
_onFieldsChanged = onFieldsChanged; _onFieldsChanged = onFieldsChanged;
@ -64,6 +75,25 @@ class BoardDataController {
fieldCache.addListener(onFields: (fields) { fieldCache.addListener(onFields: (fields) {
_onFieldsChanged?.call(UnmodifiableListView(fields)); _onFieldsChanged?.call(UnmodifiableListView(fields));
}); });
_listener.start(onBoardChanged: (result) {
result.fold(
(changeset) {
if (changeset.updateGroups.isNotEmpty) {
onUpdatedGroup.call(changeset.updateGroups);
}
if (changeset.insertedGroups.isNotEmpty) {
onInsertedGroup.call(changeset.insertedGroups);
}
if (changeset.deletedGroups.isNotEmpty) {
onDeletedGroup.call(changeset.deletedGroups);
}
},
(e) => _onError?.call(e),
);
});
} }
Future<Either<Unit, FlowyError>> loadData() async { Future<Either<Unit, FlowyError>> loadData() async {

View File

@ -0,0 +1,50 @@
import 'dart:typed_data';
import 'package:app_flowy/core/grid_notification.dart';
import 'package:flowy_infra/notifier.dart';
import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-grid/dart_notification.pb.dart';
import 'package:dartz/dartz.dart';
import 'package:flowy_sdk/protobuf/flowy-grid/group_changeset.pb.dart';
typedef UpdateBoardNotifiedValue = Either<GroupViewChangesetPB, FlowyError>;
class BoardListener {
final String viewId;
PublishNotifier<UpdateBoardNotifiedValue>? _groupNotifier = PublishNotifier();
GridNotificationListener? _listener;
BoardListener(this.viewId);
void start({
required void Function(UpdateBoardNotifiedValue) onBoardChanged,
}) {
_groupNotifier?.addPublishListener(onBoardChanged);
_listener = GridNotificationListener(
objectId: viewId,
handler: _handler,
);
}
void _handler(
GridNotification ty,
Either<Uint8List, FlowyError> result,
) {
switch (ty) {
case GridNotification.DidUpdateGroupView:
result.fold(
(payload) => _groupNotifier?.value =
left(GroupViewChangesetPB.fromBuffer(payload)),
(error) => _groupNotifier?.value = right(error),
);
break;
default:
break;
}
}
Future<void> stop() async {
await _listener?.stop();
_groupNotifier?.dispose();
_groupNotifier = null;
}
}

View File

@ -68,7 +68,6 @@ class BoardSelectOptionCellState with _$BoardSelectOptionCellState {
factory BoardSelectOptionCellState.initial( factory BoardSelectOptionCellState.initial(
GridSelectOptionCellController context) { GridSelectOptionCellController context) {
final data = context.getCellData(); final data = context.getCellData();
return BoardSelectOptionCellState( return BoardSelectOptionCellState(
selectedOptions: data?.selectOptions ?? [], selectedOptions: data?.selectOptions ?? [],
); );

View File

@ -34,9 +34,22 @@ class GroupController {
void startListening() { void startListening() {
_listener.start(onGroupChanged: (result) { _listener.start(onGroupChanged: (result) {
result.fold( result.fold(
(GroupRowsChangesetPB changeset) { (GroupChangesetPB changeset) {
for (final deletedRow in changeset.deletedRows) {
group.rows.removeWhere((rowPB) => rowPB.id == deletedRow);
delegate.removeRow(group.groupId, deletedRow);
}
for (final insertedRow in changeset.insertedRows) { for (final insertedRow in changeset.insertedRows) {
final index = insertedRow.hasIndex() ? insertedRow.index : null; final index = insertedRow.hasIndex() ? insertedRow.index : null;
if (insertedRow.hasIndex() &&
group.rows.length > insertedRow.index) {
group.rows.insert(insertedRow.index, insertedRow.row);
} else {
group.rows.add(insertedRow.row);
}
delegate.insertRow( delegate.insertRow(
group.groupId, group.groupId,
insertedRow.row, insertedRow.row,
@ -44,11 +57,15 @@ class GroupController {
); );
} }
for (final deletedRow in changeset.deletedRows) { for (final updatedRow in changeset.updatedRows) {
delegate.removeRow(group.groupId, deletedRow); final index = group.rows.indexWhere(
(rowPB) => rowPB.id == updatedRow.id,
);
if (index != -1) {
group.rows[index] = updatedRow;
} }
for (final updatedRow in changeset.updatedRows) {
delegate.updateRow(group.groupId, updatedRow); delegate.updateRow(group.groupId, updatedRow);
} }
}, },

View File

@ -8,7 +8,7 @@ import 'package:flowy_sdk/protobuf/flowy-grid/group.pb.dart';
import 'package:dartz/dartz.dart'; import 'package:dartz/dartz.dart';
import 'package:flowy_sdk/protobuf/flowy-grid/group_changeset.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/group_changeset.pb.dart';
typedef UpdateGroupNotifiedValue = Either<GroupRowsChangesetPB, FlowyError>; typedef UpdateGroupNotifiedValue = Either<GroupChangesetPB, FlowyError>;
class GroupListener { class GroupListener {
final GroupPB group; final GroupPB group;
@ -34,7 +34,7 @@ class GroupListener {
case GridNotification.DidUpdateGroup: case GridNotification.DidUpdateGroup:
result.fold( result.fold(
(payload) => _groupNotifier?.value = (payload) => _groupNotifier?.value =
left(GroupRowsChangesetPB.fromBuffer(payload)), left(GroupChangesetPB.fromBuffer(payload)),
(error) => _groupNotifier?.value = right(error), (error) => _groupNotifier?.value = right(error),
); );
break; break;

View File

@ -31,7 +31,7 @@ class BoardPluginBuilder implements PluginBuilder {
class BoardPluginConfig implements PluginConfig { class BoardPluginConfig implements PluginConfig {
@override @override
bool get creatable => false; bool get creatable => true;
} }
class BoardPlugin extends Plugin { class BoardPlugin extends Plugin {

View File

@ -9,6 +9,9 @@ import 'package:app_flowy/plugins/grid/application/row/row_data_controller.dart'
import 'package:app_flowy/plugins/grid/presentation/widgets/cell/cell_builder.dart'; import 'package:app_flowy/plugins/grid/presentation/widgets/cell/cell_builder.dart';
import 'package:app_flowy/plugins/grid/presentation/widgets/row/row_detail.dart'; import 'package:app_flowy/plugins/grid/presentation/widgets/row/row_detail.dart';
import 'package:appflowy_board/appflowy_board.dart'; import 'package:appflowy_board/appflowy_board.dart';
import 'package:flowy_infra/image.dart';
import 'package:flowy_infra/theme.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/error_page.dart'; import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart';
@ -62,12 +65,15 @@ class BoardContent extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20), padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
child: AFBoard( child: AFBoard(
// key: UniqueKey(),
scrollController: ScrollController(), scrollController: ScrollController(),
dataController: context.read<BoardBloc>().afBoardDataController, dataController: context.read<BoardBloc>().boardController,
headerBuilder: _buildHeader, headerBuilder: _buildHeader,
footBuilder: _buildFooter, footBuilder: _buildFooter,
cardBuilder: (_, data) => _buildCard(context, data), cardBuilder: (_, column, columnItem) => _buildCard(
context,
column,
columnItem,
),
columnConstraints: const BoxConstraints.tightFor(width: 240), columnConstraints: const BoxConstraints.tightFor(width: 240),
config: AFBoardConfig( config: AFBoardConfig(
columnBackgroundColor: HexColor.fromHex('#F7F8FC'), columnBackgroundColor: HexColor.fromHex('#F7F8FC'),
@ -79,34 +85,64 @@ class BoardContent extends StatelessWidget {
); );
} }
Widget _buildHeader(BuildContext context, AFBoardColumnData columnData) { Widget _buildHeader(
BuildContext context, AFBoardColumnHeaderData headerData) {
return AppFlowyColumnHeader( return AppFlowyColumnHeader(
icon: const Icon(Icons.lightbulb_circle), title: Flexible(
title: Text(columnData.desc), fit: FlexFit.tight,
addIcon: const Icon(Icons.add, size: 20), child: FlowyText.medium(
moreIcon: const Icon(Icons.more_horiz, size: 20), headerData.columnName,
fontSize: 14,
overflow: TextOverflow.clip,
color: context.read<AppTheme>().textColor,
),
),
// addIcon: const Icon(Icons.add, size: 20),
// moreIcon: SizedBox(
// width: 20,
// height: 20,
// child: svgWidget(
// 'grid/details',
// color: context.read<AppTheme>().iconColor,
// ),
// ),
height: 50, height: 50,
margin: config.columnItemPadding, margin: config.headerPadding,
); );
} }
Widget _buildFooter(BuildContext context, AFBoardColumnData columnData) { Widget _buildFooter(BuildContext context, AFBoardColumnData columnData) {
return AppFlowyColumnFooter( return AppFlowyColumnFooter(
icon: const Icon(Icons.add, size: 20), icon: SizedBox(
title: const Text('New'), height: 20,
width: 20,
child: svgWidget(
"home/add",
color: context.read<AppTheme>().iconColor,
),
),
title: FlowyText.medium(
"New",
fontSize: 14,
color: context.read<AppTheme>().textColor,
),
height: 50, height: 50,
margin: config.columnItemPadding, margin: config.footerPadding,
onAddButtonClick: () { onAddButtonClick: () {
context.read<BoardBloc>().add(BoardEvent.createRow(columnData.id)); context.read<BoardBloc>().add(BoardEvent.createRow(columnData.id));
}); });
} }
Widget _buildCard(BuildContext context, AFColumnItem item) { Widget _buildCard(
final rowPB = (item as BoardColumnItem).row; BuildContext context,
AFBoardColumnData column,
AFColumnItem columnItem,
) {
final rowPB = (columnItem as BoardColumnItem).row;
final rowCache = context.read<BoardBloc>().getRowCache(rowPB.blockId); final rowCache = context.read<BoardBloc>().getRowCache(rowPB.blockId);
/// Return placeholder widget if the rowCache is null. /// Return placeholder widget if the rowCache is null.
if (rowCache == null) return SizedBox(key: ObjectKey(item)); if (rowCache == null) return SizedBox(key: ObjectKey(columnItem));
final fieldCache = context.read<BoardBloc>().fieldCache; final fieldCache = context.read<BoardBloc>().fieldCache;
final gridId = context.read<BoardBloc>().gridId; final gridId = context.read<BoardBloc>().gridId;
@ -123,9 +159,12 @@ class BoardContent extends StatelessWidget {
); );
return AppFlowyColumnItemCard( return AppFlowyColumnItemCard(
key: ObjectKey(item), key: ObjectKey(columnItem),
margin: config.cardPadding,
decoration: _makeBoxDecoration(context),
child: BoardCard( child: BoardCard(
gridId: gridId, gridId: gridId,
groupId: column.id,
isEditing: isEditing, isEditing: isEditing,
cellBuilder: cellBuilder, cellBuilder: cellBuilder,
dataController: cardController, dataController: cardController,
@ -143,6 +182,16 @@ class BoardContent extends StatelessWidget {
); );
} }
BoxDecoration _makeBoxDecoration(BuildContext context) {
final theme = context.read<AppTheme>();
final borderSide = BorderSide(color: theme.shader6, width: 1.0);
return BoxDecoration(
color: theme.surface,
border: Border.fromBorderSide(borderSide),
borderRadius: const BorderRadius.all(Radius.circular(6)),
);
}
void _openCard(String gridId, GridFieldCache fieldCache, RowPB rowPB, void _openCard(String gridId, GridFieldCache fieldCache, RowPB rowPB,
GridRowCache rowCache, BuildContext context) { GridRowCache rowCache, BuildContext context) {
final rowInfo = RowInfo( final rowInfo = RowInfo(

View File

@ -6,9 +6,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
class BoardCheckboxCell extends StatefulWidget { class BoardCheckboxCell extends StatefulWidget {
final String groupId;
final GridCellControllerBuilder cellControllerBuilder; final GridCellControllerBuilder cellControllerBuilder;
const BoardCheckboxCell({ const BoardCheckboxCell({
required this.groupId,
required this.cellControllerBuilder, required this.cellControllerBuilder,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -34,6 +36,8 @@ class _BoardCheckboxCellState extends State<BoardCheckboxCell> {
return BlocProvider.value( return BlocProvider.value(
value: _cellBloc, value: _cellBloc,
child: BlocBuilder<BoardCheckboxCellBloc, BoardCheckboxCellState>( child: BlocBuilder<BoardCheckboxCellBloc, BoardCheckboxCellState>(
buildWhen: (previous, current) =>
previous.isSelected != current.isSelected,
builder: (context, state) { builder: (context, state) {
final icon = state.isSelected final icon = state.isSelected
? svgWidget('editor/editor_check') ? svgWidget('editor/editor_check')

View File

@ -1,13 +1,16 @@
import 'package:app_flowy/plugins/board/application/card/board_date_cell_bloc.dart'; import 'package:app_flowy/plugins/board/application/card/board_date_cell_bloc.dart';
import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart';
import 'package:flowy_infra/theme.dart';
import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
class BoardDateCell extends StatefulWidget { class BoardDateCell extends StatefulWidget {
final String groupId;
final GridCellControllerBuilder cellControllerBuilder; final GridCellControllerBuilder cellControllerBuilder;
const BoardDateCell({ const BoardDateCell({
required this.groupId,
required this.cellControllerBuilder, required this.cellControllerBuilder,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -34,6 +37,7 @@ class _BoardDateCellState extends State<BoardDateCell> {
return BlocProvider.value( return BlocProvider.value(
value: _cellBloc, value: _cellBloc,
child: BlocBuilder<BoardDateCellBloc, BoardDateCellState>( child: BlocBuilder<BoardDateCellBloc, BoardDateCellState>(
buildWhen: (previous, current) => previous.dateStr != current.dateStr,
builder: (context, state) { builder: (context, state) {
if (state.dateStr.isEmpty) { if (state.dateStr.isEmpty) {
return const SizedBox(); return const SizedBox();
@ -42,7 +46,8 @@ class _BoardDateCellState extends State<BoardDateCell> {
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: FlowyText.regular( child: FlowyText.regular(
state.dateStr, state.dateStr,
fontSize: 14, fontSize: 13,
color: context.read<AppTheme>().shader3,
), ),
); );
} }

View File

@ -5,9 +5,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
class BoardNumberCell extends StatefulWidget { class BoardNumberCell extends StatefulWidget {
final String groupId;
final GridCellControllerBuilder cellControllerBuilder; final GridCellControllerBuilder cellControllerBuilder;
const BoardNumberCell({ const BoardNumberCell({
required this.groupId,
required this.cellControllerBuilder, required this.cellControllerBuilder,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -34,13 +36,14 @@ class _BoardNumberCellState extends State<BoardNumberCell> {
return BlocProvider.value( return BlocProvider.value(
value: _cellBloc, value: _cellBloc,
child: BlocBuilder<BoardNumberCellBloc, BoardNumberCellState>( child: BlocBuilder<BoardNumberCellBloc, BoardNumberCellState>(
buildWhen: (previous, current) => previous.content != current.content,
builder: (context, state) { builder: (context, state) {
if (state.content.isEmpty) { if (state.content.isEmpty) {
return const SizedBox(); return const SizedBox();
} else { } else {
return Align( return Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: FlowyText.regular( child: FlowyText.medium(
state.content, state.content,
fontSize: 14, fontSize: 14,
), ),

View File

@ -5,9 +5,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
class BoardSelectOptionCell extends StatefulWidget { class BoardSelectOptionCell extends StatefulWidget {
final String groupId;
final GridCellControllerBuilder cellControllerBuilder; final GridCellControllerBuilder cellControllerBuilder;
const BoardSelectOptionCell({ const BoardSelectOptionCell({
required this.groupId,
required this.cellControllerBuilder, required this.cellControllerBuilder,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -33,23 +35,29 @@ class _BoardSelectOptionCellState extends State<BoardSelectOptionCell> {
return BlocProvider.value( return BlocProvider.value(
value: _cellBloc, value: _cellBloc,
child: BlocBuilder<BoardSelectOptionCellBloc, BoardSelectOptionCellState>( child: BlocBuilder<BoardSelectOptionCellBloc, BoardSelectOptionCellState>(
buildWhen: (previous, current) =>
previous.selectedOptions != current.selectedOptions,
builder: (context, state) { builder: (context, state) {
if (state.selectedOptions
.where((element) => element.id == widget.groupId)
.isNotEmpty) {
return const SizedBox();
} else {
final children = state.selectedOptions final children = state.selectedOptions
.map((option) => SelectOptionTag.fromOption( .map(
(option) => SelectOptionTag.fromOption(
context: context, context: context,
option: option, option: option,
)) ),
)
.toList(); .toList();
return Align( return Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: AbsorbPointer( child: AbsorbPointer(
child: Wrap( child: Wrap(children: children, spacing: 4, runSpacing: 2),
children: children,
spacing: 4,
runSpacing: 2,
),
), ),
); );
}
}, },
), ),
); );

View File

@ -5,9 +5,13 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
class BoardTextCell extends StatefulWidget { class BoardTextCell extends StatefulWidget {
final String groupId;
final GridCellControllerBuilder cellControllerBuilder; final GridCellControllerBuilder cellControllerBuilder;
const BoardTextCell({required this.cellControllerBuilder, Key? key}) const BoardTextCell({
: super(key: key); required this.groupId,
required this.cellControllerBuilder,
Key? key,
}) : super(key: key);
@override @override
State<BoardTextCell> createState() => _BoardTextCellState(); State<BoardTextCell> createState() => _BoardTextCellState();
@ -31,6 +35,7 @@ class _BoardTextCellState extends State<BoardTextCell> {
return BlocProvider.value( return BlocProvider.value(
value: _cellBloc, value: _cellBloc,
child: BlocBuilder<BoardTextCellBloc, BoardTextCellState>( child: BlocBuilder<BoardTextCellBloc, BoardTextCellState>(
buildWhen: (previous, current) => previous.content != current.content,
builder: (context, state) { builder: (context, state) {
if (state.content.isEmpty) { if (state.content.isEmpty) {
return const SizedBox(); return const SizedBox();
@ -38,13 +43,8 @@ class _BoardTextCellState extends State<BoardTextCell> {
return Align( return Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints.loose( constraints: const BoxConstraints(maxHeight: 120),
const Size(double.infinity, 100), child: FlowyText.medium(state.content, fontSize: 14),
),
child: FlowyText.regular(
state.content,
fontSize: 14,
),
), ),
); );
} }

View File

@ -5,9 +5,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
class BoardUrlCell extends StatefulWidget { class BoardUrlCell extends StatefulWidget {
final String groupId;
final GridCellControllerBuilder cellControllerBuilder; final GridCellControllerBuilder cellControllerBuilder;
const BoardUrlCell({ const BoardUrlCell({
required this.groupId,
required this.cellControllerBuilder, required this.cellControllerBuilder,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -34,6 +36,7 @@ class _BoardUrlCellState extends State<BoardUrlCell> {
return BlocProvider.value( return BlocProvider.value(
value: _cellBloc, value: _cellBloc,
child: BlocBuilder<BoardURLCellBloc, BoardURLCellState>( child: BlocBuilder<BoardURLCellBloc, BoardURLCellState>(
buildWhen: (previous, current) => previous.content != current.content,
builder: (context, state) { builder: (context, state) {
if (state.content.isEmpty) { if (state.content.isEmpty) {
return const SizedBox(); return const SizedBox();

View File

@ -14,6 +14,7 @@ typedef OnEndEditing = void Function(String rowId);
class BoardCard extends StatefulWidget { class BoardCard extends StatefulWidget {
final String gridId; final String gridId;
final String groupId;
final bool isEditing; final bool isEditing;
final CardDataController dataController; final CardDataController dataController;
final BoardCellBuilder cellBuilder; final BoardCellBuilder cellBuilder;
@ -22,6 +23,7 @@ class BoardCard extends StatefulWidget {
const BoardCard({ const BoardCard({
required this.gridId, required this.gridId,
required this.groupId,
required this.isEditing, required this.isEditing,
required this.dataController, required this.dataController,
required this.cellBuilder, required this.cellBuilder,
@ -42,7 +44,7 @@ class _BoardCardState extends State<BoardCard> {
_cardBloc = BoardCardBloc( _cardBloc = BoardCardBloc(
gridId: widget.gridId, gridId: widget.gridId,
dataController: widget.dataController, dataController: widget.dataController,
); )..add(const BoardCardEvent.initial());
super.initState(); super.initState();
} }
@ -71,14 +73,20 @@ class _BoardCardState extends State<BoardCard> {
List<Widget> _makeCells(BuildContext context, GridCellMap cellMap) { List<Widget> _makeCells(BuildContext context, GridCellMap cellMap) {
return cellMap.values.map( return cellMap.values.map(
(cellId) { (cellId) {
final child = widget.cellBuilder.buildCell(cellId); final child = widget.cellBuilder.buildCell(widget.groupId, cellId);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), padding: const EdgeInsets.only(left: 4, right: 4, top: 6),
child: child, child: child,
); );
}, },
).toList(); ).toList();
} }
@override
Future<void> dispose() async {
_cardBloc.close();
super.dispose();
}
} }
class _CardMoreOption extends StatelessWidget with CardAccessory { class _CardMoreOption extends StatelessWidget with CardAccessory {
@ -86,7 +94,7 @@ class _CardMoreOption extends StatelessWidget with CardAccessory {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return svgWidget('home/details', color: context.read<AppTheme>().iconColor); return svgWidget('grid/details', color: context.read<AppTheme>().iconColor);
} }
@override @override

View File

@ -19,7 +19,7 @@ class BoardCellBuilder {
BoardCellBuilder(this.delegate); BoardCellBuilder(this.delegate);
Widget buildCell(GridCellIdentifier cellId) { Widget buildCell(String groupId, GridCellIdentifier cellId) {
final cellControllerBuilder = GridCellControllerBuilder( final cellControllerBuilder = GridCellControllerBuilder(
delegate: delegate, delegate: delegate,
cellId: cellId, cellId: cellId,
@ -30,36 +30,43 @@ class BoardCellBuilder {
switch (cellId.fieldType) { switch (cellId.fieldType) {
case FieldType.Checkbox: case FieldType.Checkbox:
return BoardCheckboxCell( return BoardCheckboxCell(
groupId: groupId,
cellControllerBuilder: cellControllerBuilder, cellControllerBuilder: cellControllerBuilder,
key: key, key: key,
); );
case FieldType.DateTime: case FieldType.DateTime:
return BoardDateCell( return BoardDateCell(
groupId: groupId,
cellControllerBuilder: cellControllerBuilder, cellControllerBuilder: cellControllerBuilder,
key: key, key: key,
); );
case FieldType.SingleSelect: case FieldType.SingleSelect:
return BoardSelectOptionCell( return BoardSelectOptionCell(
groupId: groupId,
cellControllerBuilder: cellControllerBuilder, cellControllerBuilder: cellControllerBuilder,
key: key, key: key,
); );
case FieldType.MultiSelect: case FieldType.MultiSelect:
return BoardSelectOptionCell( return BoardSelectOptionCell(
groupId: groupId,
cellControllerBuilder: cellControllerBuilder, cellControllerBuilder: cellControllerBuilder,
key: key, key: key,
); );
case FieldType.Number: case FieldType.Number:
return BoardNumberCell( return BoardNumberCell(
groupId: groupId,
cellControllerBuilder: cellControllerBuilder, cellControllerBuilder: cellControllerBuilder,
key: key, key: key,
); );
case FieldType.RichText: case FieldType.RichText:
return BoardTextCell( return BoardTextCell(
groupId: groupId,
cellControllerBuilder: cellControllerBuilder, cellControllerBuilder: cellControllerBuilder,
key: key, key: key,
); );
case FieldType.URL: case FieldType.URL:
return BoardUrlCell( return BoardUrlCell(
groupId: groupId,
cellControllerBuilder: cellControllerBuilder, cellControllerBuilder: cellControllerBuilder,
key: key, key: key,
); );

View File

@ -74,6 +74,7 @@ class CardAccessoryContainer extends StatelessWidget {
width: 26, width: 26,
height: 26, height: 26,
padding: const EdgeInsets.all(3), padding: const EdgeInsets.all(3),
decoration: _makeBoxDecoration(context),
child: accessory, child: accessory,
), ),
); );
@ -88,6 +89,23 @@ class CardAccessoryContainer extends StatelessWidget {
} }
} }
BoxDecoration _makeBoxDecoration(BuildContext context) {
final theme = context.read<AppTheme>();
final borderSide = BorderSide(color: theme.shader6, width: 1.0);
return BoxDecoration(
color: theme.surface,
border: Border.fromBorderSide(borderSide),
boxShadow: [
BoxShadow(
color: theme.shader6,
spreadRadius: 0,
blurRadius: 2,
offset: Offset.zero)
],
borderRadius: const BorderRadius.all(Radius.circular(6)),
);
}
class _CardEnterRegion extends StatelessWidget { class _CardEnterRegion extends StatelessWidget {
final Widget child; final Widget child;
final List<CardAccessory> accessories; final List<CardAccessory> accessories;
@ -116,7 +134,7 @@ class _CardEnterRegion extends StatelessWidget {
.onEnter = false, .onEnter = false,
child: IntrinsicHeight( child: IntrinsicHeight(
child: Stack( child: Stack(
alignment: AlignmentDirectional.center, alignment: AlignmentDirectional.topEnd,
fit: StackFit.expand, fit: StackFit.expand,
children: children, children: children,
)), )),

View File

@ -0,0 +1,3 @@
class BoardSizes {
static double get cardCellVPadding => 6;
}

View File

@ -24,7 +24,8 @@ class GridCellDataLoader<T> {
Future<T?> loadData() { Future<T?> loadData() {
final fut = service.getCell(cellId: cellId); final fut = service.getCell(cellId: cellId);
return fut.then( return fut.then(
(result) => result.fold((GridCellPB cell) { (result) => result.fold(
(GridCellPB cell) {
try { try {
return parser.parserData(cell.data); return parser.parserData(cell.data);
} catch (e, s) { } catch (e, s) {
@ -32,10 +33,12 @@ class GridCellDataLoader<T> {
Log.error('Stack trace \n $s'); Log.error('Stack trace \n $s');
return null; return null;
} }
}, (err) { },
(err) {
Log.error(err); Log.error(err);
return null; return null;
}), },
),
); );
} }
} }
@ -58,7 +61,8 @@ class DateCellDataParser implements IGridCellDataParser<DateCellDataPB> {
} }
} }
class SelectOptionCellDataParser implements IGridCellDataParser<SelectOptionCellDataPB> { class SelectOptionCellDataParser
implements IGridCellDataParser<SelectOptionCellDataPB> {
@override @override
SelectOptionCellDataPB? parserData(List<int> data) { SelectOptionCellDataPB? parserData(List<int> data) {
if (data.isEmpty) { if (data.isEmpty) {

View File

@ -190,7 +190,10 @@ class IGridCellController<T, D> extends Equatable {
/// cell display: $12 /// cell display: $12
_cellListener?.start(onCellChanged: (result) { _cellListener?.start(onCellChanged: (result) {
result.fold( result.fold(
(_) => _loadData(), (_) {
_cellsCache.remove(fieldId);
_loadData();
},
(err) => Log.error(err), (err) => Log.error(err),
); );
}); });
@ -279,8 +282,8 @@ class IGridCellController<T, D> extends Equatable {
_loadDataOperation?.cancel(); _loadDataOperation?.cancel();
_loadDataOperation = Timer(const Duration(milliseconds: 10), () { _loadDataOperation = Timer(const Duration(milliseconds: 10), () {
_cellDataLoader.loadData().then((data) { _cellDataLoader.loadData().then((data) {
_cellDataNotifier?.value = data;
_cellsCache.insert(_cacheKey, GridCell(object: data)); _cellsCache.insert(_cacheKey, GridCell(object: data));
_cellDataNotifier?.value = data;
}); });
}); });
} }

View File

@ -3,7 +3,6 @@ import 'package:flowy_sdk/dispatch/dispatch.dart';
import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-grid/board_card.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-grid/grid_entities.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/grid_entities.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-grid/group.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/group.pb.dart';

View File

@ -3,6 +3,7 @@ import 'package:flowy_sdk/dispatch/dispatch.dart';
import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-grid/grid_entities.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/grid_entities.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-grid/group_changeset.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-grid/row_entities.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/row_entities.pb.dart';
class RowFFIService { class RowFFIService {
@ -68,4 +69,33 @@ class MoveRowFFIService {
return GridEventMoveRow(payload).send(); return GridEventMoveRow(payload).send();
} }
Future<Either<Unit, FlowyError>> moveGroupRow({
required String fromRowId,
required String toGroupId,
required String? toRowId,
}) {
var payload = MoveGroupRowPayloadPB.create()
..viewId = gridId
..fromRowId = fromRowId
..toGroupId = toGroupId;
if (toRowId != null) {
payload.toRowId = toRowId;
}
return GridEventMoveGroupRow(payload).send();
}
Future<Either<Unit, FlowyError>> moveGroup({
required String fromGroupId,
required String toGroupId,
}) {
final payload = MoveGroupPayloadPB.create()
..viewId = gridId
..fromGroupId = fromGroupId
..toGroupId = toGroupId;
return GridEventMoveGroup(payload).send();
}
} }

View File

@ -91,8 +91,11 @@ class SelectOptionTag extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ChoiceChip( return ChoiceChip(
pressElevation: 1, pressElevation: 1,
label: label: FlowyText.medium(
FlowyText.medium(name, fontSize: 12, overflow: TextOverflow.ellipsis), name,
fontSize: 12,
overflow: TextOverflow.clip,
),
selectedColor: color, selectedColor: color,
backgroundColor: color, backgroundColor: color,
labelPadding: const EdgeInsets.symmetric(horizontal: 6), labelPadding: const EdgeInsets.symmetric(horizontal: 6),

View File

@ -56,7 +56,7 @@ abstract class PluginBuilder {
ViewDataTypePB get dataType => ViewDataTypePB.Text; ViewDataTypePB get dataType => ViewDataTypePB.Text;
ViewLayoutTypePB? get subDataType => null; ViewLayoutTypePB? get subDataType => ViewLayoutTypePB.Document;
} }
abstract class PluginConfig { abstract class PluginConfig {

View File

@ -1,16 +1,21 @@
# 0.0.5
* Optimize insert card animation
* Enable insert card at the end of the column
* Fix some bugs
# 0.0.4 # 0.0.4
* fix some bugs * Fix some bugs
# 0.0.3 # 0.0.3
* Support customize UI * Support customize UI
* Update example * Update example
* Add AppFlowy style widget * Add AppFlowy style widget
## 0.0.2 # 0.0.2
* Update documentation * Update documentation
## 0.0.1 # 0.0.1
* Support drag and drop column * Support drag and drop column
* Support drag and drop column items from one to another * Support drag and drop column items from one to another

View File

@ -10,7 +10,7 @@ class MultiBoardListExample extends StatefulWidget {
class _MultiBoardListExampleState extends State<MultiBoardListExample> { class _MultiBoardListExampleState extends State<MultiBoardListExample> {
final AFBoardDataController boardDataController = AFBoardDataController( final AFBoardDataController boardDataController = AFBoardDataController(
onMoveColumn: (fromIndex, toIndex) { onMoveColumn: (fromColumnId, fromIndex, toColumnId, toIndex) {
debugPrint('Move column from $fromIndex to $toIndex'); debugPrint('Move column from $fromIndex to $toIndex');
}, },
onMoveColumnItem: (columnId, fromIndex, toIndex) { onMoveColumnItem: (columnId, fromIndex, toIndex) {
@ -26,16 +26,26 @@ class _MultiBoardListExampleState extends State<MultiBoardListExample> {
List<AFColumnItem> a = [ List<AFColumnItem> a = [
TextItem("Card 1"), TextItem("Card 1"),
TextItem("Card 2"), TextItem("Card 2"),
// RichTextItem(title: "Card 3", subtitle: 'Aug 1, 2020 4:05 PM'), RichTextItem(title: "Card 3", subtitle: 'Aug 1, 2020 4:05 PM'),
TextItem("Card 4"), TextItem("Card 4"),
TextItem("Card 5"),
TextItem("Card 6"),
RichTextItem(title: "Card 7", subtitle: 'Aug 1, 2020 4:05 PM'),
RichTextItem(title: "Card 8", subtitle: 'Aug 1, 2020 4:05 PM'),
TextItem("Card 9"),
]; ];
final column1 = AFBoardColumnData(id: "To Do", items: a); final column1 = AFBoardColumnData(id: "To Do", name: "To Do", items: a);
final column2 = AFBoardColumnData(id: "In Progress", items: <AFColumnItem>[ final column2 = AFBoardColumnData(
// RichTextItem(title: "Card 5", subtitle: 'Aug 1, 2020 4:05 PM'), id: "In Progress",
// TextItem("Card 6"), name: "In Progress",
]); items: <AFColumnItem>[
RichTextItem(title: "Card 10", subtitle: 'Aug 1, 2020 4:05 PM'),
TextItem("Card 11"),
],
);
final column3 = AFBoardColumnData(id: "Done", items: <AFColumnItem>[]); final column3 =
AFBoardColumnData(id: "Done", name: "Done", items: <AFColumnItem>[]);
boardDataController.addColumn(column1); boardDataController.addColumn(column1);
boardDataController.addColumn(column2); boardDataController.addColumn(column2);
@ -63,20 +73,31 @@ class _MultiBoardListExampleState extends State<MultiBoardListExample> {
margin: config.columnItemPadding, margin: config.columnItemPadding,
); );
}, },
headerBuilder: (context, columnData) { headerBuilder: (context, headerData) {
return AppFlowyColumnHeader( return AppFlowyColumnHeader(
icon: const Icon(Icons.lightbulb_circle), icon: const Icon(Icons.lightbulb_circle),
title: Text(columnData.id), title: SizedBox(
width: 60,
child: TextField(
controller: TextEditingController()
..text = headerData.columnName,
onSubmitted: (val) {
boardDataController
.getColumnController(headerData.columnId)!
.updateColumnName(val);
},
),
),
addIcon: const Icon(Icons.add, size: 20), addIcon: const Icon(Icons.add, size: 20),
moreIcon: const Icon(Icons.more_horiz, size: 20), moreIcon: const Icon(Icons.more_horiz, size: 20),
height: 50, height: 50,
margin: config.columnItemPadding, margin: config.columnItemPadding,
); );
}, },
cardBuilder: (context, item) { cardBuilder: (context, column, columnItem) {
return AppFlowyColumnItemCard( return AppFlowyColumnItemCard(
key: ObjectKey(item), key: ObjectKey(columnItem),
child: _buildCard(item), child: _buildCard(columnItem),
); );
}, },
columnConstraints: const BoxConstraints.tightFor(width: 240), columnConstraints: const BoxConstraints.tightFor(width: 240),
@ -93,7 +114,7 @@ class _MultiBoardListExampleState extends State<MultiBoardListExample> {
return Align( return Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 60),
child: Text(item.s), child: Text(item.s),
), ),
); );
@ -103,7 +124,7 @@ class _MultiBoardListExampleState extends State<MultiBoardListExample> {
return Align( return Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Padding( child: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 60),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [

View File

@ -13,12 +13,16 @@ class _SingleBoardListExampleState extends State<SingleBoardListExample> {
@override @override
void initState() { void initState() {
final column = AFBoardColumnData(id: "1", items: [ final column = AFBoardColumnData(
id: "1",
name: "1",
items: [
TextItem("a"), TextItem("a"),
TextItem("b"), TextItem("b"),
TextItem("c"), TextItem("c"),
TextItem("d"), TextItem("d"),
]); ],
);
boardData.addColumn(column); boardData.addColumn(column);
super.initState(); super.initState();
@ -28,8 +32,9 @@ class _SingleBoardListExampleState extends State<SingleBoardListExample> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AFBoard( return AFBoard(
dataController: boardData, dataController: boardData,
cardBuilder: (context, item) { cardBuilder: (context, column, columnItem) {
return _RowWidget(item: item as TextItem, key: ObjectKey(item)); return _RowWidget(
item: columnItem as TextItem, key: ObjectKey(columnItem));
}, },
); );
} }

View File

@ -4,8 +4,6 @@ import 'package:flutter/material.dart';
const DART_LOG = "Dart_LOG"; const DART_LOG = "Dart_LOG";
class Log { class Log {
// static const enableLog = bool.hasEnvironment(DART_LOG);
// static final shared = Log();
static const enableLog = false; static const enableLog = false;
static void info(String? message) { static void info(String? message) {
@ -16,19 +14,19 @@ class Log {
static void debug(String? message) { static void debug(String? message) {
if (enableLog) { if (enableLog) {
debugPrint('🐛[Debug]=> $message'); debugPrint('🐛[Debug] - ${DateTime.now().second}=> $message');
} }
} }
static void warn(String? message) { static void warn(String? message) {
if (enableLog) { if (enableLog) {
debugPrint('🐛[Warn]=> $message'); debugPrint('🐛[Warn] - ${DateTime.now().second} => $message');
} }
} }
static void trace(String? message) { static void trace(String? message) {
if (enableLog) { if (enableLog) {
// debugPrint('❗️[Trace]=> $message'); debugPrint('❗️[Trace] - ${DateTime.now().second}=> $message');
} }
} }
} }

View File

@ -12,12 +12,18 @@ class AFBoardConfig {
final double cornerRadius; final double cornerRadius;
final EdgeInsets columnPadding; final EdgeInsets columnPadding;
final EdgeInsets columnItemPadding; final EdgeInsets columnItemPadding;
final EdgeInsets footerPadding;
final EdgeInsets headerPadding;
final EdgeInsets cardPadding;
final Color columnBackgroundColor; final Color columnBackgroundColor;
const AFBoardConfig({ const AFBoardConfig({
this.cornerRadius = 6.0, this.cornerRadius = 6.0,
this.columnPadding = const EdgeInsets.symmetric(horizontal: 8), this.columnPadding = const EdgeInsets.symmetric(horizontal: 8),
this.columnItemPadding = const EdgeInsets.symmetric(horizontal: 10), this.columnItemPadding = const EdgeInsets.symmetric(horizontal: 12),
this.footerPadding = const EdgeInsets.symmetric(horizontal: 12),
this.headerPadding = const EdgeInsets.symmetric(horizontal: 16),
this.cardPadding = const EdgeInsets.symmetric(horizontal: 3, vertical: 4),
this.columnBackgroundColor = Colors.transparent, this.columnBackgroundColor = Colors.transparent,
}); });
} }
@ -159,7 +165,7 @@ class _BoardContentState extends State<BoardContent> {
dataSource: widget.dataController, dataSource: widget.dataController,
direction: Axis.horizontal, direction: Axis.horizontal,
interceptor: interceptor, interceptor: interceptor,
children: _buildColumns(), children: _buildColumns(interceptor.columnKeys),
); );
return Stack( return Stack(
@ -191,7 +197,7 @@ class _BoardContentState extends State<BoardContent> {
); );
} }
List<Widget> _buildColumns() { List<Widget> _buildColumns(List<ColumnKey> columnKeys) {
final List<Widget> children = final List<Widget> children =
widget.dataController.columnDatas.asMap().entries.map( widget.dataController.columnDatas.asMap().entries.map(
(item) { (item) {
@ -205,15 +211,13 @@ class _BoardContentState extends State<BoardContent> {
return ChangeNotifierProvider.value( return ChangeNotifierProvider.value(
key: ValueKey(columnData.id), key: ValueKey(columnData.id),
value: widget.dataController.columnController(columnData.id), value: widget.dataController.getColumnController(columnData.id),
child: Consumer<AFBoardColumnDataController>( child: Consumer<AFBoardColumnDataController>(
builder: (context, value, child) { builder: (context, value, child) {
return ConstrainedBox( final boardColumn = AFBoardColumnWidget(
constraints: widget.columnConstraints,
child: AFBoardColumnWidget(
margin: _marginFromIndex(columnIndex), margin: _marginFromIndex(columnIndex),
itemMargin: widget.config.columnItemPadding, itemMargin: widget.config.columnItemPadding,
headerBuilder: widget.headerBuilder, headerBuilder: _buildHeader,
footBuilder: widget.footBuilder, footBuilder: widget.footBuilder,
cardBuilder: widget.cardBuilder, cardBuilder: widget.cardBuilder,
dataSource: dataSource, dataSource: dataSource,
@ -222,7 +226,20 @@ class _BoardContentState extends State<BoardContent> {
onReorder: widget.dataController.moveColumnItem, onReorder: widget.dataController.moveColumnItem,
cornerRadius: widget.config.cornerRadius, cornerRadius: widget.config.cornerRadius,
backgroundColor: widget.config.columnBackgroundColor, backgroundColor: widget.config.columnBackgroundColor,
), );
// columnKeys
// .removeWhere((element) => element.columnId == columnData.id);
// columnKeys.add(
// ColumnKey(
// columnId: columnData.id,
// key: boardColumn.columnGlobalKey,
// ),
// );
return ConstrainedBox(
constraints: widget.columnConstraints,
child: boardColumn,
); );
}, },
), ),
@ -233,6 +250,19 @@ class _BoardContentState extends State<BoardContent> {
return children; return children;
} }
Widget? _buildHeader(
BuildContext context, AFBoardColumnHeaderData headerData) {
if (widget.headerBuilder == null) {
return null;
}
return Selector<AFBoardColumnDataController, AFBoardColumnHeaderData>(
selector: (context, controller) => controller.columnData.headerData,
builder: (context, headerData, _) {
return widget.headerBuilder!(context, headerData)!;
},
);
}
EdgeInsets _marginFromIndex(int index) { EdgeInsets _marginFromIndex(int index) {
if (widget.dataController.columnDatas.isEmpty) { if (widget.dataController.columnDatas.isEmpty) {
return widget.config.columnPadding; return widget.config.columnPadding;
@ -261,7 +291,7 @@ class _BoardColumnDataSourceImpl extends AFBoardColumnDataDataSource {
@override @override
AFBoardColumnData get columnData => AFBoardColumnData get columnData =>
dataController.columnController(columnId).columnData; dataController.getColumnController(columnId)!.columnData;
@override @override
List<String> get acceptedColumnIds => dataController.columnIds; List<String> get acceptedColumnIds => dataController.columnIds;

View File

@ -24,12 +24,13 @@ typedef OnColumnInserted = void Function(String listId, int insertedIndex);
typedef AFBoardColumnCardBuilder = Widget Function( typedef AFBoardColumnCardBuilder = Widget Function(
BuildContext context, BuildContext context,
AFBoardColumnData columnData,
AFColumnItem item, AFColumnItem item,
); );
typedef AFBoardColumnHeaderBuilder = Widget Function( typedef AFBoardColumnHeaderBuilder = Widget? Function(
BuildContext context, BuildContext context,
AFBoardColumnData columnData, AFBoardColumnHeaderData headerData,
); );
typedef AFBoardColumnFooterBuilder = Widget Function( typedef AFBoardColumnFooterBuilder = Widget Function(
@ -87,7 +88,9 @@ class AFBoardColumnWidget extends StatefulWidget {
final Color backgroundColor; final Color backgroundColor;
const AFBoardColumnWidget({ final GlobalKey columnGlobalKey = GlobalKey();
AFBoardColumnWidget({
Key? key, Key? key,
this.headerBuilder, this.headerBuilder,
this.footBuilder, this.footBuilder,
@ -123,8 +126,8 @@ class _AFBoardColumnWidgetState extends State<AFBoardColumnWidget> {
.map((item) => _buildWidget(context, item)) .map((item) => _buildWidget(context, item))
.toList(); .toList();
final header = final header = widget.headerBuilder
widget.headerBuilder?.call(context, widget.dataSource.columnData); ?.call(context, widget.dataSource.columnData.headerData);
final footer = final footer =
widget.footBuilder?.call(context, widget.dataSource.columnData); widget.footBuilder?.call(context, widget.dataSource.columnData);
@ -136,8 +139,8 @@ class _AFBoardColumnWidgetState extends State<AFBoardColumnWidget> {
draggableTargetBuilder: PhantomDraggableBuilder(), draggableTargetBuilder: PhantomDraggableBuilder(),
); );
final reorderFlex = ReorderFlex( Widget reorderFlex = ReorderFlex(
key: widget.key, key: widget.columnGlobalKey,
scrollController: widget.scrollController, scrollController: widget.scrollController,
config: widget.config, config: widget.config,
onDragStarted: (index) { onDragStarted: (index) {
@ -160,6 +163,9 @@ class _AFBoardColumnWidgetState extends State<AFBoardColumnWidget> {
children: children, children: children,
); );
// reorderFlex =
// KeyedSubtree(key: widget.columnGlobalKey, child: reorderFlex);
return Container( return Container(
margin: widget.margin, margin: widget.margin,
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
@ -202,7 +208,7 @@ class _AFBoardColumnWidgetState extends State<AFBoardColumnWidget> {
passthroughPhantomContext: item.phantomContext, passthroughPhantomContext: item.phantomContext,
); );
} else { } else {
return widget.cardBuilder(context, item); return widget.cardBuilder(context, widget.dataSource.columnData, item);
} }
} }
} }

View File

@ -34,6 +34,13 @@ class AFBoardColumnDataController extends ChangeNotifier with EquatableMixin {
UnmodifiableListView<AFColumnItem> get items => UnmodifiableListView<AFColumnItem> get items =>
UnmodifiableListView(columnData.items); UnmodifiableListView(columnData.items);
void updateColumnName(String newName) {
if (columnData.headerData.columnName != newName) {
columnData.headerData.columnName = newName;
notifyListeners();
}
}
/// Remove the item at [index]. /// Remove the item at [index].
/// * [index] the index of the item you want to remove /// * [index] the index of the item you want to remove
/// * [notify] the default value of [notify] is true, it will notify the /// * [notify] the default value of [notify] is true, it will notify the
@ -114,6 +121,10 @@ class AFBoardColumnDataController extends ChangeNotifier with EquatableMixin {
columnData._items.add(newItem); columnData._items.add(newItem);
Log.debug('[$AFBoardColumnDataController] $columnData add $newItem'); Log.debug('[$AFBoardColumnDataController] $columnData add $newItem');
} else { } else {
if (index >= columnData._items.length) {
return;
}
final removedItem = columnData._items.removeAt(index); final removedItem = columnData._items.removeAt(index);
columnData._items.insert(index, newItem); columnData._items.insert(index, newItem);
Log.debug( Log.debug(
@ -123,6 +134,18 @@ class AFBoardColumnDataController extends ChangeNotifier with EquatableMixin {
notifyListeners(); notifyListeners();
} }
void replaceOrInsertItem(AFColumnItem newItem) {
final index = columnData._items.indexWhere((item) => item.id == newItem.id);
if (index != -1) {
columnData._items.removeAt(index);
columnData._items.insert(index, newItem);
notifyListeners();
} else {
columnData._items.add(newItem);
notifyListeners();
}
}
bool _containsItem(AFColumnItem item) { bool _containsItem(AFColumnItem item) {
return columnData._items.indexWhere((element) => element.id == item.id) != return columnData._items.indexWhere((element) => element.id == item.id) !=
-1; -1;
@ -133,19 +156,24 @@ class AFBoardColumnDataController extends ChangeNotifier with EquatableMixin {
class AFBoardColumnData<CustomData> extends ReoderFlexItem with EquatableMixin { class AFBoardColumnData<CustomData> extends ReoderFlexItem with EquatableMixin {
@override @override
final String id; final String id;
final String desc; AFBoardColumnHeaderData headerData;
final List<AFColumnItem> _items; final List<AFColumnItem> _items;
final CustomData? customData; final CustomData? customData;
AFBoardColumnData({ AFBoardColumnData({
this.customData, this.customData,
required this.id, required this.id,
this.desc = "", required String name,
List<AFColumnItem> items = const [], List<AFColumnItem> items = const [],
}) : _items = items; }) : _items = items,
headerData = AFBoardColumnHeaderData(
columnId: id,
columnName: name,
);
/// Returns the readonly List<ColumnItem> /// Returns the readonly List<ColumnItem>
UnmodifiableListView<AFColumnItem> get items => UnmodifiableListView(_items); UnmodifiableListView<AFColumnItem> get items =>
UnmodifiableListView([..._items]);
@override @override
List<Object?> get props => [id, ..._items]; List<Object?> get props => [id, ..._items];
@ -155,3 +183,10 @@ class AFBoardColumnData<CustomData> extends ReoderFlexItem with EquatableMixin {
return 'Column:[$id]'; return 'Column:[$id]';
} }
} }
class AFBoardColumnHeaderData {
String columnId;
String columnName;
AFBoardColumnHeaderData({required this.columnId, required this.columnName});
}

View File

@ -8,7 +8,12 @@ import 'reorder_flex/reorder_flex.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'reorder_phantom/phantom_controller.dart'; import 'reorder_phantom/phantom_controller.dart';
typedef OnMoveColumn = void Function(int fromIndex, int toIndex); typedef OnMoveColumn = void Function(
String fromColumnId,
int fromIndex,
String toColumnId,
int toIndex,
);
typedef OnMoveColumnItem = void Function( typedef OnMoveColumnItem = void Function(
String columnId, String columnId,
@ -84,10 +89,6 @@ class AFBoardDataController extends ChangeNotifier
if (columnIds.isNotEmpty && notify) notifyListeners(); if (columnIds.isNotEmpty && notify) notifyListeners();
} }
AFBoardColumnDataController columnController(String columnId) {
return _columnControllers[columnId]!;
}
AFBoardColumnDataController? getColumnController(String columnId) { AFBoardColumnDataController? getColumnController(String columnId) {
final columnController = _columnControllers[columnId]; final columnController = _columnControllers[columnId];
if (columnController == null) { if (columnController == null) {
@ -98,9 +99,11 @@ class AFBoardDataController extends ChangeNotifier
} }
void moveColumn(int fromIndex, int toIndex, {bool notify = true}) { void moveColumn(int fromIndex, int toIndex, {bool notify = true}) {
final columnData = _columnDatas.removeAt(fromIndex); final toColumnData = _columnDatas[toIndex];
_columnDatas.insert(toIndex, columnData); final fromColumnData = _columnDatas.removeAt(fromIndex);
onMoveColumn?.call(fromIndex, toIndex);
_columnDatas.insert(toIndex, fromColumnData);
onMoveColumn?.call(fromColumnData.id, fromIndex, toColumnData.id, toIndex);
if (notify) notifyListeners(); if (notify) notifyListeners();
} }
@ -122,6 +125,10 @@ class AFBoardDataController extends ChangeNotifier
getColumnController(columnId)?.removeWhere((item) => item.id == itemId); getColumnController(columnId)?.removeWhere((item) => item.id == itemId);
} }
void updateColumnItem(String columnId, AFColumnItem item) {
getColumnController(columnId)?.replaceOrInsertItem(item);
}
@override @override
@protected @protected
void swapColumnItem( void swapColumnItem(
@ -130,15 +137,14 @@ class AFBoardDataController extends ChangeNotifier
String toColumnId, String toColumnId,
int toColumnIndex, int toColumnIndex,
) { ) {
final item = columnController(fromColumnId).removeAt(fromColumnIndex); final fromColumnController = getColumnController(fromColumnId)!;
final toColumnController = getColumnController(toColumnId)!;
if (columnController(toColumnId).items.length > toColumnIndex) { final item = fromColumnController.removeAt(fromColumnIndex);
assert(columnController(toColumnId).items[toColumnIndex] if (toColumnController.items.length > toColumnIndex) {
is PhantomColumnItem); assert(toColumnController.items[toColumnIndex] is PhantomColumnItem);
} }
columnController(toColumnId).replace(toColumnIndex, item); toColumnController.replace(toColumnIndex, item);
onMoveColumnItemToColumn?.call( onMoveColumnItemToColumn?.call(
fromColumnId, fromColumnId,
fromColumnIndex, fromColumnIndex,
@ -167,9 +173,12 @@ class AFBoardDataController extends ChangeNotifier
@override @override
@protected @protected
bool removePhantom(String columnId) { bool removePhantom(String columnId) {
final columnController = this.columnController(columnId); final columnController = getColumnController(columnId);
if (columnController == null) {
Log.warn('Can not find the column controller with columnId: $columnId');
return false;
}
final index = columnController.items.indexWhere((item) => item.isPhantom); final index = columnController.items.indexWhere((item) => item.isPhantom);
final isExist = index != -1; final isExist = index != -1;
if (isExist) { if (isExist) {
columnController.removeAt(index); columnController.removeAt(index);
@ -183,14 +192,15 @@ class AFBoardDataController extends ChangeNotifier
@override @override
@protected @protected
void updatePhantom(String columnId, int newIndex) { void updatePhantom(String columnId, int newIndex) {
final columnDataController = columnController(columnId); final columnDataController = getColumnController(columnId)!;
final index = final index =
columnDataController.items.indexWhere((item) => item.isPhantom); columnDataController.items.indexWhere((item) => item.isPhantom);
assert(index != -1); assert(index != -1);
if (index != -1) { if (index != -1) {
if (index != newIndex) { if (index != newIndex) {
// Log.debug('[$BoardPhantomController] update $toColumnId:$index to $toColumnId:$phantomIndex'); Log.trace(
'[$BoardPhantomController] update $columnId:$index to $columnId:$newIndex');
final item = columnDataController.removeAt(index, notify: false); final item = columnDataController.removeAt(index, notify: false);
columnDataController.insert(newIndex, item, notify: false); columnDataController.insert(newIndex, item, notify: false);
} }
@ -200,6 +210,6 @@ class AFBoardDataController extends ChangeNotifier
@override @override
@protected @protected
void insertPhantom(String columnId, int index, PhantomColumnItem item) { void insertPhantom(String columnId, int index, PhantomColumnItem item) {
columnController(columnId).insert(index, item); getColumnController(columnId)!.insert(index, item);
} }
} }

View File

@ -43,7 +43,7 @@ class FlexDragTargetData extends DragTargetData {
} }
class DraggingState { class DraggingState {
final String id; final String reorderFlexId;
/// The member of widget.children currently being dragged. /// The member of widget.children currently being dragged.
Widget? _draggingWidget; Widget? _draggingWidget;
@ -72,7 +72,7 @@ class DraggingState {
/// The additional margin to place around a computed drop area. /// The additional margin to place around a computed drop area.
static const double _dropAreaMargin = 0.0; static const double _dropAreaMargin = 0.0;
DraggingState(this.id); DraggingState(this.reorderFlexId);
Size get dropAreaSize { Size get dropAreaSize {
if (feedbackSize == null) { if (feedbackSize == null) {
@ -132,7 +132,7 @@ class DraggingState {
} }
void updateNextIndex(int index) { void updateNextIndex(int index) {
Log.trace('updateNextIndex: $index'); Log.debug('$reorderFlexId updateNextIndex: $index');
nextIndex = index; nextIndex = index;
} }

View File

@ -140,7 +140,7 @@ class _ReorderDragTargetState<T extends DragTargetData>
widget.insertAnimationController, widget.insertAnimationController,
widget.deleteAnimationController, widget.deleteAnimationController,
) ?? ) ??
LongPressDraggable<DragTargetData>( Draggable<DragTargetData>(
maxSimultaneousDrags: 1, maxSimultaneousDrags: 1,
data: widget.dragTargetData, data: widget.dragTargetData,
ignoringFeedbackSemantics: false, ignoringFeedbackSemantics: false,
@ -222,10 +222,10 @@ class DragTargetAnimation {
value: 0, vsync: vsync, duration: reorderAnimationDuration); value: 0, vsync: vsync, duration: reorderAnimationDuration);
insertController = AnimationController( insertController = AnimationController(
value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 100)); value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 200));
deleteController = AnimationController( deleteController = AnimationController(
value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 10)); value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 1));
} }
void startDragging() { void startDragging() {
@ -276,6 +276,31 @@ class IgnorePointerWidget extends StatelessWidget {
} }
} }
class AbsorbPointerWidget extends StatelessWidget {
final Widget? child;
final bool useIntrinsicSize;
const AbsorbPointerWidget({
required this.child,
this.useIntrinsicSize = false,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final sizedChild = useIntrinsicSize
? child
: SizedBox(width: 0.0, height: 0.0, child: child);
final opacity = useIntrinsicSize ? 0.3 : 0.0;
return AbsorbPointer(
child: Opacity(
opacity: opacity,
child: sizedChild,
),
);
}
}
class PhantomWidget extends StatelessWidget { class PhantomWidget extends StatelessWidget {
final Widget? child; final Widget? child;
final double opacity; final double opacity;
@ -371,6 +396,7 @@ class _DragTargeMovePlaceholderState extends State<DragTargeMovePlaceholder> {
} }
abstract class FakeDragTargetEventTrigger { abstract class FakeDragTargetEventTrigger {
void fakeOnDragStart(void Function(int?) callback);
void fakeOnDragEnded(VoidCallback callback); void fakeOnDragEnded(VoidCallback callback);
} }
@ -421,6 +447,10 @@ class _FakeDragTargetState<T extends DragTargetData>
/// Start insert animation /// Start insert animation
widget.insertAnimationController.forward(from: 0.0); widget.insertAnimationController.forward(from: 0.0);
// widget.eventTrigger.fakeOnDragStart((insertIndex) {
// Log.trace("[$FakeDragTarget] on drag $insertIndex");
// });
widget.eventTrigger.fakeOnDragEnded(() { widget.eventTrigger.fakeOnDragEnded(() {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onDragEnded(widget.eventData.dragTargetData as T); widget.onDragEnded(widget.eventData.dragTargetData as T);
@ -436,7 +466,7 @@ class _FakeDragTargetState<T extends DragTargetData>
return SizeTransitionWithIntrinsicSize( return SizeTransitionWithIntrinsicSize(
sizeFactor: widget.deleteAnimationController, sizeFactor: widget.deleteAnimationController,
axis: Axis.vertical, axis: Axis.vertical,
child: IgnorePointerWidget( child: AbsorbPointerWidget(
child: widget.child, child: widget.child,
), ),
); );
@ -444,7 +474,7 @@ class _FakeDragTargetState<T extends DragTargetData>
return SizeTransitionWithIntrinsicSize( return SizeTransitionWithIntrinsicSize(
sizeFactor: widget.insertAnimationController, sizeFactor: widget.insertAnimationController,
axis: Axis.vertical, axis: Axis.vertical,
child: IgnorePointerWidget( child: AbsorbPointerWidget(
useIntrinsicSize: true, useIntrinsicSize: true,
child: widget.child, child: widget.child,
), ),

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../utils/log.dart'; import '../../utils/log.dart';
@ -8,6 +10,8 @@ import 'reorder_flex.dart';
/// [DragTargetInterceptor] is used to intercept the [DragTarget]'s /// [DragTargetInterceptor] is used to intercept the [DragTarget]'s
/// [onWillAccept], [OnAccept], and [onLeave] event. /// [onWillAccept], [OnAccept], and [onLeave] event.
abstract class DragTargetInterceptor { abstract class DragTargetInterceptor {
String get reorderFlexId;
/// Returns [yes] to receive the [DragTarget]'s event. /// Returns [yes] to receive the [DragTarget]'s event.
bool canHandler(FlexDragTargetData dragTargetData); bool canHandler(FlexDragTargetData dragTargetData);
@ -37,7 +41,7 @@ abstract class OverlapDragTargetDelegate {
int dragTargetIndex, int dragTargetIndex,
); );
bool canMoveTo(String dragTargetId); int canMoveTo(String dragTargetId);
} }
/// [OverlappingDragTargetInterceptor] is used to receive the overlapping /// [OverlappingDragTargetInterceptor] is used to receive the overlapping
@ -47,9 +51,12 @@ abstract class OverlapDragTargetDelegate {
/// Receive the [DragTarget] event if the [acceptedReorderFlexId] contains /// Receive the [DragTarget] event if the [acceptedReorderFlexId] contains
/// the passed in dragTarget' reorderFlexId. /// the passed in dragTarget' reorderFlexId.
class OverlappingDragTargetInterceptor extends DragTargetInterceptor { class OverlappingDragTargetInterceptor extends DragTargetInterceptor {
@override
final String reorderFlexId; final String reorderFlexId;
final List<String> acceptedReorderFlexId; final List<String> acceptedReorderFlexId;
final OverlapDragTargetDelegate delegate; final OverlapDragTargetDelegate delegate;
final List<ColumnKey> columnKeys = [];
Timer? _delayOperation;
OverlappingDragTargetInterceptor({ OverlappingDragTargetInterceptor({
required this.delegate, required this.delegate,
@ -72,15 +79,38 @@ class OverlappingDragTargetInterceptor extends DragTargetInterceptor {
if (dragTargetId == dragTargetData.reorderFlexId) { if (dragTargetId == dragTargetData.reorderFlexId) {
delegate.cancel(); delegate.cancel();
} else { } else {
if (delegate.canMoveTo(dragTargetId)) { /// The priority of the column interactions is high than the cross column.
delegate.moveTo(dragTargetId, dragTargetData, 0); /// Workaround: delay 100 milliseconds to lower the cross column event priority.
_delayOperation?.cancel();
_delayOperation = Timer(const Duration(milliseconds: 100), () {
final index = delegate.canMoveTo(dragTargetId);
if (index != -1) {
Log.trace(
'[$OverlappingDragTargetInterceptor] move to $dragTargetId at $index');
delegate.moveTo(dragTargetId, dragTargetData, index);
// final columnIndex = columnKeys
// .indexWhere((element) => element.columnId == dragTargetId);
// if (columnIndex != -1) {
// final state = columnKeys[columnIndex].key.currentState;
// if (state is ReorderFlexState) {
// state.handleOnWillAccept(context, index);
// }
// }
} }
});
} }
return true; return true;
} }
} }
class ColumnKey {
String columnId;
GlobalKey key;
ColumnKey({required this.columnId, required this.key});
}
abstract class CrossReorderFlexDragTargetDelegate { abstract class CrossReorderFlexDragTargetDelegate {
/// * [reorderFlexId] is the id that the [ReorderFlex] passed in. /// * [reorderFlexId] is the id that the [ReorderFlex] passed in.
bool acceptNewDragTargetData( bool acceptNewDragTargetData(
@ -96,6 +126,7 @@ abstract class CrossReorderFlexDragTargetDelegate {
} }
class CrossReorderFlexDragTargetInterceptor extends DragTargetInterceptor { class CrossReorderFlexDragTargetInterceptor extends DragTargetInterceptor {
@override
final String reorderFlexId; final String reorderFlexId;
final List<String> acceptedReorderFlexIds; final List<String> acceptedReorderFlexIds;
final CrossReorderFlexDragTargetDelegate delegate; final CrossReorderFlexDragTargetDelegate delegate;
@ -119,8 +150,12 @@ class CrossReorderFlexDragTargetInterceptor extends DragTargetInterceptor {
/// If the columnId equal to the dragTargetData's columnId, /// If the columnId equal to the dragTargetData's columnId,
/// it means the dragTarget is dragging on the top of its own list. /// it means the dragTarget is dragging on the top of its own list.
/// Otherwise, it means the dargTarget was moved to another list. /// Otherwise, it means the dargTarget was moved to another list.
Log.trace(
"[$CrossReorderFlexDragTargetInterceptor] $reorderFlexId accept ${dragTargetData.reorderFlexId} ${reorderFlexId != dragTargetData.reorderFlexId}");
return reorderFlexId != dragTargetData.reorderFlexId; return reorderFlexId != dragTargetData.reorderFlexId;
} else { } else {
Log.trace(
"[$CrossReorderFlexDragTargetInterceptor] not accept ${dragTargetData.reorderFlexId}");
return false; return false;
} }
} }
@ -151,6 +186,9 @@ class CrossReorderFlexDragTargetInterceptor extends DragTargetInterceptor {
dragTargetIndex, dragTargetIndex,
); );
Log.debug(
'[$CrossReorderFlexDragTargetInterceptor] dargTargetIndex: $dragTargetIndex, reorderFlexId: $reorderFlexId');
if (isNewDragTarget == false) { if (isNewDragTarget == false) {
delegate.updateDragTargetData(reorderFlexId, dragTargetIndex); delegate.updateDragTargetData(reorderFlexId, dragTargetIndex);
reorderFlexState.handleOnWillAccept(context, dragTargetIndex); reorderFlexState.handleOnWillAccept(context, dragTargetIndex);

View File

@ -36,10 +36,10 @@ class ReorderFlexConfig {
final double draggingWidgetOpacity = 0.3; final double draggingWidgetOpacity = 0.3;
// How long an animation to reorder an element // How long an animation to reorder an element
final Duration reorderAnimationDuration = const Duration(milliseconds: 250); final Duration reorderAnimationDuration = const Duration(milliseconds: 300);
// How long an animation to scroll to an off-screen element // How long an animation to scroll to an off-screen element
final Duration scrollAnimationDuration = const Duration(milliseconds: 250); final Duration scrollAnimationDuration = const Duration(milliseconds: 300);
final bool useMoveAnimation; final bool useMoveAnimation;
@ -214,7 +214,7 @@ class ReorderFlexState extends State<ReorderFlex>
} }
Log.trace( Log.trace(
'Rebuild: Column:[${dragState.id}] ${dragState.toString()}, childIndex: $childIndex shiftedIndex: $shiftedIndex'); 'Rebuild: Column:[${dragState.reorderFlexId}] ${dragState.toString()}, childIndex: $childIndex shiftedIndex: $shiftedIndex');
final currentIndex = dragState.currentIndex; final currentIndex = dragState.currentIndex;
final dragPhantomIndex = dragState.phantomIndex; final dragPhantomIndex = dragState.phantomIndex;
@ -330,6 +330,8 @@ class ReorderFlexState extends State<ReorderFlex>
widget.onDragStarted?.call(draggingIndex); widget.onDragStarted?.call(draggingIndex);
}, },
onDragEnded: (dragTargetData) { onDragEnded: (dragTargetData) {
if (!mounted) return;
Log.debug( Log.debug(
"[DragTarget]: Column:[${widget.dataSource.identifier}] end dragging"); "[DragTarget]: Column:[${widget.dataSource.identifier}] end dragging");
_notifier.updateDragTargetIndex(-1); _notifier.updateDragTargetIndex(-1);
@ -346,21 +348,21 @@ class ReorderFlexState extends State<ReorderFlex>
}); });
}, },
onWillAccept: (FlexDragTargetData dragTargetData) { onWillAccept: (FlexDragTargetData dragTargetData) {
// Do not receive any events if the Insert item is animating.
if (_animation.deleteController.isAnimating) { if (_animation.deleteController.isAnimating) {
return false; return false;
} }
assert(widget.dataSource.items.length > dragTargetIndex); assert(widget.dataSource.items.length > dragTargetIndex);
if (_interceptDragTarget( if (_interceptDragTarget(dragTargetData, (interceptor) {
dragTargetData, interceptor.onWillAccept(
(interceptor) => interceptor.onWillAccept(
context: builderContext, context: builderContext,
reorderFlexState: this, reorderFlexState: this,
dragTargetData: dragTargetData, dragTargetData: dragTargetData,
dragTargetId: reorderFlexItem.id, dragTargetId: reorderFlexItem.id,
dragTargetIndex: dragTargetIndex, dragTargetIndex: dragTargetIndex,
), );
)) { })) {
return true; return true;
} else { } else {
return handleOnWillAccept(builderContext, dragTargetIndex); return handleOnWillAccept(builderContext, dragTargetIndex);
@ -435,7 +437,7 @@ class ReorderFlexState extends State<ReorderFlex>
/// The [willAccept] will be true if the dargTarget is the widget that gets /// The [willAccept] will be true if the dargTarget is the widget that gets
/// dragged and it is dragged on top of the other dragTargets. /// dragged and it is dragged on top of the other dragTargets.
/// ///
Log.debug( Log.trace(
'[$ReorderDragTarget] ${widget.dataSource.identifier} on will accept, dragIndex:$dragIndex, dragTargetIndex:$dragTargetIndex, count: ${widget.dataSource.items.length}'); '[$ReorderDragTarget] ${widget.dataSource.identifier} on will accept, dragIndex:$dragIndex, dragTargetIndex:$dragTargetIndex, count: ${widget.dataSource.items.length}');
bool willAccept = bool willAccept =
@ -524,7 +526,7 @@ class ReorderFlexState extends State<ReorderFlex>
// screen, then it is already on-screen. // screen, then it is already on-screen.
final double margin = widget.direction == Axis.horizontal final double margin = widget.direction == Axis.horizontal
? dragState.dropAreaSize.width ? dragState.dropAreaSize.width
: dragState.dropAreaSize.height; : dragState.dropAreaSize.height / 2.0;
if (_scrollController.hasClients) { if (_scrollController.hasClients) {
final double scrollOffset = _scrollController.offset; final double scrollOffset = _scrollController.offset;
final double topOffset = max( final double topOffset = max(

View File

@ -59,12 +59,13 @@ class BoardPhantomController extends OverlapDragTargetDelegate
} }
void columnStartDragging(String columnId) { void columnStartDragging(String columnId) {
columnsState.setColumnIsDragging(columnId, false); columnsState.setColumnIsDragging(columnId, true);
} }
/// Remove the phantom in the column when the column is end dragging. /// Remove the phantom in the column when the column is end dragging.
void columnEndDragging(String columnId) { void columnEndDragging(String columnId) {
columnsState.setColumnIsDragging(columnId, true); columnsState.setColumnIsDragging(columnId, false);
if (phantomRecord == null) return; if (phantomRecord == null) return;
final fromColumnId = phantomRecord!.fromColumnId; final fromColumnId = phantomRecord!.fromColumnId;
@ -73,10 +74,7 @@ class BoardPhantomController extends OverlapDragTargetDelegate
columnsState.notifyDidRemovePhantom(toColumnId); columnsState.notifyDidRemovePhantom(toColumnId);
} }
if (columnsState.isDragging(fromColumnId) == false) { if (phantomRecord!.toColumnId == columnId) {
return;
}
delegate.swapColumnItem( delegate.swapColumnItem(
fromColumnId, fromColumnId,
phantomRecord!.fromColumnIndex, phantomRecord!.fromColumnIndex,
@ -84,9 +82,11 @@ class BoardPhantomController extends OverlapDragTargetDelegate
phantomRecord!.toColumnIndex, phantomRecord!.toColumnIndex,
); );
Log.debug("[$BoardPhantomController] did move ${phantomRecord.toString()}"); Log.debug(
"[$BoardPhantomController] did move ${phantomRecord.toString()}");
phantomRecord = null; phantomRecord = null;
} }
}
/// Remove the phantom in the column if it contains phantom /// Remove the phantom in the column if it contains phantom
void _removePhantom(String columnId) { void _removePhantom(String columnId) {
@ -113,7 +113,7 @@ class BoardPhantomController extends OverlapDragTargetDelegate
PhantomColumnItem(phantomContext), PhantomColumnItem(phantomContext),
); );
columnsState.notifyDidInsertPhantom(toColumnId); columnsState.notifyDidInsertPhantom(toColumnId, phantomIndex);
} }
/// Reset or initial the [PhantomRecord] /// Reset or initial the [PhantomRecord]
@ -128,8 +128,9 @@ class BoardPhantomController extends OverlapDragTargetDelegate
FlexDragTargetData dragTargetData, FlexDragTargetData dragTargetData,
int dragTargetIndex, int dragTargetIndex,
) { ) {
// Log.debug('[$BoardPhantomController] move Column:[${dragTargetData.reorderFlexId}]:${dragTargetData.draggingIndex} ' // Log.debug(
// 'to Column:[$columnId]:$index'); // '[$BoardPhantomController] move Column:[${dragTargetData.reorderFlexId}]:${dragTargetData.draggingIndex} '
// 'to Column:[$columnId]:$dragTargetIndex');
phantomRecord = PhantomRecord( phantomRecord = PhantomRecord(
toColumnId: columnId, toColumnId: columnId,
@ -202,8 +203,17 @@ class BoardPhantomController extends OverlapDragTargetDelegate
} }
@override @override
bool canMoveTo(String dragTargetId) { int canMoveTo(String dragTargetId) {
return delegate.controller(dragTargetId)?.columnData.items.isEmpty ?? false; if (columnsState.isDragging(dragTargetId)) {
return -1;
}
final controller = delegate.controller(dragTargetId);
if (controller != null) {
return controller.columnData.items.length;
} else {
return 0;
}
} }
} }
@ -294,7 +304,7 @@ class PassthroughPhantomContext extends FakeDragTargetEventTrigger
AFColumnItem get itemData => dragTargetData.reorderFlexItem as AFColumnItem; AFColumnItem get itemData => dragTargetData.reorderFlexItem as AFColumnItem;
@override @override
VoidCallback? onInserted; void Function(int?)? onInserted;
@override @override
VoidCallback? onDragEnded; VoidCallback? onDragEnded;
@ -308,6 +318,11 @@ class PassthroughPhantomContext extends FakeDragTargetEventTrigger
void fakeOnDragEnded(VoidCallback callback) { void fakeOnDragEnded(VoidCallback callback) {
onDragEnded = callback; onDragEnded = callback;
} }
@override
void fakeOnDragStart(void Function(int? index) callback) {
onInserted = callback;
}
} }
class PassthroughPhantomWidget extends PhantomWidget { class PassthroughPhantomWidget extends PhantomWidget {

View File

@ -14,7 +14,7 @@ class ColumnPhantomStateController {
void addColumnListener(String columnId, PassthroughPhantomListener listener) { void addColumnListener(String columnId, PassthroughPhantomListener listener) {
_stateWithId(columnId).notifier.addListener( _stateWithId(columnId).notifier.addListener(
onInserted: (c) => listener.onInserted?.call(), onInserted: (index) => listener.onInserted?.call(index),
onDeleted: () => listener.onDragEnded?.call(), onDeleted: () => listener.onDragEnded?.call(),
); );
} }
@ -24,8 +24,8 @@ class ColumnPhantomStateController {
_states.remove(columnId); _states.remove(columnId);
} }
void notifyDidInsertPhantom(String columnId) { void notifyDidInsertPhantom(String columnId, int index) {
_stateWithId(columnId).notifier.insert(); _stateWithId(columnId).notifier.insert(index);
} }
void notifyDidRemovePhantom(String columnId) { void notifyDidRemovePhantom(String columnId) {
@ -48,7 +48,7 @@ class ColumnState {
} }
abstract class PassthroughPhantomListener { abstract class PassthroughPhantomListener {
VoidCallback? get onInserted; void Function(int?)? get onInserted;
VoidCallback? get onDragEnded; VoidCallback? get onDragEnded;
} }
@ -57,8 +57,8 @@ class PassthroughPhantomNotifier {
final removeNotifier = PhantomDeleteNotifier(); final removeNotifier = PhantomDeleteNotifier();
void insert() { void insert(int index) {
insertNotifier.insert(); insertNotifier.insert(index);
} }
void remove() { void remove() {
@ -66,12 +66,12 @@ class PassthroughPhantomNotifier {
} }
void addListener({ void addListener({
void Function(PassthroughPhantomContext? insertedPhantom)? onInserted, void Function(int? insertedIndex)? onInserted,
void Function()? onDeleted, void Function()? onDeleted,
}) { }) {
if (onInserted != null) { if (onInserted != null) {
insertNotifier.addListener(() { insertNotifier.addListener(() {
onInserted(insertNotifier.insertedPhantom); onInserted(insertNotifier.insertedIndex);
}); });
} }
@ -89,9 +89,11 @@ class PassthroughPhantomNotifier {
} }
class PhantomInsertNotifier extends ChangeNotifier { class PhantomInsertNotifier extends ChangeNotifier {
int insertedIndex = -1;
PassthroughPhantomContext? insertedPhantom; PassthroughPhantomContext? insertedPhantom;
void insert() { void insert(int index) {
insertedIndex = index;
notifyListeners(); notifyListeners();
} }
} }

View File

@ -2,16 +2,17 @@ import 'package:flutter/material.dart';
class AppFlowyColumnItemCard extends StatefulWidget { class AppFlowyColumnItemCard extends StatefulWidget {
final Widget? child; final Widget? child;
final Color backgroundColor;
final double cornerRadius;
final EdgeInsets margin; final EdgeInsets margin;
final BoxConstraints boxConstraints; final BoxConstraints boxConstraints;
final BoxDecoration decoration;
const AppFlowyColumnItemCard({ const AppFlowyColumnItemCard({
this.child, this.child,
this.cornerRadius = 0.0,
this.margin = const EdgeInsets.all(4), this.margin = const EdgeInsets.all(4),
this.backgroundColor = Colors.white, this.decoration = const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.zero,
),
this.boxConstraints = const BoxConstraints(minHeight: 40), this.boxConstraints = const BoxConstraints(minHeight: 40),
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -24,14 +25,11 @@ class _AppFlowyColumnItemCardState extends State<AppFlowyColumnItemCard> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.all(4), padding: widget.margin,
child: Container( child: Container(
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
constraints: widget.boxConstraints, constraints: widget.boxConstraints,
decoration: BoxDecoration( decoration: widget.decoration,
color: widget.backgroundColor,
borderRadius: BorderRadius.circular(widget.cornerRadius),
),
child: widget.child, child: widget.child,
), ),
); );

View File

@ -12,7 +12,7 @@ class AppFlowyColumnFooter extends StatefulWidget {
const AppFlowyColumnFooter({ const AppFlowyColumnFooter({
this.icon, this.icon,
this.title, this.title,
this.margin = EdgeInsets.zero, this.margin = const EdgeInsets.symmetric(horizontal: 12),
required this.height, required this.height,
this.onAddButtonClick, this.onAddButtonClick,
Key? key, Key? key,
@ -30,12 +30,13 @@ class _AppFlowyColumnFooterState extends State<AppFlowyColumnFooter> {
child: SizedBox( child: SizedBox(
height: widget.height, height: widget.height,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10), padding: widget.margin,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
if (widget.icon != null) widget.icon!, if (widget.icon != null) widget.icon!,
const SizedBox(width: 8),
if (widget.title != null) widget.title!, if (widget.title != null) widget.title!,
], ],
), ),

View File

@ -45,15 +45,25 @@ class _AppFlowyColumnHeaderState extends State<AppFlowyColumnHeader> {
} }
if (widget.moreIcon != null) { if (widget.moreIcon != null) {
children.add(const Spacer()); // children.add(const Spacer());
children.add( children.add(
IconButton(onPressed: widget.onMoreButtonClick, icon: widget.moreIcon!), IconButton(
onPressed: widget.onMoreButtonClick,
icon: widget.moreIcon!,
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(),
),
); );
} }
if (widget.addIcon != null) { if (widget.addIcon != null) {
children.add( children.add(
IconButton(onPressed: widget.onAddButtonClick, icon: widget.addIcon!), IconButton(
onPressed: widget.onAddButtonClick,
icon: widget.addIcon!,
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(),
),
); );
} }
@ -61,9 +71,7 @@ class _AppFlowyColumnHeaderState extends State<AppFlowyColumnHeader> {
height: widget.height, height: widget.height,
child: Padding( child: Padding(
padding: widget.margin, padding: widget.margin,
child: Row( child: Row(children: children),
children: children,
),
), ),
); );
} }

View File

@ -1,6 +1,6 @@
name: appflowy_board name: appflowy_board
description: AppFlowy board implementation. description: AppFlowy board implementation.
version: 0.0.4 version: 0.0.5
homepage: https://github.com/AppFlowy-IO/AppFlowy homepage: https://github.com/AppFlowy-IO/AppFlowy
repository: https://github.com/AppFlowy-IO/AppFlowy/tree/main/frontend/app_flowy/packages/appflowy_board repository: https://github.com/AppFlowy-IO/AppFlowy/tree/main/frontend/app_flowy/packages/appflowy_board

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="7" fill="#F2F2F2"/>
<path d="M6 6L10 10" stroke="#BDBDBD" stroke-linecap="round"/>
<path d="M10 6L6 10" stroke="#BDBDBD" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 274 B

View File

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 4.3999H4.11111H13" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.77799 4.4V3.2C5.77799 2.88174 5.89506 2.57652 6.10343 2.35147C6.31181 2.12643 6.59442 2 6.88911 2H9.11133C9.40601 2 9.68863 2.12643 9.897 2.35147C10.1054 2.57652 10.2224 2.88174 10.2224 3.2V4.4M11.8891 4.4V12.8C11.8891 13.1183 11.772 13.4235 11.5637 13.6485C11.3553 13.8736 11.0727 14 10.778 14H5.22244C4.92775 14 4.64514 13.8736 4.43676 13.6485C4.22839 13.4235 4.11133 13.1183 4.11133 12.8V4.4H11.8891Z" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.88867 7.3999V10.9999" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.11133 7.3999V10.9999" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 883 B

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4L12 4" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 8H11" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 12L12 12" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 355 B

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4L12 4" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 8H10" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 12L12 12" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 355 B

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4L12 4" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 8H12" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 12L12 12" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 355 B

View File

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.974 6.33301H7.35865C6.7922 6.33301 6.33301 6.7922 6.33301 7.35865V11.974C6.33301 12.5405 6.7922 12.9997 7.35865 12.9997H11.974C12.5405 12.9997 12.9997 12.5405 12.9997 11.974V7.35865C12.9997 6.7922 12.5405 6.33301 11.974 6.33301Z" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.53846 9.66667H4.02564C3.75362 9.66667 3.49275 9.55861 3.3004 9.36626C3.10806 9.17392 3 8.91304 3 8.64103V4.02564C3 3.75362 3.10806 3.49275 3.3004 3.3004C3.49275 3.10806 3.75362 3 4.02564 3H8.64103C8.91304 3 9.17392 3.10806 9.36626 3.3004C9.55861 3.49275 9.66667 3.75362 9.66667 4.02564V4.53846" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 781 B

View File

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 4.3999H4.11111H13" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.77799 4.4V3.2C5.77799 2.88174 5.89506 2.57652 6.10343 2.35147C6.31181 2.12643 6.59442 2 6.88911 2H9.11133C9.40601 2 9.68863 2.12643 9.897 2.35147C10.1054 2.57652 10.2224 2.88174 10.2224 3.2V4.4M11.8891 4.4V12.8C11.8891 13.1183 11.772 13.4235 11.5637 13.6485C11.3553 13.8736 11.0727 14 10.778 14H5.22244C4.92775 14 4.64514 13.8736 4.43676 13.6485C4.22839 13.4235 4.11133 13.1183 4.11133 12.8V4.4H11.8891Z" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.88867 7.3999V10.9999" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.11133 7.3999V10.9999" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 875 B

View File

@ -0,0 +1,3 @@
<svg width="1" height="16" viewBox="0 0 1 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1" height="16" rx="0.5" fill="#4F4F4F"/>
</svg>

After

Width:  |  Height:  |  Size: 155 B

View File

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 8.91521C7.21574 9.22582 7.49099 9.48283 7.80707 9.66881C8.12315 9.85479 8.47268 9.96538 8.83194 9.99309C9.1912 10.0208 9.5518 9.96497 9.88926 9.8294C10.2267 9.69383 10.5332 9.48169 10.7878 9.20736L12.2949 7.58431C12.7525 7.07413 13.0056 6.39083 12.9999 5.68156C12.9942 4.9723 12.73 4.29384 12.2643 3.7923C11.7986 3.29075 11.1686 3.00627 10.51 3.0001C9.85142 2.99394 9.21693 3.26659 8.7432 3.75935L7.87913 4.68448" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 7.08479C8.78426 6.77418 8.50901 6.51717 8.19293 6.33119C7.87685 6.14521 7.52732 6.03462 7.16806 6.00691C6.8088 5.9792 6.4482 6.03503 6.11074 6.1706C5.77327 6.30617 5.46683 6.51831 5.21218 6.79264L3.7051 8.41569C3.24755 8.92587 2.99437 9.60918 3.00009 10.3184C3.00582 11.0277 3.26998 11.7062 3.73569 12.2077C4.2014 12.7092 4.8314 12.9937 5.48999 12.9999C6.14858 13.0061 6.78307 12.7334 7.2568 12.2407L8.11584 11.3155" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 8.91521C7.21574 9.22582 7.49099 9.48283 7.80707 9.66881C8.12315 9.85479 8.47268 9.96538 8.83194 9.99309C9.1912 10.0208 9.5518 9.96497 9.88926 9.8294C10.2267 9.69383 10.5332 9.48169 10.7878 9.20736L12.2949 7.58431C12.7525 7.07413 13.0056 6.39083 12.9999 5.68156C12.9942 4.9723 12.73 4.29384 12.2643 3.7923C11.7986 3.29075 11.1686 3.00627 10.51 3.0001C9.85142 2.99394 9.21693 3.26659 8.7432 3.75935L7.87913 4.68448" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 7.08479C8.78426 6.77418 8.50901 6.51717 8.19293 6.33119C7.87685 6.14521 7.52732 6.03462 7.16806 6.00691C6.8088 5.9792 6.4482 6.03503 6.11074 6.1706C5.77327 6.30617 5.46683 6.51831 5.21218 6.79264L3.7051 8.41569C3.24755 8.92587 2.99437 9.60918 3.00009 10.3184C3.00582 11.0277 3.26998 11.7062 3.73569 12.2077C4.2014 12.7092 4.8314 12.9937 5.48999 12.9999C6.14858 13.0061 6.78307 12.7334 7.2568 12.2407L8.11584 11.3155" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="3" width="13" height="10" rx="1.5" stroke="#333333"/>
<circle cx="5.5" cy="6.5" r="1" stroke="#333333"/>
<path d="M5 13L10.112 8.45603C10.4211 8.18126 10.8674 8.12513 11.235 8.31482L14.5 10" stroke="#333333"/>
</svg>

After

Width:  |  Height:  |  Size: 330 B

View File

@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 8.91521C7.21574 9.22582 7.49099 9.48283 7.80707 9.66881C8.12315 9.85479 8.47268 9.96538 8.83194 9.99309C9.1912 10.0208 9.5518 9.96497 9.88926 9.8294C10.2267 9.69383 10.5332 9.48169 10.7878 9.20736L12.2949 7.58431C12.7525 7.07413 13.0056 6.39083 12.9999 5.68156C12.9942 4.9723 12.73 4.29384 12.2643 3.7923C11.7986 3.29075 11.1686 3.00627 10.51 3.0001C9.85142 2.99394 9.21693 3.26659 8.7432 3.75935L7.87913 4.68448" stroke="#00BCF0" stroke-linecap="round" stroke-linejoin="round"/> <path d="M7 8.91521C7.21574 9.22582 7.49099 9.48283 7.80707 9.66881C8.12315 9.85479 8.47268 9.96538 8.83194 9.99309C9.1912 10.0208 9.5518 9.96497 9.88926 9.8294C10.2267 9.69383 10.5332 9.48169 10.7878 9.20736L12.2949 7.58431C12.7525 7.07413 13.0056 6.39083 12.9999 5.68156C12.9942 4.9723 12.73 4.29384 12.2643 3.7923C11.7986 3.29075 11.1686 3.00627 10.51 3.0001C9.85142 2.99394 9.21693 3.26659 8.7432 3.75935L7.87913 4.68448" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 7.08479C8.78426 6.77418 8.50901 6.51717 8.19293 6.33119C7.87685 6.14521 7.52732 6.03462 7.16806 6.00691C6.8088 5.9792 6.4482 6.03503 6.11074 6.1706C5.77327 6.30617 5.46683 6.51831 5.21218 6.79264L3.7051 8.41569C3.24755 8.92587 2.99437 9.60918 3.00009 10.3184C3.00582 11.0277 3.26998 11.7062 3.73569 12.2077C4.2014 12.7092 4.8314 12.9937 5.48999 12.9999C6.14858 13.0061 6.78307 12.7334 7.2568 12.2407L8.11584 11.3155" stroke="#00BCF0" stroke-linecap="round" stroke-linejoin="round"/> <path d="M9 7.08479C8.78426 6.77418 8.50901 6.51717 8.19293 6.33119C7.87685 6.14521 7.52732 6.03462 7.16806 6.00691C6.8088 5.9792 6.4482 6.03503 6.11074 6.1706C5.77327 6.30617 5.46683 6.51831 5.21218 6.79264L3.7051 8.41569C3.24755 8.92587 2.99437 9.60918 3.00009 10.3184C3.00582 11.0277 3.26998 11.7062 3.73569 12.2077C4.2014 12.7092 4.8314 12.9937 5.48999 12.9999C6.14858 13.0061 6.78307 12.7334 7.2568 12.2407L8.11584 11.3155" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,267 +1,102 @@
{ {
"document": { "document": {
"type": "editor", "type": "editor",
"attributes": {},
"children": [ "children": [
{ {
"type": "image", "type": "image",
"attributes": { "attributes": {
"image_src": "https://images.squarespace-cdn.com/content/v1/617f6f16b877c06711e87373/c3f23723-37f4-44d7-9c5d-6e2a53064ae7/Asset+10.png" "image_src": "https://s1.ax1x.com/2022/08/26/v2sSbR.jpg",
"align": "center"
} }
}, },
{ {
"type": "text", "type": "text",
"attributes": { "subtype": "heading", "heading": "h1" },
"delta": [ "delta": [
{ "insert": "👋 " },
{ "insert": "Welcome to ", "attributes": { "bold": true } },
{ {
"insert": "🌶 Read Me" "insert": "AppFlowy Editor",
}
],
"attributes": { "attributes": {
"subtype": "heading", "href": "appflowy.io",
"heading": "h1" "italic": true,
"bold": true
} }
}
]
}, },
{ "type": "text", "delta": [] },
{ {
"type": "text", "type": "text",
"delta": [ "delta": [
{ { "insert": "AppFlowy Editor is a " },
"insert": "👋 Welcome to FlowyEditor" { "insert": "highly customizable", "attributes": { "bold": true } },
} { "insert": " " },
], { "insert": "rich-text editor", "attributes": { "italic": true } },
"attributes": { { "insert": " for " },
"subtype": "heading", { "insert": "Flutter", "attributes": { "underline": true } }
"heading": "h2"
}
},
{
"type": "text",
"delta": [
{
"insert": "To be honest, we are still in the alpha stage. There are still many functions that need to be completed. And we are developing more features. Please give us a star if the "
},
{
"insert": "FlowyEditor",
"attributes": {
"href": "https://github.com/AppFlowy-IO/AppFlowy"
}
},
{
"insert": " helps you. 😊😊😊"
}
] ]
}, },
{ {
"type": "text", "type": "text",
"attributes": { "checkbox": true, "subtype": "checkbox" },
"delta": [{ "insert": "Customizable" }]
},
{
"type": "text",
"attributes": { "checkbox": true, "subtype": "checkbox" },
"delta": [{ "insert": "Test-covered" }]
},
{
"type": "text",
"attributes": { "checkbox": false, "subtype": "checkbox" },
"delta": [{ "insert": "more to come!" }]
},
{ "type": "text", "delta": [] },
{
"type": "text",
"attributes": { "subtype": "quote" },
"delta": [{ "insert": "Here is an exmaple you can give it a try" }]
},
{ "type": "text", "delta": [] },
{
"type": "text",
"delta": [
{ "insert": "You can also use " },
{
"insert": "AppFlowy Editor",
"attributes": {
"italic": true,
"bold": true,
"backgroundColor": "0x6000BCF0"
}
},
{ "insert": " as a component to build your own app." }
]
},
{ "type": "text", "delta": [] },
{
"type": "text",
"attributes": { "subtype": "bulleted-list" },
"delta": [{ "insert": "Use / to insert blocks" }]
},
{
"type": "text",
"attributes": { "subtype": "bulleted-list" },
"delta": [ "delta": [
{ {
"insert": "Since the FlowyEditor are a community-driven open source editor, we very welcome and appreciate every pull request submissions from everyone.😄😄😄" "insert": "Select text to trigger to the toolbar to format your notes."
} }
] ]
}, },
{ "type": "text", "delta": [] },
{ {
"type": "text", "type": "text",
"delta": [ "delta": [
{ {
"insert": "Here are the basics:" "insert": "If you have questions or feedback, please submit an issue on Github or join the community along with 1000+ builders!"
}
],
"attributes": {
"subtype": "heading",
"heading": "h3"
}
},
{
"type": "text",
"delta": [
{ "insert": "Click " },
{ "insert": "anywhere", "attributes": { "underline": true } },
{ "insert": " and just typing." }
]
},
{
"type": "text",
"delta": [
{
"insert": "Hit"
},
{
"insert": " / ",
"attributes": { "backgroundColor": "0xFFFFFF00" }
},
{
"insert": "to see all the types of content you can add - headers, bulleted lists, checkboxes, etc."
} }
] ]
},
{
"type": "text",
"delta": [
{
"insert": "Highlight",
"attributes": { "backgroundColor": "0xFF00BCFB" }
},
{
"insert": " any text, and use the menu that pops up to "
},
{ "insert": "style", "attributes": { "bold": true } },
{ "insert": " your ", "attributes": { "italic": true } },
{ "insert": "writing", "attributes": { "strikethrough": true } },
{ "insert": "." }
]
},
{
"type": "text",
"delta": [
{
"insert": "Here are the plugins:"
}
],
"attributes": {
"subtype": "heading",
"heading": "h3"
}
},
{
"type": "text",
"delta": [
{
"insert": "Hello world"
}
],
"attributes": {
"subtype": "checkbox",
"checkbox": false
}
},
{
"type": "text",
"delta": [
{
"insert": "Hello world"
}
],
"attributes": {
"subtype": "checkbox",
"checkbox": false
}
},
{
"type": "text",
"delta": [
{
"insert": "Hello world"
}
],
"attributes": {
"subtype": "checkbox",
"checkbox": false
}
},
{
"type": "text",
"delta": [
{
"insert": "Hello world"
}
],
"attributes": {
"subtype": "bulleted-list"
}
},
{
"type": "text",
"delta": [
{
"insert": "Hello world"
}
],
"attributes": {
"subtype": "bulleted-list"
}
},
{
"type": "text",
"delta": [
{
"insert": "Hello "
},
{
"insert": "world",
"attributes": { "bold": true }
}
],
"attributes": {
"subtype": "bulleted-list"
}
},
{
"type": "text",
"delta": [
{
"insert": "Hello world"
}
],
"attributes": {
"subtype": "quote"
}
},
{
"type": "text",
"delta": [
{
"insert": "Hello world"
}
],
"attributes": {
"subtype": "quote"
}
},
{
"type": "text",
"delta": [
{
"insert": "Hello world"
}
],
"attributes": {
"subtype": "quote"
}
},
{
"type": "text",
"delta": [
{
"insert": "Hello world"
}
],
"attributes": {
"subtype": "number-list",
"number": 1
}
},
{
"type": "text",
"delta": [
{
"insert": "Hello world"
}
],
"attributes": {
"subtype": "number-list",
"number": 2
}
},
{
"type": "text",
"delta": [
{
"insert": "Hello world"
}
],
"attributes": {
"subtype": "number-list",
"number": 3
}
} }
] ]
} }

View File

@ -1,14 +1,15 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'expandable_floating_action_button.dart'; import 'package:path_provider/path_provider.dart';
import 'plugin/image_node_widget.dart';
import 'plugin/youtube_link_node_widget.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'expandable_floating_action_button.dart';
void main() { void main() {
runApp(const MyApp()); runApp(const MyApp());
} }
@ -16,20 +17,11 @@ void main() {
class MyApp extends StatelessWidget { class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key); const MyApp({Key? key}) : super(key: key);
// This widget is the root of your application.
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData( theme: ThemeData(
// This is the theme of your application.
//
// Try running your application with "flutter run". You'll see the
// application has a blue toolbar. Then, without quitting the app, try
// changing the primarySwatch below to Colors.green and then invoke
// "hot reload" (press "r" in the console where you ran "flutter run",
// or simply save your changes to "hot reload" in a Flutter IDE).
// Notice that the counter didn't reset back to zero; the application
// is not restarted.
primarySwatch: Colors.blue, primarySwatch: Colors.blue,
), ),
home: const MyHomePage(title: 'AppFlowyEditor Example'), home: const MyHomePage(title: 'AppFlowyEditor Example'),
@ -39,16 +31,6 @@ class MyApp extends StatelessWidget {
class MyHomePage extends StatefulWidget { class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key); const MyHomePage({Key? key, required this.title}) : super(key: key);
// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.
// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".
final String title; final String title;
@override @override
@ -56,94 +38,73 @@ class MyHomePage extends StatefulWidget {
} }
class _MyHomePageState extends State<MyHomePage> { class _MyHomePageState extends State<MyHomePage> {
final editorKey = GlobalKey(); int _pageIndex = 0;
int page = 0; late EditorState _editorState;
Future<String>? _jsonString;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: Container( body: Container(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: _buildBody(), child: _buildEditor(context),
), ),
floatingActionButton: _buildExpandableFab(), floatingActionButton: _buildExpandableFab(),
); );
} }
Widget _buildBody() { Widget _buildEditor(BuildContext context) {
if (page == 0) { if (_jsonString != null) {
return _buildAppFlowyEditorWithExample(); return _buildEditorWithJsonString(_jsonString!);
} else if (page == 1) {
return _buildAppFlowyEditorWithEmptyDocument();
} else if (page == 2) {
return _buildAppFlowyEditorWithBigDocument();
} }
return Container(); if (_pageIndex == 0) {
} return _buildEditorWithJsonString(
rootBundle.loadString('assets/example.json'),
Widget _buildAppFlowyEditorWithEmptyDocument() {
final editorState = EditorState.empty();
final editor = AppFlowyEditor(
editorState: editorState,
keyEventHandlers: const [],
customBuilders: const {},
); );
return editor; } else if (_pageIndex == 1) {
return _buildEditorWithJsonString(
rootBundle.loadString('assets/big_document.json'),
);
} else if (_pageIndex == 2) {
return _buildEditorWithJsonString(
Future.value(
jsonEncode(EditorState.empty().document.toJson()),
),
);
}
throw UnimplementedError();
} }
Widget _buildAppFlowyEditorWithExample() { Widget _buildEditorWithJsonString(Future<String> jsonString) {
return FutureBuilder<String>( return FutureBuilder<String>(
future: rootBundle.loadString('assets/example.json'), future: jsonString,
builder: (context, snapshot) { builder: (_, snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
final data = Map<String, Object>.from(json.decode(snapshot.data!)); _editorState = EditorState(
final editorState = EditorState(document: StateTree.fromJson(data)); document: StateTree.fromJson(
editorState.logConfiguration Map<String, Object>.from(
json.decode(snapshot.data!),
),
),
);
_editorState.logConfiguration
..level = LogLevel.all ..level = LogLevel.all
..handler = (message) { ..handler = (message) {
debugPrint(message); debugPrint(message);
}; };
return _buildAppFlowyEditor(editorState);
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
);
}
Widget _buildAppFlowyEditorWithBigDocument() {
return FutureBuilder<String>(
future: rootBundle.loadString('assets/big_document.json'),
builder: (context, snapshot) {
if (snapshot.hasData) {
final data = Map<String, Object>.from(json.decode(snapshot.data!));
return _buildAppFlowyEditor(EditorState(
document: StateTree.fromJson(data),
));
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
);
}
Widget _buildAppFlowyEditor(EditorState editorState) {
return Container( return Container(
padding: const EdgeInsets.only(left: 20, right: 20), padding: const EdgeInsets.all(20),
child: AppFlowyEditor( child: AppFlowyEditor(
key: editorKey, editorState: _editorState,
editorState: editorState,
keyEventHandlers: const [],
customBuilders: {
'image': ImageNodeBuilder(),
'youtube_link': YouTubeLinkNodeBuilder()
},
), ),
); );
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
);
} }
Widget _buildExpandableFab() { Widget _buildExpandableFab() {
@ -151,33 +112,59 @@ class _MyHomePageState extends State<MyHomePage> {
distance: 112.0, distance: 112.0,
children: [ children: [
ActionButton( ActionButton(
onPressed: () { icon: const Icon(Icons.abc),
if (page == 0) return; onPressed: () => _switchToPage(0),
setState(() {
page = 0;
});
},
icon: const Icon(Icons.note_add),
), ),
ActionButton( ActionButton(
icon: const Icon(Icons.document_scanner), icon: const Icon(Icons.abc),
onPressed: () { onPressed: () => _switchToPage(1),
if (page == 1) return;
setState(() {
page = 1;
});
},
), ),
ActionButton( ActionButton(
onPressed: () { icon: const Icon(Icons.abc),
if (page == 2) return; onPressed: () => _switchToPage(2),
setState(() { ),
page = 2; ActionButton(
}); icon: const Icon(Icons.print),
}, onPressed: () => {_exportDocument(_editorState)}),
icon: const Icon(Icons.text_fields), ActionButton(
icon: const Icon(Icons.import_export),
onPressed: () => _importDocument(),
), ),
], ],
); );
} }
void _exportDocument(EditorState editorState) async {
final document = editorState.document.toJson();
final json = jsonEncode(document);
final directory = await getTemporaryDirectory();
final path = directory.path;
final file = File('$path/editor.json');
await file.writeAsString(json);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('The document is saved to the ${file.path}'),
),
);
}
}
void _importDocument() async {
final directory = await getTemporaryDirectory();
final path = directory.path;
final file = File('$path/editor.json');
setState(() {
_jsonString = file.readAsString();
});
}
void _switchToPage(int pageIndex) {
if (pageIndex != _pageIndex) {
setState(() {
_pageIndex = pageIndex;
});
}
}
} }

View File

@ -5,11 +5,13 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import path_provider_macos
import rich_clipboard_macos import rich_clipboard_macos
import url_launcher_macos import url_launcher_macos
import wakelock_macos import wakelock_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
RichClipboardPlugin.register(with: registry.registrar(forPlugin: "RichClipboardPlugin")) RichClipboardPlugin.register(with: registry.registrar(forPlugin: "RichClipboardPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WakelockMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockMacosPlugin")) WakelockMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockMacosPlugin"))

View File

@ -1,5 +1,7 @@
PODS: PODS:
- FlutterMacOS (1.0.0) - FlutterMacOS (1.0.0)
- path_provider_macos (0.0.1):
- FlutterMacOS
- rich_clipboard_macos (0.0.1): - rich_clipboard_macos (0.0.1):
- FlutterMacOS - FlutterMacOS
- url_launcher_macos (0.0.1): - url_launcher_macos (0.0.1):
@ -9,6 +11,7 @@ PODS:
DEPENDENCIES: DEPENDENCIES:
- FlutterMacOS (from `Flutter/ephemeral`) - FlutterMacOS (from `Flutter/ephemeral`)
- path_provider_macos (from `Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos`)
- rich_clipboard_macos (from `Flutter/ephemeral/.symlinks/plugins/rich_clipboard_macos/macos`) - rich_clipboard_macos (from `Flutter/ephemeral/.symlinks/plugins/rich_clipboard_macos/macos`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
- wakelock_macos (from `Flutter/ephemeral/.symlinks/plugins/wakelock_macos/macos`) - wakelock_macos (from `Flutter/ephemeral/.symlinks/plugins/wakelock_macos/macos`)
@ -16,6 +19,8 @@ DEPENDENCIES:
EXTERNAL SOURCES: EXTERNAL SOURCES:
FlutterMacOS: FlutterMacOS:
:path: Flutter/ephemeral :path: Flutter/ephemeral
path_provider_macos:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos
rich_clipboard_macos: rich_clipboard_macos:
:path: Flutter/ephemeral/.symlinks/plugins/rich_clipboard_macos/macos :path: Flutter/ephemeral/.symlinks/plugins/rich_clipboard_macos/macos
url_launcher_macos: url_launcher_macos:
@ -25,6 +30,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424 FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424
path_provider_macos: 3c0c3b4b0d4a76d2bf989a913c2de869c5641a19
rich_clipboard_macos: 43364b66b9dc69d203eb8dd6d758e2d12e02723c rich_clipboard_macos: 43364b66b9dc69d203eb8dd6d758e2d12e02723c
url_launcher_macos: 597e05b8e514239626bcf4a850fcf9ef5c856ec3 url_launcher_macos: 597e05b8e514239626bcf4a850fcf9ef5c856ec3
wakelock_macos: bc3f2a9bd8d2e6c89fee1e1822e7ddac3bd004a9 wakelock_macos: bc3f2a9bd8d2e6c89fee1e1822e7ddac3bd004a9

View File

@ -40,6 +40,7 @@ dependencies:
video_player: ^2.4.5 video_player: ^2.4.5
pod_player: 0.0.8 pod_player: 0.0.8
flutter_inappwebview: ^5.4.3+7 flutter_inappwebview: ^5.4.3+7
path_provider: ^2.0.11
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@ -163,7 +163,8 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
'type': type, 'type': type,
}; };
if (children.isNotEmpty) { if (children.isNotEmpty) {
map['children'] = children.map((node) => node.toJson()); map['children'] =
(children.map((node) => node.toJson())).toList(growable: false);
} }
if (_attributes.isNotEmpty) { if (_attributes.isNotEmpty) {
map['attributes'] = _attributes; map['attributes'] = _attributes;

View File

@ -33,6 +33,12 @@ class StateTree {
return StateTree(root: root); return StateTree(root: root);
} }
Map<String, Object> toJson() {
return {
'document': root.toJson(),
};
}
Node? nodeAtPath(Path path) { Node? nodeAtPath(Path path) {
return root.childAtPath(path); return root.childAtPath(path);
} }

View File

@ -6,22 +6,57 @@ import 'package:appflowy_editor/src/document/text_delta.dart';
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
extension TextNodeExtension on TextNode { extension TextNodeExtension on TextNode {
dynamic getAttributeInSelection(Selection selection, String styleKey) {
final ops = delta.whereType<TextInsert>();
final startOffset =
selection.isBackward ? selection.start.offset : selection.end.offset;
final endOffset =
selection.isBackward ? selection.end.offset : selection.start.offset;
var start = 0;
for (final op in ops) {
if (start >= endOffset) {
break;
}
final length = op.length;
if (start < endOffset && start + length > startOffset) {
if (op.attributes?.containsKey(styleKey) == true) {
return op.attributes![styleKey];
}
}
start += length;
}
return null;
}
bool allSatisfyLinkInSelection(Selection selection) =>
allSatisfyInSelection(StyleKey.href, selection, (value) {
return value != null;
});
bool allSatisfyBoldInSelection(Selection selection) => bool allSatisfyBoldInSelection(Selection selection) =>
allSatisfyInSelection(StyleKey.bold, true, selection); allSatisfyInSelection(StyleKey.bold, selection, (value) {
return value == true;
});
bool allSatisfyItalicInSelection(Selection selection) => bool allSatisfyItalicInSelection(Selection selection) =>
allSatisfyInSelection(StyleKey.italic, true, selection); allSatisfyInSelection(StyleKey.italic, selection, (value) {
return value == true;
});
bool allSatisfyUnderlineInSelection(Selection selection) => bool allSatisfyUnderlineInSelection(Selection selection) =>
allSatisfyInSelection(StyleKey.underline, true, selection); allSatisfyInSelection(StyleKey.underline, selection, (value) {
return value == true;
});
bool allSatisfyStrikethroughInSelection(Selection selection) => bool allSatisfyStrikethroughInSelection(Selection selection) =>
allSatisfyInSelection(StyleKey.strikethrough, true, selection); allSatisfyInSelection(StyleKey.strikethrough, selection, (value) {
return value == true;
});
bool allSatisfyInSelection( bool allSatisfyInSelection(
String styleKey, String styleKey,
dynamic value,
Selection selection, Selection selection,
bool Function(dynamic value) test,
) { ) {
final ops = delta.whereType<TextInsert>(); final ops = delta.whereType<TextInsert>();
final startOffset = final startOffset =
@ -37,7 +72,7 @@ extension TextNodeExtension on TextNode {
if (start < endOffset && start + length > startOffset) { if (start < endOffset && start + length > startOffset) {
if (op.attributes == null || if (op.attributes == null ||
!op.attributes!.containsKey(styleKey) || !op.attributes!.containsKey(styleKey) ||
op.attributes![styleKey] != value) { !test(op.attributes![styleKey])) {
return false; return false;
} }
} }
@ -91,13 +126,15 @@ extension TextNodesExtension on List<TextNode> {
bool allSatisfyInSelection( bool allSatisfyInSelection(
String styleKey, String styleKey,
Selection selection, Selection selection,
dynamic value, dynamic matchValue,
) { ) {
if (isEmpty) { if (isEmpty) {
return false; return false;
} }
if (length == 1) { if (length == 1) {
return first.allSatisfyInSelection(styleKey, value, selection); return first.allSatisfyInSelection(styleKey, selection, (value) {
return value == matchValue;
});
} else { } else {
for (var i = 0; i < length; i++) { for (var i = 0; i < length; i++) {
final node = this[i]; final node = this[i];
@ -117,7 +154,9 @@ extension TextNodesExtension on List<TextNode> {
end: Position(path: node.path, offset: node.toRawString().length), end: Position(path: node.path, offset: node.toRawString().length),
); );
} }
if (!node.allSatisfyInSelection(styleKey, value, newSelection)) { if (!node.allSatisfyInSelection(styleKey, newSelection, (value) {
return value == matchValue;
})) {
return false; return false;
} }
} }

View File

@ -75,6 +75,11 @@ class Log {
/// For example, uses the logger when processing scroll events. /// For example, uses the logger when processing scroll events.
static Log scroll = Log._(name: 'scroll'); static Log scroll = Log._(name: 'scroll');
/// For logging message related to [AppFlowyToolbarService].
///
/// For example, uses the logger when processing toolbar events.
static Log toolbar = Log._(name: 'toolbar');
/// For logging message related to UI. /// For logging message related to UI.
/// ///
/// For example, uses the logger when building the widget. /// For example, uses the logger when building the widget.

View File

@ -2,14 +2,14 @@ import 'package:appflowy_editor/appflowy_editor.dart';
abstract class Operation { abstract class Operation {
factory Operation.fromJson(Map<String, dynamic> map) { factory Operation.fromJson(Map<String, dynamic> map) {
String t = map["type"] as String; String t = map["op"] as String;
if (t == "insert-operation") { if (t == "insert") {
return InsertOperation.fromJson(map); return InsertOperation.fromJson(map);
} else if (t == "update-operation") { } else if (t == "update") {
return UpdateOperation.fromJson(map); return UpdateOperation.fromJson(map);
} else if (t == "delete-operation") { } else if (t == "delete") {
return DeleteOperation.fromJson(map); return DeleteOperation.fromJson(map);
} else if (t == "text-edit-operation") { } else if (t == "text-edit") {
return TextEditOperation.fromJson(map); return TextEditOperation.fromJson(map);
} }
@ -51,7 +51,7 @@ class InsertOperation extends Operation {
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
"type": "insert-operation", "op": "insert",
"path": path.toList(), "path": path.toList(),
"nodes": nodes.map((n) => n.toJson()), "nodes": nodes.map((n) => n.toJson()),
}; };
@ -95,7 +95,7 @@ class UpdateOperation extends Operation {
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
"type": "update-operation", "op": "update",
"path": path.toList(), "path": path.toList(),
"attributes": {...attributes}, "attributes": {...attributes},
"oldAttributes": {...oldAttributes}, "oldAttributes": {...oldAttributes},
@ -132,7 +132,7 @@ class DeleteOperation extends Operation {
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
"type": "delete-operation", "op": "delete",
"path": path.toList(), "path": path.toList(),
"nodes": nodes.map((n) => n.toJson()), "nodes": nodes.map((n) => n.toJson()),
}; };
@ -171,7 +171,7 @@ class TextEditOperation extends Operation {
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
"type": "text-edit-operation", "op": "text-edit",
"path": path.toList(), "path": path.toList(),
"delta": delta.toJson(), "delta": delta.toJson(),
"invert": inverted.toJson(), "invert": inverted.toJson(),
@ -207,10 +207,10 @@ Path transformPath(Path preInsertPath, Path b, [int delta = 1]) {
Operation transformOperation(Operation a, Operation b) { Operation transformOperation(Operation a, Operation b) {
if (a is InsertOperation) { if (a is InsertOperation) {
final newPath = transformPath(a.path, b.path); final newPath = transformPath(a.path, b.path, a.nodes.length);
return b.copyWithPath(newPath); return b.copyWithPath(newPath);
} else if (a is DeleteOperation) { } else if (a is DeleteOperation) {
final newPath = transformPath(a.path, b.path, -1); final newPath = transformPath(a.path, b.path, -1 * a.nodes.length);
return b.copyWithPath(newPath); return b.copyWithPath(newPath);
} }
// TODO: transform update and textedit // TODO: transform update and textedit

View File

@ -116,11 +116,17 @@ class TransactionBuilder {
/// Optionally, you may specify formatting attributes that are applied to the inserted string. /// Optionally, you may specify formatting attributes that are applied to the inserted string.
/// By default, the formatting attributes before the insert position will be used. /// By default, the formatting attributes before the insert position will be used.
insertText(TextNode node, int index, String content, insertText(TextNode node, int index, String content,
[Attributes? attributes]) { {Attributes? attributes, Attributes? removedAttributes}) {
var newAttributes = attributes; var newAttributes = attributes;
if (index != 0 && attributes == null) { if (index != 0 && attributes == null) {
newAttributes = newAttributes =
node.delta.slice(max(index - 1, 0), index).first.attributes; node.delta.slice(max(index - 1, 0), index).first.attributes;
if (newAttributes != null) {
newAttributes = Attributes.from(newAttributes);
if (removedAttributes != null) {
newAttributes.addAll(removedAttributes);
}
}
} }
textEdit( textEdit(
node, node,

View File

@ -0,0 +1,72 @@
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
import 'package:appflowy_editor/src/service/render_plugin_service.dart';
import 'package:flutter/material.dart';
import 'package:rich_clipboard/rich_clipboard.dart';
import 'image_node_widget.dart';
class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
@override
Widget build(NodeWidgetContext<Node> context) {
final src = context.node.attributes['image_src'];
final align = context.node.attributes['align'];
double? width;
if (context.node.attributes.containsKey('width')) {
width = context.node.attributes['width'].toDouble();
}
return ImageNodeWidget(
key: context.node.key,
src: src,
width: width,
alignment: _textToAlignment(align),
onCopy: () {
RichClipboard.setData(RichClipboardData(text: src));
},
onDelete: () {
TransactionBuilder(context.editorState)
..deleteNode(context.node)
..commit();
},
onAlign: (alignment) {
TransactionBuilder(context.editorState)
..updateNode(context.node, {
'align': _alignmentToText(alignment),
})
..commit();
},
onResize: (width) {
TransactionBuilder(context.editorState)
..updateNode(context.node, {
'width': width,
})
..commit();
},
);
}
@override
NodeValidator<Node> get nodeValidator => ((node) {
return node.type == 'image' &&
node.attributes.containsKey('image_src') &&
node.attributes.containsKey('align');
});
Alignment _textToAlignment(String text) {
if (text == 'left') {
return Alignment.centerLeft;
} else if (text == 'right') {
return Alignment.centerRight;
}
return Alignment.center;
}
String _alignmentToText(Alignment alignment) {
if (alignment == Alignment.centerLeft) {
return 'left';
} else if (alignment == Alignment.centerRight) {
return 'right';
}
return 'center';
}
}

View File

@ -0,0 +1,340 @@
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
import 'package:flutter/material.dart';
class ImageNodeWidget extends StatefulWidget {
const ImageNodeWidget({
Key? key,
required this.src,
this.width,
required this.alignment,
required this.onCopy,
required this.onDelete,
required this.onAlign,
required this.onResize,
}) : super(key: key);
final String src;
final double? width;
final Alignment alignment;
final VoidCallback onCopy;
final VoidCallback onDelete;
final void Function(Alignment alignment) onAlign;
final void Function(double width) onResize;
@override
State<ImageNodeWidget> createState() => _ImageNodeWidgetState();
}
class _ImageNodeWidgetState extends State<ImageNodeWidget> {
double? _imageWidth;
double _initial = 0;
double _distance = 0;
bool _onFocus = false;
ImageStream? _imageStream;
late ImageStreamListener _imageStreamListener;
@override
void initState() {
super.initState();
_imageWidth = widget.width;
_imageStreamListener = ImageStreamListener(
(image, _) {
_imageWidth = image.image.width.toDouble();
},
);
}
@override
void dispose() {
_imageStream?.removeListener(_imageStreamListener);
super.dispose();
}
@override
Widget build(BuildContext context) {
// only support network image.
return Container(
width: defaultMaxTextNodeWidth,
padding: const EdgeInsets.only(top: 8, bottom: 8),
child: _buildNetworkImage(context),
);
}
Widget _buildNetworkImage(BuildContext context) {
return Align(
alignment: widget.alignment,
child: MouseRegion(
onEnter: (event) => setState(() {
_onFocus = true;
}),
onExit: (event) => setState(() {
_onFocus = false;
}),
child: _buildResizableImage(context),
),
);
}
Widget _buildResizableImage(BuildContext context) {
final networkImage = Image.network(
widget.src,
width: _imageWidth == null ? null : _imageWidth! - _distance,
gaplessPlayback: true,
loadingBuilder: (context, child, loadingProgress) =>
loadingProgress == null ? child : _buildLoading(context),
errorBuilder: (context, error, stackTrace) {
_imageWidth ??= defaultMaxTextNodeWidth;
return _buildError(context);
},
);
if (_imageWidth == null) {
_imageStream = networkImage.image.resolve(const ImageConfiguration())
..addListener(_imageStreamListener);
}
return Stack(
children: [
networkImage,
_buildEdgeGesture(
context,
top: 0,
left: 0,
bottom: 0,
width: 5,
onUpdate: (distance) {
setState(() {
_distance = distance;
});
},
),
_buildEdgeGesture(
context,
top: 0,
right: 0,
bottom: 0,
width: 5,
onUpdate: (distance) {
setState(() {
_distance = -distance;
});
},
),
if (_onFocus)
ImageToolbar(
top: 8,
right: 8,
height: 30,
alignment: widget.alignment,
onAlign: widget.onAlign,
onCopy: widget.onCopy,
onDelete: widget.onDelete,
)
],
);
}
Widget _buildLoading(BuildContext context) {
return SizedBox(
height: 150,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox.fromSize(
size: const Size(18, 18),
child: const CircularProgressIndicator(),
),
SizedBox.fromSize(
size: const Size(10, 10),
),
const Text('Loading'),
],
),
);
}
Widget _buildError(BuildContext context) {
return Container(
height: 100,
width: _imageWidth,
alignment: Alignment.center,
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
border: Border.all(width: 1, color: Colors.black),
),
child: const Text('Could not load the image'),
);
}
Widget _buildEdgeGesture(
BuildContext context, {
double? top,
double? left,
double? right,
double? bottom,
double? width,
void Function(double distance)? onUpdate,
}) {
return Positioned(
top: top,
left: left,
right: right,
bottom: bottom,
width: width,
child: GestureDetector(
onHorizontalDragStart: (details) {
_initial = details.globalPosition.dx;
},
onHorizontalDragUpdate: (details) {
if (onUpdate != null) {
onUpdate(details.globalPosition.dx - _initial);
}
},
onHorizontalDragEnd: (details) {
_imageWidth = _imageWidth! - _distance;
_initial = 0;
_distance = 0;
widget.onResize(_imageWidth!);
},
child: MouseRegion(
cursor: SystemMouseCursors.resizeLeftRight,
child: _onFocus
? Center(
child: Container(
height: 40,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.2),
borderRadius: const BorderRadius.all(
Radius.circular(5.0),
),
),
),
)
: null,
),
),
);
}
}
@visibleForTesting
class ImageToolbar extends StatelessWidget {
const ImageToolbar({
Key? key,
required this.top,
required this.right,
required this.height,
required this.alignment,
required this.onCopy,
required this.onDelete,
required this.onAlign,
}) : super(key: key);
final double top;
final double right;
final double height;
final Alignment alignment;
final VoidCallback onCopy;
final VoidCallback onDelete;
final void Function(Alignment alignment) onAlign;
@override
Widget build(BuildContext context) {
return Positioned(
top: top,
right: right,
height: height,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFF333333),
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withOpacity(0.1),
),
],
borderRadius: BorderRadius.circular(8.0),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(
hoverColor: Colors.transparent,
constraints: const BoxConstraints(),
padding: const EdgeInsets.fromLTRB(6.0, 4.0, 0.0, 4.0),
icon: FlowySvg(
name: 'image_toolbar/align_left',
color: alignment == Alignment.centerLeft
? const Color(0xFF00BCF0)
: null,
),
onPressed: () {
onAlign(Alignment.centerLeft);
},
),
IconButton(
hoverColor: Colors.transparent,
constraints: const BoxConstraints(),
padding: const EdgeInsets.fromLTRB(0.0, 4.0, 0.0, 4.0),
icon: FlowySvg(
name: 'image_toolbar/align_center',
color: alignment == Alignment.center
? const Color(0xFF00BCF0)
: null,
),
onPressed: () {
onAlign(Alignment.center);
},
),
IconButton(
hoverColor: Colors.transparent,
constraints: const BoxConstraints(),
padding: const EdgeInsets.fromLTRB(0.0, 4.0, 4.0, 4.0),
icon: FlowySvg(
name: 'image_toolbar/align_right',
color: alignment == Alignment.centerRight
? const Color(0xFF00BCF0)
: null,
),
onPressed: () {
onAlign(Alignment.centerRight);
},
),
const Center(
child: FlowySvg(
name: 'image_toolbar/divider',
),
),
IconButton(
hoverColor: Colors.transparent,
constraints: const BoxConstraints(),
padding: const EdgeInsets.fromLTRB(4.0, 4.0, 0.0, 4.0),
icon: const FlowySvg(
name: 'image_toolbar/copy',
),
onPressed: () {
onCopy();
},
),
IconButton(
hoverColor: Colors.transparent,
constraints: const BoxConstraints(),
padding: const EdgeInsets.fromLTRB(0.0, 4.0, 6.0, 4.0),
icon: const FlowySvg(
name: 'image_toolbar/delete',
),
onPressed: () {
onDelete();
},
),
],
),
),
);
}
}

View File

@ -0,0 +1,202 @@
import 'dart:collection';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
import 'package:flutter/material.dart';
OverlayEntry? _imageUploadMenu;
EditorState? _editorState;
void showImageUploadMenu(
EditorState editorState,
SelectionMenuService menuService,
BuildContext context,
) {
menuService.dismiss();
_imageUploadMenu?.remove();
_imageUploadMenu = OverlayEntry(builder: (context) {
return Positioned(
top: menuService.topLeft.dy,
left: menuService.topLeft.dx,
child: Material(
child: ImageUploadMenu(
onSubmitted: (text) {
// _dismissImageUploadMenu();
editorState.insertImageNode(text);
},
onUpload: (text) {
// _dismissImageUploadMenu();
editorState.insertImageNode(text);
},
),
),
);
});
Overlay.of(context)?.insert(_imageUploadMenu!);
editorState.service.selectionService.currentSelection
.addListener(_dismissImageUploadMenu);
}
void _dismissImageUploadMenu() {
_imageUploadMenu?.remove();
_imageUploadMenu = null;
_editorState?.service.selectionService.currentSelection
.removeListener(_dismissImageUploadMenu);
_editorState = null;
}
class ImageUploadMenu extends StatefulWidget {
const ImageUploadMenu({
Key? key,
required this.onSubmitted,
required this.onUpload,
}) : super(key: key);
final void Function(String text) onSubmitted;
final void Function(String text) onUpload;
@override
State<ImageUploadMenu> createState() => _ImageUploadMenuState();
}
class _ImageUploadMenuState extends State<ImageUploadMenu> {
final _textEditingController = TextEditingController();
final _focusNode = FocusNode();
@override
void initState() {
super.initState();
_focusNode.requestFocus();
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
width: 300,
padding: const EdgeInsets.all(24.0),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withOpacity(0.1),
),
],
borderRadius: BorderRadius.circular(6.0),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(context),
const SizedBox(height: 16.0),
_buildInput(),
const SizedBox(height: 18.0),
_buildUploadButton(context),
],
),
);
}
Widget _buildHeader(BuildContext context) {
return const Text(
'URL Image',
textAlign: TextAlign.left,
style: TextStyle(
fontSize: 14.0,
color: Colors.black,
fontWeight: FontWeight.w500,
),
);
}
Widget _buildInput() {
return TextField(
focusNode: _focusNode,
style: const TextStyle(fontSize: 14.0),
textAlign: TextAlign.left,
controller: _textEditingController,
onSubmitted: widget.onSubmitted,
decoration: InputDecoration(
hintText: 'URL',
hintStyle: const TextStyle(fontSize: 14.0),
contentPadding: const EdgeInsets.all(16.0),
isDense: true,
suffixIcon: IconButton(
padding: const EdgeInsets.all(4.0),
icon: const FlowySvg(
name: 'clear',
width: 24,
height: 24,
),
onPressed: () {
_textEditingController.clear();
},
),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12.0)),
borderSide: BorderSide(color: Color(0xFFBDBDBD)),
),
),
);
}
Widget _buildUploadButton(BuildContext context) {
return SizedBox(
width: 170,
height: 48,
child: TextButton(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(const Color(0xFF00BCF0)),
shape: MaterialStateProperty.all<RoundedRectangleBorder>(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
),
),
),
onPressed: () {
widget.onUpload(_textEditingController.text);
},
child: const Text(
'Upload',
style: TextStyle(color: Colors.white, fontSize: 14.0),
),
),
);
}
}
extension on EditorState {
void insertImageNode(String src) {
final selection = service.selectionService.currentSelection.value;
if (selection == null) {
return;
}
final imageNode = Node(
type: 'image',
children: LinkedList(),
attributes: {
'image_src': src,
'align': 'center',
},
);
TransactionBuilder(this)
..insertNode(
selection.start.path,
imageNode,
)
..commit();
}
}

View File

@ -0,0 +1,151 @@
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:flutter/material.dart';
class LinkMenu extends StatefulWidget {
const LinkMenu({
Key? key,
this.linkText,
required this.onSubmitted,
required this.onCopyLink,
required this.onRemoveLink,
}) : super(key: key);
final String? linkText;
final void Function(String text) onSubmitted;
final VoidCallback onCopyLink;
final VoidCallback onRemoveLink;
@override
State<LinkMenu> createState() => _LinkMenuState();
}
class _LinkMenuState extends State<LinkMenu> {
final _textEditingController = TextEditingController();
final _focusNode = FocusNode();
@override
void initState() {
super.initState();
_textEditingController.text = widget.linkText ?? '';
_focusNode.requestFocus();
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: 350,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withOpacity(0.1),
),
],
borderRadius: BorderRadius.circular(6.0),
),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildHeader(),
const SizedBox(height: 16.0),
_buildInput(),
const SizedBox(height: 16.0),
if (widget.linkText != null) ...[
_buildIconButton(
iconName: 'link',
text: 'Copy link',
onPressed: widget.onCopyLink,
),
_buildIconButton(
iconName: 'delete',
text: 'Remove link',
onPressed: widget.onRemoveLink,
),
]
],
),
),
),
);
}
Widget _buildHeader() {
return const Text(
'Add your link',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.bold,
),
);
}
Widget _buildInput() {
return TextField(
focusNode: _focusNode,
style: const TextStyle(fontSize: 14.0),
textAlign: TextAlign.left,
controller: _textEditingController,
onSubmitted: widget.onSubmitted,
decoration: InputDecoration(
hintText: 'URL',
hintStyle: const TextStyle(fontSize: 14.0),
contentPadding: const EdgeInsets.all(16.0),
isDense: true,
suffixIcon: IconButton(
padding: const EdgeInsets.all(4.0),
icon: const FlowySvg(
name: 'clear',
width: 24,
height: 24,
),
onPressed: () {
_textEditingController.clear();
},
),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12.0)),
borderSide: BorderSide(color: Color(0xFFBDBDBD)),
),
),
);
}
Widget _buildIconButton({
required String iconName,
required String text,
required VoidCallback onPressed,
}) {
return TextButton.icon(
icon: FlowySvg(name: iconName),
style: TextButton.styleFrom(
minimumSize: const Size.fromHeight(40),
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
alignment: Alignment.centerLeft,
),
label: Text(
text,
textAlign: TextAlign.left,
style: const TextStyle(
color: Colors.black,
fontSize: 14.0,
),
),
onPressed: onPressed,
);
}
}

View File

@ -56,8 +56,6 @@ class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
return SizedBox( return SizedBox(
width: defaultMaxTextNodeWidth, width: defaultMaxTextNodeWidth,
child: Padding( child: Padding(
@ -69,8 +67,7 @@ class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
key: iconKey, key: iconKey,
width: _iconWidth, width: _iconWidth,
height: _iconWidth, height: _iconWidth,
padding: padding: EdgeInsets.only(right: _iconRightPadding),
EdgeInsets.only(top: topPadding, right: _iconRightPadding),
name: 'point', name: 'point',
), ),
Expanded( Expanded(

View File

@ -63,7 +63,6 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
Widget _buildWithSingle(BuildContext context) { Widget _buildWithSingle(BuildContext context) {
final check = widget.textNode.attributes.check; final check = widget.textNode.attributes.check;
final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
return SizedBox( return SizedBox(
width: defaultMaxTextNodeWidth, width: defaultMaxTextNodeWidth,
child: Padding( child: Padding(
@ -76,10 +75,7 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
child: FlowySvg( child: FlowySvg(
width: _iconWidth, width: _iconWidth,
height: _iconWidth, height: _iconWidth,
padding: EdgeInsets.only( padding: EdgeInsets.only(right: _iconRightPadding),
top: topPadding,
right: _iconRightPadding,
),
name: check ? 'check' : 'uncheck', name: check ? 'check' : 'uncheck',
), ),
onTap: () { onTap: () {

View File

@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
@ -11,6 +13,7 @@ import 'package:appflowy_editor/src/document/text_delta.dart';
import 'package:appflowy_editor/src/editor_state.dart'; import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart'; import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:url_launcher/url_launcher_string.dart';
typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan); typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan);
@ -65,18 +68,35 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
@override @override
Rect? getCursorRectInPosition(Position position) { Rect? getCursorRectInPosition(Position position) {
final textPosition = TextPosition(offset: position.offset); final textPosition = TextPosition(offset: position.offset);
final cursorOffset =
_renderParagraph.getOffsetForCaret(textPosition, Rect.zero);
final cursorHeight = widget.cursorHeight ??
_renderParagraph.getFullHeightForCaret(textPosition) ??
_placeholderRenderParagraph.getFullHeightForCaret(textPosition) ??
16.0; // default height
var cursorHeight = _renderParagraph.getFullHeightForCaret(textPosition);
var cursorOffset =
_renderParagraph.getOffsetForCaret(textPosition, Rect.zero);
if (cursorHeight == null) {
cursorHeight =
_placeholderRenderParagraph.getFullHeightForCaret(textPosition);
cursorOffset = _placeholderRenderParagraph.getOffsetForCaret(
textPosition, Rect.zero);
}
if (cursorHeight != null) {
// workaround: Calling the `getFullHeightForCaret` function will return
// the full height of rich text component instead of the plain text
// if we set the line height.
// So need to divide by the line height to get the expected value.
//
// And the default height of plain text is too short. Add a magic height
// to expand it.
const magicHeight = 3.0;
cursorOffset = cursorOffset.translate(
0, (cursorHeight - cursorHeight / _lineHeight) / 2.0);
cursorHeight /= _lineHeight;
cursorHeight += magicHeight;
}
final rect = Rect.fromLTWH( final rect = Rect.fromLTWH(
cursorOffset.dx - (widget.cursorWidth / 2), cursorOffset.dx - (widget.cursorWidth / 2),
cursorOffset.dy, cursorOffset.dy,
widget.cursorWidth, widget.cursorWidth,
cursorHeight, widget.cursorHeight ?? cursorHeight ?? 16.0,
); );
return rect; return rect;
} }
@ -126,6 +146,11 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
); );
} }
@override
Offset localToGlobal(Offset offset) {
return _renderParagraph.localToGlobal(offset);
}
Widget _buildRichText(BuildContext context) { Widget _buildRichText(BuildContext context) {
return MouseRegion( return MouseRegion(
cursor: SystemMouseCursors.text, cursor: SystemMouseCursors.text,
@ -164,43 +189,62 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
); );
} }
// unused now. TextSpan get _textSpan {
// Widget _buildRichTextWithChildren(BuildContext context) { var offset = 0;
// return Column( return TextSpan(
// crossAxisAlignment: CrossAxisAlignment.start, children: widget.textNode.delta.whereType<TextInsert>().map((insert) {
// children: [ GestureRecognizer? gestureDetector;
// _buildSingleRichText(context), if (insert.attributes?[StyleKey.href] != null) {
// ...widget.textNode.children final startOffset = offset;
// .map( Timer? timer;
// (child) => widget.editorState.service.renderPluginService var tapCount = 0;
// .buildPluginWidget( gestureDetector = TapGestureRecognizer()
// NodeWidgetContext( ..onTap = () async {
// context: context, // implement a simple double tap logic
// node: child, tapCount += 1;
// editorState: widget.editorState, timer?.cancel();
// ),
// ),
// )
// .toList()
// ],
// );
// }
@override if (tapCount == 2) {
Offset localToGlobal(Offset offset) { tapCount = 0;
return _renderParagraph.localToGlobal(offset); final href = insert.attributes![StyleKey.href];
final uri = Uri.parse(href);
// url_launcher cannot open a link without scheme.
final newHref =
(uri.scheme.isNotEmpty ? href : 'http://$href').trim();
if (await canLaunchUrlString(newHref)) {
await launchUrlString(newHref);
}
return;
} }
TextSpan get _textSpan => TextSpan( timer = Timer(const Duration(milliseconds: 200), () {
children: widget.textNode.delta tapCount = 0;
.whereType<TextInsert>() // update selection
.map((insert) => RichTextStyle( final selection = Selection.single(
path: widget.textNode.path,
startOffset: startOffset,
endOffset: startOffset + insert.length,
);
widget.editorState.service.selectionService
.updateSelection(selection);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
widget.editorState.service.toolbarService
?.triggerHandler('appflowy.toolbar.link');
});
});
};
}
offset += insert.length;
final textSpan = RichTextStyle(
attributes: insert.attributes ?? {}, attributes: insert.attributes ?? {},
text: insert.content, text: insert.content,
height: _lineHeight, height: _lineHeight,
).toTextSpan()) gestureRecognizer: gestureDetector,
.toList(growable: false), ).toTextSpan();
return textSpan;
}).toList(growable: false),
); );
}
TextSpan get _placeholderTextSpan => TextSpan(children: [ TextSpan get _placeholderTextSpan => TextSpan(children: [
RichTextStyle( RichTextStyle(

View File

@ -56,7 +56,6 @@ class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
return Padding( return Padding(
padding: EdgeInsets.only(bottom: defaultLinePadding), padding: EdgeInsets.only(bottom: defaultLinePadding),
child: SizedBox( child: SizedBox(
@ -68,8 +67,7 @@ class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
key: iconKey, key: iconKey,
width: _iconWidth, width: _iconWidth,
height: _iconWidth, height: _iconWidth,
padding: padding: EdgeInsets.only(right: _iconRightPadding),
EdgeInsets.only(top: topPadding, right: _iconRightPadding),
number: widget.textNode.attributes.number, number: widget.textNode.attributes.number,
), ),
Expanded( Expanded(

View File

@ -55,7 +55,6 @@ class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
return SizedBox( return SizedBox(
width: defaultMaxTextNodeWidth, width: defaultMaxTextNodeWidth,
child: Padding( child: Padding(
@ -67,8 +66,7 @@ class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget>
FlowySvg( FlowySvg(
key: iconKey, key: iconKey,
width: _iconWidth, width: _iconWidth,
padding: EdgeInsets.only( padding: EdgeInsets.only(right: _iconRightPadding),
top: topPadding, right: _iconRightPadding),
name: 'quote', name: 'quote',
), ),
Expanded( Expanded(
@ -82,12 +80,7 @@ class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget>
], ],
), ),
), ),
)); ),
} );
double get _quoteHeight {
final lines =
widget.textNode.toRawString().characters.where((c) => c == '\n').length;
return (lines + 1) * _iconWidth;
} }
} }

View File

@ -1,8 +1,6 @@
import 'package:appflowy_editor/src/document/attributes.dart'; import 'package:appflowy_editor/src/document/attributes.dart';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher_string.dart';
/// ///
/// Supported partial rendering types: /// Supported partial rendering types:
@ -182,14 +180,13 @@ class RichTextStyle {
RichTextStyle({ RichTextStyle({
required this.attributes, required this.attributes,
required this.text, required this.text,
this.gestureRecognizer,
this.height = 1.5, this.height = 1.5,
}); });
RichTextStyle.fromTextNode(TextNode textNode)
: this(attributes: textNode.attributes, text: textNode.toRawString());
final Attributes attributes; final Attributes attributes;
final String text; final String text;
final GestureRecognizer? gestureRecognizer;
final double height; final double height;
TextSpan toTextSpan() => _toTextSpan(height); TextSpan toTextSpan() => _toTextSpan(height);
@ -201,6 +198,7 @@ class RichTextStyle {
TextSpan _toTextSpan(double? height) { TextSpan _toTextSpan(double? height) {
return TextSpan( return TextSpan(
text: text, text: text,
recognizer: _recognizer,
style: TextStyle( style: TextStyle(
fontWeight: _fontWeight, fontWeight: _fontWeight,
fontStyle: _fontStyle, fontStyle: _fontStyle,
@ -210,7 +208,6 @@ class RichTextStyle {
background: _background, background: _background,
height: height, height: height,
), ),
recognizer: _recognizer,
); );
} }
@ -273,13 +270,6 @@ class RichTextStyle {
// recognizer // recognizer
GestureRecognizer? get _recognizer { GestureRecognizer? get _recognizer {
final href = attributes.href; return gestureRecognizer;
if (href != null) {
return TapGestureRecognizer()
..onTap = () async {
await launchUrlString(href);
};
}
return null;
} }
} }

View File

@ -1,217 +0,0 @@
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
import 'package:flutter/material.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
typedef ToolbarEventHandler = void Function(EditorState editorState);
typedef ToolbarEventHandlers = Map<String, ToolbarEventHandler>;
ToolbarEventHandlers defaultToolbarEventHandlers = {
'bold': (editorState) => formatBold(editorState),
'italic': (editorState) => formatItalic(editorState),
'strikethrough': (editorState) => formatStrikethrough(editorState),
'underline': (editorState) => formatUnderline(editorState),
'quote': (editorState) => formatQuote(editorState),
'bulleted_list': (editorState) => formatBulletedList(editorState),
'highlight': (editorState) => formatHighlight(editorState),
'Text': (editorState) => formatText(editorState),
'h1': (editorState) => formatHeading(editorState, StyleKey.h1),
'h2': (editorState) => formatHeading(editorState, StyleKey.h2),
'h3': (editorState) => formatHeading(editorState, StyleKey.h3),
};
List<String> defaultListToolbarEventNames = [
'Text',
'H1',
'H2',
'H3',
];
mixin ToolbarMixin<T extends StatefulWidget> on State<T> {
void hide();
}
class ToolbarWidget extends StatefulWidget {
const ToolbarWidget({
Key? key,
required this.editorState,
required this.layerLink,
required this.offset,
required this.handlers,
}) : super(key: key);
final EditorState editorState;
final LayerLink layerLink;
final Offset offset;
final ToolbarEventHandlers handlers;
@override
State<ToolbarWidget> createState() => _ToolbarWidgetState();
}
class _ToolbarWidgetState extends State<ToolbarWidget> with ToolbarMixin {
// final GlobalKey _listToolbarKey = GlobalKey();
final toolbarHeight = 32.0;
final topPadding = 5.0;
final listToolbarWidth = 60.0;
final listToolbarHeight = 120.0;
final cornerRadius = 8.0;
OverlayEntry? _listToolbarOverlay;
@override
Widget build(BuildContext context) {
return Positioned(
top: widget.offset.dx,
left: widget.offset.dy,
child: CompositedTransformFollower(
link: widget.layerLink,
showWhenUnlinked: true,
offset: widget.offset,
child: _buildToolbar(context),
),
);
}
@override
void hide() {
_listToolbarOverlay?.remove();
_listToolbarOverlay = null;
}
Widget _buildToolbar(BuildContext context) {
return Material(
borderRadius: BorderRadius.circular(cornerRadius),
color: const Color(0xFF333333),
child: SizedBox(
height: toolbarHeight,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// _listToolbar(context),
_centerToolbarIcon('h1', tooltipMessage: 'Heading 1'),
_centerToolbarIcon('h2', tooltipMessage: 'Heading 2'),
_centerToolbarIcon('h3', tooltipMessage: 'Heading 3'),
_centerToolbarIcon('divider', width: 2),
_centerToolbarIcon('bold', tooltipMessage: 'Bold'),
_centerToolbarIcon('italic', tooltipMessage: 'Italic'),
_centerToolbarIcon('strikethrough',
tooltipMessage: 'Strikethrough'),
_centerToolbarIcon('underline', tooltipMessage: 'Underline'),
_centerToolbarIcon('divider', width: 2),
_centerToolbarIcon('quote', tooltipMessage: 'Quote'),
// _centerToolbarIcon('number_list'),
_centerToolbarIcon('bulleted_list',
tooltipMessage: 'Bulleted List'),
_centerToolbarIcon('divider', width: 2),
_centerToolbarIcon('highlight', tooltipMessage: 'Highlight'),
],
),
),
);
}
// Widget _listToolbar(BuildContext context) {
// return _centerToolbarIcon(
// 'quote',
// key: _listToolbarKey,
// width: listToolbarWidth,
// onTap: () => _onTapListToolbar(context),
// );
// }
Widget _centerToolbarIcon(String name,
{Key? key, String? tooltipMessage, double? width, VoidCallback? onTap}) {
return Tooltip(
key: key,
preferBelow: false,
message: tooltipMessage ?? '',
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onTap ?? () => _onTap(name),
child: SizedBox.fromSize(
size:
Size(toolbarHeight - (width != null ? 20 : 0), toolbarHeight),
child: Center(
child: FlowySvg(
width: width ?? 20,
name: 'toolbar/$name',
),
),
),
),
));
}
// void _onTapListToolbar(BuildContext context) {
// // TODO: implement more detailed UI.
// final items = defaultListToolbarEventNames;
// final renderBox =
// _listToolbarKey.currentContext?.findRenderObject() as RenderBox;
// final offset = renderBox
// .localToGlobal(Offset.zero)
// .translate(0, toolbarHeight - cornerRadius);
// final rect = offset & Size(listToolbarWidth, listToolbarHeight);
// _listToolbarOverlay?.remove();
// _listToolbarOverlay = OverlayEntry(builder: (context) {
// return Positioned.fromRect(
// rect: rect,
// child: Material(
// borderRadius: BorderRadius.only(
// bottomLeft: Radius.circular(cornerRadius),
// bottomRight: Radius.circular(cornerRadius),
// ),
// color: const Color(0xFF333333),
// child: SingleChildScrollView(
// child: ListView.builder(
// itemExtent: toolbarHeight,
// padding: const EdgeInsets.only(bottom: 10.0),
// shrinkWrap: true,
// itemCount: items.length,
// itemBuilder: ((context, index) {
// return ListTile(
// contentPadding: const EdgeInsets.only(
// left: 3.0,
// right: 3.0,
// ),
// minVerticalPadding: 0.0,
// title: FittedBox(
// fit: BoxFit.scaleDown,
// child: Text(
// items[index],
// textAlign: TextAlign.center,
// style: const TextStyle(
// color: Colors.white,
// ),
// ),
// ),
// onTap: () {
// _onTap(items[index]);
// },
// );
// }),
// ),
// ),
// ),
// );
// });
// // TODO: disable scrolling.
// Overlay.of(context)?.insert(_listToolbarOverlay!);
// }
void _onTap(String eventName) {
if (defaultToolbarEventHandlers.containsKey(eventName)) {
defaultToolbarEventHandlers[eventName]!(widget.editorState);
return;
}
assert(false, 'Could not find the event handler for $eventName');
}
}

View File

@ -45,7 +45,7 @@ class SelectionMenuItemWidget extends StatelessWidget {
), ),
), ),
onPressed: () { onPressed: () {
item.handler(editorState, menuService); item.handler(editorState, menuService, context);
}, },
), ),
), ),

View File

@ -1,5 +1,6 @@
import 'package:appflowy_editor/src/editor_state.dart'; import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart'; import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/render/image/image_upload_widget.dart';
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart'; import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart'; import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
@ -23,6 +24,7 @@ class SelectionMenu implements SelectionMenuService {
OverlayEntry? _selectionMenuEntry; OverlayEntry? _selectionMenuEntry;
bool _selectionUpdateByInner = false; bool _selectionUpdateByInner = false;
Offset? _topLeft;
@override @override
void dismiss() { void dismiss() {
@ -53,6 +55,7 @@ class SelectionMenu implements SelectionMenuService {
return; return;
} }
final offset = selectionRects.first.bottomRight + const Offset(10, 10); final offset = selectionRects.first.bottomRight + const Offset(10, 10);
_topLeft = offset;
_selectionMenuEntry = OverlayEntry(builder: (context) { _selectionMenuEntry = OverlayEntry(builder: (context) {
return Positioned( return Positioned(
@ -84,8 +87,9 @@ class SelectionMenu implements SelectionMenuService {
} }
@override @override
// TODO: implement topLeft Offset get topLeft {
Offset get topLeft => throw UnimplementedError(); return _topLeft ?? Offset.zero;
}
void _onSelectionChange() { void _onSelectionChange() {
// workaround: SelectionService has been released after hot reload. // workaround: SelectionService has been released after hot reload.
@ -115,7 +119,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
name: 'Text', name: 'Text',
icon: _selectionMenuIcon('text'), icon: _selectionMenuIcon('text'),
keywords: ['text'], keywords: ['text'],
handler: (editorState, menuService) { handler: (editorState, _, __) {
insertTextNodeAfterSelection(editorState, {}); insertTextNodeAfterSelection(editorState, {});
}, },
), ),
@ -123,7 +127,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
name: 'Heading 1', name: 'Heading 1',
icon: _selectionMenuIcon('h1'), icon: _selectionMenuIcon('h1'),
keywords: ['heading 1, h1'], keywords: ['heading 1, h1'],
handler: (editorState, menuService) { handler: (editorState, _, __) {
insertHeadingAfterSelection(editorState, StyleKey.h1); insertHeadingAfterSelection(editorState, StyleKey.h1);
}, },
), ),
@ -131,7 +135,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
name: 'Heading 2', name: 'Heading 2',
icon: _selectionMenuIcon('h2'), icon: _selectionMenuIcon('h2'),
keywords: ['heading 2, h2'], keywords: ['heading 2, h2'],
handler: (editorState, menuService) { handler: (editorState, _, __) {
insertHeadingAfterSelection(editorState, StyleKey.h2); insertHeadingAfterSelection(editorState, StyleKey.h2);
}, },
), ),
@ -139,15 +143,21 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
name: 'Heading 3', name: 'Heading 3',
icon: _selectionMenuIcon('h3'), icon: _selectionMenuIcon('h3'),
keywords: ['heading 3, h3'], keywords: ['heading 3, h3'],
handler: (editorState, menuService) { handler: (editorState, _, __) {
insertHeadingAfterSelection(editorState, StyleKey.h3); insertHeadingAfterSelection(editorState, StyleKey.h3);
}, },
), ),
SelectionMenuItem(
name: 'Image',
icon: _selectionMenuIcon('image'),
keywords: ['image'],
handler: showImageUploadMenu,
),
SelectionMenuItem( SelectionMenuItem(
name: 'Bulleted list', name: 'Bulleted list',
icon: _selectionMenuIcon('bulleted_list'), icon: _selectionMenuIcon('bulleted_list'),
keywords: ['bulleted list', 'list', 'unordered list'], keywords: ['bulleted list', 'list', 'unordered list'],
handler: (editorState, menuService) { handler: (editorState, _, __) {
insertBulletedListAfterSelection(editorState); insertBulletedListAfterSelection(editorState);
}, },
), ),
@ -155,7 +165,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
name: 'Checkbox', name: 'Checkbox',
icon: _selectionMenuIcon('checkbox'), icon: _selectionMenuIcon('checkbox'),
keywords: ['todo list', 'list', 'checkbox list'], keywords: ['todo list', 'list', 'checkbox list'],
handler: (editorState, menuService) { handler: (editorState, _, __) {
insertCheckboxAfterSelection(editorState); insertCheckboxAfterSelection(editorState);
}, },
), ),

View File

@ -22,8 +22,11 @@ class SelectionMenuItem {
/// ///
/// The keywords are used to quickly retrieve items. /// The keywords are used to quickly retrieve items.
final List<String> keywords; final List<String> keywords;
final void Function(EditorState editorState, SelectionMenuService menuService) final void Function(
handler; EditorState editorState,
SelectionMenuService menuService,
BuildContext context,
) handler;
} }
class SelectionMenuWidget extends StatefulWidget { class SelectionMenuWidget extends StatefulWidget {
@ -202,8 +205,10 @@ class _SelectionMenuWidgetState extends State<SelectionMenuWidget> {
if (event.logicalKey == LogicalKeyboardKey.enter) { if (event.logicalKey == LogicalKeyboardKey.enter) {
if (0 <= _selectedIndex && _selectedIndex < _showingItems.length) { if (0 <= _selectedIndex && _selectedIndex < _showingItems.length) {
_deleteLastCharacters(length: keyword.length + 1); _deleteLastCharacters(length: keyword.length + 1);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_showingItems[_selectedIndex] _showingItems[_selectedIndex]
.handler(widget.editorState, widget.menuService); .handler(widget.editorState, widget.menuService, context);
});
return KeyEventResult.handled; return KeyEventResult.handled;
} }
} else if (event.logicalKey == LogicalKeyboardKey.escape) { } else if (event.logicalKey == LogicalKeyboardKey.escape) {

View File

@ -0,0 +1,231 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/render/link_menu/link_menu.dart';
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
import 'package:appflowy_editor/src/extensions/text_node_extensions.dart';
import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
import 'package:flutter/material.dart';
import 'package:rich_clipboard/rich_clipboard.dart';
typedef ToolbarEventHandler = void Function(
EditorState editorState, BuildContext context);
typedef ToolbarShowValidator = bool Function(EditorState editorState);
class ToolbarItem {
ToolbarItem({
required this.id,
required this.type,
required this.icon,
this.tooltipsMessage = '',
required this.validator,
required this.handler,
});
final String id;
final int type;
final Widget icon;
final String tooltipsMessage;
final ToolbarShowValidator validator;
final ToolbarEventHandler handler;
factory ToolbarItem.divider() {
return ToolbarItem(
id: 'divider',
type: -1,
icon: const FlowySvg(name: 'toolbar/divider'),
validator: (editorState) => true,
handler: (editorState, context) {},
);
}
@override
bool operator ==(Object other) {
if (other is! ToolbarItem) {
return false;
}
if (identical(this, other)) {
return true;
}
return id == other.id;
}
@override
int get hashCode => id.hashCode;
}
List<ToolbarItem> defaultToolbarItems = [
ToolbarItem(
id: 'appflowy.toolbar.h1',
type: 1,
tooltipsMessage: 'Heading 1',
icon: const FlowySvg(name: 'toolbar/h1'),
validator: _onlyShowInSingleTextSelection,
handler: (editorState, context) => formatHeading(editorState, StyleKey.h1),
),
ToolbarItem(
id: 'appflowy.toolbar.h2',
type: 1,
tooltipsMessage: 'Heading 2',
icon: const FlowySvg(name: 'toolbar/h2'),
validator: _onlyShowInSingleTextSelection,
handler: (editorState, context) => formatHeading(editorState, StyleKey.h2),
),
ToolbarItem(
id: 'appflowy.toolbar.h3',
type: 1,
tooltipsMessage: 'Heading 3',
icon: const FlowySvg(name: 'toolbar/h3'),
validator: _onlyShowInSingleTextSelection,
handler: (editorState, context) => formatHeading(editorState, StyleKey.h3),
),
ToolbarItem(
id: 'appflowy.toolbar.bold',
type: 2,
tooltipsMessage: 'Bold',
icon: const FlowySvg(name: 'toolbar/bold'),
validator: _showInTextSelection,
handler: (editorState, context) => formatBold(editorState),
),
ToolbarItem(
id: 'appflowy.toolbar.italic',
type: 2,
tooltipsMessage: 'Italic',
icon: const FlowySvg(name: 'toolbar/italic'),
validator: _showInTextSelection,
handler: (editorState, context) => formatItalic(editorState),
),
ToolbarItem(
id: 'appflowy.toolbar.underline',
type: 2,
tooltipsMessage: 'Underline',
icon: const FlowySvg(name: 'toolbar/underline'),
validator: _showInTextSelection,
handler: (editorState, context) => formatUnderline(editorState),
),
ToolbarItem(
id: 'appflowy.toolbar.strikethrough',
type: 2,
tooltipsMessage: 'Strikethrough',
icon: const FlowySvg(name: 'toolbar/strikethrough'),
validator: _showInTextSelection,
handler: (editorState, context) => formatStrikethrough(editorState),
),
ToolbarItem(
id: 'appflowy.toolbar.quote',
type: 3,
tooltipsMessage: 'Quote',
icon: const FlowySvg(name: 'toolbar/quote'),
validator: _onlyShowInSingleTextSelection,
handler: (editorState, context) => formatQuote(editorState),
),
ToolbarItem(
id: 'appflowy.toolbar.bulleted_list',
type: 3,
tooltipsMessage: 'Bulleted list',
icon: const FlowySvg(name: 'toolbar/bulleted_list'),
validator: _onlyShowInSingleTextSelection,
handler: (editorState, context) => formatBulletedList(editorState),
),
ToolbarItem(
id: 'appflowy.toolbar.link',
type: 4,
tooltipsMessage: 'Link',
icon: const FlowySvg(name: 'toolbar/link'),
validator: _onlyShowInSingleTextSelection,
handler: (editorState, context) => _showLinkMenu(editorState, context),
),
ToolbarItem(
id: 'appflowy.toolbar.highlight',
type: 4,
tooltipsMessage: 'Highlight',
icon: const FlowySvg(name: 'toolbar/highlight'),
validator: _showInTextSelection,
handler: (editorState, context) => formatHighlight(editorState),
),
];
ToolbarShowValidator _onlyShowInSingleTextSelection = (editorState) {
final nodes = editorState.service.selectionService.currentSelectedNodes;
return (nodes.length == 1 && nodes.first is TextNode);
};
ToolbarShowValidator _showInTextSelection = (editorState) {
final nodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>();
return nodes.isNotEmpty;
};
OverlayEntry? _linkMenuOverlay;
EditorState? _editorState;
void _showLinkMenu(EditorState editorState, BuildContext context) {
final rects = editorState.service.selectionService.selectionRects;
var maxBottom = 0.0;
late Rect matchRect;
for (final rect in rects) {
if (rect.bottom > maxBottom) {
maxBottom = rect.bottom;
matchRect = rect;
}
}
_dismissLinkMenu();
_editorState = editorState;
// Since the link menu will only show in single text selection,
// We get the text node directly instead of judging details again.
final selection =
editorState.service.selectionService.currentSelection.value!;
final index =
selection.isBackward ? selection.start.offset : selection.end.offset;
final length = (selection.start.offset - selection.end.offset).abs();
final node = editorState.service.selectionService.currentSelectedNodes.first
as TextNode;
String? linkText;
if (node.allSatisfyLinkInSelection(selection)) {
linkText = node.getAttributeInSelection(selection, StyleKey.href);
}
_linkMenuOverlay = OverlayEntry(builder: (context) {
return Positioned(
top: matchRect.bottom + 5.0,
left: matchRect.left,
child: Material(
child: LinkMenu(
linkText: linkText,
onSubmitted: (text) {
TransactionBuilder(editorState)
..formatText(node, index, length, {StyleKey.href: text})
..commit();
_dismissLinkMenu();
},
onCopyLink: () {
RichClipboard.setData(RichClipboardData(text: linkText));
_dismissLinkMenu();
},
onRemoveLink: () {
TransactionBuilder(editorState)
..formatText(node, index, length, {StyleKey.href: null})
..commit();
_dismissLinkMenu();
},
),
),
);
});
Overlay.of(context)?.insert(_linkMenuOverlay!);
editorState.service.scrollService?.disable();
editorState.service.keyboardService?.disable();
editorState.service.selectionService.currentSelection
.addListener(_dismissLinkMenu);
}
void _dismissLinkMenu() {
_linkMenuOverlay?.remove();
_linkMenuOverlay = null;
_editorState?.service.scrollService?.enable();
_editorState?.service.keyboardService?.enable();
_editorState?.service.selectionService.currentSelection
.removeListener(_dismissLinkMenu);
_editorState = null;
}

View File

@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'toolbar_item.dart';
class ToolbarItemWidget extends StatelessWidget {
const ToolbarItemWidget({
Key? key,
required this.item,
required this.onPressed,
}) : super(key: key);
final ToolbarItem item;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 28,
height: 28,
child: Tooltip(
preferBelow: false,
message: item.tooltipsMessage,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: IconButton(
padding: EdgeInsets.zero,
icon: item.icon,
iconSize: 28,
onPressed: onPressed,
),
),
),
);
}
}

View File

@ -0,0 +1,79 @@
import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
import 'package:appflowy_editor/src/render/toolbar/toolbar_item_widget.dart';
import 'package:flutter/material.dart';
import 'package:appflowy_editor/src/editor_state.dart';
mixin ToolbarMixin<T extends StatefulWidget> on State<T> {
void hide();
}
class ToolbarWidget extends StatefulWidget {
const ToolbarWidget({
Key? key,
required this.editorState,
required this.layerLink,
required this.offset,
required this.items,
}) : super(key: key);
final EditorState editorState;
final LayerLink layerLink;
final Offset offset;
final List<ToolbarItem> items;
@override
State<ToolbarWidget> createState() => _ToolbarWidgetState();
}
class _ToolbarWidgetState extends State<ToolbarWidget> with ToolbarMixin {
OverlayEntry? _listToolbarOverlay;
@override
Widget build(BuildContext context) {
return Positioned(
top: widget.offset.dx,
left: widget.offset.dy,
child: CompositedTransformFollower(
link: widget.layerLink,
showWhenUnlinked: true,
offset: widget.offset,
child: _buildToolbar(context),
),
);
}
@override
void hide() {
_listToolbarOverlay?.remove();
_listToolbarOverlay = null;
}
Widget _buildToolbar(BuildContext context) {
return Material(
borderRadius: BorderRadius.circular(8.0),
color: const Color(0xFF333333),
child: Padding(
padding: const EdgeInsets.only(left: 8.0, right: 8.0),
child: SizedBox(
height: 32.0,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: widget.items
.map(
(item) => Center(
child: ToolbarItemWidget(
item: item,
onPressed: () {
item.handler(widget.editorState, context);
},
),
),
)
.toList(growable: false),
),
),
),
);
}
}

View File

@ -1,3 +1,4 @@
import 'package:appflowy_editor/src/render/image/image_node_builder.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart'; import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/default_key_event_handlers.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/default_key_event_handlers.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -25,6 +26,7 @@ NodeWidgetBuilders defaultBuilders = {
'text/bulleted-list': BulletedListTextNodeWidgetBuilder(), 'text/bulleted-list': BulletedListTextNodeWidgetBuilder(),
'text/number-list': NumberListTextNodeWidgetBuilder(), 'text/number-list': NumberListTextNodeWidgetBuilder(),
'text/quote': QuotedTextNodeWidgetBuilder(), 'text/quote': QuotedTextNodeWidgetBuilder(),
'image': ImageNodeBuilder(),
}; };
class AppFlowyEditor extends StatefulWidget { class AppFlowyEditor extends StatefulWidget {

View File

@ -1,4 +1,5 @@
import 'package:appflowy_editor/src/infra/log.dart'; import 'package:appflowy_editor/src/infra/log.dart';
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -87,7 +88,9 @@ class _AppFlowyInputState extends State<AppFlowyInput>
@override @override
void attach(TextEditingValue textEditingValue) { void attach(TextEditingValue textEditingValue) {
_textInputConnection ??= TextInput.attach( if (_textInputConnection == null ||
_textInputConnection!.attached == false) {
_textInputConnection = TextInput.attach(
this, this,
const TextInputConfiguration( const TextInputConfiguration(
// TODO: customize // TODO: customize
@ -96,6 +99,7 @@ class _AppFlowyInputState extends State<AppFlowyInput>
textCapitalization: TextCapitalization.sentences, textCapitalization: TextCapitalization.sentences,
), ),
); );
}
_textInputConnection! _textInputConnection!
..setEditingState(textEditingValue) ..setEditingState(textEditingValue)
@ -146,6 +150,9 @@ class _AppFlowyInputState extends State<AppFlowyInput>
textNode, textNode,
delta.insertionOffset, delta.insertionOffset,
delta.textInserted, delta.textInserted,
removedAttributes: {
StyleKey.href: null,
},
) )
..commit(); ..commit();
} else { } else {

View File

@ -13,9 +13,6 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
selection = selection.isBackward ? selection : selection.reversed; selection = selection.isBackward ? selection : selection.reversed;
// make sure all nodes is [TextNode]. // make sure all nodes is [TextNode].
final textNodes = nodes.whereType<TextNode>().toList(); final textNodes = nodes.whereType<TextNode>().toList();
if (textNodes.length != nodes.length) {
return KeyEventResult.ignored;
}
final transactionBuilder = TransactionBuilder(editorState); final transactionBuilder = TransactionBuilder(editorState);
if (textNodes.length == 1) { if (textNodes.length == 1) {
@ -37,9 +34,9 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
} else { } else {
// 2. non-style // 2. non-style
// find previous text node. // find previous text node.
while (textNode.previous != null) { var previous = textNode.previous;
if (textNode.previous is TextNode) { while (previous != null) {
final previous = textNode.previous as TextNode; if (previous is TextNode) {
transactionBuilder transactionBuilder
..mergeText(previous, textNode) ..mergeText(previous, textNode)
..deleteNode(textNode) ..deleteNode(textNode)
@ -50,6 +47,8 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
), ),
); );
break; break;
} else {
previous = previous.previous;
} }
} }
} }

View File

@ -36,6 +36,12 @@ AppFlowyKeyEventHandler updateTextStyleByCommandXHandler =
event.isShiftPressed) { event.isShiftPressed) {
formatHighlight(editorState); formatHighlight(editorState);
return KeyEventResult.handled; return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.keyK) {
if (editorState.service.toolbarService
?.triggerHandler('appflowy.toolbar.link') ==
true) {
return KeyEventResult.handled;
}
} }
return KeyEventResult.ignored; return KeyEventResult.ignored;

View File

@ -36,10 +36,10 @@ class FlowyService {
// toolbar service // toolbar service
final toolbarServiceKey = GlobalKey(debugLabel: 'flowy_toolbar_service'); final toolbarServiceKey = GlobalKey(debugLabel: 'flowy_toolbar_service');
FlowyToolbarService? get toolbarService { AppFlowyToolbarService? get toolbarService {
if (toolbarServiceKey.currentState != null && if (toolbarServiceKey.currentState != null &&
toolbarServiceKey.currentState is FlowyToolbarService) { toolbarServiceKey.currentState is AppFlowyToolbarService) {
return toolbarServiceKey.currentState! as FlowyToolbarService; return toolbarServiceKey.currentState! as AppFlowyToolbarService;
} }
return null; return null;
} }

View File

@ -1,15 +1,19 @@
import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/render/selection/toolbar_widget.dart'; import 'package:appflowy_editor/src/render/toolbar/toolbar_widget.dart';
import 'package:appflowy_editor/src/extensions/object_extensions.dart'; import 'package:appflowy_editor/src/extensions/object_extensions.dart';
abstract class FlowyToolbarService { abstract class AppFlowyToolbarService {
/// Show the toolbar widget beside the offset. /// Show the toolbar widget beside the offset.
void showInOffset(Offset offset, LayerLink layerLink); void showInOffset(Offset offset, LayerLink layerLink);
/// Hide the toolbar widget. /// Hide the toolbar widget.
void hide(); void hide();
/// Trigger the specified handler.
bool triggerHandler(String id);
} }
class FlowyToolbar extends StatefulWidget { class FlowyToolbar extends StatefulWidget {
@ -27,7 +31,7 @@ class FlowyToolbar extends StatefulWidget {
} }
class _FlowyToolbarState extends State<FlowyToolbar> class _FlowyToolbarState extends State<FlowyToolbar>
implements FlowyToolbarService { implements AppFlowyToolbarService {
OverlayEntry? _toolbarOverlay; OverlayEntry? _toolbarOverlay;
final _toolbarWidgetKey = GlobalKey(debugLabel: '_toolbar_widget'); final _toolbarWidgetKey = GlobalKey(debugLabel: '_toolbar_widget');
@ -41,7 +45,7 @@ class _FlowyToolbarState extends State<FlowyToolbar>
editorState: widget.editorState, editorState: widget.editorState,
layerLink: layerLink, layerLink: layerLink,
offset: offset.translate(0, -37.0), offset: offset.translate(0, -37.0),
handlers: const {}, items: _filterItems(defaultToolbarItems),
), ),
); );
Overlay.of(context)?.insert(_toolbarOverlay!); Overlay.of(context)?.insert(_toolbarOverlay!);
@ -54,6 +58,17 @@ class _FlowyToolbarState extends State<FlowyToolbar>
_toolbarOverlay = null; _toolbarOverlay = null;
} }
@override
bool triggerHandler(String id) {
final items = defaultToolbarItems.where((item) => item.id == id);
if (items.length != 1) {
assert(items.length == 1, 'The toolbar item\'s id must be unique');
return false;
}
items.first.handler(widget.editorState, context);
return true;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
@ -67,4 +82,24 @@ class _FlowyToolbarState extends State<FlowyToolbar>
super.dispose(); super.dispose();
} }
// Filter items that should not be displayed, sort according to type,
// and insert dividers between different types.
List<ToolbarItem> _filterItems(List<ToolbarItem> items) {
final filterItems = items
.where((item) => item.validator(widget.editorState))
.toList(growable: false)
..sort((a, b) => a.type.compareTo(b.type));
if (items.isEmpty) {
return [];
}
final List<ToolbarItem> dividedItems = [filterItems.first];
for (var i = 1; i < filterItems.length; i++) {
if (filterItems[i].type != filterItems[i - 1].type) {
dividedItems.add(ToolbarItem.divider());
}
dividedItems.add(filterItems[i]);
}
return dividedItems;
}
} }

View File

@ -22,6 +22,7 @@ dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^2.0.0 flutter_lints: ^2.0.0
network_image_mock: ^2.1.1
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec
@ -32,6 +33,7 @@ flutter:
assets: assets:
- assets/images/toolbar/ - assets/images/toolbar/
- assets/images/selection_menu/ - assets/images/selection_menu/
- assets/images/image_toolbar/
- assets/images/ - assets/images/
# #
# For details regarding assets in packages, see # For details regarding assets in packages, see

View File

@ -57,6 +57,19 @@ class EditorWidgetTester {
); );
} }
void insertImageNode(String src, {String? align}) {
insert(
Node(
type: 'image',
children: LinkedList(),
attributes: {
'image_src': src,
'align': align ?? 'center',
},
),
);
}
Node? nodeAtPath(Path path) { Node? nodeAtPath(Path path) {
return root.childAtPath(path); return root.childAtPath(path);
} }

View File

@ -115,6 +115,9 @@ extension on LogicalKeyboardKey {
if (this == LogicalKeyboardKey.keyI) { if (this == LogicalKeyboardKey.keyI) {
return PhysicalKeyboardKey.keyI; return PhysicalKeyboardKey.keyI;
} }
if (this == LogicalKeyboardKey.keyK) {
return PhysicalKeyboardKey.keyK;
}
if (this == LogicalKeyboardKey.keyS) { if (this == LogicalKeyboardKey.keyS) {
return PhysicalKeyboardKey.keyS; return PhysicalKeyboardKey.keyS;
} }

View File

@ -84,7 +84,7 @@ void main() {
expect(transaction.toJson(), { expect(transaction.toJson(), {
"operations": [ "operations": [
{ {
"type": "insert-operation", "op": "insert",
"path": [0], "path": [0],
"nodes": [item1.toJson()], "nodes": [item1.toJson()],
} }
@ -107,7 +107,7 @@ void main() {
expect(transaction.toJson(), { expect(transaction.toJson(), {
"operations": [ "operations": [
{ {
"type": "delete-operation", "op": "delete",
"path": [0], "path": [0],
"nodes": [item1.toJson()], "nodes": [item1.toJson()],
} }

View File

@ -0,0 +1,131 @@
import 'package:appflowy_editor/src/render/image/image_node_widget.dart';
import 'package:appflowy_editor/src/service/editor_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:network_image_mock/network_image_mock.dart';
import '../../infra/test_editor.dart';
void main() async {
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
group('image_node_builder.dart', () {
testWidgets('render image node', (tester) async {
mockNetworkImagesFor(() async {
const text = 'Welcome to Appflowy 😁';
const src =
'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb';
final editor = tester.editor
..insertTextNode(text)
..insertImageNode(src)
..insertTextNode(text);
await editor.startTesting();
expect(editor.documentLength, 3);
expect(find.byType(Image), findsOneWidget);
});
});
testWidgets('render image align', (tester) async {
mockNetworkImagesFor(() async {
const text = 'Welcome to Appflowy 😁';
const src =
'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb';
final editor = tester.editor
..insertTextNode(text)
..insertImageNode(src, align: 'left')
..insertImageNode(src, align: 'center')
..insertImageNode(src, align: 'right')
..insertTextNode(text);
await editor.startTesting();
expect(editor.documentLength, 5);
final imageFinder = find.byType(Image);
expect(imageFinder, findsNWidgets(3));
final editorFinder = find.byType(AppFlowyEditor);
final editorRect = tester.getRect(editorFinder);
final leftImageRect = tester.getRect(imageFinder.at(0));
expect(leftImageRect.left, editorRect.left);
final rightImageRect = tester.getRect(imageFinder.at(2));
expect(rightImageRect.right, editorRect.right);
final centerImageRect = tester.getRect(imageFinder.at(1));
expect(centerImageRect.left,
(leftImageRect.left + rightImageRect.left) / 2.0);
expect(leftImageRect.size, centerImageRect.size);
expect(rightImageRect.size, centerImageRect.size);
final imageNodeWidgetFinder = find.byType(ImageNodeWidget);
final leftImage =
tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget;
leftImage.onAlign(Alignment.center);
await tester.pump(const Duration(milliseconds: 100));
expect(
tester.getRect(imageFinder.at(0)).left,
centerImageRect.left,
);
leftImage.onAlign(Alignment.centerRight);
await tester.pump(const Duration(milliseconds: 100));
expect(
tester.getRect(imageFinder.at(0)).left,
rightImageRect.left,
);
});
});
testWidgets('render image copy', (tester) async {
mockNetworkImagesFor(() async {
const text = 'Welcome to Appflowy 😁';
const src =
'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb';
final editor = tester.editor
..insertTextNode(text)
..insertImageNode(src)
..insertTextNode(text);
await editor.startTesting();
expect(editor.documentLength, 3);
final imageFinder = find.byType(Image);
expect(imageFinder, findsOneWidget);
final imageNodeWidgetFinder = find.byType(ImageNodeWidget);
final image =
tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget;
image.onCopy();
});
});
testWidgets('render image delete', (tester) async {
mockNetworkImagesFor(() async {
const text = 'Welcome to Appflowy 😁';
const src =
'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb';
final editor = tester.editor
..insertTextNode(text)
..insertImageNode(src)
..insertImageNode(src)
..insertTextNode(text);
await editor.startTesting();
expect(editor.documentLength, 4);
final imageFinder = find.byType(Image);
expect(imageFinder, findsNWidgets(2));
final imageNodeWidgetFinder = find.byType(ImageNodeWidget);
final image =
tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget;
image.onDelete();
await tester.pump(const Duration(milliseconds: 100));
expect(editor.documentLength, 3);
expect(find.byType(Image), findsNWidgets(1));
});
});
});
}

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