Merge remote-tracking branch 'origin/main' into fix/click-selection-menu-item-delete-text

This commit is contained in:
Lucas.Xu 2022-09-25 16:28:06 +08:00
commit d1d5b37c14
102 changed files with 1672 additions and 896 deletions

View File

@ -181,7 +181,7 @@
"includeTime": " Include time",
"dateFormatFriendly": "Month Day,Year",
"dateFormatISO": "Year-Month-Day",
"dateFormatLocal": "Year/Month/Day",
"dateFormatLocal": "Month/Day/Year",
"dateFormatUS": "Year/Month/Day",
"timeFormat": " Time format",
"invalidTimeFormat": "Invalid format",

View File

@ -172,7 +172,7 @@
"includeTime": " Incluir tiempo",
"dateFormatFriendly": "Mes Día, Año",
"dateFormatISO": "Año-Mes-Día",
"dateFormatLocal": "Año/Mes/Día",
"dateFormatLocal": "Mes/Día/Año",
"dateFormatUS": "Año/Mes/Día",
"timeFormat": " Time format",
"invalidTimeFormat": "Formato de tiempo",

View File

@ -170,7 +170,7 @@
"includeTime": " Inclure l'heure",
"dateFormatFriendly": "Mois Jour, Année",
"dateFormatISO": "Année-Mois-Jour",
"dateFormatLocal": "Année/Mois/Jour",
"dateFormatLocal": "Mois/Jour/Année",
"dateFormatUS": "Année/Mois/Jour",
"timeFormat": " Format du temps",
"invalidTimeFormat": "Format invalide",

View File

@ -173,7 +173,7 @@
"includeTime": " Sertakan waktu",
"dateFormatFriendly": "Bulan Hari,Tahun",
"dateFormatISO": "Tahun-Bulan-Hari",
"dateFormatLocal": "Tahun/Bulan/Hari",
"dateFormatLocal": "Bulan/Hari/Tahun",
"dateFormatUS": "Tahun/Bulan/Hari",
"timeFormat": " Format waktu",
"invalidTimeFormat": "Format yang tidak valid",

View File

@ -165,7 +165,7 @@
"includeTime": " 時刻を含める",
"dateFormatFriendly": "月 日,年",
"dateFormatISO": "年-月-日",
"dateFormatLocal": "年/月/日",
"dateFormatLocal": "月/日/年",
"dateFormatUS": "年/月/日",
"timeFormat": " 時刻書式",
"timeFormatTwelveHour": "12 時間表記",

View File

@ -63,13 +63,13 @@
"deletePermanent": "Apagar permanentemente"
},
"dialogCreatePageNameHint": "Nome da página",
"questionBubble": {
"questionBubble": {
"whatsNew": "O que há de novo?",
"help": "Ajuda e Suporte",
"debug": {
"name": "Informação de depuração",
"success": "Informação de depuração copiada para a área de transferência!",
"fail": "Falha ao copiar a informação de depuração para a área de transferência"
"name": "Informação de depuração",
"success": "Informação de depuração copiada para a área de transferência!",
"fail": "Falha ao copiar a informação de depuração para a área de transferência"
}
},
"menuAppHeader": {
@ -148,7 +148,7 @@
"menu": {
"appearance": "Aparência",
"language": "Idioma",
"user":"Usuário",
"user": "Usuário",
"open": "Abrir as Configurações"
},
"appearance": {
@ -181,7 +181,7 @@
"includeTime": "Incluir horário",
"dateFormatFriendly": "Mês/Dia/Ano",
"dateFormatISO": "Ano/Mês/Dia",
"dateFormatLocal": "Ano/Mês/Dia",
"dateFormatLocal": "Mês/Dia/Ano",
"dateFormatUS": "Ano/Mês/Dia",
"timeFormat": "Formato de hora",
"invalidTimeFormat": "Formato Inválido",
@ -231,4 +231,4 @@
"create_new_card": "Novo"
}
}
}
}

View File

