Merge branch 'main' into feat/flowy-overlay

This commit is contained in:
Nathan.fooo 2022-09-06 09:46:10 +08:00 committed by GitHub
commit f54f90b647
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 762 additions and 199 deletions

View File

@ -1,4 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection';
import 'package:app_flowy/plugins/grid/application/block/block_cache.dart'; import 'package:app_flowy/plugins/grid/application/block/block_cache.dart';
import 'package:app_flowy/plugins/grid/application/field/field_controller.dart'; import 'package:app_flowy/plugins/grid/application/field/field_controller.dart';
import 'package:app_flowy/plugins/grid/application/row/row_cache.dart'; import 'package:app_flowy/plugins/grid/application/row/row_cache.dart';
@ -12,7 +14,6 @@ import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-grid/protobuf.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/protobuf.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'dart:collection';
import 'board_data_controller.dart'; import 'board_data_controller.dart';
import 'group_controller.dart'; import 'group_controller.dart';
@ -164,12 +165,17 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
boardController.clear(); boardController.clear();
// //
List<AFBoardColumnData> columns = groups.map((group) { List<AFBoardColumnData> columns = groups
.where((group) => fieldController.getField(group.fieldId) != null)
.map((group) {
return AFBoardColumnData( return AFBoardColumnData(
id: group.groupId, id: group.groupId,
name: group.desc, name: group.desc,
items: _buildRows(group), items: _buildRows(group),
customData: group, customData: BoardCustomData(
group: group,
fieldContext: fieldController.getField(group.fieldId)!,
),
); );
}).toList(); }).toList();
boardController.addColumns(columns); boardController.addColumns(columns);
@ -177,6 +183,7 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
for (final group in groups) { for (final group in groups) {
final delegate = GroupControllerDelegateImpl( final delegate = GroupControllerDelegateImpl(
controller: boardController, controller: boardController,
fieldController: fieldController,
onNewColumnItem: (groupId, row, index) { onNewColumnItem: (groupId, row, index) {
add(BoardEvent.didCreateRow(groupId, row, index)); add(BoardEvent.didCreateRow(groupId, row, index));
}, },
@ -238,10 +245,8 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
List<AFColumnItem> _buildRows(GroupPB group) { List<AFColumnItem> _buildRows(GroupPB group) {
final items = group.rows.map((row) { final items = group.rows.map((row) {
return BoardColumnItem( final fieldContext = fieldController.getField(group.fieldId);
row: row, return BoardColumnItem(row: row, fieldContext: fieldContext!);
fieldId: group.fieldId,
);
}).toList(); }).toList();
return <AFColumnItem>[...items]; return <AFColumnItem>[...items];
@ -332,15 +337,11 @@ class GridFieldEquatable extends Equatable {
class BoardColumnItem extends AFColumnItem { class BoardColumnItem extends AFColumnItem {
final RowPB row; final RowPB row;
final GridFieldContext fieldContext;
final String fieldId;
final bool requestFocus;
BoardColumnItem({ BoardColumnItem({
required this.row, required this.row,
required this.fieldId, required this.fieldContext,
this.requestFocus = false,
}); });
@override @override
@ -348,24 +349,29 @@ class BoardColumnItem extends AFColumnItem {
} }
class GroupControllerDelegateImpl extends GroupControllerDelegate { class GroupControllerDelegateImpl extends GroupControllerDelegate {
final GridFieldController fieldController;
final AFBoardDataController controller; final AFBoardDataController controller;
final void Function(String, RowPB, int?) onNewColumnItem; final void Function(String, RowPB, int?) onNewColumnItem;
GroupControllerDelegateImpl({ GroupControllerDelegateImpl({
required this.controller, required this.controller,
required this.fieldController,
required this.onNewColumnItem, required this.onNewColumnItem,
}); });
@override @override
void insertRow(GroupPB group, RowPB row, int? index) { void insertRow(GroupPB group, RowPB row, int? index) {
final fieldContext = fieldController.getField(group.fieldId);
if (fieldContext == null) {
Log.warn("FieldContext should not be null");
return;
}
if (index != null) { if (index != null) {
final item = BoardColumnItem(row: row, fieldId: group.fieldId); final item = BoardColumnItem(row: row, fieldContext: fieldContext);
controller.insertColumnItem(group.groupId, index, item); controller.insertColumnItem(group.groupId, index, item);
} else { } else {
final item = BoardColumnItem( final item = BoardColumnItem(row: row, fieldContext: fieldContext);
row: row,
fieldId: group.fieldId,
);
controller.addColumnItem(group.groupId, item); controller.addColumnItem(group.groupId, item);
} }
} }
@ -377,22 +383,25 @@ class GroupControllerDelegateImpl extends GroupControllerDelegate {
@override @override
void updateRow(GroupPB group, RowPB row) { void updateRow(GroupPB group, RowPB row) {
final fieldContext = fieldController.getField(group.fieldId);
if (fieldContext == null) {
Log.warn("FieldContext should not be null");
return;
}
controller.updateColumnItem( controller.updateColumnItem(
group.groupId, group.groupId,
BoardColumnItem( BoardColumnItem(row: row, fieldContext: fieldContext),
row: row,
fieldId: group.fieldId,
),
); );
} }
@override @override
void addNewRow(GroupPB group, RowPB row, int? index) { void addNewRow(GroupPB group, RowPB row, int? index) {
final item = BoardColumnItem( final fieldContext = fieldController.getField(group.fieldId);
row: row, if (fieldContext == null) {
fieldId: group.fieldId, Log.warn("FieldContext should not be null");
requestFocus: true, return;
); }
final item = BoardColumnItem(row: row, fieldContext: fieldContext);
if (index != null) { if (index != null) {
controller.insertColumnItem(group.groupId, index, item); controller.insertColumnItem(group.groupId, index, item);
@ -414,3 +423,29 @@ class BoardEditingRow {
required this.index, required this.index,
}); });
} }
class BoardCustomData {
final GroupPB group;
final GridFieldContext fieldContext;
BoardCustomData({
required this.group,
required this.fieldContext,
});
CheckboxGroup? asCheckboxGroup() {
if (fieldType != FieldType.Checkbox) return null;
return CheckboxGroup(group);
}
FieldType get fieldType => fieldContext.fieldType;
}
class CheckboxGroup {
final GroupPB group;
CheckboxGroup(this.group);
// Hardcode value: "Yes" that equal to the value defined in Rust
// pub const CHECK: &str = "Yes";
bool get isCheck => group.groupId == "Yes";
}

View File

@ -18,7 +18,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui_web.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';
import 'package:flowy_sdk/protobuf/flowy-grid/group.pbserver.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.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';
import '../../grid/application/row/row_cache.dart'; import '../../grid/application/row/row_cache.dart';
@ -37,8 +37,7 @@ class BoardPage extends StatelessWidget {
create: (context) => create: (context) =>
BoardBloc(view: view)..add(const BoardEvent.initial()), BoardBloc(view: view)..add(const BoardEvent.initial()),
child: BlocBuilder<BoardBloc, BoardState>( child: BlocBuilder<BoardBloc, BoardState>(
buildWhen: (previous, current) => buildWhen: (p, c) => p.loadingState != c.loadingState,
previous.loadingState != current.loadingState,
builder: (context, state) { builder: (context, state) {
return state.loadingState.map( return state.loadingState.map(
loading: (_) => loading: (_) =>
@ -85,40 +84,40 @@ class _BoardContentState extends State<BoardContent> {
child: BlocBuilder<BoardBloc, BoardState>( child: BlocBuilder<BoardBloc, BoardState>(
buildWhen: (previous, current) => previous.groupIds != current.groupIds, buildWhen: (previous, current) => previous.groupIds != current.groupIds,
builder: (context, state) { builder: (context, state) {
final theme = context.read<AppTheme>(); final column = Column(
children: [const _ToolbarBlocAdaptor(), _buildBoard(context)],
);
return Container( return Container(
color: theme.surface, color: context.read<AppTheme>().surface,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column( child: column,
children: [ ),
const _ToolbarBlocAdaptor(), );
Expanded( },
),
);
}
Expanded _buildBoard(BuildContext context) {
return Expanded(
child: AFBoard( child: AFBoard(
key: UniqueKey(),
scrollManager: scrollManager, scrollManager: scrollManager,
scrollController: scrollController, scrollController: scrollController,
dataController: context.read<BoardBloc>().boardController, dataController: context.read<BoardBloc>().boardController,
headerBuilder: _buildHeader, headerBuilder: _buildHeader,
footBuilder: _buildFooter, footerBuilder: _buildFooter,
cardBuilder: (_, column, columnItem) => _buildCard( cardBuilder: (_, column, columnItem) => _buildCard(
context, context,
column, column,
columnItem, columnItem,
), ),
columnConstraints: columnConstraints: const BoxConstraints.tightFor(width: 300),
const BoxConstraints.tightFor(width: 300),
config: AFBoardConfig( config: AFBoardConfig(
columnBackgroundColor: HexColor.fromHex('#F7F8FC'), columnBackgroundColor: HexColor.fromHex('#F7F8FC'),
), ),
), ),
),
],
),
),
);
},
),
); );
} }
@ -153,6 +152,7 @@ class _BoardContentState extends State<BoardContent> {
BuildContext context, BuildContext context,
AFBoardColumnData columnData, AFBoardColumnData columnData,
) { ) {
final boardCustomData = columnData.customData as BoardCustomData;
return AppFlowyColumnHeader( return AppFlowyColumnHeader(
title: Flexible( title: Flexible(
fit: FlexFit.tight, fit: FlexFit.tight,
@ -163,6 +163,7 @@ class _BoardContentState extends State<BoardContent> {
color: context.read<AppTheme>().textColor, color: context.read<AppTheme>().textColor,
), ),
), ),
icon: _buildHeaderIcon(boardCustomData),
addIcon: SizedBox( addIcon: SizedBox(
height: 20, height: 20,
width: 20, width: 20,
@ -182,7 +183,9 @@ class _BoardContentState extends State<BoardContent> {
} }
Widget _buildFooter(BuildContext context, AFBoardColumnData columnData) { Widget _buildFooter(BuildContext context, AFBoardColumnData columnData) {
final group = columnData.customData as GroupPB; final boardCustomData = columnData.customData as BoardCustomData;
final group = boardCustomData.group;
if (group.isDefault) { if (group.isDefault) {
return const SizedBox(); return const SizedBox();
} else { } else {
@ -247,7 +250,7 @@ class _BoardContentState extends State<BoardContent> {
child: BoardCard( child: BoardCard(
gridId: gridId, gridId: gridId,
groupId: column.id, groupId: column.id,
fieldId: boardColumnItem.fieldId, fieldId: boardColumnItem.fieldContext.id,
isEditing: isEditing, isEditing: isEditing,
cellBuilder: cellBuilder, cellBuilder: cellBuilder,
dataController: cardController, dataController: cardController,
@ -325,3 +328,38 @@ extension HexColor on Color {
return Color(int.parse(buffer.toString(), radix: 16)); return Color(int.parse(buffer.toString(), radix: 16));
} }
} }
Widget? _buildHeaderIcon(BoardCustomData customData) {
Widget? widget;
switch (customData.fieldType) {
case FieldType.Checkbox:
final group = customData.asCheckboxGroup()!;
if (group.isCheck) {
widget = svgWidget('editor/editor_check');
} else {
widget = svgWidget('editor/editor_uncheck');
}
break;
case FieldType.DateTime:
break;
case FieldType.MultiSelect:
break;
case FieldType.Number:
break;
case FieldType.RichText:
break;
case FieldType.SingleSelect:
break;
case FieldType.URL:
break;
}
if (widget != null) {
widget = SizedBox(
width: 20,
height: 20,
child: widget,
);
}
return widget;
}

View File

@ -14,33 +14,37 @@ class EditableCellNotifier {
} }
class EditableRowNotifier { class EditableRowNotifier {
Map<EditableCellId, EditableCellNotifier> cells = {}; final Map<EditableCellId, EditableCellNotifier> _cells = {};
void insertCell( void insertCell(
GridCellIdentifier cellIdentifier, GridCellIdentifier cellIdentifier,
EditableCellNotifier notifier, EditableCellNotifier notifier,
) { ) {
cells[EditableCellId.from(cellIdentifier)] = notifier; _cells[EditableCellId.from(cellIdentifier)] = notifier;
} }
void becomeFirstResponder() { void becomeFirstResponder() {
for (final notifier in cells.values) { for (final notifier in _cells.values) {
notifier.becomeFirstResponder.notify(); notifier.becomeFirstResponder.notify();
} }
} }
void resignFirstResponder() { void resignFirstResponder() {
for (final notifier in cells.values) { for (final notifier in _cells.values) {
notifier.resignFirstResponder.notify(); notifier.resignFirstResponder.notify();
} }
} }
void clear() {
_cells.clear();
}
void dispose() { void dispose() {
for (final notifier in cells.values) { for (final notifier in _cells.values) {
notifier.resignFirstResponder.notify(); notifier.resignFirstResponder.notify();
} }
cells.clear(); _cells.clear();
} }
} }

View File

@ -4,7 +4,6 @@ import 'package:app_flowy/plugins/grid/presentation/widgets/cell/cell_builder.da
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';
import 'board_cell.dart'; import 'board_cell.dart';
import 'define.dart'; import 'define.dart';

View File

@ -89,20 +89,20 @@ class _BoardCardState extends State<BoardCard> {
List<GridCellIdentifier> cells, List<GridCellIdentifier> cells,
) { ) {
final List<Widget> children = []; final List<Widget> children = [];
rowNotifier.clear();
cells.asMap().forEach( cells.asMap().forEach(
(int index, GridCellIdentifier cellId) { (int index, GridCellIdentifier cellId) {
final cellNotifier = EditableCellNotifier(); final cellNotifier = EditableCellNotifier();
Widget child = widget.cellBuilder.buildCell( Widget child = widget.cellBuilder.buildCell(
widget.groupId, widget.groupId,
cellId, cellId,
widget.isEditing, index == 0 ? widget.isEditing : false,
cellNotifier, cellNotifier,
); );
if (index == 0) { if (index == 0) {
rowNotifier.insertCell(cellId, cellNotifier); rowNotifier.insertCell(cellId, cellNotifier);
} }
child = Padding( child = Padding(
key: cellId.key(), key: cellId.key(),
padding: const EdgeInsets.only(left: 4, right: 4), padding: const EdgeInsets.only(left: 4, right: 4),

View File

@ -7,6 +7,7 @@ import 'package:dartz/dartz.dart';
import 'package:flowy_sdk/log.dart'; import 'package:flowy_sdk/log.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/field_entities.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-grid/group.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-grid/setting_entities.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/setting_entities.pb.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../row/row_cache.dart'; import '../row/row_cache.dart';
@ -35,12 +36,12 @@ class GridFieldController {
final SettingListener _settingListener; final SettingListener _settingListener;
final Map<OnReceiveFields, VoidCallback> _fieldCallbackMap = {}; final Map<OnReceiveFields, VoidCallback> _fieldCallbackMap = {};
final Map<OnChangeset, OnChangeset> _changesetCallbackMap = {}; final Map<OnChangeset, OnChangeset> _changesetCallbackMap = {};
_GridFieldNotifier? _fieldNotifier = _GridFieldNotifier();
List<String> _groupFieldIds = [];
final GridFFIService _gridFFIService; final GridFFIService _gridFFIService;
final SettingFFIService _settingFFIService; final SettingFFIService _settingFFIService;
_GridFieldNotifier? _fieldNotifier = _GridFieldNotifier();
final Map<String, GridGroupConfigurationPB> _configurationByFieldId = {};
List<GridFieldContext> get fieldContexts => List<GridFieldContext> get fieldContexts =>
[..._fieldNotifier?.fieldContexts ?? []]; [..._fieldNotifier?.fieldContexts ?? []];
@ -67,31 +68,43 @@ class GridFieldController {
//Listen on setting changes //Listen on setting changes
_settingListener.start(onSettingUpdated: (result) { _settingListener.start(onSettingUpdated: (result) {
result.fold( result.fold(
(setting) => _updateFieldsWhenSettingChanged(setting), (setting) => _updateGroupConfiguration(setting),
(r) => Log.error(r), (r) => Log.error(r),
); );
}); });
_settingFFIService.getSetting().then((result) { _settingFFIService.getSetting().then((result) {
result.fold( result.fold(
(setting) => _updateFieldsWhenSettingChanged(setting), (setting) => _updateGroupConfiguration(setting),
(err) => Log.error(err), (err) => Log.error(err),
); );
}); });
} }
void _updateFieldsWhenSettingChanged(GridSettingPB setting) { GridFieldContext? getField(String fieldId) {
_groupFieldIds = setting.groupConfigurations.items final fields = _fieldNotifier?.fieldContexts
.map((item) => item.groupFieldId) .where(
(element) => element.id == fieldId,
)
.toList(); .toList();
if (fields?.isEmpty ?? true) {
return null;
}
return fields!.first;
}
void _updateGroupConfiguration(GridSettingPB setting) {
_configurationByFieldId.clear();
for (final configuration in setting.groupConfigurations.items) {
_configurationByFieldId[configuration.fieldId] = configuration;
}
_updateFieldContexts(); _updateFieldContexts();
} }
void _updateFieldContexts() { void _updateFieldContexts() {
if (_fieldNotifier != null) { if (_fieldNotifier != null) {
for (var field in _fieldNotifier!.fieldContexts) { for (var field in _fieldNotifier!.fieldContexts) {
if (_groupFieldIds.contains(field.id)) { if (_configurationByFieldId[field.id] != null) {
field._isGroupField = true; field._isGroupField = true;
} else { } else {
field._isGroupField = false; field._isGroupField = false;
@ -277,5 +290,26 @@ class GridFieldContext {
bool get isGroupField => _isGroupField; bool get isGroupField => _isGroupField;
bool get canGroup {
switch (_field.fieldType) {
case FieldType.Checkbox:
return true;
case FieldType.DateTime:
return false;
case FieldType.MultiSelect:
return true;
case FieldType.Number:
return false;
case FieldType.RichText:
return false;
case FieldType.SingleSelect:
return true;
case FieldType.URL:
return false;
}
return false;
}
GridFieldContext({required FieldPB field}) : _field = field; GridFieldContext({required FieldPB field}) : _field = field;
} }

View File

@ -31,10 +31,15 @@ class GridGroupList extends StatelessWidget {
child: BlocBuilder<GridGroupBloc, GridGroupState>( child: BlocBuilder<GridGroupBloc, GridGroupState>(
builder: (context, state) { builder: (context, state) {
final cells = state.fieldContexts.map((fieldContext) { final cells = state.fieldContexts.map((fieldContext) {
return _GridGroupCell( Widget cell = _GridGroupCell(
fieldContext: fieldContext, fieldContext: fieldContext,
key: ValueKey(fieldContext.id), key: ValueKey(fieldContext.id),
); );
if (!fieldContext.canGroup) {
cell = IgnorePointer(child: Opacity(opacity: 0.3, child: cell));
}
return cell;
}).toList(); }).toList();
return ListView.separated( return ListView.separated(

View File

@ -1,87 +1,71 @@
# appflowy_board # appflowy_board
The **appflowy_board** is a package that is used in [AppFlowy](https://github.com/AppFlowy-IO/AppFlowy). For the moment, this package is iterated very fast. <h1 align="center"><b>AppFlowy Board</b></h1>
<p align="center">A customizable and draggable Kanban Board widget for Flutter</p>
**appflowy_board** will be a standard git repository when it becomes stable. <p align="center">
## Getting Started <a href="https://discord.gg/ZCCYN4Anzq"><b>Discord</b></a>
<a href="https://twitter.com/appflowy"><b>Twitter</b></a>
</p>
<p> <p align="center">
<img src="https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_board/example/gifs/appflowy_board_video_2.gif?raw=true" width="680" title="AppFlowyBoard">
<img src="https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_board/example/gifs/appflowy_board_video_1.gif?raw=true" width="680" title="AppFlowyBoard"> <img src="https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_board/example/gifs/appflowy_board_video_1.gif?raw=true" width="680" title="AppFlowyBoard">
</p> </p>
## Intro
appflowy_board is a customizable and draggable Kanban Board widget for Flutter.
You can use it to create a Kanban Board tool like those in Trello.
Check out [AppFlowy](https://github.com/AppFlowy-IO/AppFlowy) to see how appflowy_board is used to build a BoardView database.
<p align="center">
<img src="https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_board/example/gifs/appflowy_board_video_2.gif?raw=true" width="680" title="AppFlowyBoard">
</p>
## Getting Started
Add the AppFlowy Board [Flutter package](https://docs.flutter.dev/development/packages-and-plugins/using-packages) to your environment.
With Flutter:
```dart ```dart
@override flutter pub add appflowy_board
void initState() {
final column1 = BoardColumnData(id: "To Do", items: [
TextItem("Card 1"),
TextItem("Card 2"),
TextItem("Card 3"),
TextItem("Card 4"),
]);
final column2 = BoardColumnData(id: "In Progress", items: [
TextItem("Card 5"),
TextItem("Card 6"),
]);
final column3 = BoardColumnData(id: "Done", items: []);
boardDataController.addColumn(column1);
boardDataController.addColumn(column2);
boardDataController.addColumn(column3);
super.initState();
}
@override
Widget build(BuildContext context) {
final config = BoardConfig(
columnBackgroundColor: HexColor.fromHex('#F7F8FC'),
);
return Container(
color: Colors.white,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 30, horizontal: 20),
child: Board(
dataController: boardDataController,
footBuilder: (context, columnData) {
return AppFlowyColumnFooter(
icon: const Icon(Icons.add, size: 20),
title: const Text('New'),
height: 50,
margin: config.columnItemPadding,
);
},
headerBuilder: (context, columnData) {
return AppFlowyColumnHeader(
icon: const Icon(Icons.lightbulb_circle),
title: Text(columnData.id),
addIcon: const Icon(Icons.add, size: 20),
moreIcon: const Icon(Icons.more_horiz, size: 20),
height: 50,
margin: config.columnItemPadding,
);
},
cardBuilder: (context, item) {
final textItem = item as TextItem;
return AppFlowyColumnItemCard(
key: ObjectKey(item),
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(textItem.s),
),
),
);
},
columnConstraints: const BoxConstraints.tightFor(width: 240),
config: BoardConfig(
columnBackgroundColor: HexColor.fromHex('#F7F8FC'),
),
),
),
);
}
``` ```
This will add a line like this to your package's pubspec.yaml (and run an implicit flutter pub get):
```dart
dependencies:
appflowy_board: ^0.0.6
```
Import the package in your Dart file:
```dart
import 'package:appflowy_board/appflowy_board.dart';
```
## Usage Example
To quickly grasp how it can be used, look at the /example/lib folder.
First, run main.dart to play with the demo.
Second, let's delve into multi_board_list_example.dart to understand a few key components:
* A Board widget is created via instantiating an AFBoard() object.
* In the AFBoard() object, you can find:
* AFBoardDataController, which is defined in board_data.dart, is feeded with prepopulated mock data. It also contains callback functions to materialize future user data.
* Three builders: AppFlowyColumnHeader, AppFlowyColumnFooter, AppFlowyColumnItemCard. See below image for what they are used for.
<p>
<img src="https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_board/example/gifs/appflowy_board_builders.jpg?raw=true" width="100%" title="AppFlowyBoard">
</p>
## Glossary
Please refer to the API documentation.
## Contributing
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated.
Please look at [CONTRIBUTING.md](https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/contributing-to-appflowy) for details.
## License
Distributed under the AGPLv3 License. See [LICENSE](https://github.com/AppFlowy-IO/AppFlowy-Docs/blob/main/LICENSE) for more information.

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View File

@ -66,7 +66,7 @@ class _MultiBoardListExampleState extends State<MultiBoardListExample> {
padding: const EdgeInsets.symmetric(vertical: 30, horizontal: 20), padding: const EdgeInsets.symmetric(vertical: 30, horizontal: 20),
child: AFBoard( child: AFBoard(
dataController: boardDataController, dataController: boardDataController,
footBuilder: (context, columnData) { footerBuilder: (context, columnData) {
return AppFlowyColumnFooter( return AppFlowyColumnFooter(
icon: const Icon(Icons.add, size: 20), icon: const Icon(Icons.add, size: 20),
title: const Text('New'), title: const Text('New'),

View File

@ -56,7 +56,7 @@ class AFBoard extends StatelessWidget {
final AFBoardColumnHeaderBuilder? headerBuilder; final AFBoardColumnHeaderBuilder? headerBuilder;
/// ///
final AFBoardColumnFooterBuilder? footBuilder; final AFBoardColumnFooterBuilder? footerBuilder;
/// ///
final AFBoardDataController dataController; final AFBoardDataController dataController;
@ -78,7 +78,7 @@ class AFBoard extends StatelessWidget {
required this.dataController, required this.dataController,
required this.cardBuilder, required this.cardBuilder,
this.background, this.background,
this.footBuilder, this.footerBuilder,
this.headerBuilder, this.headerBuilder,
this.scrollController, this.scrollController,
this.scrollManager, this.scrollManager,
@ -112,7 +112,7 @@ class AFBoard extends StatelessWidget {
delegate: phantomController, delegate: phantomController,
columnConstraints: columnConstraints, columnConstraints: columnConstraints,
cardBuilder: cardBuilder, cardBuilder: cardBuilder,
footBuilder: footBuilder, footBuilder: footerBuilder,
headerBuilder: headerBuilder, headerBuilder: headerBuilder,
phantomController: phantomController, phantomController: phantomController,
onReorder: dataController.moveColumn, onReorder: dataController.moveColumn,

View File

@ -31,7 +31,7 @@ typedef AFBoardColumnCardBuilder = Widget Function(
typedef AFBoardColumnHeaderBuilder = Widget? Function( typedef AFBoardColumnHeaderBuilder = Widget? Function(
BuildContext context, BuildContext context,
AFBoardColumnData headerData, AFBoardColumnData columnData,
); );
typedef AFBoardColumnFooterBuilder = Widget Function( typedef AFBoardColumnFooterBuilder = Widget Function(

View File

@ -44,14 +44,14 @@ pub struct GridGroupConfigurationPB {
pub id: String, pub id: String,
#[pb(index = 2)] #[pb(index = 2)]
pub group_field_id: String, pub field_id: String,
} }
impl std::convert::From<&GroupConfigurationRevision> for GridGroupConfigurationPB { impl std::convert::From<&GroupConfigurationRevision> for GridGroupConfigurationPB {
fn from(rev: &GroupConfigurationRevision) -> Self { fn from(rev: &GroupConfigurationRevision) -> Self {
GridGroupConfigurationPB { GridGroupConfigurationPB {
id: rev.id.clone(), id: rev.id.clone(),
group_field_id: rev.field_id.clone(), field_id: rev.field_id.clone(),
} }
} }
} }

View File

@ -107,7 +107,7 @@ impl GridBlockManager {
let editor = self.get_editor_from_row_id(&changeset.row_id).await?; let editor = self.get_editor_from_row_id(&changeset.row_id).await?;
let _ = editor.update_row(changeset.clone()).await?; let _ = editor.update_row(changeset.clone()).await?;
match editor.get_row_rev(&changeset.row_id).await? { match editor.get_row_rev(&changeset.row_id).await? {
None => tracing::error!("Internal error: can't find the row with id: {}", changeset.row_id), None => tracing::error!("Update row failed, can't find the row with id: {}", changeset.row_id),
Some(row_rev) => { Some(row_rev) => {
let row_pb = make_row_from_row_rev(row_rev.clone()); let row_pb = make_row_from_row_rev(row_rev.clone());
let block_order_changeset = GridBlockChangesetPB::update(&editor.block_id, vec![row_pb]); let block_order_changeset = GridBlockChangesetPB::update(&editor.block_id, vec![row_pb]);

View File

@ -182,7 +182,6 @@ pub fn delete_select_option_cell(option_id: String, field_rev: &FieldRevision) -
CellRevision::new(data) CellRevision::new(data)
} }
/// If the cell data is not String type, it should impl this trait.
/// Deserialize the String into cell specific data type. /// Deserialize the String into cell specific data type.
pub trait FromCellString { pub trait FromCellString {
fn from_cell_str(s: &str) -> FlowyResult<Self> fn from_cell_str(s: &str) -> FlowyResult<Self>

View File

@ -96,6 +96,8 @@ impl GridViewRevisionEditor {
None => Some(0), None => Some(0),
Some(_) => None, Some(_) => None,
}; };
self.group_controller.write().await.did_create_row(row_pb, group_id);
let inserted_row = InsertedRowPB { let inserted_row = InsertedRowPB {
row: row_pb.clone(), row: row_pb.clone(),
index, index,

View File

@ -1,13 +1,16 @@
use crate::entities::GroupChangesetPB; use crate::entities::GroupChangesetPB;
use crate::services::group::controller::MoveGroupRowContext; use crate::services::group::controller::MoveGroupRowContext;
use flowy_grid_data_model::revision::RowRevision; use flowy_grid_data_model::revision::{CellRevision, RowRevision};
pub trait GroupAction: Send + Sync { pub trait GroupAction: Send + Sync {
type CellDataType; type CellDataType;
fn default_cell_rev(&self) -> Option<CellRevision> {
None
}
fn can_group(&self, content: &str, cell_data: &Self::CellDataType) -> bool; fn can_group(&self, content: &str, cell_data: &Self::CellDataType) -> bool;
fn add_row_if_match(&mut self, row_rev: &RowRevision, cell_data: &Self::CellDataType) -> Vec<GroupChangesetPB>; fn add_row_if_match(&mut self, row_rev: &RowRevision, cell_data: &Self::CellDataType) -> Vec<GroupChangesetPB>;
fn remove_row_if_match(&mut self, row_rev: &RowRevision, cell_data: &Self::CellDataType) -> Vec<GroupChangesetPB>; fn remove_row_if_match(&mut self, row_rev: &RowRevision, cell_data: &Self::CellDataType) -> Vec<GroupChangesetPB>;
fn move_row(&mut self, cell_data: &Self::CellDataType, context: MoveGroupRowContext) -> Vec<GroupChangesetPB>; fn move_row(&mut self, cell_data: &Self::CellDataType, context: MoveGroupRowContext) -> Vec<GroupChangesetPB>;
} }

View File

@ -7,7 +7,6 @@ use flowy_error::FlowyResult;
use flowy_grid_data_model::revision::{ use flowy_grid_data_model::revision::{
FieldRevision, GroupConfigurationContentSerde, GroupRevision, RowChangeset, RowRevision, TypeOptionDataDeserializer, FieldRevision, GroupConfigurationContentSerde, GroupRevision, RowChangeset, RowRevision, TypeOptionDataDeserializer,
}; };
use std::marker::PhantomData; use std::marker::PhantomData;
use std::sync::Arc; use std::sync::Arc;
@ -16,6 +15,7 @@ use std::sync::Arc;
// a new row. // a new row.
pub trait GroupController: GroupControllerSharedOperation + Send + Sync { pub trait GroupController: GroupControllerSharedOperation + Send + Sync {
fn will_create_row(&mut self, row_rev: &mut RowRevision, field_rev: &FieldRevision, group_id: &str); fn will_create_row(&mut self, row_rev: &mut RowRevision, field_rev: &FieldRevision, group_id: &str);
fn did_create_row(&mut self, row_pb: &RowPB, group_id: &str);
} }
pub trait GroupGenerator { pub trait GroupGenerator {
@ -193,9 +193,14 @@ where
#[tracing::instrument(level = "trace", skip_all, fields(row_count=%row_revs.len(), group_result))] #[tracing::instrument(level = "trace", skip_all, fields(row_count=%row_revs.len(), group_result))]
fn fill_groups(&mut self, row_revs: &[Arc<RowRevision>], field_rev: &FieldRevision) -> FlowyResult<()> { fn fill_groups(&mut self, row_revs: &[Arc<RowRevision>], field_rev: &FieldRevision) -> FlowyResult<()> {
for row_rev in row_revs { for row_rev in row_revs {
if let Some(cell_rev) = row_rev.cells.get(&self.field_id) { let cell_rev = match row_rev.cells.get(&self.field_id) {
None => self.default_cell_rev(),
Some(cell_rev) => Some(cell_rev.clone()),
};
if let Some(cell_rev) = cell_rev {
let mut grouped_rows: Vec<GroupedRow> = vec![]; let mut grouped_rows: Vec<GroupedRow> = vec![];
let cell_bytes = decode_any_cell_data(cell_rev.data.clone(), field_rev); let cell_bytes = decode_any_cell_data(cell_rev.data, field_rev);
let cell_data = cell_bytes.parser::<P>()?; let cell_data = cell_bytes.parser::<P>()?;
for group in self.group_ctx.concrete_groups() { for group in self.group_ctx.concrete_groups() {
if self.can_group(&group.filter_content, &cell_data) { if self.can_group(&group.filter_content, &cell_data) {

View File

@ -1,4 +1,4 @@
use crate::entities::GroupChangesetPB; use crate::entities::{GroupChangesetPB, InsertedRowPB, RowPB};
use crate::services::field::{CheckboxCellData, CheckboxCellDataParser, CheckboxTypeOptionPB, CHECK, UNCHECK}; use crate::services::field::{CheckboxCellData, CheckboxCellDataParser, CheckboxTypeOptionPB, CHECK, UNCHECK};
use crate::services::group::action::GroupAction; use crate::services::group::action::GroupAction;
use crate::services::group::configuration::GroupContext; use crate::services::group::configuration::GroupContext;
@ -6,8 +6,11 @@ use crate::services::group::controller::{
GenericGroupController, GroupController, GroupGenerator, MoveGroupRowContext, GenericGroupController, GroupController, GroupGenerator, MoveGroupRowContext,
}; };
use crate::services::group::GeneratedGroup; use crate::services::cell::insert_checkbox_cell;
use flowy_grid_data_model::revision::{CheckboxGroupConfigurationRevision, FieldRevision, GroupRevision, RowRevision}; use crate::services::group::{move_group_row, GeneratedGroup};
use flowy_grid_data_model::revision::{
CellRevision, CheckboxGroupConfigurationRevision, FieldRevision, GroupRevision, RowRevision,
};
pub type CheckboxGroupController = GenericGroupController< pub type CheckboxGroupController = GenericGroupController<
CheckboxGroupConfigurationRevision, CheckboxGroupConfigurationRevision,
@ -20,30 +23,83 @@ pub type CheckboxGroupContext = GroupContext<CheckboxGroupConfigurationRevision>
impl GroupAction for CheckboxGroupController { impl GroupAction for CheckboxGroupController {
type CellDataType = CheckboxCellData; type CellDataType = CheckboxCellData;
fn can_group(&self, _content: &str, _cell_data: &Self::CellDataType) -> bool { fn default_cell_rev(&self) -> Option<CellRevision> {
false Some(CellRevision::new(UNCHECK.to_string()))
} }
fn add_row_if_match(&mut self, _row_rev: &RowRevision, _cell_data: &Self::CellDataType) -> Vec<GroupChangesetPB> { fn can_group(&self, content: &str, cell_data: &Self::CellDataType) -> bool {
todo!() if cell_data.is_check() {
content == CHECK
} else {
content == UNCHECK
}
} }
fn remove_row_if_match( fn add_row_if_match(&mut self, row_rev: &RowRevision, cell_data: &Self::CellDataType) -> Vec<GroupChangesetPB> {
&mut self, let mut changesets = vec![];
_row_rev: &RowRevision, self.group_ctx.iter_mut_groups(|group| {
_cell_data: &Self::CellDataType, let mut changeset = GroupChangesetPB::new(group.id.clone());
) -> Vec<GroupChangesetPB> { let is_contained = group.contains_row(&row_rev.id);
todo!() if group.id == CHECK && cell_data.is_check() {
if !is_contained {
let row_pb = RowPB::from(row_rev);
changeset.inserted_rows.push(InsertedRowPB::new(row_pb.clone()));
group.add_row(row_pb);
}
} else if is_contained {
changeset.deleted_rows.push(row_rev.id.clone());
group.remove_row(&row_rev.id);
}
if !changeset.is_empty() {
changesets.push(changeset);
}
});
changesets
} }
fn move_row(&mut self, _cell_data: &Self::CellDataType, _context: MoveGroupRowContext) -> Vec<GroupChangesetPB> { fn remove_row_if_match(&mut self, row_rev: &RowRevision, _cell_data: &Self::CellDataType) -> Vec<GroupChangesetPB> {
todo!() let mut changesets = vec![];
self.group_ctx.iter_mut_groups(|group| {
let mut changeset = GroupChangesetPB::new(group.id.clone());
if group.contains_row(&row_rev.id) {
changeset.deleted_rows.push(row_rev.id.clone());
group.remove_row(&row_rev.id);
}
if !changeset.is_empty() {
changesets.push(changeset);
}
});
changesets
}
fn move_row(&mut self, _cell_data: &Self::CellDataType, mut context: MoveGroupRowContext) -> Vec<GroupChangesetPB> {
let mut group_changeset = vec![];
self.group_ctx.iter_mut_groups(|group| {
if let Some(changeset) = move_group_row(group, &mut context) {
group_changeset.push(changeset);
}
});
group_changeset
} }
} }
impl GroupController for CheckboxGroupController { impl GroupController for CheckboxGroupController {
fn will_create_row(&mut self, _row_rev: &mut RowRevision, _field_rev: &FieldRevision, _group_id: &str) { fn will_create_row(&mut self, row_rev: &mut RowRevision, field_rev: &FieldRevision, group_id: &str) {
todo!() match self.group_ctx.get_group(group_id) {
None => tracing::warn!("Can not find the group: {}", group_id),
Some((_, group)) => {
let is_check = group.id == CHECK;
let cell_rev = insert_checkbox_cell(is_check, field_rev);
row_rev.cells.insert(field_rev.id.clone(), cell_rev);
}
}
}
fn did_create_row(&mut self, row_pb: &RowPB, group_id: &str) {
if let Some(group) = self.group_ctx.get_mut_group(group_id) {
group.add_row(row_pb.clone())
}
} }
} }
@ -58,13 +114,13 @@ impl GroupGenerator for CheckboxGroupGenerator {
_type_option: &Option<Self::TypeOptionType>, _type_option: &Option<Self::TypeOptionType>,
) -> Vec<GeneratedGroup> { ) -> Vec<GeneratedGroup> {
let check_group = GeneratedGroup { let check_group = GeneratedGroup {
group_rev: GroupRevision::new("true".to_string(), CHECK.to_string()), group_rev: GroupRevision::new(CHECK.to_string(), "".to_string()),
filter_content: "".to_string(), filter_content: CHECK.to_string(),
}; };
let uncheck_group = GeneratedGroup { let uncheck_group = GeneratedGroup {
group_rev: GroupRevision::new("false".to_string(), UNCHECK.to_string()), group_rev: GroupRevision::new(UNCHECK.to_string(), "".to_string()),
filter_content: "".to_string(), filter_content: UNCHECK.to_string(),
}; };
vec![check_group, uncheck_group] vec![check_group, uncheck_group]
} }

View File

@ -77,4 +77,6 @@ impl GroupControllerSharedOperation for DefaultGroupController {
impl GroupController for DefaultGroupController { impl GroupController for DefaultGroupController {
fn will_create_row(&mut self, _row_rev: &mut RowRevision, _field_rev: &FieldRevision, _group_id: &str) {} fn will_create_row(&mut self, _row_rev: &mut RowRevision, _field_rev: &FieldRevision, _group_id: &str) {}
fn did_create_row(&mut self, _row_rev: &RowPB, _group_id: &str) {}
} }

View File

@ -1,4 +1,4 @@
use crate::entities::GroupChangesetPB; use crate::entities::{GroupChangesetPB, RowPB};
use crate::services::cell::insert_select_option_cell; use crate::services::cell::insert_select_option_cell;
use crate::services::field::{MultiSelectTypeOptionPB, SelectOptionCellDataPB, SelectOptionCellDataParser}; use crate::services::field::{MultiSelectTypeOptionPB, SelectOptionCellDataPB, SelectOptionCellDataParser};
use crate::services::group::action::GroupAction; use crate::services::group::action::GroupAction;
@ -46,10 +46,10 @@ impl GroupAction for MultiSelectGroupController {
changesets changesets
} }
fn move_row(&mut self, cell_data: &Self::CellDataType, mut context: MoveGroupRowContext) -> Vec<GroupChangesetPB> { fn move_row(&mut self, _cell_data: &Self::CellDataType, mut context: MoveGroupRowContext) -> Vec<GroupChangesetPB> {
let mut group_changeset = vec![]; let mut group_changeset = vec![];
self.group_ctx.iter_mut_groups(|group| { self.group_ctx.iter_mut_groups(|group| {
if let Some(changeset) = move_select_option_row(group, cell_data, &mut context) { if let Some(changeset) = move_group_row(group, &mut context) {
group_changeset.push(changeset); group_changeset.push(changeset);
} }
}); });
@ -67,6 +67,12 @@ impl GroupController for MultiSelectGroupController {
} }
} }
} }
fn did_create_row(&mut self, row_pb: &RowPB, group_id: &str) {
if let Some(group) = self.group_ctx.get_mut_group(group_id) {
group.add_row(row_pb.clone())
}
}
} }
pub struct MultiSelectGroupGenerator(); pub struct MultiSelectGroupGenerator();

View File

@ -46,10 +46,10 @@ impl GroupAction for SingleSelectGroupController {
changesets changesets
} }
fn move_row(&mut self, cell_data: &Self::CellDataType, mut context: MoveGroupRowContext) -> Vec<GroupChangesetPB> { fn move_row(&mut self, _cell_data: &Self::CellDataType, mut context: MoveGroupRowContext) -> Vec<GroupChangesetPB> {
let mut group_changeset = vec![]; let mut group_changeset = vec![];
self.group_ctx.iter_mut_groups(|group| { self.group_ctx.iter_mut_groups(|group| {
if let Some(changeset) = move_select_option_row(group, cell_data, &mut context) { if let Some(changeset) = move_group_row(group, &mut context) {
group_changeset.push(changeset); group_changeset.push(changeset);
} }
}); });
@ -65,10 +65,14 @@ impl GroupController for SingleSelectGroupController {
Some(group) => { Some(group) => {
let cell_rev = insert_select_option_cell(group.id.clone(), field_rev); let cell_rev = insert_select_option_cell(group.id.clone(), field_rev);
row_rev.cells.insert(field_rev.id.clone(), cell_rev); row_rev.cells.insert(field_rev.id.clone(), cell_rev);
group.add_row(RowPB::from(row_rev));
} }
} }
} }
fn did_create_row(&mut self, row_pb: &RowPB, group_id: &str) {
if let Some(group) = self.group_ctx.get_mut_group(group_id) {
group.add_row(row_pb.clone())
}
}
} }
pub struct SingleSelectGroupGenerator(); pub struct SingleSelectGroupGenerator();

View File

@ -62,11 +62,7 @@ pub fn remove_select_option_row(
} }
} }
pub fn move_select_option_row( pub fn move_group_row(group: &mut Group, context: &mut MoveGroupRowContext) -> Option<GroupChangesetPB> {
group: &mut Group,
_cell_data: &SelectOptionCellDataPB,
context: &mut MoveGroupRowContext,
) -> Option<GroupChangesetPB> {
let mut changeset = GroupChangesetPB::new(group.id.clone()); let mut changeset = GroupChangesetPB::new(group.id.clone());
let MoveGroupRowContext { let MoveGroupRowContext {
row_rev, row_rev,

View File

@ -0,0 +1,387 @@
use crate::core::{Delta, DeltaIterator};
use crate::rich_text::{is_block, RichTextAttributeKey, RichTextAttributeValue, RichTextAttributes};
use std::collections::HashMap;
const LINEFEEDASCIICODE: i32 = 0x0A;
#[cfg(test)]
mod tests {
use crate::codec::markdown::markdown_encoder::markdown_encoder;
use crate::rich_text::RichTextDelta;
#[test]
fn markdown_encoder_header_1_test() {
let json = r#"[{"insert":"header 1"},{"insert":"\n","attributes":{"header":1}}]"#;
let delta = RichTextDelta::from_json(json).unwrap();
let md = markdown_encoder(&delta);
assert_eq!(md, "# header 1\n");
}
#[test]
fn markdown_encoder_header_2_test() {
let json = r#"[{"insert":"header 2"},{"insert":"\n","attributes":{"header":2}}]"#;
let delta = RichTextDelta::from_json(json).unwrap();
let md = markdown_encoder(&delta);
assert_eq!(md, "## header 2\n");
}
#[test]
fn markdown_encoder_header_3_test() {
let json = r#"[{"insert":"header 3"},{"insert":"\n","attributes":{"header":3}}]"#;
let delta = RichTextDelta::from_json(json).unwrap();
let md = markdown_encoder(&delta);
assert_eq!(md, "### header 3\n");
}
#[test]
fn markdown_encoder_bold_italics_underlined_test() {
let json = r#"[{"insert":"bold","attributes":{"bold":true}},{"insert":" "},{"insert":"italics","attributes":{"italic":true}},{"insert":" "},{"insert":"underlined","attributes":{"underline":true}},{"insert":" "},{"insert":"\n","attributes":{"header":3}}]"#;
let delta = RichTextDelta::from_json(json).unwrap();
let md = markdown_encoder(&delta);
assert_eq!(md, "### **bold** _italics_ <u>underlined</u> \n");
}
#[test]
fn markdown_encoder_strikethrough_highlight_test() {
let json = r##"[{"insert":"strikethrough","attributes":{"strike":true}},{"insert":" "},{"insert":"highlighted","attributes":{"background":"#ffefe3"}},{"insert":"\n"}]"##;
let delta = RichTextDelta::from_json(json).unwrap();
let md = markdown_encoder(&delta);
assert_eq!(md, "~~strikethrough~~ <mark>highlighted</mark>\n");
}
#[test]
fn markdown_encoder_numbered_list_test() {
let json = r#"[{"insert":"numbered list\nitem 1"},{"insert":"\n","attributes":{"list":"ordered"}},{"insert":"item 2"},{"insert":"\n","attributes":{"list":"ordered"}},{"insert":"item3"},{"insert":"\n","attributes":{"list":"ordered"}}]"#;
let delta = RichTextDelta::from_json(json).unwrap();
let md = markdown_encoder(&delta);
assert_eq!(md, "numbered list\n\n1. item 1\n1. item 2\n1. item3\n");
}
#[test]
fn markdown_encoder_bullet_list_test() {
let json = r#"[{"insert":"bullet list\nitem1"},{"insert":"\n","attributes":{"list":"bullet"}}]"#;
let delta = RichTextDelta::from_json(json).unwrap();
let md = markdown_encoder(&delta);
assert_eq!(md, "bullet list\n\n* item1\n");
}
#[test]
fn markdown_encoder_check_list_test() {
let json = r#"[{"insert":"check list\nchecked"},{"insert":"\n","attributes":{"list":"checked"}},{"insert":"unchecked"},{"insert":"\n","attributes":{"list":"unchecked"}}]"#;
let delta = RichTextDelta::from_json(json).unwrap();
let md = markdown_encoder(&delta);
assert_eq!(md, "check list\n\n- [x] checked\n\n- [ ] unchecked\n");
}
#[test]
fn markdown_encoder_code_test() {
let json = r#"[{"insert":"code this "},{"insert":"print(\"hello world\")","attributes":{"code":true}},{"insert":"\n"}]"#;
let delta = RichTextDelta::from_json(json).unwrap();
let md = markdown_encoder(&delta);
assert_eq!(md, "code this `print(\"hello world\")`\n");
}
#[test]
fn markdown_encoder_quote_block_test() {
let json = r#"[{"insert":"this is a quote block"},{"insert":"\n","attributes":{"blockquote":true}}]"#;
let delta = RichTextDelta::from_json(json).unwrap();
let md = markdown_encoder(&delta);
assert_eq!(md, "> this is a quote block\n");
}
#[test]
fn markdown_encoder_link_test() {
let json = r#"[{"insert":"appflowy","attributes":{"link":"https://www.appflowy.io/"}},{"insert":"\n"}]"#;
let delta = RichTextDelta::from_json(json).unwrap();
let md = markdown_encoder(&delta);
assert_eq!(md, "[appflowy](https://www.appflowy.io/)\n");
}
}
struct Attribute {
key: RichTextAttributeKey,
value: RichTextAttributeValue,
}
pub fn markdown_encoder(delta: &Delta<RichTextAttributes>) -> String {
let mut markdown_buffer = String::new();
let mut line_buffer = String::new();
let mut current_inline_style = RichTextAttributes::default();
let mut current_block_lines: Vec<String> = Vec::new();
let mut iterator = DeltaIterator::new(delta);
let mut current_block_style: Option<Attribute> = None;
while iterator.has_next() {
let operation = iterator.next().unwrap();
let operation_data = operation.get_data();
if !operation_data.contains("\n") {
handle_inline(
&mut current_inline_style,
&mut line_buffer,
String::from(operation_data),
operation.get_attributes(),
)
} else {
handle_line(
&mut line_buffer,
&mut markdown_buffer,
String::from(operation_data),
operation.get_attributes(),
&mut current_block_style,
&mut current_block_lines,
&mut current_inline_style,
)
}
}
handle_block(&mut current_block_style, &mut current_block_lines, &mut markdown_buffer);
markdown_buffer
}
fn handle_inline(
current_inline_style: &mut RichTextAttributes,
buffer: &mut String,
mut text: String,
attributes: RichTextAttributes,
) {
let mut marked_for_removal: HashMap<RichTextAttributeKey, RichTextAttributeValue> = HashMap::new();
for key in current_inline_style
.clone()
.keys()
.collect::<Vec<&RichTextAttributeKey>>()
.into_iter()
.rev()
{
if is_block(key) {
continue;
}
if attributes.contains_key(key) {
continue;
}
let padding = trim_right(buffer);
write_attribute(buffer, key, current_inline_style.get(key).unwrap(), true);
if !padding.is_empty() {
buffer.push_str(&padding)
}
marked_for_removal.insert(key.clone(), current_inline_style.get(key).unwrap().clone());
}
for (marked_for_removal_key, marked_for_removal_value) in &marked_for_removal {
current_inline_style.retain(|inline_style_key, inline_style_value| {
inline_style_key != marked_for_removal_key && inline_style_value != marked_for_removal_value
})
}
for (key, value) in attributes.iter() {
if is_block(key) {
continue;
}
if current_inline_style.contains_key(key) {
continue;
}
let original_text = text.clone();
text = text.trim_start().to_string();
let padding = " ".repeat(original_text.len() - text.len());
if !padding.is_empty() {
buffer.push_str(&padding)
}
write_attribute(buffer, key, value, false)
}
buffer.push_str(&text);
*current_inline_style = attributes;
}
fn trim_right(buffer: &mut String) -> String {
let text = buffer.clone();
if !text.ends_with(" ") {
return String::from("");
}
let result = text.trim_end();
buffer.clear();
buffer.push_str(result);
" ".repeat(text.len() - result.len())
}
fn write_attribute(buffer: &mut String, key: &RichTextAttributeKey, value: &RichTextAttributeValue, close: bool) {
match key {
RichTextAttributeKey::Bold => buffer.push_str("**"),
RichTextAttributeKey::Italic => buffer.push_str("_"),
RichTextAttributeKey::Underline => {
if close {
buffer.push_str("</u>")
} else {
buffer.push_str("<u>")
}
}
RichTextAttributeKey::StrikeThrough => {
if close {
buffer.push_str("~~")
} else {
buffer.push_str("~~")
}
}
RichTextAttributeKey::Link => {
if close {
buffer.push_str(format!("]({})", value.0.as_ref().unwrap()).as_str())
} else {
buffer.push_str("[")
}
}
RichTextAttributeKey::Background => {
if close {
buffer.push_str("</mark>")
} else {
buffer.push_str("<mark>")
}
}
RichTextAttributeKey::CodeBlock => {
if close {
buffer.push_str("\n```")
} else {
buffer.push_str("```\n")
}
}
RichTextAttributeKey::InlineCode => {
if close {
buffer.push_str("`")
} else {
buffer.push_str("`")
}
}
_ => {}
}
}
fn handle_line(
buffer: &mut String,
markdown_buffer: &mut String,
data: String,
attributes: RichTextAttributes,
current_block_style: &mut Option<Attribute>,
current_block_lines: &mut Vec<String>,
current_inline_style: &mut RichTextAttributes,
) {
let mut span = String::new();
for c in data.chars() {
if (c as i32) == LINEFEEDASCIICODE {
if !span.is_empty() {
handle_inline(current_inline_style, buffer, span.clone(), attributes.clone());
}
handle_inline(
current_inline_style,
buffer,
String::from(""),
RichTextAttributes::default(),
);
let line_block_key = attributes.keys().find(|key| {
if is_block(*key) {
return true;
} else {
return false;
}
});
match (line_block_key, &current_block_style) {
(Some(line_block_key), Some(current_block_style))
if *line_block_key == current_block_style.key
&& *attributes.get(line_block_key).unwrap() == current_block_style.value =>
{
current_block_lines.push(buffer.clone());
}
(None, None) => {
current_block_lines.push(buffer.clone());
}
_ => {
handle_block(current_block_style, current_block_lines, markdown_buffer);
current_block_lines.clear();
current_block_lines.push(buffer.clone());
match line_block_key {
None => *current_block_style = None,
Some(line_block_key) => {
*current_block_style = Some(Attribute {
key: line_block_key.clone(),
value: attributes.get(line_block_key).unwrap().clone(),
})
}
}
}
}
buffer.clear();
span.clear();
} else {
span.push(c);
}
}
if !span.is_empty() {
handle_inline(current_inline_style, buffer, span.clone(), attributes)
}
}
fn handle_block(
block_style: &mut Option<Attribute>,
current_block_lines: &mut Vec<String>,
markdown_buffer: &mut String,
) {
if current_block_lines.is_empty() {
return;
}
if !markdown_buffer.is_empty() {
markdown_buffer.push('\n')
}
match block_style {
None => {
markdown_buffer.push_str(&current_block_lines.join("\n"));
markdown_buffer.push('\n');
}
Some(block_style) if block_style.key == RichTextAttributeKey::CodeBlock => {
write_attribute(markdown_buffer, &block_style.key, &block_style.value, false);
markdown_buffer.push_str(&current_block_lines.join("\n"));
write_attribute(markdown_buffer, &block_style.key, &block_style.value, true);
markdown_buffer.push('\n');
}
Some(block_style) => {
for line in current_block_lines {
write_block_tag(markdown_buffer, &block_style, false);
markdown_buffer.push_str(line);
markdown_buffer.push('\n');
}
}
}
}
fn write_block_tag(buffer: &mut String, block: &Attribute, close: bool) {
if close {
return;
}
if block.key == RichTextAttributeKey::BlockQuote {
buffer.push_str("> ");
} else if block.key == RichTextAttributeKey::List {
if block.value.0.as_ref().unwrap().eq("bullet") {
buffer.push_str("* ");
} else if block.value.0.as_ref().unwrap().eq("checked") {
buffer.push_str("- [x] ");
} else if block.value.0.as_ref().unwrap().eq("unchecked") {
buffer.push_str("- [ ] ");
} else if block.value.0.as_ref().unwrap().eq("ordered") {
buffer.push_str("1. ");
} else {
buffer.push_str("* ");
}
} else if block.key == RichTextAttributeKey::Header {
if block.value.0.as_ref().unwrap().eq("1") {
buffer.push_str("# ");
} else if block.value.0.as_ref().unwrap().eq("2") {
buffer.push_str("## ");
} else if block.value.0.as_ref().unwrap().eq("3") {
buffer.push_str("### ");
} else if block.key == RichTextAttributeKey::List {
}
}
}

View File

@ -1 +1 @@
pub mod markdown_encoder;

View File

@ -361,6 +361,10 @@ pub fn is_block_except_header(k: &RichTextAttributeKey) -> bool {
BLOCK_KEYS.contains(k) BLOCK_KEYS.contains(k)
} }
pub fn is_block(k: &RichTextAttributeKey) -> bool {
BLOCK_KEYS.contains(k)
}
lazy_static! { lazy_static! {
static ref BLOCK_KEYS: HashSet<RichTextAttributeKey> = HashSet::from_iter(vec![ static ref BLOCK_KEYS: HashSet<RichTextAttributeKey> = HashSet::from_iter(vec![
RichTextAttributeKey::Header, RichTextAttributeKey::Header,