mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Merge branch 'main' into feat/flowy-overlay
This commit is contained in:
commit
f54f90b647
@ -1,4 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
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/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:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'board_data_controller.dart';
|
||||
import 'group_controller.dart';
|
||||
@ -164,12 +165,17 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
|
||||
boardController.clear();
|
||||
|
||||
//
|
||||
List<AFBoardColumnData> columns = groups.map((group) {
|
||||
List<AFBoardColumnData> columns = groups
|
||||
.where((group) => fieldController.getField(group.fieldId) != null)
|
||||
.map((group) {
|
||||
return AFBoardColumnData(
|
||||
id: group.groupId,
|
||||
name: group.desc,
|
||||
items: _buildRows(group),
|
||||
customData: group,
|
||||
customData: BoardCustomData(
|
||||
group: group,
|
||||
fieldContext: fieldController.getField(group.fieldId)!,
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
boardController.addColumns(columns);
|
||||
@ -177,6 +183,7 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
|
||||
for (final group in groups) {
|
||||
final delegate = GroupControllerDelegateImpl(
|
||||
controller: boardController,
|
||||
fieldController: fieldController,
|
||||
onNewColumnItem: (groupId, row, index) {
|
||||
add(BoardEvent.didCreateRow(groupId, row, index));
|
||||
},
|
||||
@ -238,10 +245,8 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
|
||||
|
||||
List<AFColumnItem> _buildRows(GroupPB group) {
|
||||
final items = group.rows.map((row) {
|
||||
return BoardColumnItem(
|
||||
row: row,
|
||||
fieldId: group.fieldId,
|
||||
);
|
||||
final fieldContext = fieldController.getField(group.fieldId);
|
||||
return BoardColumnItem(row: row, fieldContext: fieldContext!);
|
||||
}).toList();
|
||||
|
||||
return <AFColumnItem>[...items];
|
||||
@ -332,15 +337,11 @@ class GridFieldEquatable extends Equatable {
|
||||
|
||||
class BoardColumnItem extends AFColumnItem {
|
||||
final RowPB row;
|
||||
|
||||
final String fieldId;
|
||||
|
||||
final bool requestFocus;
|
||||
final GridFieldContext fieldContext;
|
||||
|
||||
BoardColumnItem({
|
||||
required this.row,
|
||||
required this.fieldId,
|
||||
this.requestFocus = false,
|
||||
required this.fieldContext,
|
||||
});
|
||||
|
||||
@override
|
||||
@ -348,24 +349,29 @@ class BoardColumnItem extends AFColumnItem {
|
||||
}
|
||||
|
||||
class GroupControllerDelegateImpl extends GroupControllerDelegate {
|
||||
final GridFieldController fieldController;
|
||||
final AFBoardDataController controller;
|
||||
final void Function(String, RowPB, int?) onNewColumnItem;
|
||||
|
||||
GroupControllerDelegateImpl({
|
||||
required this.controller,
|
||||
required this.fieldController,
|
||||
required this.onNewColumnItem,
|
||||
});
|
||||
|
||||
@override
|
||||
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) {
|
||||
final item = BoardColumnItem(row: row, fieldId: group.fieldId);
|
||||
final item = BoardColumnItem(row: row, fieldContext: fieldContext);
|
||||
controller.insertColumnItem(group.groupId, index, item);
|
||||
} else {
|
||||
final item = BoardColumnItem(
|
||||
row: row,
|
||||
fieldId: group.fieldId,
|
||||
);
|
||||
final item = BoardColumnItem(row: row, fieldContext: fieldContext);
|
||||
controller.addColumnItem(group.groupId, item);
|
||||
}
|
||||
}
|
||||
@ -377,22 +383,25 @@ class GroupControllerDelegateImpl extends GroupControllerDelegate {
|
||||
|
||||
@override
|
||||
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(
|
||||
group.groupId,
|
||||
BoardColumnItem(
|
||||
row: row,
|
||||
fieldId: group.fieldId,
|
||||
),
|
||||
BoardColumnItem(row: row, fieldContext: fieldContext),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void addNewRow(GroupPB group, RowPB row, int? index) {
|
||||
final item = BoardColumnItem(
|
||||
row: row,
|
||||
fieldId: group.fieldId,
|
||||
requestFocus: true,
|
||||
);
|
||||
final fieldContext = fieldController.getField(group.fieldId);
|
||||
if (fieldContext == null) {
|
||||
Log.warn("FieldContext should not be null");
|
||||
return;
|
||||
}
|
||||
final item = BoardColumnItem(row: row, fieldContext: fieldContext);
|
||||
|
||||
if (index != null) {
|
||||
controller.insertColumnItem(group.groupId, index, item);
|
||||
@ -414,3 +423,29 @@ class BoardEditingRow {
|
||||
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";
|
||||
}
|
||||
|
@ -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_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/group.pbserver.dart';
|
||||
import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../grid/application/row/row_cache.dart';
|
||||
@ -37,8 +37,7 @@ class BoardPage extends StatelessWidget {
|
||||
create: (context) =>
|
||||
BoardBloc(view: view)..add(const BoardEvent.initial()),
|
||||
child: BlocBuilder<BoardBloc, BoardState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.loadingState != current.loadingState,
|
||||
buildWhen: (p, c) => p.loadingState != c.loadingState,
|
||||
builder: (context, state) {
|
||||
return state.loadingState.map(
|
||||
loading: (_) =>
|
||||
@ -85,40 +84,40 @@ class _BoardContentState extends State<BoardContent> {
|
||||
child: BlocBuilder<BoardBloc, BoardState>(
|
||||
buildWhen: (previous, current) => previous.groupIds != current.groupIds,
|
||||
builder: (context, state) {
|
||||
final theme = context.read<AppTheme>();
|
||||
final column = Column(
|
||||
children: [const _ToolbarBlocAdaptor(), _buildBoard(context)],
|
||||
);
|
||||
|
||||
return Container(
|
||||
color: theme.surface,
|
||||
color: context.read<AppTheme>().surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
const _ToolbarBlocAdaptor(),
|
||||
Expanded(
|
||||
child: column,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Expanded _buildBoard(BuildContext context) {
|
||||
return Expanded(
|
||||
child: AFBoard(
|
||||
key: UniqueKey(),
|
||||
scrollManager: scrollManager,
|
||||
scrollController: scrollController,
|
||||
dataController: context.read<BoardBloc>().boardController,
|
||||
headerBuilder: _buildHeader,
|
||||
footBuilder: _buildFooter,
|
||||
footerBuilder: _buildFooter,
|
||||
cardBuilder: (_, column, columnItem) => _buildCard(
|
||||
context,
|
||||
column,
|
||||
columnItem,
|
||||
),
|
||||
columnConstraints:
|
||||
const BoxConstraints.tightFor(width: 300),
|
||||
columnConstraints: const BoxConstraints.tightFor(width: 300),
|
||||
config: AFBoardConfig(
|
||||
columnBackgroundColor: HexColor.fromHex('#F7F8FC'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -153,6 +152,7 @@ class _BoardContentState extends State<BoardContent> {
|
||||
BuildContext context,
|
||||
AFBoardColumnData columnData,
|
||||
) {
|
||||
final boardCustomData = columnData.customData as BoardCustomData;
|
||||
return AppFlowyColumnHeader(
|
||||
title: Flexible(
|
||||
fit: FlexFit.tight,
|
||||
@ -163,6 +163,7 @@ class _BoardContentState extends State<BoardContent> {
|
||||
color: context.read<AppTheme>().textColor,
|
||||
),
|
||||
),
|
||||
icon: _buildHeaderIcon(boardCustomData),
|
||||
addIcon: SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
@ -182,7 +183,9 @@ class _BoardContentState extends State<BoardContent> {
|
||||
}
|
||||
|
||||
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) {
|
||||
return const SizedBox();
|
||||
} else {
|
||||
@ -247,7 +250,7 @@ class _BoardContentState extends State<BoardContent> {
|
||||
child: BoardCard(
|
||||
gridId: gridId,
|
||||
groupId: column.id,
|
||||
fieldId: boardColumnItem.fieldId,
|
||||
fieldId: boardColumnItem.fieldContext.id,
|
||||
isEditing: isEditing,
|
||||
cellBuilder: cellBuilder,
|
||||
dataController: cardController,
|
||||
@ -325,3 +328,38 @@ extension HexColor on Color {
|
||||
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;
|
||||
}
|
||||
|
@ -14,33 +14,37 @@ class EditableCellNotifier {
|
||||
}
|
||||
|
||||
class EditableRowNotifier {
|
||||
Map<EditableCellId, EditableCellNotifier> cells = {};
|
||||
final Map<EditableCellId, EditableCellNotifier> _cells = {};
|
||||
|
||||
void insertCell(
|
||||
GridCellIdentifier cellIdentifier,
|
||||
EditableCellNotifier notifier,
|
||||
) {
|
||||
cells[EditableCellId.from(cellIdentifier)] = notifier;
|
||||
_cells[EditableCellId.from(cellIdentifier)] = notifier;
|
||||
}
|
||||
|
||||
void becomeFirstResponder() {
|
||||
for (final notifier in cells.values) {
|
||||
for (final notifier in _cells.values) {
|
||||
notifier.becomeFirstResponder.notify();
|
||||
}
|
||||
}
|
||||
|
||||
void resignFirstResponder() {
|
||||
for (final notifier in cells.values) {
|
||||
for (final notifier in _cells.values) {
|
||||
notifier.resignFirstResponder.notify();
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_cells.clear();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
for (final notifier in cells.values) {
|
||||
for (final notifier in _cells.values) {
|
||||
notifier.resignFirstResponder.notify();
|
||||
}
|
||||
|
||||
cells.clear();
|
||||
_cells.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'board_cell.dart';
|
||||
import 'define.dart';
|
||||
|
||||
|
@ -89,20 +89,20 @@ class _BoardCardState extends State<BoardCard> {
|
||||
List<GridCellIdentifier> cells,
|
||||
) {
|
||||
final List<Widget> children = [];
|
||||
rowNotifier.clear();
|
||||
cells.asMap().forEach(
|
||||
(int index, GridCellIdentifier cellId) {
|
||||
final cellNotifier = EditableCellNotifier();
|
||||
Widget child = widget.cellBuilder.buildCell(
|
||||
widget.groupId,
|
||||
cellId,
|
||||
widget.isEditing,
|
||||
index == 0 ? widget.isEditing : false,
|
||||
cellNotifier,
|
||||
);
|
||||
|
||||
if (index == 0) {
|
||||
rowNotifier.insertCell(cellId, cellNotifier);
|
||||
}
|
||||
|
||||
child = Padding(
|
||||
key: cellId.key(),
|
||||
padding: const EdgeInsets.only(left: 4, right: 4),
|
||||
|
@ -7,6 +7,7 @@ import 'package:dartz/dartz.dart';
|
||||
import 'package:flowy_sdk/log.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/group.pb.dart';
|
||||
import 'package:flowy_sdk/protobuf/flowy-grid/setting_entities.pb.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../row/row_cache.dart';
|
||||
@ -35,12 +36,12 @@ class GridFieldController {
|
||||
final SettingListener _settingListener;
|
||||
final Map<OnReceiveFields, VoidCallback> _fieldCallbackMap = {};
|
||||
final Map<OnChangeset, OnChangeset> _changesetCallbackMap = {};
|
||||
|
||||
_GridFieldNotifier? _fieldNotifier = _GridFieldNotifier();
|
||||
List<String> _groupFieldIds = [];
|
||||
final GridFFIService _gridFFIService;
|
||||
final SettingFFIService _settingFFIService;
|
||||
|
||||
_GridFieldNotifier? _fieldNotifier = _GridFieldNotifier();
|
||||
final Map<String, GridGroupConfigurationPB> _configurationByFieldId = {};
|
||||
|
||||
List<GridFieldContext> get fieldContexts =>
|
||||
[..._fieldNotifier?.fieldContexts ?? []];
|
||||
|
||||
@ -67,31 +68,43 @@ class GridFieldController {
|
||||
//Listen on setting changes
|
||||
_settingListener.start(onSettingUpdated: (result) {
|
||||
result.fold(
|
||||
(setting) => _updateFieldsWhenSettingChanged(setting),
|
||||
(setting) => _updateGroupConfiguration(setting),
|
||||
(r) => Log.error(r),
|
||||
);
|
||||
});
|
||||
|
||||
_settingFFIService.getSetting().then((result) {
|
||||
result.fold(
|
||||
(setting) => _updateFieldsWhenSettingChanged(setting),
|
||||
(setting) => _updateGroupConfiguration(setting),
|
||||
(err) => Log.error(err),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _updateFieldsWhenSettingChanged(GridSettingPB setting) {
|
||||
_groupFieldIds = setting.groupConfigurations.items
|
||||
.map((item) => item.groupFieldId)
|
||||
GridFieldContext? getField(String fieldId) {
|
||||
final fields = _fieldNotifier?.fieldContexts
|
||||
.where(
|
||||
(element) => element.id == fieldId,
|
||||
)
|
||||
.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();
|
||||
}
|
||||
|
||||
void _updateFieldContexts() {
|
||||
if (_fieldNotifier != null) {
|
||||
for (var field in _fieldNotifier!.fieldContexts) {
|
||||
if (_groupFieldIds.contains(field.id)) {
|
||||
if (_configurationByFieldId[field.id] != null) {
|
||||
field._isGroupField = true;
|
||||
} else {
|
||||
field._isGroupField = false;
|
||||
@ -277,5 +290,26 @@ class GridFieldContext {
|
||||
|
||||
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;
|
||||
}
|
||||
|
@ -31,10 +31,15 @@ class GridGroupList extends StatelessWidget {
|
||||
child: BlocBuilder<GridGroupBloc, GridGroupState>(
|
||||
builder: (context, state) {
|
||||
final cells = state.fieldContexts.map((fieldContext) {
|
||||
return _GridGroupCell(
|
||||
Widget cell = _GridGroupCell(
|
||||
fieldContext: fieldContext,
|
||||
key: ValueKey(fieldContext.id),
|
||||
);
|
||||
|
||||
if (!fieldContext.canGroup) {
|
||||
cell = IgnorePointer(child: Opacity(opacity: 0.3, child: cell));
|
||||
}
|
||||
return cell;
|
||||
}).toList();
|
||||
|
||||
return ListView.separated(
|
||||
|
@ -1,87 +1,71 @@
|
||||
# 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.
|
||||
## Getting Started
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/ZCCYN4Anzq"><b>Discord</b></a> •
|
||||
<a href="https://twitter.com/appflowy"><b>Twitter</b></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<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 align="center">
|
||||
<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>
|
||||
|
||||
## 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
|
||||
@override
|
||||
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'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
flutter pub add appflowy_board
|
||||
```
|
||||
|
||||
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 |
@ -66,7 +66,7 @@ class _MultiBoardListExampleState extends State<MultiBoardListExample> {
|
||||
padding: const EdgeInsets.symmetric(vertical: 30, horizontal: 20),
|
||||
child: AFBoard(
|
||||
dataController: boardDataController,
|
||||
footBuilder: (context, columnData) {
|
||||
footerBuilder: (context, columnData) {
|
||||
return AppFlowyColumnFooter(
|
||||
icon: const Icon(Icons.add, size: 20),
|
||||
title: const Text('New'),
|
||||
|
@ -56,7 +56,7 @@ class AFBoard extends StatelessWidget {
|
||||
final AFBoardColumnHeaderBuilder? headerBuilder;
|
||||
|
||||
///
|
||||
final AFBoardColumnFooterBuilder? footBuilder;
|
||||
final AFBoardColumnFooterBuilder? footerBuilder;
|
||||
|
||||
///
|
||||
final AFBoardDataController dataController;
|
||||
@ -78,7 +78,7 @@ class AFBoard extends StatelessWidget {
|
||||
required this.dataController,
|
||||
required this.cardBuilder,
|
||||
this.background,
|
||||
this.footBuilder,
|
||||
this.footerBuilder,
|
||||
this.headerBuilder,
|
||||
this.scrollController,
|
||||
this.scrollManager,
|
||||
@ -112,7 +112,7 @@ class AFBoard extends StatelessWidget {
|
||||
delegate: phantomController,
|
||||
columnConstraints: columnConstraints,
|
||||
cardBuilder: cardBuilder,
|
||||
footBuilder: footBuilder,
|
||||
footBuilder: footerBuilder,
|
||||
headerBuilder: headerBuilder,
|
||||
phantomController: phantomController,
|
||||
onReorder: dataController.moveColumn,
|
||||
|
@ -31,7 +31,7 @@ typedef AFBoardColumnCardBuilder = Widget Function(
|
||||
|
||||
typedef AFBoardColumnHeaderBuilder = Widget? Function(
|
||||
BuildContext context,
|
||||
AFBoardColumnData headerData,
|
||||
AFBoardColumnData columnData,
|
||||
);
|
||||
|
||||
typedef AFBoardColumnFooterBuilder = Widget Function(
|
||||
|
@ -44,14 +44,14 @@ pub struct GridGroupConfigurationPB {
|
||||
pub id: String,
|
||||
|
||||
#[pb(index = 2)]
|
||||
pub group_field_id: String,
|
||||
pub field_id: String,
|
||||
}
|
||||
|
||||
impl std::convert::From<&GroupConfigurationRevision> for GridGroupConfigurationPB {
|
||||
fn from(rev: &GroupConfigurationRevision) -> Self {
|
||||
GridGroupConfigurationPB {
|
||||
id: rev.id.clone(),
|
||||
group_field_id: rev.field_id.clone(),
|
||||
field_id: rev.field_id.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -107,7 +107,7 @@ impl GridBlockManager {
|
||||
let editor = self.get_editor_from_row_id(&changeset.row_id).await?;
|
||||
let _ = editor.update_row(changeset.clone()).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) => {
|
||||
let row_pb = make_row_from_row_rev(row_rev.clone());
|
||||
let block_order_changeset = GridBlockChangesetPB::update(&editor.block_id, vec![row_pb]);
|
||||
|
@ -182,7 +182,6 @@ pub fn delete_select_option_cell(option_id: String, field_rev: &FieldRevision) -
|
||||
CellRevision::new(data)
|
||||
}
|
||||
|
||||
/// If the cell data is not String type, it should impl this trait.
|
||||
/// Deserialize the String into cell specific data type.
|
||||
pub trait FromCellString {
|
||||
fn from_cell_str(s: &str) -> FlowyResult<Self>
|
||||
|
@ -96,6 +96,8 @@ impl GridViewRevisionEditor {
|
||||
None => Some(0),
|
||||
Some(_) => None,
|
||||
};
|
||||
|
||||
self.group_controller.write().await.did_create_row(row_pb, group_id);
|
||||
let inserted_row = InsertedRowPB {
|
||||
row: row_pb.clone(),
|
||||
index,
|
||||
|
@ -1,13 +1,16 @@
|
||||
use crate::entities::GroupChangesetPB;
|
||||
|
||||
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 {
|
||||
type CellDataType;
|
||||
fn default_cell_rev(&self) -> Option<CellRevision> {
|
||||
None
|
||||
}
|
||||
|
||||
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 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>;
|
||||
}
|
||||
|
@ -7,7 +7,6 @@ use flowy_error::FlowyResult;
|
||||
use flowy_grid_data_model::revision::{
|
||||
FieldRevision, GroupConfigurationContentSerde, GroupRevision, RowChangeset, RowRevision, TypeOptionDataDeserializer,
|
||||
};
|
||||
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
@ -16,6 +15,7 @@ use std::sync::Arc;
|
||||
// a new row.
|
||||
pub trait GroupController: GroupControllerSharedOperation + Send + Sync {
|
||||
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 {
|
||||
@ -193,9 +193,14 @@ where
|
||||
#[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<()> {
|
||||
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 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>()?;
|
||||
for group in self.group_ctx.concrete_groups() {
|
||||
if self.can_group(&group.filter_content, &cell_data) {
|
||||
|
@ -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::group::action::GroupAction;
|
||||
use crate::services::group::configuration::GroupContext;
|
||||
@ -6,8 +6,11 @@ use crate::services::group::controller::{
|
||||
GenericGroupController, GroupController, GroupGenerator, MoveGroupRowContext,
|
||||
};
|
||||
|
||||
use crate::services::group::GeneratedGroup;
|
||||
use flowy_grid_data_model::revision::{CheckboxGroupConfigurationRevision, FieldRevision, GroupRevision, RowRevision};
|
||||
use crate::services::cell::insert_checkbox_cell;
|
||||
use crate::services::group::{move_group_row, GeneratedGroup};
|
||||
use flowy_grid_data_model::revision::{
|
||||
CellRevision, CheckboxGroupConfigurationRevision, FieldRevision, GroupRevision, RowRevision,
|
||||
};
|
||||
|
||||
pub type CheckboxGroupController = GenericGroupController<
|
||||
CheckboxGroupConfigurationRevision,
|
||||
@ -20,30 +23,83 @@ pub type CheckboxGroupContext = GroupContext<CheckboxGroupConfigurationRevision>
|
||||
|
||||
impl GroupAction for CheckboxGroupController {
|
||||
type CellDataType = CheckboxCellData;
|
||||
fn can_group(&self, _content: &str, _cell_data: &Self::CellDataType) -> bool {
|
||||
false
|
||||
fn default_cell_rev(&self) -> Option<CellRevision> {
|
||||
Some(CellRevision::new(UNCHECK.to_string()))
|
||||
}
|
||||
|
||||
fn add_row_if_match(&mut self, _row_rev: &RowRevision, _cell_data: &Self::CellDataType) -> Vec<GroupChangesetPB> {
|
||||
todo!()
|
||||
fn can_group(&self, content: &str, cell_data: &Self::CellDataType) -> bool {
|
||||
if cell_data.is_check() {
|
||||
content == CHECK
|
||||
} else {
|
||||
content == UNCHECK
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_row_if_match(
|
||||
&mut self,
|
||||
_row_rev: &RowRevision,
|
||||
_cell_data: &Self::CellDataType,
|
||||
) -> Vec<GroupChangesetPB> {
|
||||
todo!()
|
||||
fn add_row_if_match(&mut self, row_rev: &RowRevision, cell_data: &Self::CellDataType) -> Vec<GroupChangesetPB> {
|
||||
let mut changesets = vec![];
|
||||
self.group_ctx.iter_mut_groups(|group| {
|
||||
let mut changeset = GroupChangesetPB::new(group.id.clone());
|
||||
let is_contained = group.contains_row(&row_rev.id);
|
||||
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> {
|
||||
todo!()
|
||||
fn remove_row_if_match(&mut self, row_rev: &RowRevision, _cell_data: &Self::CellDataType) -> Vec<GroupChangesetPB> {
|
||||
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 {
|
||||
fn will_create_row(&mut self, _row_rev: &mut RowRevision, _field_rev: &FieldRevision, _group_id: &str) {
|
||||
todo!()
|
||||
fn will_create_row(&mut self, row_rev: &mut RowRevision, field_rev: &FieldRevision, group_id: &str) {
|
||||
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>,
|
||||
) -> Vec<GeneratedGroup> {
|
||||
let check_group = GeneratedGroup {
|
||||
group_rev: GroupRevision::new("true".to_string(), CHECK.to_string()),
|
||||
filter_content: "".to_string(),
|
||||
group_rev: GroupRevision::new(CHECK.to_string(), "".to_string()),
|
||||
filter_content: CHECK.to_string(),
|
||||
};
|
||||
|
||||
let uncheck_group = GeneratedGroup {
|
||||
group_rev: GroupRevision::new("false".to_string(), UNCHECK.to_string()),
|
||||
filter_content: "".to_string(),
|
||||
group_rev: GroupRevision::new(UNCHECK.to_string(), "".to_string()),
|
||||
filter_content: UNCHECK.to_string(),
|
||||
};
|
||||
vec![check_group, uncheck_group]
|
||||
}
|
||||
|
@ -77,4 +77,6 @@ impl GroupControllerSharedOperation for DefaultGroupController {
|
||||
|
||||
impl GroupController for DefaultGroupController {
|
||||
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) {}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
use crate::entities::GroupChangesetPB;
|
||||
use crate::entities::{GroupChangesetPB, RowPB};
|
||||
use crate::services::cell::insert_select_option_cell;
|
||||
use crate::services::field::{MultiSelectTypeOptionPB, SelectOptionCellDataPB, SelectOptionCellDataParser};
|
||||
use crate::services::group::action::GroupAction;
|
||||
@ -46,10 +46,10 @@ impl GroupAction for MultiSelectGroupController {
|
||||
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![];
|
||||
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);
|
||||
}
|
||||
});
|
||||
@ -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();
|
||||
|
@ -46,10 +46,10 @@ impl GroupAction for SingleSelectGroupController {
|
||||
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![];
|
||||
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);
|
||||
}
|
||||
});
|
||||
@ -65,10 +65,14 @@ impl GroupController for SingleSelectGroupController {
|
||||
Some(group) => {
|
||||
let cell_rev = insert_select_option_cell(group.id.clone(), field_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();
|
||||
|
@ -62,11 +62,7 @@ pub fn remove_select_option_row(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_select_option_row(
|
||||
group: &mut Group,
|
||||
_cell_data: &SelectOptionCellDataPB,
|
||||
context: &mut MoveGroupRowContext,
|
||||
) -> Option<GroupChangesetPB> {
|
||||
pub fn move_group_row(group: &mut Group, context: &mut MoveGroupRowContext) -> Option<GroupChangesetPB> {
|
||||
let mut changeset = GroupChangesetPB::new(group.id.clone());
|
||||
let MoveGroupRowContext {
|
||||
row_rev,
|
||||
|
387
shared-lib/lib-ot/src/codec/markdown/markdown_encoder.rs
Normal file
387
shared-lib/lib-ot/src/codec/markdown/markdown_encoder.rs
Normal 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, ¤t_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(¤t_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(¤t_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 {
|
||||
}
|
||||
}
|
||||
}
|
@ -1 +1 @@
|
||||
|
||||
pub mod markdown_encoder;
|
||||
|
@ -361,6 +361,10 @@ pub fn is_block_except_header(k: &RichTextAttributeKey) -> bool {
|
||||
BLOCK_KEYS.contains(k)
|
||||
}
|
||||
|
||||
pub fn is_block(k: &RichTextAttributeKey) -> bool {
|
||||
BLOCK_KEYS.contains(k)
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref BLOCK_KEYS: HashSet<RichTextAttributeKey> = HashSet::from_iter(vec![
|
||||
RichTextAttributeKey::Header,
|
||||
|
Loading…
Reference in New Issue
Block a user