@ -94,9 +94,9 @@
},
"tooltip": {
"darkMode": "Переключиться в тёмную тему",
"openAsPage": "Открыть как страницу",
"addNewRow": "Добавить новую строку",
"openMenu": "Открыть меню"
"openAsPage": "Открыть как страницу",
"addNewRow": "Добавить новую строку",
"openMenu": "Открыть меню"
},
"sideBar": {
"closeSidebar": "Закрыть боковое меню",
@ -180,7 +180,7 @@
"includeTime": " Время",
"dateFormatFriendly": "День Месяц, Год",
"dateFormatISO": "Год-Месяц-День",
"dateFormatLocal": "Год/Месяц/День",
"dateFormatLocal": "Месяц/День/Год",
"dateFormatUS": "Год/Месяц/День",
"timeFormat": " Форматировать время",
"invalidTimeFormat": "Неверный формат",

View File

@ -177,7 +177,7 @@
"includeTime": " 包含时间",
"dateFormatFriendly": "月 日,年",
"dateFormatISO": "年-月-日",
"dateFormatLocal": "年/月/日",
"dateFormatLocal": "月/日/年",
"dateFormatUS": "年/月/日",
"timeFormat": " 时间格式",
"invalidTimeFormat": "时间格式错误",

View File

@ -173,7 +173,7 @@
"includeTime": " 包含時間",
"dateFormatFriendly": "月 日,年",
"dateFormatISO": "年-月-日",
"dateFormatLocal": "年/月/日",
"dateFormatLocal": "月/日/年",
"dateFormatUS": "年/月/日",
"timeFormat": " 時間格式",
"invalidTimeFormat": "格式無效",

View File

@ -83,7 +83,9 @@ class _BoardCardState extends State<BoardCard> {
builder: (context, state) {
return AppFlowyPopover(
controller: popoverController,
triggerActions: PopoverTriggerFlags.none,
constraints: BoxConstraints.loose(const Size(140, 200)),
margin: const EdgeInsets.all(6),
direction: PopoverDirection.rightWithCenterAligned,
popupBuilder: (popoverContext) => _handlePopoverBuilder(
context,
@ -132,7 +134,8 @@ class _BoardCardState extends State<BoardCard> {
throw UnimplementedError();
case AccessoryType.more:
return GridRowActionSheet(
rowData: context.read<BoardCardBloc>().rowInfo());
rowData: context.read<BoardCardBloc>().rowInfo(),
);
}
}

View File

@ -195,9 +195,6 @@ class ShareActions with ActionList<ShareActionWrapper>, FlowyOverlayDelegate {
ShareActions({required this.onSelected});
@override
double get maxWidth => 130;
@override
double get itemHeight => 22;
@ -233,7 +230,7 @@ class ShareActionWrapper extends ActionItem {
ShareActionWrapper(this.inner);
@override
Widget? get icon => null;
Widget? icon(Color iconColor) => null;
@override
String get name => inner.name;

View File

@ -149,18 +149,13 @@ class IGridCellController<T, D> extends Equatable {
_cellDataPersistence = cellDataPersistence,
_fieldNotifier = fieldNotifier,
_fieldService = FieldService(
gridId: cellId.gridId, fieldId: cellId.fieldContext.id),
gridId: cellId.gridId,
fieldId: cellId.fieldContext.id,
),
_cacheKey = GridCellCacheKey(
rowId: cellId.rowId, fieldId: cellId.fieldContext.id);
IGridCellController<T, D> clone() {
return IGridCellController(
cellId: cellId,
cellDataLoader: _cellDataLoader,
cellCache: _cellsCache,
fieldNotifier: _fieldNotifier,
cellDataPersistence: _cellDataPersistence);
}
rowId: cellId.rowId,
fieldId: cellId.fieldContext.id,
);
String get gridId => cellId.gridId;
@ -172,9 +167,10 @@ class IGridCellController<T, D> extends Equatable {
FieldType get fieldType => cellId.fieldContext.fieldType;
VoidCallback? startListening(
{required void Function(T?) onCellChanged,
VoidCallback? onCellFieldChanged}) {
VoidCallback? startListening({
required void Function(T?) onCellChanged,
VoidCallback? onCellFieldChanged,
}) {
if (isListening) {
Log.error("Already started. It seems like you should call clone first");
return null;
@ -283,7 +279,12 @@ class IGridCellController<T, D> extends Equatable {
_loadDataOperation?.cancel();
_loadDataOperation = Timer(const Duration(milliseconds: 10), () {
_cellDataLoader.loadData().then((data) {
_cellsCache.insert(_cacheKey, GridCell(object: data));
if (data != null) {
_cellsCache.insert(_cacheKey, GridCell(object: data));
} else {
_cellsCache.remove(_cacheKey);
}
_cellDataNotifier?.value = data;
});
});

View File

@ -54,6 +54,9 @@ class SelectOptionCellEditorBloc
selectOption: (_SelectOption value) {
_onSelectOption(value.optionId);
},
trySelectOption: (_TrySelectOption value) {
_trySelectOption(value.optionName, emit);
},
filterOption: (_SelectOptionFilter value) {
_filterOption(value.optionName, emit);
},
@ -100,6 +103,36 @@ class SelectOptionCellEditorBloc
}
}
void _trySelectOption(
String optionName, Emitter<SelectOptionEditorState> emit) async {
SelectOptionPB? matchingOption;
bool optionExistsButSelected = false;
for (final option in state.options) {
if (option.name.toLowerCase() == optionName.toLowerCase()) {
if (!state.selectedOptions.contains(option)) {
matchingOption = option;
break;
} else {
optionExistsButSelected = true;
}
}
}
// if there isn't a matching option at all, then create it
if (matchingOption == null && !optionExistsButSelected) {
_createOption(optionName);
}
// if there is an unselected matching option, select it
if (matchingOption != null) {
_selectOptionService.select(optionId: matchingOption.id);
}
// clear the filter
emit(state.copyWith(filter: none()));
}
void _filterOption(String optionName, Emitter<SelectOptionEditorState> emit) {
final _MakeOptionResult result =
_makeOptions(Some(optionName), state.allOptions);
@ -187,6 +220,8 @@ class SelectOptionEditorEvent with _$SelectOptionEditorEvent {
_DeleteOption;
const factory SelectOptionEditorEvent.filterOption(String optionName) =
_SelectOptionFilter;
const factory SelectOptionEditorEvent.trySelectOption(String optionName) =
_TrySelectOption;
}
@freezed

View File

@ -65,30 +65,24 @@ class _DateCellState extends GridCellState<GridDateCell> {
builder: (context, state) {
return AppFlowyPopover(
controller: _popover,
offset: const Offset(0, 20),
triggerActions: PopoverTriggerFlags.none,
direction: PopoverDirection.bottomWithLeftAligned,
constraints: BoxConstraints.loose(const Size(320, 500)),
margin: EdgeInsets.zero,
child: SizedBox.expand(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _showCalendar(context),
child: MouseRegion(
opaque: false,
cursor: SystemMouseCursors.click,
child: Align(
alignment: alignment,
child: FlowyText.medium(
state.dateStr,
fontSize: 12,
),
),
onTap: () => _popover.show(),
child: Align(
alignment: alignment,
child: FlowyText.medium(state.dateStr, fontSize: 12),
),
),
),
popupBuilder: (BuildContext popoverContent) {
final bloc = context.read<DateCellBloc>();
return DateCellEditor(
cellController: bloc.cellController.clone(),
cellController: widget.cellControllerBuilder.build()
as GridDateCellController,
onDismissed: () => widget.onCellEditing.value = false,
);
},
@ -101,10 +95,6 @@ class _DateCellState extends GridCellState<GridDateCell> {
);
}
void _showCalendar(BuildContext context) {
_popover.show();
}
@override
Future<void> dispose() async {
_cellBloc.close();

View File

@ -2,6 +2,7 @@ import 'package:app_flowy/generated/locale_keys.g.dart';
import 'package:app_flowy/plugins/grid/application/cell/date_cal_bloc.dart';
import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:dartz/dartz.dart' show Either;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart';
import 'package:flowy_infra/theme.dart';
@ -11,6 +12,7 @@ import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/rounded_input_field.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flowy_sdk/log.dart';
import 'package:flowy_sdk/protobuf/flowy-error/errors.pbserver.dart';
import 'package:flowy_sdk/protobuf/flowy-grid/date_type_option.pb.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@ -39,39 +41,42 @@ class DateCellEditor extends StatefulWidget {
}
class _DateCellEditor extends State<DateCellEditor> {
DateTypeOptionPB? _dateTypeOptionPB;
@override
void initState() {
super.initState();
_fetchData();
}
_fetchData() async {
final result = await widget.cellController
.getFieldTypeOption(DateTypeOptionDataParser());
result.fold((dateTypeOptionPB) {
setState(() {
_dateTypeOptionPB = dateTypeOptionPB;
});
}, (err) => Log.error(err));
}
@override
Widget build(BuildContext context) {
if (_dateTypeOptionPB == null) {
return Container();
}
return FutureBuilder<Either<dynamic, FlowyError>>(
future: widget.cellController.getFieldTypeOption(
DateTypeOptionDataParser(),
),
builder: (BuildContext context, snapshot) {
if (snapshot.hasData) {
return _buildWidget(snapshot);
} else {
return const SizedBox();
}
},
);
}
return _CellCalendarWidget(
cellContext: widget.cellController,
dateTypeOptionPB: _dateTypeOptionPB!,
Widget _buildWidget(AsyncSnapshot<Either<dynamic, FlowyError>> snapshot) {
return snapshot.data!.fold(
(dateTypeOptionPB) {
return Padding(
padding: const EdgeInsets.all(12),
child: _CellCalendarWidget(
cellContext: widget.cellController,
dateTypeOptionPB: dateTypeOptionPB,
),
);
},
(err) {
Log.error(err);
return const SizedBox();
},
);
}
}
class _CellCalendarWidget extends StatelessWidget {
class _CellCalendarWidget extends StatefulWidget {
final GridDateCellController cellContext;
final DateTypeOptionPB dateTypeOptionPB;
@ -81,26 +86,43 @@ class _CellCalendarWidget extends StatelessWidget {
Key? key,
}) : super(key: key);
@override
State<_CellCalendarWidget> createState() => _CellCalendarWidgetState();
}
class _CellCalendarWidgetState extends State<_CellCalendarWidget> {
late PopoverMutex popoverMutex;
late DateCalBloc bloc;
@override
void initState() {
popoverMutex = PopoverMutex();
bloc = DateCalBloc(
dateTypeOptionPB: widget.dateTypeOptionPB,
cellData: widget.cellContext.getCellData(),
cellController: widget.cellContext,
)..add(const DateCalEvent.initial());
super.initState();
}
@override
Widget build(BuildContext context) {
final theme = context.watch<AppTheme>();
return BlocProvider(
create: (context) {
return DateCalBloc(
dateTypeOptionPB: dateTypeOptionPB,
cellData: cellContext.getCellData(),
cellController: cellContext,
)..add(const DateCalEvent.initial());
},
return BlocProvider.value(
value: bloc,
child: BlocBuilder<DateCalBloc, DateCalState>(
buildWhen: (p, c) => false,
builder: (context, state) {
List<Widget> children = [
_buildCalendar(theme, context),
_TimeTextField(bloc: context.read<DateCalBloc>()),
_TimeTextField(
bloc: context.read<DateCalBloc>(),
popoverMutex: popoverMutex,
),
Divider(height: 1, color: theme.shader5),
const _IncludeTimeButton(),
const _DateTypeOptionButton()
_DateTypeOptionButton(popoverMutex: popoverMutex)
];
return ListView.separated(
@ -119,6 +141,13 @@ class _CellCalendarWidget extends StatelessWidget {
);
}
@override
void dispose() {
bloc.close();
popoverMutex.dispose();
super.dispose();
}
Widget _buildCalendar(AppTheme theme, BuildContext context) {
return BlocBuilder<DateCalBloc, DateCalState>(
builder: (context, state) {
@ -218,8 +247,10 @@ class _IncludeTimeButton extends StatelessWidget {
class _TimeTextField extends StatefulWidget {
final DateCalBloc bloc;
final PopoverMutex popoverMutex;
const _TimeTextField({
required this.bloc,
required this.popoverMutex,
Key? key,
}) : super(key: key);
@ -240,9 +271,18 @@ class _TimeTextFieldState extends State<_TimeTextField> {
if (mounted) {
widget.bloc.add(DateCalEvent.setTime(_controller.text));
}
if (_focusNode.hasFocus) {
widget.popoverMutex.close();
}
});
widget.popoverMutex.listenOnPopoverChanged(() {
if (_focusNode.hasFocus) {
_focusNode.unfocus();
}
});
}
super.initState();
}
@ -290,7 +330,11 @@ class _TimeTextFieldState extends State<_TimeTextField> {
}
class _DateTypeOptionButton extends StatelessWidget {
const _DateTypeOptionButton({Key? key}) : super(key: key);
final PopoverMutex popoverMutex;
const _DateTypeOptionButton({
required this.popoverMutex,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
@ -301,6 +345,7 @@ class _DateTypeOptionButton extends StatelessWidget {
selector: (state) => state.dateTypeOptionPB,
builder: (context, dateTypeOptionPB) {
return AppFlowyPopover(
mutex: popoverMutex,
triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click,
offset: const Offset(20, 0),
constraints: BoxConstraints.loose(const Size(140, 100)),
@ -313,7 +358,10 @@ class _DateTypeOptionButton extends StatelessWidget {
popupBuilder: (BuildContext popContext) {
return _CalDateTimeSetting(
dateTypeOptionPB: dateTypeOptionPB,
onEvent: (event) => context.read<DateCalBloc>().add(event),
onEvent: (event) {
context.read<DateCalBloc>().add(event);
popoverMutex.close();
},
);
},
);
@ -325,46 +373,49 @@ class _DateTypeOptionButton extends StatelessWidget {
class _CalDateTimeSetting extends StatefulWidget {
final DateTypeOptionPB dateTypeOptionPB;
final Function(DateCalEvent) onEvent;
const _CalDateTimeSetting(
{required this.dateTypeOptionPB, required this.onEvent, Key? key})
: super(key: key);
const _CalDateTimeSetting({
required this.dateTypeOptionPB,
required this.onEvent,
Key? key,
}) : super(key: key);
@override
State<_CalDateTimeSetting> createState() => _CalDateTimeSettingState();
}
class _CalDateTimeSettingState extends State<_CalDateTimeSetting> {
final timeSettingPopoverMutex = PopoverMutex();
String? overlayIdentifier;
final _popoverMutex = PopoverMutex();
@override
Widget build(BuildContext context) {
List<Widget> children = [
AppFlowyPopover(
mutex: _popoverMutex,
asBarrier: true,
mutex: timeSettingPopoverMutex,
triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click,
offset: const Offset(20, 0),
popupBuilder: (BuildContext context) {
return DateFormatList(
selectedFormat: widget.dateTypeOptionPB.dateFormat,
onSelected: (format) =>
widget.onEvent(DateCalEvent.setDateFormat(format)),
onSelected: (format) {
widget.onEvent(DateCalEvent.setDateFormat(format));
timeSettingPopoverMutex.close();
},
);
},
child: const DateFormatButton(),
),
AppFlowyPopover(
mutex: _popoverMutex,
asBarrier: true,
mutex: timeSettingPopoverMutex,
triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click,
offset: const Offset(20, 0),
popupBuilder: (BuildContext context) {
return TimeFormatList(
selectedFormat: widget.dateTypeOptionPB.timeFormat,
onSelected: (format) =>
widget.onEvent(DateCalEvent.setTimeFormat(format)),
);
selectedFormat: widget.dateTypeOptionPB.timeFormat,
onSelected: (format) {
widget.onEvent(DateCalEvent.setTimeFormat(format));
timeSettingPopoverMutex.close();
});
},
child: TimeFormatButton(timeFormat: widget.dateTypeOptionPB.timeFormat),
),

View File

@ -154,10 +154,10 @@ class _TextField extends StatelessWidget {
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.filterOption(text));
},
onNewTag: (tagName) {
onSubmitted: (tagName) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.newOption(tagName));
.add(SelectOptionEditorEvent.trySelectOption(tagName));
},
),
);

View File

@ -17,7 +17,7 @@ class SelectOptionTextField extends StatefulWidget {
final LinkedHashMap<String, SelectOptionPB> selectedOptionMap;
final double distanceToText;
final Function(String) onNewTag;
final Function(String) onSubmitted;
final Function(String) newText;
final VoidCallback? onClick;
@ -26,7 +26,7 @@ class SelectOptionTextField extends StatefulWidget {
required this.selectedOptionMap,
required this.distanceToText,
required this.tagController,
required this.onNewTag,
required this.onSubmitted,
required this.newText,
this.onClick,
TextEditingController? textController,
@ -88,7 +88,7 @@ class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
}
if (text.isNotEmpty) {
widget.onNewTag(text);
widget.onSubmitted(text);
focusNode.requestFocus();
}
},

View File

@ -54,13 +54,11 @@ class GridURLCell extends GridCellWidget {
GridURLCellAccessoryType ty, GridCellAccessoryBuildContext buildContext) {
switch (ty) {
case GridURLCellAccessoryType.edit:
final cellController =
cellControllerBuilder.build() as GridURLCellController;
return GridCellAccessoryBuilder(
builder: (Key key) => _EditURLAccessory(
key: key,
cellContext: cellController,
anchorContext: buildContext.anchorContext,
cellControllerBuilder: cellControllerBuilder,
),
);
@ -191,10 +189,10 @@ class _GridURLCellState extends GridCellState<GridURLCell> {
}
class _EditURLAccessory extends StatefulWidget {
final GridURLCellController cellContext;
final GridCellControllerBuilder cellControllerBuilder;
final BuildContext anchorContext;
const _EditURLAccessory({
required this.cellContext,
required this.cellControllerBuilder,
required this.anchorContext,
Key? key,
}) : super(key: key);
@ -224,7 +222,8 @@ class _EditURLAccessoryState extends State<_EditURLAccessory>
child: svgWidget("editor/edit", color: theme.iconColor),
popupBuilder: (BuildContext popoverContext) {
return URLEditorPopover(
cellController: widget.cellContext.clone(),
cellController:
widget.cellControllerBuilder.build() as GridURLCellController,
);
},
);

View File

@ -162,6 +162,7 @@ class FieldCellButton extends StatelessWidget {
Widget build(BuildContext context) {
final theme = context.watch<AppTheme>();
return FlowyButton(
radius: BorderRadius.zero,
hoverColor: theme.shader6,
onTap: onTap,
leftIcon: svgWidget(field.fieldType.iconName(), color: theme.iconColor),

View File

@ -93,9 +93,9 @@ class _EditFieldButton extends StatelessWidget {
}
class _FieldOperationList extends StatelessWidget {
final GridFieldCellContext fieldData;
final GridFieldCellContext fieldContext;
final VoidCallback onDismissed;
const _FieldOperationList(this.fieldData, this.onDismissed, {Key? key})
const _FieldOperationList(this.fieldContext, this.onDismissed, {Key? key})
: super(key: key);
@override
@ -118,14 +118,14 @@ class _FieldOperationList extends StatelessWidget {
bool enable = true;
switch (action) {
case FieldAction.delete:
enable = !fieldData.field.isPrimary;
enable = !fieldContext.field.isPrimary;
break;
default:
break;
}
return FieldActionCell(
fieldId: fieldData.field.id,
fieldContext: fieldContext,
action: action,
onTap: onDismissed,
enable: enable,
@ -136,13 +136,13 @@ class _FieldOperationList extends StatelessWidget {
}
class FieldActionCell extends StatelessWidget {
final String fieldId;
final GridFieldCellContext fieldContext;
final VoidCallback onTap;
final FieldAction action;
final bool enable;
const FieldActionCell({
required this.fieldId,
required this.fieldContext,
required this.action,
required this.onTap,
required this.enable,
@ -161,7 +161,7 @@ class FieldActionCell extends StatelessWidget {
hoverColor: theme.hover,
onTap: () {
if (enable) {
action.run(context);
action.run(context, fieldContext);
onTap();
}
},
@ -202,7 +202,7 @@ extension _FieldActionExtension on FieldAction {
}
}
void run(BuildContext context) {
void run(BuildContext context, GridFieldCellContext fieldContext) {
switch (this) {
case FieldAction.hide:
context
@ -210,18 +210,24 @@ extension _FieldActionExtension on FieldAction {
.add(const FieldActionSheetEvent.hideField());
break;
case FieldAction.duplicate:
context
.read<FieldActionSheetBloc>()
.add(const FieldActionSheetEvent.duplicateField());
PopoverContainer.of(context).close();
FieldService(
gridId: fieldContext.gridId,
fieldId: fieldContext.field.id,
).duplicateField();
break;
case FieldAction.delete:
PopoverContainer.of(context).close();
NavigatorAlertDialog(
title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(),
confirm: () {
context
.read<FieldActionSheetBloc>()
.add(const FieldActionSheetEvent.deleteField());
FieldService(
gridId: fieldContext.gridId,
fieldId: fieldContext.field.id,
).deleteField();
},
).show(context);

View File

@ -139,7 +139,6 @@ class _FieldNameTextField extends StatefulWidget {
class _FieldNameTextFieldState extends State<_FieldNameTextField> {
FocusNode focusNode = FocusNode();
VoidCallback? _popoverCallback;
late TextEditingController controller;
@override
@ -151,6 +150,12 @@ class _FieldNameTextFieldState extends State<_FieldNameTextField> {
}
});
widget.popoverMutex.listenOnPopoverChanged(() {
if (focusNode.hasFocus) {
focusNode.unfocus();
}
});
super.initState();
}
@ -176,8 +181,6 @@ class _FieldNameTextFieldState extends State<_FieldNameTextField> {
buildWhen: (previous, current) =>
previous.errorText != current.errorText,
builder: (context, state) {
listenOnPopoverChanged(context);
return RoundedInputField(
height: 36,
focusNode: focusNode,
@ -198,18 +201,6 @@ class _FieldNameTextFieldState extends State<_FieldNameTextField> {
),
);
}
void listenOnPopoverChanged(BuildContext context) {
if (_popoverCallback != null) {
widget.popoverMutex.removePopoverListener(_popoverCallback!);
}
_popoverCallback = widget.popoverMutex.listenOnPopoverChanged(() {
if (focusNode.hasFocus) {
final node = FocusScope.of(context);
node.unfocus();
}
});
}
}
class _DeleteFieldButton extends StatelessWidget {
@ -236,9 +227,10 @@ class _DeleteFieldButton extends StatelessWidget {
color: enable ? null : theme.shader4,
),
onTap: () => onDeleted?.call(),
hoverColor: theme.hover,
onHover: (_) => popoverMutex.close(),
);
// if (enable) button = button;
return button;
return SizedBox(height: 36, child: button);
},
);
}

View File

@ -180,6 +180,7 @@ class CreateFieldButton extends StatelessWidget {
asBarrier: true,
constraints: BoxConstraints.loose(const Size(240, 600)),
child: FlowyButton(
radius: BorderRadius.zero,
text: FlowyText.medium(
LocaleKeys.grid_field_newColumn.tr(),
fontSize: 12,

View File

@ -109,6 +109,7 @@ class _RowLeadingState extends State<_RowLeading> {
triggerActions: PopoverTriggerFlags.none,
constraints: BoxConstraints.loose(const Size(140, 200)),
direction: PopoverDirection.rightWithCenterAligned,
margin: const EdgeInsets.all(6),
popupBuilder: (BuildContext popoverContext) {
return GridRowActionSheet(
rowData: context.read<RowBloc>().state.rowInfo);

View File

@ -280,6 +280,7 @@ class _RowDetailCellState extends State<_RowDetailCell> {
AppFlowyPopover(
controller: popover,
constraints: BoxConstraints.loose(const Size(240, 600)),
triggerActions: PopoverTriggerFlags.none,
popupBuilder: (popoverContext) => buildFieldEditor(),
child: SizedBox(
width: 150,

View File

@ -55,6 +55,7 @@ class _SettingButton extends StatelessWidget {
return AppFlowyPopover(
constraints: BoxConstraints.loose(const Size(260, 400)),
offset: const Offset(0, 10),
margin: const EdgeInsets.all(6),
child: FlowyIconButton(
width: 22,
hoverColor: theme.hover,

View File

@ -61,8 +61,7 @@ class ActionList {
itemBuilder: (context, index) => items[index],
anchorContext: anchorContext,
anchorDirection: AnchorDirection.bottomRight,
width: 120,
height: 80,
constraints: BoxConstraints.tight(const Size(120, 80)),
);
}
}

View File

@ -86,7 +86,8 @@ class MenuAppHeader extends StatelessWidget {
?.toggle(),
onSecondaryTap: () {
final actionList = AppDisclosureActionSheet(
onSelected: (action) => _handleAction(context, action));
onSelected: (action) => _handleAction(context, action),
);
actionList.show(
context,
anchorDirection: AnchorDirection.bottomWithCenterAligned,
@ -158,12 +159,12 @@ extension AppDisclosureExtension on AppDisclosureAction {
}
}
Widget get icon {
Widget icon(Color iconColor) {
switch (this) {
case AppDisclosureAction.rename:
return svgWidget('editor/edit', color: const Color(0xffe5e5e5));
return svgWidget('editor/edit', color: iconColor);
case AppDisclosureAction.delete:
return svgWidget('editor/delete', color: const Color(0xffe5e5e5));
return svgWidget('editor/delete', color: iconColor);
}
}
}

View File

@ -5,9 +5,12 @@ import 'package:flutter/material.dart';
import 'header.dart';
class AppDisclosureActionSheet with ActionList<DisclosureActionWrapper>, FlowyOverlayDelegate {
class AppDisclosureActionSheet
with ActionList<DisclosureActionWrapper>, FlowyOverlayDelegate {
final Function(dartz.Option<AppDisclosureAction>) onSelected;
final _items = AppDisclosureAction.values.map((action) => DisclosureActionWrapper(action)).toList();
final _items = AppDisclosureAction.values
.map((action) => DisclosureActionWrapper(action))
.toList();
AppDisclosureActionSheet({
required this.onSelected,
@ -17,7 +20,8 @@ class AppDisclosureActionSheet with ActionList<DisclosureActionWrapper>, FlowyOv
List<DisclosureActionWrapper> get items => _items;
@override
void Function(dartz.Option<DisclosureActionWrapper> p1) get selectCallback => (result) {
void Function(dartz.Option<DisclosureActionWrapper> p1) get selectCallback =>
(result) {
result.fold(
() => onSelected(dartz.none()),
(wrapper) => onSelected(
@ -40,7 +44,7 @@ class DisclosureActionWrapper extends ActionItem {
DisclosureActionWrapper(this.inner);
@override
Widget? get icon => inner.icon;
Widget? icon(Color iconColor) => inner.icon(iconColor);
@override
String get name => inner.name;

View File

@ -80,7 +80,7 @@ class ViewDisclosureRegion extends StatelessWidget
@override
Widget build(BuildContext context) {
return Listener(
onPointerDown: (event) => {_handleClick(event, context)},
onPointerDown: (event) => _handleClick(event, context),
child: child,
);
}
@ -123,7 +123,7 @@ class ViewDisclosureActionWrapper extends ActionItem {
ViewDisclosureActionWrapper(this.inner);
@override
Widget? get icon => inner.icon;
Widget? icon(Color iconColor) => inner.icon(iconColor);
@override
String get name => inner.name;

View File

@ -147,14 +147,14 @@ extension ViewDisclosureExtension on ViewDisclosureAction {
}
}
Widget get icon {
Widget icon(Color iconColor) {
switch (this) {
case ViewDisclosureAction.rename:
return svgWidget('editor/edit', color: const Color(0xff999999));
return svgWidget('editor/edit', color: iconColor);
case ViewDisclosureAction.delete:
return svgWidget('editor/delete', color: const Color(0xff999999));
return svgWidget('editor/delete', color: iconColor);
case ViewDisclosureAction.duplicate:
return svgWidget('editor/copy', color: const Color(0xff999999));
return svgWidget('editor/copy', color: iconColor);
}
}
}

View File

@ -101,17 +101,16 @@ class _DebugToast {
}
}
class QuestionBubbleActionSheet with ActionList<BubbleActionWrapper>, FlowyOverlayDelegate {
class QuestionBubbleActionSheet
with ActionList<BubbleActionWrapper>, FlowyOverlayDelegate {
final Function(dartz.Option<BubbleAction>) onSelected;
final _items = BubbleAction.values.map((action) => BubbleActionWrapper(action)).toList();
final _items =
BubbleAction.values.map((action) => BubbleActionWrapper(action)).toList();
QuestionBubbleActionSheet({
required this.onSelected,
});
@override
double get maxWidth => 170;
@override
double get itemHeight => 22;
@ -119,7 +118,8 @@ class QuestionBubbleActionSheet with ActionList<BubbleActionWrapper>, FlowyOverl
List<BubbleActionWrapper> get items => _items;
@override
void Function(dartz.Option<BubbleActionWrapper> p1) get selectCallback => (result) {
void Function(dartz.Option<BubbleActionWrapper> p1) get selectCallback =>
(result) {
result.fold(
() => onSelected(dartz.none()),
(wrapper) => onSelected(
@ -139,7 +139,7 @@ class QuestionBubbleActionSheet with ActionList<BubbleActionWrapper>, FlowyOverl
@override
ListOverlayFooter? get footer => ListOverlayFooter(
widget: const FlowyVersionDescription(),
height: 30,
height: 40,
padding: const EdgeInsets.only(top: 6),
);
}
@ -156,7 +156,8 @@ class FlowyVersionDescription extends StatelessWidget {
builder: (BuildContext context, AsyncSnapshot<dynamic> snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.hasError) {
return FlowyText("Error: ${snapshot.error}", fontSize: 12, color: theme.shader4);
return FlowyText("Error: ${snapshot.error}",
fontSize: 12, color: theme.shader4);
}
PackageInfo packageInfo = snapshot.data;
@ -170,7 +171,11 @@ class FlowyVersionDescription extends StatelessWidget {
children: [
Divider(height: 1, color: theme.shader6, thickness: 1.0),
const VSpace(6),
FlowyText("$appName $version.$buildNumber", fontSize: 12, color: theme.shader4),
FlowyText(
"$appName $version.$buildNumber",
fontSize: 12,
color: theme.shader4,
),
],
).padding(
horizontal: ActionListSizes.itemHPadding + ActionListSizes.padding,
@ -190,7 +195,7 @@ class BubbleActionWrapper extends ActionItem {
BubbleActionWrapper(this.inner);
@override
Widget? get icon => inner.emoji;
Widget? icon(Color iconColor) => inner.emoji;
@override
String get name => inner.name;

View File

@ -13,7 +13,9 @@ abstract class ActionList<T extends ActionItem> {
String get identifier => toString();
double get maxWidth => 162;
double get maxWidth => 300;
double get minWidth => 120;
double get itemHeight => ActionListSizes.itemHeight;
@ -29,28 +31,29 @@ abstract class ActionList<T extends ActionItem> {
AnchorDirection anchorDirection = AnchorDirection.bottomRight,
Offset? anchorOffset,
}) {
final widgets = items
.map(
(action) => ActionCell<T>(
action: action,
itemHeight: itemHeight,
onSelected: (action) {
FlowyOverlay.of(buildContext).remove(identifier);
selectCallback(dartz.some(action));
},
),
)
.toList();
ListOverlay.showWithAnchor(
buildContext,
identifier: identifier,
itemCount: widgets.length,
itemBuilder: (context, index) => widgets[index],
itemCount: items.length,
itemBuilder: (context, index) {
final action = items[index];
return ActionCell<T>(
action: action,
itemHeight: itemHeight,
onSelected: (action) {
FlowyOverlay.of(buildContext).remove(identifier);
selectCallback(dartz.some(action));
},
);
},
anchorContext: anchorContext ?? buildContext,
anchorDirection: anchorDirection,
width: maxWidth,
height: widgets.length * (itemHeight + ActionListSizes.padding * 2),
constraints: BoxConstraints(
minHeight: items.length * (itemHeight + ActionListSizes.padding * 2),
maxHeight: items.length * (itemHeight + ActionListSizes.padding * 2),
maxWidth: maxWidth,
minWidth: minWidth,
),
delegate: delegate,
anchorOffset: anchorOffset,
footer: footer,
@ -59,7 +62,7 @@ abstract class ActionList<T extends ActionItem> {
}
abstract class ActionItem {
Widget? get icon;
Widget? icon(Color iconColor);
String get name;
}
@ -83,6 +86,7 @@ class ActionCell<T extends ActionItem> extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = context.watch<AppTheme>();
final icon = action.icon(theme.iconColor);
return FlowyHover(
style: HoverStyle(hoverColor: theme.hover),
@ -92,14 +96,11 @@ class ActionCell<T extends ActionItem> extends StatelessWidget {
child: SizedBox(
height: itemHeight,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (action.icon != null) action.icon!,
if (icon != null) icon,
HSpace(ActionListSizes.itemHPadding),
FlowyText.medium(
action.name,
fontSize: 12,
),
FlowyText.medium(action.name, fontSize: 12),
],
),
).padding(

View File

@ -0,0 +1,5 @@
{
"projects": {
"default": "appflowy-editor"
}
}

View File

@ -0,0 +1,23 @@
{
"hosting": {
"public": "build/web",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
],
"headers": [ {
"source": "**/*.@(png|jpg|jpeg|gif)",
"headers": [ {
"key": "Access-Control-Allow-Origin",
"value": "*"
} ]
} ]
}
}

View File

@ -1,13 +1,16 @@
import 'dart:convert';
import 'dart:io';
import 'package:example/plugin/underscore_to_italic.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:example/plugin/underscore_to_italic.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:path_provider/path_provider.dart';
import 'package:universal_html/html.dart' as html;
import 'package:appflowy_editor/appflowy_editor.dart';
@ -112,6 +115,7 @@ class _MyHomePageState extends State<MyHomePage> {
child: AppFlowyEditor(
editorState: _editorState!,
editorStyle: _editorStyle,
editable: true,
shortcutEvents: [
underscoreToItalic,
],
@ -148,7 +152,7 @@ class _MyHomePageState extends State<MyHomePage> {
),
ActionButton(
icon: const Icon(Icons.import_export),
onPressed: () => _importDocument(),
onPressed: () async => await _importDocument(),
),
ActionButton(
icon: const Icon(Icons.color_lens),
@ -167,28 +171,53 @@ class _MyHomePageState extends State<MyHomePage> {
void _exportDocument(EditorState editorState) async {
final document = editorState.document.toJson();
final json = jsonEncode(document);
final directory = await getTemporaryDirectory();
final path = directory.path;
final file = File('$path/editor.json');
await file.writeAsString(json);
if (kIsWeb) {
final blob = html.Blob([json], 'text/plain', 'native');
html.AnchorElement(
href: html.Url.createObjectUrlFromBlob(blob).toString(),
)
..setAttribute('download', 'editor.json')
..click();
} else {
final directory = await getTemporaryDirectory();
final path = directory.path;
final file = File('$path/editor.json');
await file.writeAsString(json);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('The document is saved to the ${file.path}'),
),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('The document is saved to the ${file.path}'),
),
);
}
}
}
void _importDocument() async {
final directory = await getTemporaryDirectory();
final path = directory.path;
final file = File('$path/editor.json');
setState(() {
_editorState = null;
_jsonString = file.readAsString();
});
Future<void> _importDocument() async {
if (kIsWeb) {
final result = await FilePicker.platform.pickFiles(
allowMultiple: false,
allowedExtensions: ['json'],
type: FileType.custom,
);
final bytes = result?.files.first.bytes;
if (bytes != null) {
final jsonString = const Utf8Decoder().convert(bytes);
setState(() {
_editorState = null;
_jsonString = Future.value(jsonString);
});
}
} else {
final directory = await getTemporaryDirectory();
final path = '${directory.path}/editor.json';
final file = File(path);
setState(() {
_editorState = null;
_jsonString = file.readAsString();
});
}
}
void _switchToPage(int pageIndex) {

View File

@ -1,165 +0,0 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
/// 1. define your custom type in example.json
/// For example I need to define an image plugin, then I define type equals
/// "image", and add "image_src" into "attributes".
/// {
/// "type": "image",
/// "attributes", { "image_src": "https://s1.ax1x.com/2022/07/28/vCgz1x.png" }
/// }
/// 2. create a class extends [NodeWidgetBuilder]
/// 3. override the function `Widget build(NodeWidgetContext<Node> context)`
/// and return a widget to render. The returned widget should be
/// a StatefulWidget and mixin with [SelectableMixin].
///
/// 4. override the getter `nodeValidator`
/// to verify the data structure in [Node].
/// 5. register the plugin with `type` to `AppFlowyEditor` in `main.dart`.
/// 6. Congratulations!
class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
@override
Widget build(NodeWidgetContext<Node> context) {
return ImageNodeWidget(
key: context.node.key,
node: context.node,
editorState: context.editorState,
);
}
@override
NodeValidator<Node> get nodeValidator => ((node) {
return node.type == 'image';
});
}
const double placeholderHeight = 132;
class ImageNodeWidget extends StatefulWidget {
final Node node;
final EditorState editorState;
const ImageNodeWidget({
Key? key,
required this.node,
required this.editorState,
}) : super(key: key);
@override
State<ImageNodeWidget> createState() => _ImageNodeWidgetState();
}
class _ImageNodeWidgetState extends State<ImageNodeWidget>
with SelectableMixin {
bool isHovered = false;
Node get node => widget.node;
EditorState get editorState => widget.editorState;
String get src => widget.node.attributes['image_src'] as String;
@override
Position end() {
return Position(path: node.path, offset: 0);
}
@override
Position start() {
return Position(path: node.path, offset: 0);
}
@override
List<Rect> getRectsInSelection(Selection selection) {
return [];
}
@override
Selection getSelectionInRange(Offset start, Offset end) {
return Selection.collapsed(Position(path: node.path, offset: 0));
}
@override
Offset localToGlobal(Offset offset) {
throw UnimplementedError();
}
@override
Position getPositionInOffset(Offset start) {
return Position(path: node.path, offset: 0);
}
@override
Widget build(BuildContext context) {
return _build(context);
}
Widget _loadingBuilder(
BuildContext context, Widget widget, ImageChunkEvent? evt) {
if (evt == null) {
return widget;
}
return Container(
alignment: Alignment.center,
height: placeholderHeight,
child: const Text("Loading..."),
);
}
Widget _errorBuilder(
BuildContext context, Object obj, StackTrace? stackTrace) {
return Container(
alignment: Alignment.center,
height: placeholderHeight,
child: const Text("Error..."),
);
}
Widget _frameBuilder(
BuildContext context,
Widget child,
int? frame,
bool wasSynchronouslyLoaded,
) {
if (frame == null) {
return Container(
alignment: Alignment.center,
height: placeholderHeight,
child: const Text("Loading..."),
);
}
return child;
}
Widget _build(BuildContext context) {
return Column(
children: [
MouseRegion(
onEnter: (event) {
setState(() {
isHovered = true;
});
},
onExit: (event) {
setState(() {
isHovered = false;
});
},
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
border: Border.all(
color: isHovered ? Colors.blue : Colors.grey,
),
borderRadius: const BorderRadius.all(Radius.circular(20))),
child: Image.network(
src,
width: MediaQuery.of(context).size.width,
frameBuilder: _frameBuilder,
loadingBuilder: _loadingBuilder,
errorBuilder: _errorBuilder,
),
)),
],
);
}
}

View File

@ -1,100 +0,0 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:pod_player/pod_player.dart';
class YouTubeLinkNodeBuilder extends NodeWidgetBuilder<Node> {
@override
Widget build(NodeWidgetContext<Node> context) {
return LinkNodeWidget(
key: context.node.key,
node: context.node,
editorState: context.editorState,
);
}
@override
NodeValidator<Node> get nodeValidator => ((node) {
return node.type == 'youtube_link';
});
}
class LinkNodeWidget extends StatefulWidget {
final Node node;
final EditorState editorState;
const LinkNodeWidget({
Key? key,
required this.node,
required this.editorState,
}) : super(key: key);
@override
State<LinkNodeWidget> createState() => _YouTubeLinkNodeWidgetState();
}
class _YouTubeLinkNodeWidgetState extends State<LinkNodeWidget>
with SelectableMixin {
Node get node => widget.node;
EditorState get editorState => widget.editorState;
String get src => widget.node.attributes['youtube_link'] as String;
@override
Position end() {
// TODO: implement end
throw UnimplementedError();
}
@override
Position start() {
// TODO: implement start
throw UnimplementedError();
}
@override
List<Rect> getRectsInSelection(Selection selection) {
// TODO: implement getRectsInSelection
throw UnimplementedError();
}
@override
Selection getSelectionInRange(Offset start, Offset end) {
// TODO: implement getSelectionInRange
throw UnimplementedError();
}
@override
Offset localToGlobal(Offset offset) {
throw UnimplementedError();
}
@override
Position getPositionInOffset(Offset start) {
// TODO: implement getPositionInOffset
throw UnimplementedError();
}
@override
Widget build(BuildContext context) {
return _build(context);
}
late final PodPlayerController controller;
@override
void initState() {
controller = PodPlayerController(
playVideoFrom: PlayVideoFrom.network(
src,
),
)..initialise();
super.initState();
}
Widget _build(BuildContext context) {
return Column(
children: [
PodVideoPlayer(controller: controller),
],
);
}
}

View File

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

View File

@ -6,15 +6,12 @@ PODS:
- FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
- wakelock_macos (0.0.1):
- FlutterMacOS
DEPENDENCIES:
- FlutterMacOS (from `Flutter/ephemeral`)
- path_provider_macos (from `Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos`)
- rich_clipboard_macos (from `Flutter/ephemeral/.symlinks/plugins/rich_clipboard_macos/macos`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
- wakelock_macos (from `Flutter/ephemeral/.symlinks/plugins/wakelock_macos/macos`)
EXTERNAL SOURCES:
FlutterMacOS:
@ -25,15 +22,12 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/rich_clipboard_macos/macos
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
wakelock_macos:
:path: Flutter/ephemeral/.symlinks/plugins/wakelock_macos/macos
SPEC CHECKSUMS:
FlutterMacOS: ae6af50a8ea7d6103d888583d46bd8328a7e9811
path_provider_macos: 3c0c3b4b0d4a76d2bf989a913c2de869c5641a19
rich_clipboard_macos: 43364b66b9dc69d203eb8dd6d758e2d12e02723c
url_launcher_macos: 597e05b8e514239626bcf4a850fcf9ef5c856ec3
wakelock_macos: bc3f2a9bd8d2e6c89fee1e1822e7ddac3bd004a9
PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c

View File

@ -37,12 +37,12 @@ dependencies:
path: ../
provider: ^6.0.3
url_launcher: ^6.1.5
video_player: ^2.4.5
pod_player: 0.0.8
path_provider: ^2.0.11
google_fonts: ^3.0.1
flutter_localizations:
sdk: flutter
file_picker: ^5.0.1
universal_html: ^2.0.8
dev_dependencies:
flutter_test:

View File

@ -193,16 +193,24 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
return parent!._path([index, ...previous]);
}
Node deepClone() {
final newNode = Node(
type: type, children: LinkedList<Node>(), attributes: {...attributes});
for (final node in children) {
final newNode = node.deepClone();
newNode.parent = this;
newNode.children.add(newNode);
Node copyWith({
String? type,
LinkedList<Node>? children,
Attributes? attributes,
}) {
final node = Node(
type: type ?? this.type,
attributes: attributes ?? {..._attributes},
children: children ?? LinkedList(),
);
if (children == null && this.children.isNotEmpty) {
for (final child in this.children) {
node.children.add(
child.copyWith()..parent = node,
);
}
}
return newNode;
return node;
}
}
@ -215,7 +223,10 @@ class TextNode extends Node {
LinkedList<Node>? children,
Attributes? attributes,
}) : _delta = delta,
super(children: children ?? LinkedList(), attributes: attributes ?? {});
super(
children: children ?? LinkedList(),
attributes: attributes ?? {},
);
TextNode.empty({Attributes? attributes})
: _delta = Delta([TextInsert('')]),
@ -241,33 +252,27 @@ class TextNode extends Node {
return map;
}
@override
TextNode copyWith({
String? type,
LinkedList<Node>? children,
Attributes? attributes,
Delta? delta,
}) =>
TextNode(
type: type ?? this.type,
children: children ?? this.children,
attributes: attributes ?? _attributes,
delta: delta ?? this.delta,
);
@override
TextNode deepClone() {
final newNode = TextNode(
type: type,
children: LinkedList<Node>(),
delta: delta.slice(0),
attributes: {...attributes});
for (final node in children) {
final newNode = node.deepClone();
newNode.parent = this;
newNode.children.add(newNode);
}) {
final textNode = TextNode(
type: type ?? this.type,
children: children,
attributes: attributes ?? _attributes,
delta: delta ?? this.delta,
);
if (children == null && this.children.isNotEmpty) {
for (final child in this.children) {
textNode.children.add(
child.copyWith()..parent = textNode,
);
}
}
return newNode;
return textNode;
}
String toRawString() => _delta.toRawString();

View File

@ -40,11 +40,9 @@ class Selection {
bool get isCollapsed => start == end;
bool get isSingle => pathEquals(start.path, end.path);
bool get isForward =>
(start.path >= end.path && !pathEquals(start.path, end.path)) ||
(isSingle && start.offset > end.offset);
(start.path > end.path) || (isSingle && start.offset > end.offset);
bool get isBackward =>
(start.path <= end.path && !pathEquals(start.path, end.path)) ||
(isSingle && start.offset < end.offset);
(start.path < end.path) || (isSingle && start.offset < end.offset);
Selection get normalize {
if (isForward) {

View File

@ -4,22 +4,52 @@ import 'dart:math';
extension PathExtensions on Path {
bool operator >=(Path other) {
if (pathEquals(this, other)) {
return true;
}
return this > other;
}
bool operator >(Path other) {
if (pathEquals(this, other)) {
return false;
}
final length = min(this.length, other.length);
for (var i = 0; i < length; i++) {
if (this[i] < other[i]) {
return false;
} else if (this[i] > other[i]) {
return true;
}
}
if (this.length < other.length) {
return false;
}
return true;
}
bool operator <=(Path other) {
if (pathEquals(this, other)) {
return true;
}
return this < other;
}
bool operator <(Path other) {
if (pathEquals(this, other)) {
return false;
}
final length = min(this.length, other.length);
for (var i = 0; i < length; i++) {
if (this[i] > other[i]) {
return false;
} else if (this[i] < other[i]) {
return true;
}
}
if (this.length > other.length) {
return false;
}
return true;
}

View File

@ -0,0 +1,46 @@
import 'package:appflowy_editor/src/document/node.dart';
class Infra {
// find the forward nearest text node
static TextNode? forwardNearestTextNode(Node node) {
var previous = node.previous;
while (previous != null) {
final lastTextNode = findLastTextNode(previous);
if (lastTextNode != null) {
return lastTextNode;
}
if (previous is TextNode) {
return previous;
}
previous = previous.previous;
}
final parent = node.parent;
if (parent != null) {
if (parent is TextNode) {
return parent;
}
return forwardNearestTextNode(parent);
}
return null;
}
// find the last text node
static TextNode? findLastTextNode(Node node) {
final children = node.children.toList(growable: false).reversed;
for (final child in children) {
if (child.children.isNotEmpty) {
final result = findLastTextNode(child);
if (result != null) {
return result;
}
}
if (child is TextNode) {
return child;
}
}
if (node is TextNode) {
return node;
}
return null;
}
}

View File

@ -36,7 +36,7 @@ class TransactionBuilder {
/// Inserts a sequence of nodes at the position of path.
insertNodes(Path path, List<Node> nodes) {
beforeSelection = state.cursorSelection;
add(InsertOperation(path, nodes.map((node) => node.deepClone()).toList()));
add(InsertOperation(path, nodes.map((node) => node.copyWith()).toList()));
}
/// Updates the attributes of nodes.
@ -75,7 +75,7 @@ class TransactionBuilder {
nodes.add(node);
}
add(DeleteOperation(path, nodes.map((node) => node.deepClone()).toList()));
add(DeleteOperation(path, nodes.map((node) => node.copyWith()).toList()));
}
textEdit(TextNode node, Delta Function() f) {

View File

@ -1,4 +1,5 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart';
import 'package:flutter/material.dart';
abstract class BuiltInTextWidget extends StatefulWidget {
@ -59,3 +60,58 @@ mixin BuiltInStyleMixin<T extends BuiltInTextWidget> on State<T> {
return const EdgeInsets.all(0);
}
}
mixin BuiltInTextWidgetMixin<T extends BuiltInTextWidget> on State<T>
implements DefaultSelectable {
@override
Widget build(BuildContext context) {
if (widget.textNode.children.isEmpty) {
return buildWithSingle(context);
} else {
return buildWithChildren(context);
}
}
Widget buildWithSingle(BuildContext context);
Widget buildWithChildren(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildWithSingle(context),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// TODO: customize
const SizedBox(
width: 20,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widget.textNode.children
.map(
(child) => widget.editorState.service.renderPluginService
.buildPluginWidget(
child is TextNode
? NodeWidgetContext<TextNode>(
context: context,
node: child,
editorState: widget.editorState,
)
: NodeWidgetContext<Node>(
context: context,
node: child,
editorState: widget.editorState,
),
),
)
.toList(),
),
)
],
)
],
);
}
}

View File

@ -45,7 +45,11 @@ class BulletedListTextNodeWidget extends BuiltInTextWidget {
// customize
class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
with SelectableMixin, DefaultSelectable, BuiltInStyleMixin {
with
SelectableMixin,
DefaultSelectable,
BuiltInStyleMixin,
BuiltInTextWidgetMixin {
@override
final iconKey = GlobalKey();
@ -61,7 +65,7 @@ class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
}
@override
Widget build(BuildContext context) {
Widget buildWithSingle(BuildContext context) {
return Padding(
padding: padding,
child: Row(

View File

@ -46,7 +46,11 @@ class CheckboxNodeWidget extends BuiltInTextWidget {
}
class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
with SelectableMixin, DefaultSelectable, BuiltInStyleMixin {
with
SelectableMixin,
DefaultSelectable,
BuiltInStyleMixin,
BuiltInTextWidgetMixin {
@override
final iconKey = GlobalKey();
@ -62,15 +66,7 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
}
@override
Widget build(BuildContext context) {
if (widget.textNode.children.isEmpty) {
return _buildWithSingle(context);
} else {
return _buildWithChildren(context);
}
}
Widget _buildWithSingle(BuildContext context) {
Widget buildWithSingle(BuildContext context) {
final check = widget.textNode.attributes.check;
return Padding(
padding: padding,
@ -106,40 +102,4 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
),
);
}
Widget _buildWithChildren(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildWithSingle(context),
Row(
children: [
const SizedBox(
width: 20,
),
Column(
children: widget.textNode.children
.map(
(child) => widget.editorState.service.renderPluginService
.buildPluginWidget(
child is TextNode
? NodeWidgetContext<TextNode>(
context: context,
node: child,
editorState: widget.editorState,
)
: NodeWidgetContext<Node>(
context: context,
node: child,
editorState: widget.editorState,
),
),
)
.toList(),
)
],
)
],
);
}
}

View File

@ -43,7 +43,11 @@ class RichTextNodeWidget extends BuiltInTextWidget {
// customize
class _RichTextNodeWidgetState extends State<RichTextNodeWidget>
with SelectableMixin, DefaultSelectable, BuiltInStyleMixin {
with
SelectableMixin,
DefaultSelectable,
BuiltInStyleMixin,
BuiltInTextWidgetMixin {
@override
GlobalKey? get iconKey => null;
@ -59,7 +63,7 @@ class _RichTextNodeWidgetState extends State<RichTextNodeWidget>
}
@override
Widget build(BuildContext context) {
Widget buildWithSingle(BuildContext context) {
return Padding(
padding: padding,
child: FlowyRichText(

View File

@ -38,6 +38,7 @@ class AppFlowyEditor extends StatefulWidget {
this.customBuilders = const {},
this.shortcutEvents = const [],
this.selectionMenuItems = const [],
this.editable = true,
required this.editorStyle,
}) : super(key: key);
@ -53,6 +54,8 @@ class AppFlowyEditor extends StatefulWidget {
final EditorStyle editorStyle;
final bool editable;
@override
State<AppFlowyEditor> createState() => _AppFlowyEditorState();
}
@ -106,11 +109,14 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
cursorColor: widget.editorStyle.cursorColor,
selectionColor: widget.editorStyle.selectionColor,
editorState: editorState,
editable: widget.editable,
child: AppFlowyInput(
key: editorState.service.inputServiceKey,
editorState: editorState,
editable: widget.editable,
child: AppFlowyKeyboard(
key: editorState.service.keyboardServiceKey,
editable: widget.editable,
shortcutEvents: [
...builtInShortcutEvents,
...widget.shortcutEvents,

View File

@ -1,4 +1,5 @@
import 'package:appflowy_editor/src/infra/log.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -43,11 +44,13 @@ abstract class AppFlowyInputService {
class AppFlowyInput extends StatefulWidget {
const AppFlowyInput({
Key? key,
this.editable = true,
required this.editorState,
required this.child,
}) : super(key: key);
final EditorState editorState;
final bool editable;
final Widget child;
@override
@ -61,26 +64,39 @@ class _AppFlowyInputState extends State<AppFlowyInput>
EditorState get _editorState => widget.editorState;
// Disable space shortcut on the Web platform.
final Map<ShortcutActivator, Intent> _shortcuts = kIsWeb
? {
LogicalKeySet(LogicalKeyboardKey.space):
DoNothingAndStopPropagationIntent(),
}
: {};
@override
void initState() {
super.initState();
_editorState.service.selectionService.currentSelection
.addListener(_onSelectionChange);
if (widget.editable) {
_editorState.service.selectionService.currentSelection
.addListener(_onSelectionChange);
}
}
@override
void dispose() {
close();
_editorState.service.selectionService.currentSelection
.removeListener(_onSelectionChange);
if (widget.editable) {
close();
_editorState.service.selectionService.currentSelection
.removeListener(_onSelectionChange);
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
return Shortcuts(
shortcuts: _shortcuts,
child: widget.child,
);
}

View File

@ -1,8 +1,9 @@
import 'package:appflowy_editor/src/infra/infra.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/number_list_helper.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/extensions/path_extensions.dart';
// Handle delete text.
ShortcutEventHandler deleteTextHandler = (editorState, event) {
@ -121,32 +122,46 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
}
KeyEventResult _backDeleteToPreviousTextNode(
EditorState editorState,
TextNode textNode,
TransactionBuilder transactionBuilder,
List<Node> nonTextNodes,
Selection selection) {
var previous = textNode.previous;
bool prevIsNumberList = false;
while (previous != null) {
if (previous is TextNode) {
if (previous.subtype == BuiltInAttributeKey.numberList) {
prevIsNumberList = true;
}
EditorState editorState,
TextNode textNode,
TransactionBuilder transactionBuilder,
List<Node> nonTextNodes,
Selection selection,
) {
if (textNode.next == null &&
textNode.children.isEmpty &&
textNode.parent?.parent != null) {
transactionBuilder
..deleteNode(textNode)
..insertNode(textNode.parent!.path.next, textNode)
..afterSelection = Selection.collapsed(
Position(path: textNode.parent!.path.next, offset: 0),
)
..commit();
return KeyEventResult.handled;
}
transactionBuilder
..mergeText(previous, textNode)
..deleteNode(textNode)
..afterSelection = Selection.collapsed(
Position(
path: previous.path,
offset: previous.toRawString().length,
),
);
break;
} else {
previous = previous.previous;
bool prevIsNumberList = false;
final previousTextNode = Infra.forwardNearestTextNode(textNode);
if (previousTextNode != null) {
if (previousTextNode.subtype == BuiltInAttributeKey.numberList) {
prevIsNumberList = true;
}
transactionBuilder.mergeText(previousTextNode, textNode);
if (textNode.children.isNotEmpty) {
transactionBuilder.insertNodes(
previousTextNode.path.next,
textNode.children.toList(growable: false),
);
}
transactionBuilder.deleteNode(textNode);
transactionBuilder.afterSelection = Selection.collapsed(
Position(
path: previousTextNode.path,
offset: previousTextNode.toRawString().length,
),
);
}
if (transactionBuilder.operations.isNotEmpty) {
@ -157,8 +172,8 @@ KeyEventResult _backDeleteToPreviousTextNode(
}
if (prevIsNumberList) {
makeFollowingNodesIncremental(
editorState, previous!.path, transactionBuilder.afterSelection!);
makeFollowingNodesIncremental(editorState, previousTextNode!.path,
transactionBuilder.afterSelection!);
}
return KeyEventResult.handled;

View File

@ -1,9 +1,9 @@
import 'dart:collection';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy_editor/src/extensions/path_extensions.dart';
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
import './number_list_helper.dart';
/// Handle some cases where enter is pressed and shift is not pressed.
@ -16,10 +16,6 @@ import './number_list_helper.dart';
/// 2.2 or insert a empty text node before.
ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
(editorState, event) {
if (event.logicalKey != LogicalKeyboardKey.enter || event.isShiftPressed) {
return KeyEventResult.ignored;
}
var selection = editorState.service.selectionService.currentSelection.value;
var nodes = editorState.service.selectionService.currentSelectedNodes;
if (selection == null) {
@ -124,7 +120,10 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
TransactionBuilder(editorState)
..insertNode(
textNode.path,
TextNode.empty(),
textNode.copyWith(
children: LinkedList(),
delta: Delta(),
),
)
..afterSelection = afterSelection
..commit();
@ -142,21 +141,25 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
Position(path: nextPath, offset: 0),
);
TransactionBuilder(editorState)
..insertNode(
textNode.path.next,
textNode.copyWith(
attributes: attributes,
delta: textNode.delta.slice(selection.end.offset),
),
)
..deleteText(
textNode,
selection.start.offset,
textNode.toRawString().length - selection.start.offset,
)
..afterSelection = afterSelection
..commit();
final transactionBuilder = TransactionBuilder(editorState);
transactionBuilder.insertNode(
textNode.path.next,
textNode.copyWith(
attributes: attributes,
delta: textNode.delta.slice(selection.end.offset),
),
);
transactionBuilder.deleteText(
textNode,
selection.start.offset,
textNode.toRawString().length - selection.start.offset,
);
if (textNode.children.isNotEmpty) {
final children = textNode.children.toList(growable: false);
transactionBuilder.deleteNodes(children);
}
transactionBuilder.afterSelection = afterSelection;
transactionBuilder.commit();
// If the new type of a text node is number list,
// the numbers of the following nodes should be incremental.

View File

@ -0,0 +1,34 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
ShortcutEventHandler tabHandler = (editorState, event) {
// Only Supports BulletedList For Now.
final selection = editorState.service.selectionService.currentSelection.value;
final textNodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>();
if (textNodes.length != 1 || selection == null || !selection.isSingle) {
return KeyEventResult.ignored;
}
final textNode = textNodes.first;
final previous = textNode.previous;
if (textNode.subtype != BuiltInAttributeKey.bulletedList ||
previous == null ||
previous.subtype != BuiltInAttributeKey.bulletedList) {
return KeyEventResult.handled;
}
final path = previous.path + [previous.children.length];
final afterSelection = Selection(
start: selection.start.copyWith(path: path),
end: selection.end.copyWith(path: path),
);
TransactionBuilder(editorState)
..deleteNode(textNode)
..insertNode(path, textNode)
..setAfterSelection(afterSelection)
..commit();
return KeyEventResult.handled;
};

View File

@ -42,6 +42,7 @@ abstract class AppFlowyKeyboardService {
class AppFlowyKeyboard extends StatefulWidget {
const AppFlowyKeyboard({
Key? key,
this.editable = true,
required this.shortcutEvents,
required this.editorState,
required this.child,
@ -50,6 +51,7 @@ class AppFlowyKeyboard extends StatefulWidget {
final EditorState editorState;
final Widget child;
final List<ShortcutEvent> shortcutEvents;
final bool editable;
@override
State<AppFlowyKeyboard> createState() => _AppFlowyKeyboardState();
@ -62,7 +64,6 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
bool isFocus = true;
@override
// TODO: implement shortcutEvents
List<ShortcutEvent> get shortcutEvents => widget.shortcutEvents;
@override
@ -91,8 +92,12 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
@override
void enable() {
isFocus = true;
_focusNode.requestFocus();
if (widget.editable) {
isFocus = true;
_focusNode.requestFocus();
} else {
disable();
}
}
@override

View File

@ -84,6 +84,7 @@ class AppFlowySelection extends StatefulWidget {
Key? key,
this.cursorColor = const Color(0xFF00BCF0),
this.selectionColor = const Color.fromARGB(53, 111, 201, 231),
this.editable = true,
required this.editorState,
required this.child,
}) : super(key: key);
@ -92,6 +93,7 @@ class AppFlowySelection extends StatefulWidget {
final Widget child;
final Color cursorColor;
final Color selectionColor;
final bool editable;
@override
State<AppFlowySelection> createState() => _AppFlowySelectionState();
@ -144,15 +146,21 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
@override
Widget build(BuildContext context) {
return SelectionGestureDetector(
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
onPanEnd: _onPanEnd,
onTapDown: _onTapDown,
onDoubleTapDown: _onDoubleTapDown,
onTripleTapDown: _onTripleTapDown,
child: widget.child,
);
if (!widget.editable) {
return Container(
child: widget.child,
);
} else {
return SelectionGestureDetector(
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
onPanEnd: _onPanEnd,
onTapDown: _onTapDown,
onDoubleTapDown: _onDoubleTapDown,
onTripleTapDown: _onTripleTapDown,
child: widget.child,
);
}
}
@override
@ -184,6 +192,10 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
@override
void updateSelection(Selection? selection) {
if (!widget.editable) {
return;
}
selectionRects.clear();
clearSelection();
@ -323,6 +335,7 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
// compute the selection in range.
if (first != null && last != null) {
Log.selection.debug('first = $first, last = $last');
final start =
first.getSelectionInRange(panStartOffset, panEndOffset).start;
final end = last.getSelectionInRange(panStartOffset, panEndOffset).end;
@ -353,6 +366,8 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
final normalizedSelection = selection.normalize;
assert(normalizedSelection.isBackward);
Log.selection.debug('update selection areas, $normalizedSelection');
for (var i = 0; i < backwardNodes.length; i++) {
final node = backwardNodes[i];
final selectable = node.selectable;

View File

@ -9,6 +9,7 @@ import 'package:appflowy_editor/src/service/internal_key_event_handlers/redo_und
import 'package:appflowy_editor/src/service/internal_key_event_handlers/select_all_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/slash_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/format_style_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/tab_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/whitespace_handler.dart';
import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event.dart';
@ -243,4 +244,9 @@ List<ShortcutEvent> builtInShortcutEvents = [
command: 'page down',
handler: pageDownHandler,
),
ShortcutEvent(
key: 'Tab',
command: 'tab',
handler: tabHandler,
),
];

View File

@ -2,6 +2,7 @@ import 'dart:io';
import 'package:appflowy_editor/src/service/shortcut_event/keybinding.dart';
import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
import 'package:flutter/foundation.dart';
/// Defines the implementation of shortcut event.
class ShortcutEvent {
@ -56,7 +57,10 @@ class ShortcutEvent {
String? linuxCommand,
}) {
var matched = false;
if (Platform.isWindows &&
if (kIsWeb && command != null && command.isNotEmpty) {
this.command = command;
matched = true;
} else if (Platform.isWindows &&
windowsCommand != null &&
windowsCommand.isNotEmpty) {
this.command = windowsCommand;

View File

@ -0,0 +1,153 @@
import 'dart:collection';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
void main() async {
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
group('node.dart', () {
test('test node copyWith', () {
final node = Node(
type: 'example',
children: LinkedList(),
attributes: {
'example': 'example',
},
);
expect(node.toJson(), {
'type': 'example',
'attributes': {
'example': 'example',
},
});
expect(
node.copyWith().toJson(),
node.toJson(),
);
final nodeWithChildren = Node(
type: 'example',
children: LinkedList()..add(node),
attributes: {
'example': 'example',
},
);
expect(nodeWithChildren.toJson(), {
'type': 'example',
'attributes': {
'example': 'example',
},
'children': [
{
'type': 'example',
'attributes': {
'example': 'example',
},
},
],
});
expect(
nodeWithChildren.copyWith().toJson(),
nodeWithChildren.toJson(),
);
});
test('test textNode copyWith', () {
final textNode = TextNode(
type: 'example',
children: LinkedList(),
attributes: {
'example': 'example',
},
delta: Delta()..insert('AppFlowy'),
);
expect(textNode.toJson(), {
'type': 'example',
'attributes': {
'example': 'example',
},
'delta': [
{'insert': 'AppFlowy'},
],
});
expect(
textNode.copyWith().toJson(),
textNode.toJson(),
);
final textNodeWithChildren = TextNode(
type: 'example',
children: LinkedList()..add(textNode),
attributes: {
'example': 'example',
},
delta: Delta()..insert('AppFlowy'),
);
expect(textNodeWithChildren.toJson(), {
'type': 'example',
'attributes': {
'example': 'example',
},
'delta': [
{'insert': 'AppFlowy'},
],
'children': [
{
'type': 'example',
'attributes': {
'example': 'example',
},
'delta': [
{'insert': 'AppFlowy'},
],
},
],
});
expect(
textNodeWithChildren.copyWith().toJson(),
textNodeWithChildren.toJson(),
);
});
test('test node path', () {
Node previous = Node(
type: 'example',
attributes: {},
children: LinkedList(),
);
const len = 10;
for (var i = 0; i < len; i++) {
final node = Node(
type: 'example_$i',
attributes: {},
children: LinkedList(),
);
previous.children.add(node..parent = previous);
previous = node;
}
expect(previous.path, List.filled(len, 0));
});
test('test copy with', () {
final child = Node(
type: 'child',
attributes: {},
children: LinkedList(),
);
final base = Node(
type: 'base',
attributes: {},
children: LinkedList()..add(child),
);
final node = base.copyWith(
type: 'node',
);
expect(identical(node.attributes, base.attributes), false);
expect(identical(node.children, base.children), false);
expect(identical(node.children.first, base.children.first), false);
});
});
}

View File

@ -0,0 +1,38 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:appflowy_editor/src/extensions/path_extensions.dart';
void main() async {
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
group('path_extensions.dart', () {
test('test path equality', () {
var p1 = [0, 0];
var p2 = [0];
expect(p1 > p2, true);
expect(p1 >= p2, true);
expect(p1 < p2, false);
expect(p1 <= p2, false);
p1 = [1, 1, 2];
p2 = [1, 1, 3];
expect(p2 > p1, true);
expect(p2 >= p1, true);
expect(p2 < p1, false);
expect(p2 <= p1, false);
p1 = [2, 0, 1];
p2 = [2, 0, 1];
expect(p2 > p1, false);
expect(p1 > p2, false);
expect(p2 >= p1, true);
expect(p2 <= p1, true);
expect(pathEquals(p1, p2), true);
});
});
}

View File

@ -0,0 +1,51 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/infra/infra.dart';
import 'package:flutter_test/flutter_test.dart';
void main() async {
group('infra.dart', () {
test('find the last text node', () {
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
const text = 'Welcome to Appflowy 😁';
TextNode textNode() {
return TextNode(
type: 'text',
delta: Delta()..insert(text),
);
}
final node110 = textNode();
final node111 = textNode();
final node11 = textNode()
..insert(node110)
..insert(node111);
final node10 = textNode();
final node1 = textNode()
..insert(node10)
..insert(node11);
final node0 = textNode();
final node = textNode()
..insert(node0)
..insert(node1);
expect(Infra.findLastTextNode(node)?.path, [1, 1, 1]);
expect(Infra.findLastTextNode(node0)?.path, [0]);
expect(Infra.findLastTextNode(node1)?.path, [1, 1, 1]);
expect(Infra.findLastTextNode(node10)?.path, [1, 0]);
expect(Infra.findLastTextNode(node11)?.path, [1, 1, 1]);
expect(Infra.forwardNearestTextNode(node111)?.path, [1, 1, 0]);
expect(Infra.forwardNearestTextNode(node110)?.path, [1, 1]);
expect(Infra.forwardNearestTextNode(node11)?.path, [1, 0]);
expect(Infra.forwardNearestTextNode(node10)?.path, [1]);
expect(Infra.forwardNearestTextNode(node1)?.path, [0]);
expect(Infra.forwardNearestTextNode(node0)?.path, []);
});
});
}

View File

@ -19,6 +19,7 @@ class EditorWidgetTester {
EditorState get editorState => _editorState;
Node get root => _editorState.document.root;
StateTree get document => _editorState.document;
int get documentLength => _editorState.document.root.children.length;
Selection? get documentSelection =>
_editorState.service.selectionService.currentSelection.value;

View File

@ -1,49 +0,0 @@
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_item_widget.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../infra/test_editor.dart';
void main() async {
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
group('selection_menu_item_widget.dart', () {
testWidgets('test selection menu item widget', (tester) async {
bool flag = false;
final editorState = tester.editor.editorState;
final menuService = _TestSelectionMenuService();
const icon = Icon(Icons.abc);
final item = SelectionMenuItem(
name: () => 'example',
icon: icon,
keywords: ['example A', 'example B'],
handler: (editorState, menuService, context) {
flag = true;
},
);
final widget = SelectionMenuItemWidget(
editorState: editorState,
menuService: menuService,
item: item,
isSelected: true,
);
await tester.pumpWidget(MaterialApp(home: widget));
await tester.tap(find.byType(SelectionMenuItemWidget));
expect(flag, true);
});
});
}
class _TestSelectionMenuService implements SelectionMenuService {
@override
void dismiss() {}
@override
void show() {}
@override
Offset get topLeft => throw UnimplementedError();
}

View File

@ -1,5 +1,4 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_item_widget.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';

View File

@ -1,10 +1,12 @@
import 'dart:collection';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/render/image/image_node_widget.dart';
import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:network_image_mock/network_image_mock.dart';
import '../../infra/test_editor.dart';
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
void main() async {
setUpAll(() {
@ -267,6 +269,140 @@ void main() async {
BuiltInAttributeKey.h1,
);
});
testWidgets('Delete the nested bulleted list', (tester) async {
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
const text = 'Welcome to Appflowy 😁';
final node = TextNode(
type: 'text',
delta: Delta()..insert(text),
attributes: {
BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList,
},
);
node.insert(
node.copyWith()
..insert(
node.copyWith(),
),
);
final editor = tester.editor..insert(node);
await editor.startTesting();
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// Welcome to Appflowy 😁
await editor.updateSelection(
Selection.single(path: [0, 0, 0], startOffset: 0),
);
await editor.pressLogicKey(LogicalKeyboardKey.backspace);
expect(editor.nodeAtPath([0, 0, 0])?.subtype, null);
await editor.updateSelection(
Selection.single(path: [0, 0, 0], startOffset: 0),
);
await editor.pressLogicKey(LogicalKeyboardKey.backspace);
expect(editor.nodeAtPath([0, 1]) != null, true);
await editor.updateSelection(
Selection.single(path: [0, 1], startOffset: 0),
);
await editor.pressLogicKey(LogicalKeyboardKey.backspace);
expect(editor.nodeAtPath([1]) != null, true);
await editor.updateSelection(
Selection.single(path: [1], startOffset: 0),
);
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁Welcome to Appflowy 😁
await editor.pressLogicKey(LogicalKeyboardKey.backspace);
expect(
editor.documentSelection,
Selection.single(path: [0, 0], startOffset: text.length),
);
expect((editor.nodeAtPath([0, 0]) as TextNode).toRawString(), text * 2);
});
testWidgets('Delete the complicated nested bulleted list', (tester) async {
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
const text = 'Welcome to Appflowy 😁';
final node = TextNode(
type: 'text',
delta: Delta()..insert(text),
attributes: {
BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList,
},
);
node
..insert(
node.copyWith(children: LinkedList()),
)
..insert(
node.copyWith(children: LinkedList())
..insert(
node.copyWith(children: LinkedList()),
)
..insert(
node.copyWith(children: LinkedList()),
),
);
final editor = tester.editor..insert(node);
await editor.startTesting();
await editor.updateSelection(
Selection.single(path: [0, 1], startOffset: 0),
);
await editor.pressLogicKey(LogicalKeyboardKey.backspace);
expect(
editor.nodeAtPath([0, 1])!.subtype != BuiltInAttributeKey.bulletedList,
true,
);
expect(
editor.nodeAtPath([0, 1, 0])!.subtype,
BuiltInAttributeKey.bulletedList,
);
expect(
editor.nodeAtPath([0, 1, 1])!.subtype,
BuiltInAttributeKey.bulletedList,
);
expect(find.byType(FlowyRichText), findsNWidgets(5));
// Before
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// After
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
await editor.pressLogicKey(LogicalKeyboardKey.backspace);
expect(
editor.nodeAtPath([0, 0])!.subtype == BuiltInAttributeKey.bulletedList,
true,
);
expect(
(editor.nodeAtPath([0, 0]) as TextNode).toRawString() == text * 2,
true,
);
expect(
editor.nodeAtPath([0, 1])!.subtype == BuiltInAttributeKey.bulletedList,
true,
);
expect(
editor.nodeAtPath([0, 2])!.subtype == BuiltInAttributeKey.bulletedList,
true,
);
});
}
Future<void> _deleteFirstImage(WidgetTester tester, bool isBackward) async {

View File

@ -0,0 +1,151 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../infra/test_editor.dart';
void main() async {
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
group('tab_handler.dart', () {
testWidgets('press tab in plain text', (tester) async {
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor
..insertTextNode(text)
..insertTextNode(text);
await editor.startTesting();
final document = editor.document;
var selection = Selection.single(path: [0], startOffset: 0);
await editor.updateSelection(selection);
await editor.pressLogicKey(LogicalKeyboardKey.tab);
// nothing happens
expect(editor.documentSelection, selection);
expect(editor.document.toJson(), document.toJson());
selection = Selection.single(path: [1], startOffset: 0);
await editor.updateSelection(selection);
await editor.pressLogicKey(LogicalKeyboardKey.tab);
// nothing happens
expect(editor.documentSelection, selection);
expect(editor.document.toJson(), document.toJson());
});
testWidgets('press tab in bulleted list', (tester) async {
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor
..insertTextNode(
text,
attributes: {
BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList
},
)
..insertTextNode(
text,
attributes: {
BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList
},
)
..insertTextNode(
text,
attributes: {
BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList
},
);
await editor.startTesting();
var document = editor.document;
var selection = Selection.single(path: [0], startOffset: 0);
await editor.updateSelection(selection);
await editor.pressLogicKey(LogicalKeyboardKey.tab);
// nothing happens
expect(editor.documentSelection, selection);
expect(editor.document.toJson(), document.toJson());
// Before
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// After
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
selection = Selection.single(path: [1], startOffset: 0);
await editor.updateSelection(selection);
await editor.pressLogicKey(LogicalKeyboardKey.tab);
expect(
editor.documentSelection,
Selection.single(path: [0, 0], startOffset: 0),
);
expect(editor.nodeAtPath([0])!.subtype, BuiltInAttributeKey.bulletedList);
expect(editor.nodeAtPath([1])!.subtype, BuiltInAttributeKey.bulletedList);
expect(editor.nodeAtPath([2]), null);
expect(
editor.nodeAtPath([0, 0])!.subtype, BuiltInAttributeKey.bulletedList);
selection = Selection.single(path: [1], startOffset: 0);
await editor.updateSelection(selection);
await editor.pressLogicKey(LogicalKeyboardKey.tab);
expect(
editor.documentSelection,
Selection.single(path: [0, 1], startOffset: 0),
);
expect(editor.nodeAtPath([0])!.subtype, BuiltInAttributeKey.bulletedList);
expect(editor.nodeAtPath([1]), null);
expect(editor.nodeAtPath([2]), null);
expect(
editor.nodeAtPath([0, 0])!.subtype, BuiltInAttributeKey.bulletedList);
expect(
editor.nodeAtPath([0, 1])!.subtype, BuiltInAttributeKey.bulletedList);
// Before
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// After
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
document = editor.document;
selection = Selection.single(path: [0, 0], startOffset: 0);
await editor.updateSelection(selection);
await editor.pressLogicKey(LogicalKeyboardKey.tab);
expect(
editor.documentSelection,
Selection.single(path: [0, 0], startOffset: 0),
);
expect(editor.document.toJson(), document.toJson());
selection = Selection.single(path: [0, 1], startOffset: 0);
await editor.updateSelection(selection);
await editor.pressLogicKey(LogicalKeyboardKey.tab);
expect(
editor.documentSelection,
Selection.single(path: [0, 0, 0], startOffset: 0),
);
expect(
editor.nodeAtPath([0])!.subtype,
BuiltInAttributeKey.bulletedList,
);
expect(
editor.nodeAtPath([0, 0])!.subtype,
BuiltInAttributeKey.bulletedList,
);
expect(editor.nodeAtPath([0, 1]), null);
expect(
editor.nodeAtPath([0, 0, 0])!.subtype,
BuiltInAttributeKey.bulletedList,
);
});
});
}

View File

@ -136,7 +136,6 @@ class PopoverState extends State<Popover> {
return Stack(children: children);
});
_rootEntry.addEntry(context, this, newEntry, widget.asBarrier);
}
@ -243,7 +242,7 @@ class PopoverContainerState extends State<PopoverContainer> {
);
}
close() => widget.onClose();
void close() => widget.onClose();
closeAll() => widget.onCloseAll();
void closeAll() => widget.onCloseAll();
}

View File

@ -104,7 +104,7 @@ class AppTheme {
..tint6 = const Color(0xfff5ffdc)
..tint7 = const Color(0xffddffd6)
..tint8 = const Color(0xffdefff1)
..tint9 = const Color(0xffdefff1)
..tint9 = const Color(0xffe1fbff)
..main1 = const Color(0xff00bcf0)
..main2 = const Color(0xff00b7ea)
..textColor = _black
@ -152,7 +152,8 @@ class AppTheme {
ThemeData get themeData {
var t = ThemeData(
textTheme: TextTheme(bodyText2: TextStyle(color: textColor)),
textSelectionTheme: TextSelectionThemeData(cursorColor: main2, selectionHandleColor: main2),
textSelectionTheme: TextSelectionThemeData(
cursorColor: main2, selectionHandleColor: main2),
primaryIconTheme: IconThemeData(color: hover),
iconTheme: IconThemeData(color: shader1),
canvasColor: shader6,
@ -179,7 +180,8 @@ class AppTheme {
toggleableActiveColor: main1);
}
Color shift(Color c, double d) => ColorUtils.shiftHsl(c, d * (isDark ? -1 : 1));
Color shift(Color c, double d) =>
ColorUtils.shiftHsl(c, d * (isDark ? -1 : 1));
}
class ColorUtils {
@ -188,14 +190,18 @@ class ColorUtils {
return hslc.withLightness((hslc.lightness + amt).clamp(0.0, 1.0)).toColor();
}
static Color parseHex(String value) => Color(int.parse(value.substring(1, 7), radix: 16) + 0xFF000000);
static Color parseHex(String value) =>
Color(int.parse(value.substring(1, 7), radix: 16) + 0xFF000000);
static Color blend(Color dst, Color src, double opacity) {
return Color.fromARGB(
255,
(dst.red.toDouble() * (1.0 - opacity) + src.red.toDouble() * opacity).toInt(),
(dst.green.toDouble() * (1.0 - opacity) + src.green.toDouble() * opacity).toInt(),
(dst.blue.toDouble() * (1.0 - opacity) + src.blue.toDouble() * opacity).toInt(),
(dst.red.toDouble() * (1.0 - opacity) + src.red.toDouble() * opacity)
.toInt(),
(dst.green.toDouble() * (1.0 - opacity) + src.green.toDouble() * opacity)
.toInt(),
(dst.blue.toDouble() * (1.0 - opacity) + src.blue.toDouble() * opacity)
.toInt(),
);
}
}

View File

@ -218,8 +218,8 @@ class OverlayScreen extends StatelessWidget {
overlapBehaviour: providerContext
.read<OverlayDemoConfiguration>()
.overlapBehaviour,
width: 200.0,
height: 200.0,
constraints:
BoxConstraints.tight(const Size(200, 200)),
);
},
child: const Text('Show List Overlay'),

View File

@ -9,4 +9,4 @@ export 'src/flowy_overlay/flowy_overlay.dart';
export 'src/flowy_overlay/list_overlay.dart';
export 'src/flowy_overlay/option_overlay.dart';
export 'src/flowy_overlay/flowy_dialog.dart';
export 'src/flowy_overlay/appflowy_stype_popover.dart';
export 'src/flowy_overlay/appflowy_popover.dart';

View File

@ -1,7 +1,10 @@
import 'package:flowy_infra_ui/flowy_infra_ui_web.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flutter/material.dart';
import 'package:flowy_infra/theme.dart';
import 'package:flowy_infra_ui/style_widget/decoration.dart';
import 'package:provider/provider.dart';
class AppFlowyPopover extends StatelessWidget {
final Widget child;
final PopoverController? controller;
@ -13,6 +16,7 @@ class AppFlowyPopover extends StatelessWidget {
final PopoverMutex? mutex;
final Offset? offset;
final bool asBarrier;
final EdgeInsets margin;
const AppFlowyPopover({
Key? key,
@ -26,6 +30,7 @@ class AppFlowyPopover extends StatelessWidget {
this.offset,
this.controller,
this.asBarrier = false,
this.margin = const EdgeInsets.all(12),
}) : super(key: key);
@override
@ -39,13 +44,44 @@ class AppFlowyPopover extends StatelessWidget {
triggerActions: triggerActions,
popupBuilder: (context) {
final child = popupBuilder(context);
debugPrint('$child popover');
return OverlayContainer(
debugPrint('Show $child popover');
return _PopoverContainer(
constraints: constraints,
child: popupBuilder(context),
margin: margin,
child: child,
);
},
child: child,
);
}
}
class _PopoverContainer extends StatelessWidget {
final Widget child;
final BoxConstraints? constraints;
final EdgeInsets margin;
const _PopoverContainer({
required this.child,
required this.margin,
this.constraints,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = context.watch<AppTheme>();
final decoration = FlowyDecoration.decoration(
theme.surface,
theme.shadowColor.withOpacity(0.15),
);
return Material(
type: MaterialType.transparency,
child: Container(
padding: margin,
decoration: decoration,
constraints: constraints,
child: child,
),
);
}
}

View File

@ -4,7 +4,6 @@ import 'dart:ui';
import 'package:flowy_infra_ui/src/flowy_overlay/layout.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
export './overlay_container.dart';
/// Specifies how overlay are anchored to the SourceWidget
enum AnchorDirection {

View File

@ -1,5 +1,10 @@
import 'package:flowy_infra_ui/flowy_infra_ui_web.dart';
import 'dart:math';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flowy_infra/theme.dart';
import 'package:flowy_infra_ui/style_widget/decoration.dart';
import 'package:provider/provider.dart';
class ListOverlayFooter {
Widget widget;
@ -16,46 +21,55 @@ class ListOverlay extends StatelessWidget {
const ListOverlay({
Key? key,
required this.itemBuilder,
this.itemCount,
this.itemCount = 0,
this.controller,
this.width = double.infinity,
this.height = double.infinity,
this.constraints = const BoxConstraints(),
this.footer,
}) : super(key: key);
final IndexedWidgetBuilder itemBuilder;
final int? itemCount;
final int itemCount;
final ScrollController? controller;
final double width;
final double height;
final BoxConstraints constraints;
final ListOverlayFooter? footer;
@override
Widget build(BuildContext context) {
const padding = EdgeInsets.symmetric(horizontal: 6, vertical: 6);
double totalHeight = height + padding.vertical;
double totalHeight = constraints.minHeight + padding.vertical;
if (footer != null) {
totalHeight = totalHeight + footer!.height + footer!.padding.vertical;
}
final innerConstraints = BoxConstraints(
minHeight: totalHeight,
maxHeight: max(constraints.maxHeight, totalHeight),
minWidth: constraints.minWidth,
maxWidth: constraints.maxWidth,
);
List<Widget> children = [];
for (var i = 0; i < itemCount; i++) {
children.add(itemBuilder(context, i));
}
return OverlayContainer(
constraints: BoxConstraints.tight(Size(width, totalHeight)),
constraints: innerConstraints,
padding: padding,
child: SingleChildScrollView(
child: Column(
children: [
ListView.builder(
shrinkWrap: true,
itemBuilder: itemBuilder,
itemCount: itemCount,
controller: controller,
),
if (footer != null)
Padding(
padding: footer!.padding,
child: footer!.widget,
),
],
scrollDirection: Axis.horizontal,
child: IntrinsicWidth(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
...children,
if (footer != null)
Padding(
padding: footer!.padding,
child: footer!.widget,
),
],
),
),
),
);
@ -65,10 +79,9 @@ class ListOverlay extends StatelessWidget {
BuildContext context, {
required String identifier,
required IndexedWidgetBuilder itemBuilder,
int? itemCount,
int itemCount = 0,
ScrollController? controller,
double width = double.infinity,
double height = double.infinity,
BoxConstraints constraints = const BoxConstraints(),
required BuildContext anchorContext,
AnchorDirection? anchorDirection,
FlowyOverlayDelegate? delegate,
@ -82,8 +95,7 @@ class ListOverlay extends StatelessWidget {
itemBuilder: itemBuilder,
itemCount: itemCount,
controller: controller,
width: width,
height: height,
constraints: constraints,
footer: footer,
),
identifier: identifier,
@ -96,3 +108,35 @@ class ListOverlay extends StatelessWidget {
);
}
}
const overlayContainerPadding = EdgeInsets.all(12);
class OverlayContainer extends StatelessWidget {
final Widget child;
final BoxConstraints? constraints;
final EdgeInsets padding;
const OverlayContainer({
required this.child,
this.constraints,
this.padding = overlayContainerPadding,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme =
context.watch<AppTheme?>() ?? AppTheme.fromType(ThemeType.light);
return Material(
type: MaterialType.transparency,
child: Container(
padding: padding,
decoration: FlowyDecoration.decoration(
theme.surface,
theme.shadowColor.withOpacity(0.15),
),
constraints: constraints,
child: child,
),
);
}
}

View File

@ -1,34 +0,0 @@
import 'package:flowy_infra/theme.dart';
import 'package:flowy_infra_ui/style_widget/decoration.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
const overlayContainerPadding = EdgeInsets.all(12);
class OverlayContainer extends StatelessWidget {
final Widget child;
final BoxConstraints? constraints;
final EdgeInsets padding;
const OverlayContainer({
required this.child,
this.constraints,
this.padding = overlayContainerPadding,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme =
context.watch<AppTheme?>() ?? AppTheme.fromType(ThemeType.light);
return Material(
type: MaterialType.transparency,
child: Container(
padding: padding,
decoration: FlowyDecoration.decoration(
theme.surface, theme.shadowColor.withOpacity(0.15)),
constraints: constraints,
child: child,
),
);
}
}

View File

@ -12,6 +12,8 @@ class FlowyButton extends StatelessWidget {
final Widget? rightIcon;
final Color hoverColor;
final bool isSelected;
final BorderRadius radius;
const FlowyButton({
Key? key,
required this.text,
@ -22,6 +24,7 @@ class FlowyButton extends StatelessWidget {
this.rightIcon,
this.hoverColor = Colors.transparent,
this.isSelected = false,
this.radius = const BorderRadius.all(Radius.circular(6)),
}) : super(key: key);
@override
@ -29,8 +32,10 @@ class FlowyButton extends StatelessWidget {
return InkWell(
onTap: onTap,
child: FlowyHover(
style:
HoverStyle(borderRadius: BorderRadius.zero, hoverColor: hoverColor),
style: HoverStyle(
borderRadius: radius,
hoverColor: hoverColor,
),
onHover: onHover,
setSelected: () => isSelected,
builder: (context, onHover) => _render(),

View File

@ -221,8 +221,9 @@ impl DefaultFolderBuilder {
initial_quill_delta_string()
};
let _ = view_controller.set_latest_view(&view.id);
let layout_type = ViewLayoutTypePB::from(view.layout.clone());
let _ = view_controller
.create_view(&view.id, ViewDataTypePB::Text, Bytes::from(view_data))
.create_view(&view.id, ViewDataTypePB::Text, layout_type, Bytes::from(view_data))
.await?;
}
}
@ -249,11 +250,17 @@ impl FolderManager {
pub trait ViewDataProcessor {
fn initialize(&self) -> FutureResult<(), FlowyError>;
fn create_container(&self, user_id: &str, view_id: &str, delta_data: Bytes) -> FutureResult<(), FlowyError>;
fn create_container(
&self,
user_id: &str,
view_id: &str,
layout: ViewLayoutTypePB,
delta_data: Bytes,
) -> FutureResult<(), FlowyError>;
fn close_container(&self, view_id: &str) -> FutureResult<(), FlowyError>;
fn get_delta_data(&self, view_id: &str) -> FutureResult<Bytes, FlowyError>;
fn get_view_data(&self, view_id: &str) -> FutureResult<Bytes, FlowyError>;
fn create_default_view(
&self,
@ -267,6 +274,7 @@ pub trait ViewDataProcessor {
user_id: &str,
view_id: &str,
data: Vec<u8>,
layout: ViewLayoutTypePB,
) -> FutureResult<Bytes, FlowyError>;
fn data_type(&self) -> ViewDataTypePB;

View File

@ -1,5 +1,5 @@
pub use crate::entities::view::ViewDataTypePB;
use crate::entities::ViewInfoPB;
use crate::entities::{ViewInfoPB, ViewLayoutTypePB};
use crate::manager::{ViewDataProcessor, ViewDataProcessorMap};
use crate::{
dart_notification::{send_dart_notification, FolderNotification},
@ -61,16 +61,28 @@ impl ViewController {
let processor = self.get_data_processor(params.data_type.clone())?;
let user_id = self.user.user_id()?;
if params.view_content_data.is_empty() {
tracing::trace!("Create view with build-in data");
let view_data = processor
.create_default_view(&user_id, &params.view_id, params.layout.clone())
.await?;
params.view_content_data = view_data.to_vec();
} else {
tracing::trace!("Create view with view data");
let delta_data = processor
.create_view_from_delta_data(&user_id, &params.view_id, params.view_content_data.clone())
.create_view_from_delta_data(
&user_id,
&params.view_id,
params.view_content_data.clone(),
params.layout.clone(),
)
.await?;
let _ = self
.create_view(&params.view_id, params.data_type.clone(), delta_data)
.create_view(
&params.view_id,
params.data_type.clone(),
params.layout.clone(),
delta_data,
)
.await?;
};
@ -84,6 +96,7 @@ impl ViewController {
&self,
view_id: &str,
data_type: ViewDataTypePB,
layout_type: ViewLayoutTypePB,
delta_data: Bytes,
) -> Result<(), FlowyError> {
if delta_data.is_empty() {
@ -91,7 +104,9 @@ impl ViewController {
}
let user_id = self.user.user_id()?;
let processor = self.get_data_processor(data_type)?;
let _ = processor.create_container(&user_id, view_id, delta_data).await?;
let _ = processor
.create_container(&user_id, view_id, layout_type, delta_data)
.await?;
Ok(())
}
@ -218,7 +233,7 @@ impl ViewController {
.await?;
let processor = self.get_data_processor(view_rev.data_type.clone())?;
let delta_bytes = processor.get_delta_data(view_id).await?;
let view_data = processor.get_view_data(view_id).await?;
let duplicate_params = CreateViewParams {
belong_to_id: view_rev.app_id.clone(),
name: format!("{} (copy)", &view_rev.name),
@ -226,7 +241,7 @@ impl ViewController {
thumbnail: view_rev.thumbnail,
data_type: view_rev.data_type.into(),
layout: view_rev.layout.into(),
view_content_data: delta_bytes.to_vec(),
view_content_data: view_data.to_vec(),
view_id: gen_view_id(),
};

View File

@ -33,8 +33,3 @@ impl std::convert::From<GridNotification> for i32 {
pub fn send_dart_notification(id: &str, ty: GridNotification) -> DartNotifyBuilder {
DartNotifyBuilder::new(id, ty, OBSERVABLE_CATEGORY)
}
#[tracing::instrument(level = "trace")]
pub fn send_anonymous_dart_notification(ty: GridNotification) -> DartNotifyBuilder {
DartNotifyBuilder::new("", ty, OBSERVABLE_CATEGORY)
}

View File

@ -44,7 +44,7 @@ pub(crate) async fn update_grid_setting_handler(
let editor = manager.get_grid_editor(&params.grid_id)?;
if let Some(insert_params) = params.insert_group {
let _ = editor.create_group(insert_params).await?;
let _ = editor.insert_group(insert_params).await?;
}
if let Some(delete_params) = params.delete_group {

View File

@ -73,7 +73,12 @@ macro_rules! impl_type_option {
match serde_json::from_str(s) {
Ok(obj) => obj,
Err(err) => {
tracing::error!("{} convert from any data failed, {:?}", stringify!($target), err);
tracing::error!(
"{} type option deserialize from {} failed, {:?}",
stringify!($target),
s,
err
);
$target::default()
}
}

View File

@ -1,3 +1,4 @@
use crate::entities::GridLayout;
use crate::services::block_editor::GridBlockRevisionCompactor;
use crate::services::grid_editor::{GridRevisionCompactor, GridRevisionEditor};
use crate::services::grid_view_manager::make_grid_view_rev_manager;
@ -178,10 +179,18 @@ impl GridManager {
pub async fn make_grid_view_data(
user_id: &str,
view_id: &str,
layout: GridLayout,
grid_manager: Arc<GridManager>,
build_context: BuildGridContext,
) -> FlowyResult<Bytes> {
for block_meta_data in &build_context.blocks {
let BuildGridContext {
field_revs,
block_metas,
blocks,
grid_view_revision_data,
} = build_context;
for block_meta_data in &blocks {
let block_id = &block_meta_data.block_id;
// Indexing the block's rows
block_meta_data.rows.iter().for_each(|row| {
@ -198,7 +207,7 @@ pub async fn make_grid_view_data(
// Will replace the grid_id with the value returned by the gen_grid_id()
let grid_id = view_id.to_owned();
let grid_rev = GridRevision::from_build_context(&grid_id, build_context);
let grid_rev = GridRevision::from_build_context(&grid_id, field_revs, block_metas);
// Create grid
let grid_rev_delta = make_grid_delta(&grid_rev);
@ -208,7 +217,11 @@ pub async fn make_grid_view_data(
let _ = grid_manager.create_grid(&grid_id, repeated_revision).await?;
// Create grid view
let grid_view = GridViewRevision::new(grid_id, view_id.to_owned());
let grid_view = if grid_view_revision_data.is_empty() {
GridViewRevision::new(grid_id, view_id.to_owned(), layout.into())
} else {
GridViewRevision::from_json(grid_view_revision_data)?
};
let grid_view_delta = make_grid_view_delta(&grid_view);
let grid_view_delta_bytes = grid_view_delta.json_bytes();
let repeated_revision: RepeatedRevision =

View File

@ -19,7 +19,10 @@ impl std::str::FromStr for AnyCellData {
type Err = FlowyError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let type_option_cell_data: AnyCellData = serde_json::from_str(s)?;
let type_option_cell_data: AnyCellData = serde_json::from_str(s).map_err(|err| {
let msg = format!("Deserialize {} to any cell data failed. Serde error: {}", s, err);
FlowyError::internal().context(msg)
})?;
Ok(type_option_cell_data)
}
}

View File

@ -1,6 +1,7 @@
use crate::entities::FieldType;
use crate::services::cell::{AnyCellData, CellBytes};
use crate::services::field::*;
use std::fmt::Debug;
use flowy_error::{ErrorCode, FlowyError, FlowyResult};
use flowy_grid_data_model::revision::{CellRevision, FieldRevision, FieldTypeRevision};
@ -73,20 +74,28 @@ pub fn apply_cell_data_changeset<C: ToString, T: AsRef<FieldRevision>>(
Ok(AnyCellData::new(s, field_type).json())
}
pub fn decode_any_cell_data<T: TryInto<AnyCellData>>(data: T, field_rev: &FieldRevision) -> CellBytes {
if let Ok(any_cell_data) = data.try_into() {
let AnyCellData { data, field_type } = any_cell_data;
let to_field_type = field_rev.ty.into();
match try_decode_cell_data(data.into(), field_rev, &field_type, &to_field_type) {
Ok(cell_bytes) => cell_bytes,
Err(e) => {
tracing::error!("Decode cell data failed, {:?}", e);
CellBytes::default()
pub fn decode_any_cell_data<T: TryInto<AnyCellData, Error = FlowyError> + Debug>(
data: T,
field_rev: &FieldRevision,
) -> CellBytes {
match data.try_into() {
Ok(any_cell_data) => {
let AnyCellData { data, field_type } = any_cell_data;
let to_field_type = field_rev.ty.into();
match try_decode_cell_data(data.into(), field_rev, &field_type, &to_field_type) {
Ok(cell_bytes) => cell_bytes,
Err(e) => {
tracing::error!("Decode cell data failed, {:?}", e);
CellBytes::default()
}
}
}
} else {
tracing::error!("Decode type option data failed");
CellBytes::default()
Err(_err) => {
// It's okay to ignore this error, because it's okay that the current cell can't
// display the existing cell data. For example, the UI of the text cell will be blank if
// the type of the data of cell is Number.
CellBytes::default()
}
}
}

View File

@ -24,7 +24,7 @@ mod tests {
assert_date(&type_option, 1647251762, None, "2022-03-14", &field_rev);
}
DateFormat::Local => {
assert_date(&type_option, 1647251762, None, "2022/03/14", &field_rev);
assert_date(&type_option, 1647251762, None, "03/14/2022", &field_rev);
}
}
}

View File

@ -153,7 +153,7 @@ impl DateFormat {
// https://docs.rs/chrono/0.4.19/chrono/format/strftime/index.html
pub fn format_str(&self) -> &'static str {
match self {
DateFormat::Local => "%Y/%m/%d",
DateFormat::Local => "%m/%d/%Y",
DateFormat::US => "%Y/%m/%d",
DateFormat::ISO => "%Y-%m-%d",
DateFormat::Friendly => "%b %d,%Y",

View File

@ -179,23 +179,20 @@ impl GridRevisionEditor {
None => Err(ErrorCode::FieldDoesNotExist.into()),
Some(field_type) => {
let _ = self.update_field_rev(params, field_type).await?;
match self.view_manager.did_update_field(&field_id).await {
Ok(_) => {}
Err(e) => tracing::error!("View manager update field failed: {:?}", e),
}
let _ = self.notify_did_update_grid_field(&field_id).await?;
Ok(())
}
}
}
// Replaces the field revision with new field revision.
pub async fn replace_field(&self, field_rev: Arc<FieldRevision>) -> FlowyResult<()> {
let field_id = field_rev.id.clone();
let _ = self
.modify(|grid_pad| Ok(grid_pad.replace_field_rev(field_rev.clone())?))
.await?;
match self.view_manager.did_update_field(&field_rev.id).await {
match self.view_manager.did_update_field(&field_rev.id, false).await {
Ok(_) => {}
Err(e) => tracing::error!("View manager update field failed: {:?}", e),
}
@ -279,6 +276,7 @@ impl GridRevisionEditor {
}
async fn update_field_rev(&self, params: FieldChangesetParams, field_type: FieldType) -> FlowyResult<()> {
let mut is_type_option_changed = false;
let _ = self
.modify(|grid| {
let deserializer = TypeOptionJsonDeserializer(field_type);
@ -319,6 +317,7 @@ impl GridRevisionEditor {
Ok(json_str) => {
let field_type = field.ty;
field.insert_type_option_str(&field_type, json_str);
is_type_option_changed = true;
is_changed = Some(())
}
Err(err) => {
@ -333,7 +332,11 @@ impl GridRevisionEditor {
})
.await?;
match self.view_manager.did_update_field(&params.field_id).await {
match self
.view_manager
.did_update_field(&params.field_id, is_type_option_changed)
.await
{
Ok(_) => {}
Err(e) => tracing::error!("View manager update field failed: {:?}", e),
}
@ -537,7 +540,7 @@ impl GridRevisionEditor {
self.view_manager.get_filters().await
}
pub async fn create_group(&self, params: InsertGroupParams) -> FlowyResult<()> {
pub async fn insert_group(&self, params: InsertGroupParams) -> FlowyResult<()> {
self.view_manager.insert_or_update_group(params).await
}
@ -673,6 +676,7 @@ impl GridRevisionEditor {
pub async fn duplicate_grid(&self) -> FlowyResult<BuildGridContext> {
let grid_pad = self.grid_pad.read().await;
let grid_view_revision_data = self.view_manager.duplicate_grid_view().await?;
let original_blocks = grid_pad.get_block_meta_revs();
let (duplicated_fields, duplicated_blocks) = grid_pad.duplicate_grid_block_meta().await;
@ -698,6 +702,7 @@ impl GridRevisionEditor {
field_revs: duplicated_fields.into_iter().map(Arc::new).collect(),
block_metas: duplicated_blocks,
blocks: blocks_meta_data,
grid_view_revision_data,
})
}

View File

@ -74,6 +74,11 @@ impl GridViewRevisionEditor {
})
}
pub(crate) async fn duplicate_view_data(&self) -> FlowyResult<String> {
let json_str = self.pad.read().await.json_str()?;
Ok(json_str)
}
pub(crate) async fn will_create_row(&self, row_rev: &mut RowRevision, params: &CreateRowParams) {
if params.group_id.is_none() {
return;
@ -277,6 +282,7 @@ impl GridViewRevisionEditor {
Ok(())
}
#[tracing::instrument(level = "debug", skip_all, err)]
pub(crate) async fn group_by_field(&self, field_id: &str) -> FlowyResult<()> {
if let Some(field_rev) = self.field_delegate.get_field_rev(field_id).await {
let new_group_controller = new_group_controller_with_field_rev(
@ -374,13 +380,14 @@ impl GridViewRevisionEditor {
async fn new_group_controller(
user_id: String,
view_id: String,
pad: Arc<RwLock<GridViewRevisionPad>>,
view_rev_pad: Arc<RwLock<GridViewRevisionPad>>,
rev_manager: Arc<RevisionManager>,
field_delegate: Arc<dyn GridViewFieldDelegate>,
row_delegate: Arc<dyn GridViewRowDelegate>,
) -> FlowyResult<Box<dyn GroupController>> {
let configuration_reader = GroupConfigurationReaderImpl(pad.clone());
let configuration_reader = GroupConfigurationReaderImpl(view_rev_pad.clone());
let field_revs = field_delegate.get_field_revs().await;
let layout = view_rev_pad.read().await.layout();
// Read the group field or find a new group field
let field_rev = configuration_reader
.get_configuration()
@ -391,24 +398,24 @@ async fn new_group_controller(
.find(|field_rev| field_rev.id == configuration.field_id)
.cloned()
})
.unwrap_or_else(|| find_group_field(&field_revs).unwrap());
.unwrap_or_else(|| find_group_field(&field_revs, &layout).unwrap());
new_group_controller_with_field_rev(user_id, view_id, pad, rev_manager, field_rev, row_delegate).await
new_group_controller_with_field_rev(user_id, view_id, view_rev_pad, rev_manager, field_rev, row_delegate).await
}
async fn new_group_controller_with_field_rev(
user_id: String,
view_id: String,
pad: Arc<RwLock<GridViewRevisionPad>>,
view_rev_pad: Arc<RwLock<GridViewRevisionPad>>,
rev_manager: Arc<RevisionManager>,
field_rev: Arc<FieldRevision>,
row_delegate: Arc<dyn GridViewRowDelegate>,
) -> FlowyResult<Box<dyn GroupController>> {
let configuration_reader = GroupConfigurationReaderImpl(pad.clone());
let configuration_reader = GroupConfigurationReaderImpl(view_rev_pad.clone());
let configuration_writer = GroupConfigurationWriterImpl {
user_id,
rev_manager,
view_pad: pad,
view_pad: view_rev_pad,
};
let row_revs = row_delegate.gv_row_revs().await;
make_group_controller(view_id, field_rev, row_revs, configuration_reader, configuration_writer).await

View File

@ -56,6 +56,12 @@ impl GridViewManager {
})
}
pub(crate) async fn duplicate_grid_view(&self) -> FlowyResult<String> {
let editor = self.get_default_view_editor().await?;
let view_data = editor.duplicate_view_data().await?;
Ok(view_data)
}
/// When the row was created, we may need to modify the [RowRevision] according to the [CreateRowParams].
pub(crate) async fn will_create_row(&self, row_rev: &mut RowRevision, params: &CreateRowParams) {
for view_editor in self.view_editors.iter() {
@ -169,9 +175,15 @@ impl GridViewManager {
Ok(())
}
pub(crate) async fn did_update_field(&self, field_id: &str) -> FlowyResult<()> {
#[tracing::instrument(level = "trace", skip(self), err)]
pub(crate) async fn did_update_field(&self, field_id: &str, is_type_option_changed: bool) -> FlowyResult<()> {
let view_editor = self.get_default_view_editor().await?;
let _ = view_editor.did_update_field(field_id).await?;
if is_type_option_changed {
let _ = view_editor.group_by_field(field_id).await?;
} else {
let _ = view_editor.did_update_field(field_id).await?;
}
Ok(())
}

View File

@ -14,5 +14,6 @@ pub trait GroupAction: Send + Sync {
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>;
// Move row from one group to another
fn move_row(&mut self, cell_data: &Self::CellDataType, context: MoveGroupRowContext) -> Vec<GroupChangesetPB>;
}

View File

@ -86,8 +86,7 @@ where
G: GroupGenerator<Context = GroupContext<C>, TypeOptionType = T>,
{
pub async fn new(field_rev: &Arc<FieldRevision>, mut configuration: GroupContext<C>) -> FlowyResult<Self> {
let field_type_rev = field_rev.ty;
let type_option = field_rev.get_type_option::<T>(field_type_rev);
let type_option = field_rev.get_type_option::<T>(field_rev.ty);
let groups = G::generate_groups(&field_rev.id, &configuration, &type_option);
let _ = configuration.init_groups(groups, true)?;
@ -278,12 +277,19 @@ where
}
}
#[tracing::instrument(level = "trace", skip_all, err)]
fn move_group_row(&mut self, context: MoveGroupRowContext) -> FlowyResult<Vec<GroupChangesetPB>> {
if let Some(cell_rev) = context.row_rev.cells.get(&self.field_id) {
let cell_bytes = decode_any_cell_data(cell_rev.data.clone(), context.field_rev);
let cell_rev = match context.row_rev.cells.get(&self.field_id) {
Some(cell_rev) => Some(cell_rev.clone()),
None => self.default_cell_rev(),
};
if let Some(cell_rev) = cell_rev {
let cell_bytes = decode_any_cell_data(cell_rev.data, context.field_rev);
let cell_data = cell_bytes.parser::<P>()?;
Ok(self.move_row(&cell_data, context))
} else {
tracing::warn!("Unexpected moving group row, changes should not be empty");
Ok(vec![])
}
}

View File

@ -55,7 +55,7 @@ impl GroupControllerSharedOperation for DefaultGroupController {
_row_rev: &RowRevision,
_field_rev: &FieldRevision,
) -> FlowyResult<Vec<GroupChangesetPB>> {
todo!()
Ok(vec![])
}
fn did_delete_row(
@ -63,7 +63,7 @@ impl GroupControllerSharedOperation for DefaultGroupController {
_row_rev: &RowRevision,
_field_rev: &FieldRevision,
) -> FlowyResult<Vec<GroupChangesetPB>> {
todo!()
Ok(vec![])
}
fn move_group_row(&mut self, _context: MoveGroupRowContext) -> FlowyResult<Vec<GroupChangesetPB>> {

View File

@ -1,11 +1,12 @@
use crate::entities::{GroupChangesetPB, InsertedRowPB, RowPB};
use crate::services::cell::insert_select_option_cell;
use crate::services::field::{SelectOptionCellDataPB, SelectOptionPB};
use crate::entities::{FieldType, GroupChangesetPB, InsertedRowPB, RowPB};
use crate::services::cell::{insert_checkbox_cell, insert_select_option_cell};
use crate::services::field::{SelectOptionCellDataPB, SelectOptionPB, CHECK};
use crate::services::group::configuration::GroupContext;
use crate::services::group::{GeneratedGroup, Group};
use crate::services::group::controller::MoveGroupRowContext;
use flowy_grid_data_model::revision::{GroupRevision, RowRevision, SelectOptionGroupConfigurationRevision};
use crate::services::group::{GeneratedGroup, Group};
use flowy_grid_data_model::revision::{
CellRevision, FieldRevision, GroupRevision, RowRevision, SelectOptionGroupConfigurationRevision,
};
pub type SelectOptionGroupContext = GroupContext<SelectOptionGroupConfigurationRevision>;
@ -109,10 +110,16 @@ pub fn move_group_row(group: &mut Group, context: &mut MoveGroupRowContext) -> O
// Update the corresponding row's cell content.
if from_index.is_none() {
tracing::debug!("Mark row:{} belong to group:{}", row_rev.id, group.id);
let cell_rev = insert_select_option_cell(group.id.clone(), field_rev);
row_changeset.cell_by_field_id.insert(field_rev.id.clone(), cell_rev);
changeset.updated_rows.push(RowPB::from(*row_rev));
let cell_rev = make_inserted_cell_rev(&group.id, field_rev);
if let Some(cell_rev) = cell_rev {
tracing::debug!(
"Update content of the cell in the row:{} to group:{}",
row_rev.id,
group.id
);
row_changeset.cell_by_field_id.insert(field_rev.id.clone(), cell_rev);
changeset.updated_rows.push(RowPB::from(*row_rev));
}
}
}
if changeset.is_empty() {
@ -122,6 +129,27 @@ pub fn move_group_row(group: &mut Group, context: &mut MoveGroupRowContext) -> O
}
}
pub fn make_inserted_cell_rev(group_id: &str, field_rev: &FieldRevision) -> Option<CellRevision> {
let field_type: FieldType = field_rev.ty.into();
match field_type {
FieldType::SingleSelect => {
let cell_rev = insert_select_option_cell(group_id.to_owned(), field_rev);
Some(cell_rev)
}
FieldType::MultiSelect => {
let cell_rev = insert_select_option_cell(group_id.to_owned(), field_rev);
Some(cell_rev)
}
FieldType::Checkbox => {
let cell_rev = insert_checkbox_cell(group_id == CHECK, field_rev);
Some(cell_rev)
}
_ => {
tracing::warn!("Unknown field type: {:?}", field_type);
None
}
}
}
pub fn generate_select_option_groups(
_field_id: &str,
_group_ctx: &SelectOptionGroupContext,

View File

@ -8,7 +8,7 @@ use crate::services::group::{
use flowy_error::FlowyResult;
use flowy_grid_data_model::revision::{
CheckboxGroupConfigurationRevision, DateGroupConfigurationRevision, FieldRevision, GroupConfigurationRevision,
NumberGroupConfigurationRevision, RowRevision, SelectOptionGroupConfigurationRevision,
LayoutRevision, NumberGroupConfigurationRevision, RowRevision, SelectOptionGroupConfigurationRevision,
TextGroupConfigurationRevision, UrlGroupConfigurationRevision,
};
use std::sync::Arc;
@ -62,15 +62,17 @@ where
Ok(group_controller)
}
pub fn find_group_field(field_revs: &[Arc<FieldRevision>]) -> Option<Arc<FieldRevision>> {
let field_rev = field_revs
.iter()
.find(|field_rev| {
let field_type: FieldType = field_rev.ty.into();
field_type.can_be_group()
})
.cloned();
field_rev
pub fn find_group_field(field_revs: &[Arc<FieldRevision>], layout: &LayoutRevision) -> Option<Arc<FieldRevision>> {
match layout {
LayoutRevision::Table => field_revs.iter().find(|field_rev| field_rev.is_primary).cloned(),
LayoutRevision::Board => field_revs
.iter()
.find(|field_rev| {
let field_type: FieldType = field_rev.ty.into();
field_type.can_be_group()
})
.cloned(),
}
}
pub fn default_group_configuration(field_rev: &FieldRevision) -> GroupConfigurationRevision {

View File

@ -127,6 +127,7 @@ fn make_test_grid() -> BuildGridContext {
let text_field = FieldBuilder::new(RichTextTypeOptionBuilder::default())
.name("Name")
.visibility(true)
.primary(true)
.build();
grid_builder.add_field(text_field);
}

View File

@ -382,11 +382,8 @@ async fn group_insert_single_select_option_test() {
AssertGroupCount(5),
];
test.run_scripts(scripts).await;
// the group at index 4 is the default_group, so the new insert group will be the
// index 3.
let group_3 = test.group_at_index(3).await;
assert_eq!(group_3.desc, new_option_name);
let new_group = test.group_at_index(0).await;
assert_eq!(new_group.desc, new_option_name);
}
#[tokio::test]

View File

@ -7,6 +7,7 @@ use flowy_folder::{
event_map::{FolderCouldServiceV1, WorkspaceDatabase, WorkspaceUser},
manager::FolderManager,
};
use flowy_grid::entities::GridLayout;
use flowy_grid::manager::{make_grid_view_data, GridManager};
use flowy_grid::util::{make_default_board, make_default_grid};
use flowy_grid_data_model::revision::BuildGridContext;
@ -142,7 +143,15 @@ impl ViewDataProcessor for TextBlockViewDataProcessor {
FutureResult::new(async move { manager.init() })
}
fn create_container(&self, user_id: &str, view_id: &str, delta_data: Bytes) -> FutureResult<(), FlowyError> {
fn create_container(
&self,
user_id: &str,
view_id: &str,
layout: ViewLayoutTypePB,
delta_data: Bytes,
) -> FutureResult<(), FlowyError> {
// Only accept Document type
debug_assert_eq!(layout, ViewLayoutTypePB::Document);
let repeated_revision: RepeatedRevision = Revision::initial_revision(user_id, view_id, delta_data).into();
let view_id = view_id.to_string();
let manager = self.0.clone();
@ -161,7 +170,7 @@ impl ViewDataProcessor for TextBlockViewDataProcessor {
})
}
fn get_delta_data(&self, view_id: &str) -> FutureResult<Bytes, FlowyError> {
fn get_view_data(&self, view_id: &str) -> FutureResult<Bytes, FlowyError> {
let view_id = view_id.to_string();
let manager = self.0.clone();
FutureResult::new(async move {
@ -196,7 +205,9 @@ impl ViewDataProcessor for TextBlockViewDataProcessor {
_user_id: &str,
_view_id: &str,
data: Vec<u8>,
layout: ViewLayoutTypePB,
) -> FutureResult<Bytes, FlowyError> {
debug_assert_eq!(layout, ViewLayoutTypePB::Document);
FutureResult::new(async move { Ok(Bytes::from(data)) })
}
@ -211,7 +222,13 @@ impl ViewDataProcessor for GridViewDataProcessor {
FutureResult::new(async { Ok(()) })
}
fn create_container(&self, user_id: &str, view_id: &str, delta_data: Bytes) -> FutureResult<(), FlowyError> {
fn create_container(
&self,
user_id: &str,
view_id: &str,
_layout: ViewLayoutTypePB,
delta_data: Bytes,
) -> FutureResult<(), FlowyError> {
let repeated_revision: RepeatedRevision = Revision::initial_revision(user_id, view_id, delta_data).into();
let view_id = view_id.to_string();
let grid_manager = self.0.clone();
@ -230,7 +247,7 @@ impl ViewDataProcessor for GridViewDataProcessor {
})
}
fn get_delta_data(&self, view_id: &str) -> FutureResult<Bytes, FlowyError> {
fn get_view_data(&self, view_id: &str) -> FutureResult<Bytes, FlowyError> {
let view_id = view_id.to_string();
let grid_manager = self.0.clone();
FutureResult::new(async move {
@ -246,19 +263,22 @@ impl ViewDataProcessor for GridViewDataProcessor {
view_id: &str,
layout: ViewLayoutTypePB,
) -> FutureResult<Bytes, FlowyError> {
let build_context = match layout {
ViewLayoutTypePB::Grid => make_default_grid(),
ViewLayoutTypePB::Board => make_default_board(),
let (build_context, layout) = match layout {
ViewLayoutTypePB::Grid => (make_default_grid(), GridLayout::Table),
ViewLayoutTypePB::Board => (make_default_board(), GridLayout::Board),
ViewLayoutTypePB::Document => {
return FutureResult::new(async move {
Err(FlowyError::internal().context(format!("Can't handle {:?} layout type", layout)))
});
}
};
let user_id = user_id.to_string();
let view_id = view_id.to_string();
let grid_manager = self.0.clone();
FutureResult::new(async move { make_grid_view_data(&user_id, &view_id, grid_manager, build_context).await })
FutureResult::new(
async move { make_grid_view_data(&user_id, &view_id, layout, grid_manager, build_context).await },
)
}
fn create_view_from_delta_data(
@ -266,15 +286,26 @@ impl ViewDataProcessor for GridViewDataProcessor {
user_id: &str,
view_id: &str,
data: Vec<u8>,
layout: ViewLayoutTypePB,
) -> FutureResult<Bytes, FlowyError> {
let user_id = user_id.to_string();
let view_id = view_id.to_string();
let grid_manager = self.0.clone();
let layout = match layout {
ViewLayoutTypePB::Grid => GridLayout::Table,
ViewLayoutTypePB::Board => GridLayout::Board,
ViewLayoutTypePB::Document => {
return FutureResult::new(async move {
Err(FlowyError::internal().context(format!("Can't handle {:?} layout type", layout)))
});
}
};
FutureResult::new(async move {
let bytes = Bytes::from(data);
let build_context = BuildGridContext::try_from(bytes)?;
make_grid_view_data(&user_id, &view_id, grid_manager, build_context).await
make_grid_view_data(&user_id, &view_id, layout, grid_manager, build_context).await
})
}

View File

@ -34,8 +34,6 @@ else
printMessage "Skipping Rust installation."
fi
abvc
# Install sqllite
printMessage "Installing sqlLite3."
brew install sqlite3

View File

@ -34,11 +34,15 @@ impl GridRevision {
}
}
pub fn from_build_context(grid_id: &str, context: BuildGridContext) -> Self {
pub fn from_build_context(
grid_id: &str,
field_revs: Vec<Arc<FieldRevision>>,
block_metas: Vec<GridBlockMetaRevision>,
) -> Self {
Self {
grid_id: grid_id.to_owned(),
fields: context.field_revs,
blocks: context.block_metas.into_iter().map(Arc::new).collect(),
fields: field_revs,
blocks: block_metas.into_iter().map(Arc::new).collect(),
}
}
}
@ -188,6 +192,9 @@ pub struct BuildGridContext {
pub field_revs: Vec<Arc<FieldRevision>>,
pub block_metas: Vec<GridBlockMetaRevision>,
pub blocks: Vec<GridBlockRevision>,
// String in JSON format. It can be deserialized into [GridViewRevision]
pub grid_view_revision_data: String,
}
impl BuildGridContext {

View File

@ -48,16 +48,20 @@ pub struct GridViewRevision {
}
impl GridViewRevision {
pub fn new(grid_id: String, view_id: String) -> Self {
pub fn new(grid_id: String, view_id: String, layout: LayoutRevision) -> Self {
GridViewRevision {
view_id,
grid_id,
layout: Default::default(),
layout,
filters: Default::default(),
groups: Default::default(),
// row_orders: vec![],
}
}
pub fn from_json(json: String) -> Result<Self, serde_json::Error> {
serde_json::from_str(&json)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]

View File

@ -103,8 +103,12 @@ impl GridRevisionPad {
|grid_meta| match grid_meta.fields.iter().position(|field| field.id == field_id) {
None => Ok(None),
Some(index) => {
grid_meta.fields.remove(index);
Ok(Some(()))
if grid_meta.fields[index].is_primary {
Err(CollaborateError::can_not_delete_primary_field())
} else {
grid_meta.fields.remove(index);
Ok(Some(()))
}
}
},
)

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