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", "includeTime": " Include time",
"dateFormatFriendly": "Month Day,Year", "dateFormatFriendly": "Month Day,Year",
"dateFormatISO": "Year-Month-Day", "dateFormatISO": "Year-Month-Day",
"dateFormatLocal": "Year/Month/Day", "dateFormatLocal": "Month/Day/Year",
"dateFormatUS": "Year/Month/Day", "dateFormatUS": "Year/Month/Day",
"timeFormat": " Time format", "timeFormat": " Time format",
"invalidTimeFormat": "Invalid format", "invalidTimeFormat": "Invalid format",

View File

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

View File

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

View File

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

View File

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

View File

@ -148,7 +148,7 @@
"menu": { "menu": {
"appearance": "Aparência", "appearance": "Aparência",
"language": "Idioma", "language": "Idioma",
"user":"Usuário", "user": "Usuário",
"open": "Abrir as Configurações" "open": "Abrir as Configurações"
}, },
"appearance": { "appearance": {
@ -181,7 +181,7 @@
"includeTime": "Incluir horário", "includeTime": "Incluir horário",
"dateFormatFriendly": "Mês/Dia/Ano", "dateFormatFriendly": "Mês/Dia/Ano",
"dateFormatISO": "Ano/Mês/Dia", "dateFormatISO": "Ano/Mês/Dia",
"dateFormatLocal": "Ano/Mês/Dia", "dateFormatLocal": "Mês/Dia/Ano",
"dateFormatUS": "Ano/Mês/Dia", "dateFormatUS": "Ano/Mês/Dia",
"timeFormat": "Formato de hora", "timeFormat": "Formato de hora",
"invalidTimeFormat": "Formato Inválido", "invalidTimeFormat": "Formato Inválido",

View File

@ -180,7 +180,7 @@
"includeTime": " Время", "includeTime": " Время",
"dateFormatFriendly": "День Месяц, Год", "dateFormatFriendly": "День Месяц, Год",
"dateFormatISO": "Год-Месяц-День", "dateFormatISO": "Год-Месяц-День",
"dateFormatLocal": "Год/Месяц/День", "dateFormatLocal": "Месяц/День/Год",
"dateFormatUS": "Год/Месяц/День", "dateFormatUS": "Год/Месяц/День",
"timeFormat": " Форматировать время", "timeFormat": " Форматировать время",
"invalidTimeFormat": "Неверный формат", "invalidTimeFormat": "Неверный формат",

View File

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

View File

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

View File

@ -83,7 +83,9 @@ class _BoardCardState extends State<BoardCard> {
builder: (context, state) { builder: (context, state) {
return AppFlowyPopover( return AppFlowyPopover(
controller: popoverController, controller: popoverController,
triggerActions: PopoverTriggerFlags.none,
constraints: BoxConstraints.loose(const Size(140, 200)), constraints: BoxConstraints.loose(const Size(140, 200)),
margin: const EdgeInsets.all(6),
direction: PopoverDirection.rightWithCenterAligned, direction: PopoverDirection.rightWithCenterAligned,
popupBuilder: (popoverContext) => _handlePopoverBuilder( popupBuilder: (popoverContext) => _handlePopoverBuilder(
context, context,
@ -132,7 +134,8 @@ class _BoardCardState extends State<BoardCard> {
throw UnimplementedError(); throw UnimplementedError();
case AccessoryType.more: case AccessoryType.more:
return GridRowActionSheet( 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}); ShareActions({required this.onSelected});
@override
double get maxWidth => 130;
@override @override
double get itemHeight => 22; double get itemHeight => 22;
@ -233,7 +230,7 @@ class ShareActionWrapper extends ActionItem {
ShareActionWrapper(this.inner); ShareActionWrapper(this.inner);
@override @override
Widget? get icon => null; Widget? icon(Color iconColor) => null;
@override @override
String get name => inner.name; String get name => inner.name;

View File

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

View File

@ -54,6 +54,9 @@ class SelectOptionCellEditorBloc
selectOption: (_SelectOption value) { selectOption: (_SelectOption value) {
_onSelectOption(value.optionId); _onSelectOption(value.optionId);
}, },
trySelectOption: (_TrySelectOption value) {
_trySelectOption(value.optionName, emit);
},
filterOption: (_SelectOptionFilter value) { filterOption: (_SelectOptionFilter value) {
_filterOption(value.optionName, emit); _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) { void _filterOption(String optionName, Emitter<SelectOptionEditorState> emit) {
final _MakeOptionResult result = final _MakeOptionResult result =
_makeOptions(Some(optionName), state.allOptions); _makeOptions(Some(optionName), state.allOptions);
@ -187,6 +220,8 @@ class SelectOptionEditorEvent with _$SelectOptionEditorEvent {
_DeleteOption; _DeleteOption;
const factory SelectOptionEditorEvent.filterOption(String optionName) = const factory SelectOptionEditorEvent.filterOption(String optionName) =
_SelectOptionFilter; _SelectOptionFilter;
const factory SelectOptionEditorEvent.trySelectOption(String optionName) =
_TrySelectOption;
} }
@freezed @freezed

View File

@ -65,30 +65,24 @@ class _DateCellState extends GridCellState<GridDateCell> {
builder: (context, state) { builder: (context, state) {
return AppFlowyPopover( return AppFlowyPopover(
controller: _popover, controller: _popover,
offset: const Offset(0, 20), triggerActions: PopoverTriggerFlags.none,
direction: PopoverDirection.bottomWithLeftAligned, direction: PopoverDirection.bottomWithLeftAligned,
constraints: BoxConstraints.loose(const Size(320, 500)), constraints: BoxConstraints.loose(const Size(320, 500)),
margin: EdgeInsets.zero,
child: SizedBox.expand( child: SizedBox.expand(
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () => _showCalendar(context), onTap: () => _popover.show(),
child: MouseRegion(
opaque: false,
cursor: SystemMouseCursors.click,
child: Align( child: Align(
alignment: alignment, alignment: alignment,
child: FlowyText.medium( child: FlowyText.medium(state.dateStr, fontSize: 12),
state.dateStr,
fontSize: 12,
),
),
), ),
), ),
), ),
popupBuilder: (BuildContext popoverContent) { popupBuilder: (BuildContext popoverContent) {
final bloc = context.read<DateCellBloc>();
return DateCellEditor( return DateCellEditor(
cellController: bloc.cellController.clone(), cellController: widget.cellControllerBuilder.build()
as GridDateCellController,
onDismissed: () => widget.onCellEditing.value = false, onDismissed: () => widget.onCellEditing.value = false,
); );
}, },
@ -101,10 +95,6 @@ class _DateCellState extends GridCellState<GridDateCell> {
); );
} }
void _showCalendar(BuildContext context) {
_popover.show();
}
@override @override
Future<void> dispose() async { Future<void> dispose() async {
_cellBloc.close(); _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/cell/date_cal_bloc.dart';
import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart';
import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:dartz/dartz.dart' show Either;
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart'; import 'package:flowy_infra/image.dart';
import 'package:flowy_infra/theme.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/rounded_input_field.dart';
import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flowy_sdk/log.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:flowy_sdk/protobuf/flowy-grid/date_type_option.pb.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@ -39,39 +41,42 @@ class DateCellEditor extends StatefulWidget {
} }
class _DateCellEditor extends State<DateCellEditor> { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_dateTypeOptionPB == null) { return FutureBuilder<Either<dynamic, FlowyError>>(
return Container(); future: widget.cellController.getFieldTypeOption(
DateTypeOptionDataParser(),
),
builder: (BuildContext context, snapshot) {
if (snapshot.hasData) {
return _buildWidget(snapshot);
} else {
return const SizedBox();
}
},
);
} }
return _CellCalendarWidget( Widget _buildWidget(AsyncSnapshot<Either<dynamic, FlowyError>> snapshot) {
return snapshot.data!.fold(
(dateTypeOptionPB) {
return Padding(
padding: const EdgeInsets.all(12),
child: _CellCalendarWidget(
cellContext: widget.cellController, cellContext: widget.cellController,
dateTypeOptionPB: _dateTypeOptionPB!, dateTypeOptionPB: dateTypeOptionPB,
),
);
},
(err) {
Log.error(err);
return const SizedBox();
},
); );
} }
} }
class _CellCalendarWidget extends StatelessWidget { class _CellCalendarWidget extends StatefulWidget {
final GridDateCellController cellContext; final GridDateCellController cellContext;
final DateTypeOptionPB dateTypeOptionPB; final DateTypeOptionPB dateTypeOptionPB;
@ -81,26 +86,43 @@ class _CellCalendarWidget extends StatelessWidget {
Key? key, Key? key,
}) : super(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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = context.watch<AppTheme>(); final theme = context.watch<AppTheme>();
return BlocProvider( return BlocProvider.value(
create: (context) { value: bloc,
return DateCalBloc(
dateTypeOptionPB: dateTypeOptionPB,
cellData: cellContext.getCellData(),
cellController: cellContext,
)..add(const DateCalEvent.initial());
},
child: BlocBuilder<DateCalBloc, DateCalState>( child: BlocBuilder<DateCalBloc, DateCalState>(
buildWhen: (p, c) => false, buildWhen: (p, c) => false,
builder: (context, state) { builder: (context, state) {
List<Widget> children = [ List<Widget> children = [
_buildCalendar(theme, context), _buildCalendar(theme, context),
_TimeTextField(bloc: context.read<DateCalBloc>()), _TimeTextField(
bloc: context.read<DateCalBloc>(),
popoverMutex: popoverMutex,
),
Divider(height: 1, color: theme.shader5), Divider(height: 1, color: theme.shader5),
const _IncludeTimeButton(), const _IncludeTimeButton(),
const _DateTypeOptionButton() _DateTypeOptionButton(popoverMutex: popoverMutex)
]; ];
return ListView.separated( 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) { Widget _buildCalendar(AppTheme theme, BuildContext context) {
return BlocBuilder<DateCalBloc, DateCalState>( return BlocBuilder<DateCalBloc, DateCalState>(
builder: (context, state) { builder: (context, state) {
@ -218,8 +247,10 @@ class _IncludeTimeButton extends StatelessWidget {
class _TimeTextField extends StatefulWidget { class _TimeTextField extends StatefulWidget {
final DateCalBloc bloc; final DateCalBloc bloc;
final PopoverMutex popoverMutex;
const _TimeTextField({ const _TimeTextField({
required this.bloc, required this.bloc,
required this.popoverMutex,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -240,9 +271,18 @@ class _TimeTextFieldState extends State<_TimeTextField> {
if (mounted) { if (mounted) {
widget.bloc.add(DateCalEvent.setTime(_controller.text)); widget.bloc.add(DateCalEvent.setTime(_controller.text));
} }
if (_focusNode.hasFocus) {
widget.popoverMutex.close();
}
});
widget.popoverMutex.listenOnPopoverChanged(() {
if (_focusNode.hasFocus) {
_focusNode.unfocus();
}
}); });
} }
super.initState(); super.initState();
} }
@ -290,7 +330,11 @@ class _TimeTextFieldState extends State<_TimeTextField> {
} }
class _DateTypeOptionButton extends StatelessWidget { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -301,6 +345,7 @@ class _DateTypeOptionButton extends StatelessWidget {
selector: (state) => state.dateTypeOptionPB, selector: (state) => state.dateTypeOptionPB,
builder: (context, dateTypeOptionPB) { builder: (context, dateTypeOptionPB) {
return AppFlowyPopover( return AppFlowyPopover(
mutex: popoverMutex,
triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click,
offset: const Offset(20, 0), offset: const Offset(20, 0),
constraints: BoxConstraints.loose(const Size(140, 100)), constraints: BoxConstraints.loose(const Size(140, 100)),
@ -313,7 +358,10 @@ class _DateTypeOptionButton extends StatelessWidget {
popupBuilder: (BuildContext popContext) { popupBuilder: (BuildContext popContext) {
return _CalDateTimeSetting( return _CalDateTimeSetting(
dateTypeOptionPB: dateTypeOptionPB, 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 { class _CalDateTimeSetting extends StatefulWidget {
final DateTypeOptionPB dateTypeOptionPB; final DateTypeOptionPB dateTypeOptionPB;
final Function(DateCalEvent) onEvent; final Function(DateCalEvent) onEvent;
const _CalDateTimeSetting( const _CalDateTimeSetting({
{required this.dateTypeOptionPB, required this.onEvent, Key? key}) required this.dateTypeOptionPB,
: super(key: key); required this.onEvent,
Key? key,
}) : super(key: key);
@override @override
State<_CalDateTimeSetting> createState() => _CalDateTimeSettingState(); State<_CalDateTimeSetting> createState() => _CalDateTimeSettingState();
} }
class _CalDateTimeSettingState extends State<_CalDateTimeSetting> { class _CalDateTimeSettingState extends State<_CalDateTimeSetting> {
final timeSettingPopoverMutex = PopoverMutex();
String? overlayIdentifier; String? overlayIdentifier;
final _popoverMutex = PopoverMutex();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
List<Widget> children = [ List<Widget> children = [
AppFlowyPopover( AppFlowyPopover(
mutex: _popoverMutex, mutex: timeSettingPopoverMutex,
asBarrier: true,
triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click,
offset: const Offset(20, 0), offset: const Offset(20, 0),
popupBuilder: (BuildContext context) { popupBuilder: (BuildContext context) {
return DateFormatList( return DateFormatList(
selectedFormat: widget.dateTypeOptionPB.dateFormat, selectedFormat: widget.dateTypeOptionPB.dateFormat,
onSelected: (format) => onSelected: (format) {
widget.onEvent(DateCalEvent.setDateFormat(format)), widget.onEvent(DateCalEvent.setDateFormat(format));
timeSettingPopoverMutex.close();
},
); );
}, },
child: const DateFormatButton(), child: const DateFormatButton(),
), ),
AppFlowyPopover( AppFlowyPopover(
mutex: _popoverMutex, mutex: timeSettingPopoverMutex,
asBarrier: true,
triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click,
offset: const Offset(20, 0), offset: const Offset(20, 0),
popupBuilder: (BuildContext context) { popupBuilder: (BuildContext context) {
return TimeFormatList( return TimeFormatList(
selectedFormat: widget.dateTypeOptionPB.timeFormat, selectedFormat: widget.dateTypeOptionPB.timeFormat,
onSelected: (format) => onSelected: (format) {
widget.onEvent(DateCalEvent.setTimeFormat(format)), widget.onEvent(DateCalEvent.setTimeFormat(format));
); timeSettingPopoverMutex.close();
});
}, },
child: TimeFormatButton(timeFormat: widget.dateTypeOptionPB.timeFormat), child: TimeFormatButton(timeFormat: widget.dateTypeOptionPB.timeFormat),
), ),

View File

@ -154,10 +154,10 @@ class _TextField extends StatelessWidget {
.read<SelectOptionCellEditorBloc>() .read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.filterOption(text)); .add(SelectOptionEditorEvent.filterOption(text));
}, },
onNewTag: (tagName) { onSubmitted: (tagName) {
context context
.read<SelectOptionCellEditorBloc>() .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 LinkedHashMap<String, SelectOptionPB> selectedOptionMap;
final double distanceToText; final double distanceToText;
final Function(String) onNewTag; final Function(String) onSubmitted;
final Function(String) newText; final Function(String) newText;
final VoidCallback? onClick; final VoidCallback? onClick;
@ -26,7 +26,7 @@ class SelectOptionTextField extends StatefulWidget {
required this.selectedOptionMap, required this.selectedOptionMap,
required this.distanceToText, required this.distanceToText,
required this.tagController, required this.tagController,
required this.onNewTag, required this.onSubmitted,
required this.newText, required this.newText,
this.onClick, this.onClick,
TextEditingController? textController, TextEditingController? textController,
@ -88,7 +88,7 @@ class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
} }
if (text.isNotEmpty) { if (text.isNotEmpty) {
widget.onNewTag(text); widget.onSubmitted(text);
focusNode.requestFocus(); focusNode.requestFocus();
} }
}, },

View File

@ -54,13 +54,11 @@ class GridURLCell extends GridCellWidget {
GridURLCellAccessoryType ty, GridCellAccessoryBuildContext buildContext) { GridURLCellAccessoryType ty, GridCellAccessoryBuildContext buildContext) {
switch (ty) { switch (ty) {
case GridURLCellAccessoryType.edit: case GridURLCellAccessoryType.edit:
final cellController =
cellControllerBuilder.build() as GridURLCellController;
return GridCellAccessoryBuilder( return GridCellAccessoryBuilder(
builder: (Key key) => _EditURLAccessory( builder: (Key key) => _EditURLAccessory(
key: key, key: key,
cellContext: cellController,
anchorContext: buildContext.anchorContext, anchorContext: buildContext.anchorContext,
cellControllerBuilder: cellControllerBuilder,
), ),
); );
@ -191,10 +189,10 @@ class _GridURLCellState extends GridCellState<GridURLCell> {
} }
class _EditURLAccessory extends StatefulWidget { class _EditURLAccessory extends StatefulWidget {
final GridURLCellController cellContext; final GridCellControllerBuilder cellControllerBuilder;
final BuildContext anchorContext; final BuildContext anchorContext;
const _EditURLAccessory({ const _EditURLAccessory({
required this.cellContext, required this.cellControllerBuilder,
required this.anchorContext, required this.anchorContext,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -224,7 +222,8 @@ class _EditURLAccessoryState extends State<_EditURLAccessory>
child: svgWidget("editor/edit", color: theme.iconColor), child: svgWidget("editor/edit", color: theme.iconColor),
popupBuilder: (BuildContext popoverContext) { popupBuilder: (BuildContext popoverContext) {
return URLEditorPopover( 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) { Widget build(BuildContext context) {
final theme = context.watch<AppTheme>(); final theme = context.watch<AppTheme>();
return FlowyButton( return FlowyButton(
radius: BorderRadius.zero,
hoverColor: theme.shader6, hoverColor: theme.shader6,
onTap: onTap, onTap: onTap,
leftIcon: svgWidget(field.fieldType.iconName(), color: theme.iconColor), leftIcon: svgWidget(field.fieldType.iconName(), color: theme.iconColor),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -86,7 +86,8 @@ class MenuAppHeader extends StatelessWidget {
?.toggle(), ?.toggle(),
onSecondaryTap: () { onSecondaryTap: () {
final actionList = AppDisclosureActionSheet( final actionList = AppDisclosureActionSheet(
onSelected: (action) => _handleAction(context, action)); onSelected: (action) => _handleAction(context, action),
);
actionList.show( actionList.show(
context, context,
anchorDirection: AnchorDirection.bottomWithCenterAligned, anchorDirection: AnchorDirection.bottomWithCenterAligned,
@ -158,12 +159,12 @@ extension AppDisclosureExtension on AppDisclosureAction {
} }
} }
Widget get icon { Widget icon(Color iconColor) {
switch (this) { switch (this) {
case AppDisclosureAction.rename: case AppDisclosureAction.rename:
return svgWidget('editor/edit', color: const Color(0xffe5e5e5)); return svgWidget('editor/edit', color: iconColor);
case AppDisclosureAction.delete: 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'; import 'header.dart';
class AppDisclosureActionSheet with ActionList<DisclosureActionWrapper>, FlowyOverlayDelegate { class AppDisclosureActionSheet
with ActionList<DisclosureActionWrapper>, FlowyOverlayDelegate {
final Function(dartz.Option<AppDisclosureAction>) onSelected; 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({ AppDisclosureActionSheet({
required this.onSelected, required this.onSelected,
@ -17,7 +20,8 @@ class AppDisclosureActionSheet with ActionList<DisclosureActionWrapper>, FlowyOv
List<DisclosureActionWrapper> get items => _items; List<DisclosureActionWrapper> get items => _items;
@override @override
void Function(dartz.Option<DisclosureActionWrapper> p1) get selectCallback => (result) { void Function(dartz.Option<DisclosureActionWrapper> p1) get selectCallback =>
(result) {
result.fold( result.fold(
() => onSelected(dartz.none()), () => onSelected(dartz.none()),
(wrapper) => onSelected( (wrapper) => onSelected(
@ -40,7 +44,7 @@ class DisclosureActionWrapper extends ActionItem {
DisclosureActionWrapper(this.inner); DisclosureActionWrapper(this.inner);
@override @override
Widget? get icon => inner.icon; Widget? icon(Color iconColor) => inner.icon(iconColor);
@override @override
String get name => inner.name; String get name => inner.name;

View File

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

View File

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

View File

@ -13,7 +13,9 @@ abstract class ActionList<T extends ActionItem> {
String get identifier => toString(); String get identifier => toString();
double get maxWidth => 162; double get maxWidth => 300;
double get minWidth => 120;
double get itemHeight => ActionListSizes.itemHeight; double get itemHeight => ActionListSizes.itemHeight;
@ -29,28 +31,29 @@ abstract class ActionList<T extends ActionItem> {
AnchorDirection anchorDirection = AnchorDirection.bottomRight, AnchorDirection anchorDirection = AnchorDirection.bottomRight,
Offset? anchorOffset, Offset? anchorOffset,
}) { }) {
final widgets = items ListOverlay.showWithAnchor(
.map( buildContext,
(action) => ActionCell<T>( identifier: identifier,
itemCount: items.length,
itemBuilder: (context, index) {
final action = items[index];
return ActionCell<T>(
action: action, action: action,
itemHeight: itemHeight, itemHeight: itemHeight,
onSelected: (action) { onSelected: (action) {
FlowyOverlay.of(buildContext).remove(identifier); FlowyOverlay.of(buildContext).remove(identifier);
selectCallback(dartz.some(action)); selectCallback(dartz.some(action));
}, },
), );
) },
.toList();
ListOverlay.showWithAnchor(
buildContext,
identifier: identifier,
itemCount: widgets.length,
itemBuilder: (context, index) => widgets[index],
anchorContext: anchorContext ?? buildContext, anchorContext: anchorContext ?? buildContext,
anchorDirection: anchorDirection, anchorDirection: anchorDirection,
width: maxWidth, constraints: BoxConstraints(
height: widgets.length * (itemHeight + ActionListSizes.padding * 2), minHeight: items.length * (itemHeight + ActionListSizes.padding * 2),
maxHeight: items.length * (itemHeight + ActionListSizes.padding * 2),
maxWidth: maxWidth,
minWidth: minWidth,
),
delegate: delegate, delegate: delegate,
anchorOffset: anchorOffset, anchorOffset: anchorOffset,
footer: footer, footer: footer,
@ -59,7 +62,7 @@ abstract class ActionList<T extends ActionItem> {
} }
abstract class ActionItem { abstract class ActionItem {
Widget? get icon; Widget? icon(Color iconColor);
String get name; String get name;
} }
@ -83,6 +86,7 @@ class ActionCell<T extends ActionItem> extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = context.watch<AppTheme>(); final theme = context.watch<AppTheme>();
final icon = action.icon(theme.iconColor);
return FlowyHover( return FlowyHover(
style: HoverStyle(hoverColor: theme.hover), style: HoverStyle(hoverColor: theme.hover),
@ -92,14 +96,11 @@ class ActionCell<T extends ActionItem> extends StatelessWidget {
child: SizedBox( child: SizedBox(
height: itemHeight, height: itemHeight,
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (action.icon != null) action.icon!, if (icon != null) icon,
HSpace(ActionListSizes.itemHPadding), HSpace(ActionListSizes.itemHPadding),
FlowyText.medium( FlowyText.medium(action.name, fontSize: 12),
action.name,
fontSize: 12,
),
], ],
), ),
).padding( ).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:convert';
import 'dart:io'; import 'dart:io';
import 'package:example/plugin/underscore_to_italic.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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:flutter_localizations/flutter_localizations.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:universal_html/html.dart' as html;
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
@ -112,6 +115,7 @@ class _MyHomePageState extends State<MyHomePage> {
child: AppFlowyEditor( child: AppFlowyEditor(
editorState: _editorState!, editorState: _editorState!,
editorStyle: _editorStyle, editorStyle: _editorStyle,
editable: true,
shortcutEvents: [ shortcutEvents: [
underscoreToItalic, underscoreToItalic,
], ],
@ -148,7 +152,7 @@ class _MyHomePageState extends State<MyHomePage> {
), ),
ActionButton( ActionButton(
icon: const Icon(Icons.import_export), icon: const Icon(Icons.import_export),
onPressed: () => _importDocument(), onPressed: () async => await _importDocument(),
), ),
ActionButton( ActionButton(
icon: const Icon(Icons.color_lens), icon: const Icon(Icons.color_lens),
@ -167,6 +171,14 @@ class _MyHomePageState extends State<MyHomePage> {
void _exportDocument(EditorState editorState) async { void _exportDocument(EditorState editorState) async {
final document = editorState.document.toJson(); final document = editorState.document.toJson();
final json = jsonEncode(document); final json = jsonEncode(document);
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 directory = await getTemporaryDirectory();
final path = directory.path; final path = directory.path;
final file = File('$path/editor.json'); final file = File('$path/editor.json');
@ -180,16 +192,33 @@ class _MyHomePageState extends State<MyHomePage> {
); );
} }
} }
}
void _importDocument() async { 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 directory = await getTemporaryDirectory();
final path = directory.path; final path = '${directory.path}/editor.json';
final file = File('$path/editor.json'); final file = File(path);
setState(() { setState(() {
_editorState = null; _editorState = null;
_jsonString = file.readAsString(); _jsonString = file.readAsString();
}); });
} }
}
void _switchToPage(int pageIndex) { void _switchToPage(int pageIndex) {
if (pageIndex != _pageIndex) { if (pageIndex != _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 path_provider_macos
import rich_clipboard_macos import rich_clipboard_macos
import url_launcher_macos import url_launcher_macos
import wakelock_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
RichClipboardPlugin.register(with: registry.registrar(forPlugin: "RichClipboardPlugin")) RichClipboardPlugin.register(with: registry.registrar(forPlugin: "RichClipboardPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WakelockMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockMacosPlugin"))
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -4,22 +4,52 @@ import 'dart:math';
extension PathExtensions on Path { extension PathExtensions on Path {
bool operator >=(Path other) { 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); final length = min(this.length, other.length);
for (var i = 0; i < length; i++) { for (var i = 0; i < length; i++) {
if (this[i] < other[i]) { if (this[i] < other[i]) {
return false; return false;
} else if (this[i] > other[i]) {
return true;
} }
} }
if (this.length < other.length) {
return false;
}
return true; return true;
} }
bool operator <=(Path other) { 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); final length = min(this.length, other.length);
for (var i = 0; i < length; i++) { for (var i = 0; i < length; i++) {
if (this[i] > other[i]) { if (this[i] > other[i]) {
return false; return false;
} else if (this[i] < other[i]) {
return true;
} }
} }
if (this.length > other.length) {
return false;
}
return true; 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. /// Inserts a sequence of nodes at the position of path.
insertNodes(Path path, List<Node> nodes) { insertNodes(Path path, List<Node> nodes) {
beforeSelection = state.cursorSelection; 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. /// Updates the attributes of nodes.
@ -75,7 +75,7 @@ class TransactionBuilder {
nodes.add(node); 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) { textEdit(TextNode node, Delta Function() f) {

View File

@ -1,4 +1,5 @@
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
abstract class BuiltInTextWidget extends StatefulWidget { abstract class BuiltInTextWidget extends StatefulWidget {
@ -59,3 +60,58 @@ mixin BuiltInStyleMixin<T extends BuiltInTextWidget> on State<T> {
return const EdgeInsets.all(0); 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 // customize
class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget> class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
with SelectableMixin, DefaultSelectable, BuiltInStyleMixin { with
SelectableMixin,
DefaultSelectable,
BuiltInStyleMixin,
BuiltInTextWidgetMixin {
@override @override
final iconKey = GlobalKey(); final iconKey = GlobalKey();
@ -61,7 +65,7 @@ class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
} }
@override @override
Widget build(BuildContext context) { Widget buildWithSingle(BuildContext context) {
return Padding( return Padding(
padding: padding, padding: padding,
child: Row( child: Row(

View File

@ -46,7 +46,11 @@ class CheckboxNodeWidget extends BuiltInTextWidget {
} }
class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget> class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
with SelectableMixin, DefaultSelectable, BuiltInStyleMixin { with
SelectableMixin,
DefaultSelectable,
BuiltInStyleMixin,
BuiltInTextWidgetMixin {
@override @override
final iconKey = GlobalKey(); final iconKey = GlobalKey();
@ -62,15 +66,7 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
} }
@override @override
Widget build(BuildContext context) { Widget buildWithSingle(BuildContext context) {
if (widget.textNode.children.isEmpty) {
return _buildWithSingle(context);
} else {
return _buildWithChildren(context);
}
}
Widget _buildWithSingle(BuildContext context) {
final check = widget.textNode.attributes.check; final check = widget.textNode.attributes.check;
return Padding( return Padding(
padding: 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 // customize
class _RichTextNodeWidgetState extends State<RichTextNodeWidget> class _RichTextNodeWidgetState extends State<RichTextNodeWidget>
with SelectableMixin, DefaultSelectable, BuiltInStyleMixin { with
SelectableMixin,
DefaultSelectable,
BuiltInStyleMixin,
BuiltInTextWidgetMixin {
@override @override
GlobalKey? get iconKey => null; GlobalKey? get iconKey => null;
@ -59,7 +63,7 @@ class _RichTextNodeWidgetState extends State<RichTextNodeWidget>
} }
@override @override
Widget build(BuildContext context) { Widget buildWithSingle(BuildContext context) {
return Padding( return Padding(
padding: padding, padding: padding,
child: FlowyRichText( child: FlowyRichText(

View File

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

View File

@ -1,4 +1,5 @@
import 'package:appflowy_editor/src/infra/log.dart'; import 'package:appflowy_editor/src/infra/log.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -43,11 +44,13 @@ abstract class AppFlowyInputService {
class AppFlowyInput extends StatefulWidget { class AppFlowyInput extends StatefulWidget {
const AppFlowyInput({ const AppFlowyInput({
Key? key, Key? key,
this.editable = true,
required this.editorState, required this.editorState,
required this.child, required this.child,
}) : super(key: key); }) : super(key: key);
final EditorState editorState; final EditorState editorState;
final bool editable;
final Widget child; final Widget child;
@override @override
@ -61,26 +64,39 @@ class _AppFlowyInputState extends State<AppFlowyInput>
EditorState get _editorState => widget.editorState; EditorState get _editorState => widget.editorState;
// Disable space shortcut on the Web platform.
final Map<ShortcutActivator, Intent> _shortcuts = kIsWeb
? {
LogicalKeySet(LogicalKeyboardKey.space):
DoNothingAndStopPropagationIntent(),
}
: {};
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (widget.editable) {
_editorState.service.selectionService.currentSelection _editorState.service.selectionService.currentSelection
.addListener(_onSelectionChange); .addListener(_onSelectionChange);
} }
}
@override @override
void dispose() { void dispose() {
if (widget.editable) {
close(); close();
_editorState.service.selectionService.currentSelection _editorState.service.selectionService.currentSelection
.removeListener(_onSelectionChange); .removeListener(_onSelectionChange);
}
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Shortcuts(
shortcuts: _shortcuts,
child: widget.child, 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:appflowy_editor/src/service/internal_key_event_handlers/number_list_helper.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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/appflowy_editor.dart';
import 'package:appflowy_editor/src/extensions/path_extensions.dart';
// Handle delete text. // Handle delete text.
ShortcutEventHandler deleteTextHandler = (editorState, event) { ShortcutEventHandler deleteTextHandler = (editorState, event) {
@ -125,28 +126,42 @@ KeyEventResult _backDeleteToPreviousTextNode(
TextNode textNode, TextNode textNode,
TransactionBuilder transactionBuilder, TransactionBuilder transactionBuilder,
List<Node> nonTextNodes, List<Node> nonTextNodes,
Selection selection) { Selection selection,
var previous = textNode.previous; ) {
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;
}
bool prevIsNumberList = false; bool prevIsNumberList = false;
while (previous != null) { final previousTextNode = Infra.forwardNearestTextNode(textNode);
if (previous is TextNode) { if (previousTextNode != null) {
if (previous.subtype == BuiltInAttributeKey.numberList) { if (previousTextNode.subtype == BuiltInAttributeKey.numberList) {
prevIsNumberList = true; prevIsNumberList = true;
} }
transactionBuilder transactionBuilder.mergeText(previousTextNode, textNode);
..mergeText(previous, textNode) if (textNode.children.isNotEmpty) {
..deleteNode(textNode) transactionBuilder.insertNodes(
..afterSelection = Selection.collapsed( previousTextNode.path.next,
textNode.children.toList(growable: false),
);
}
transactionBuilder.deleteNode(textNode);
transactionBuilder.afterSelection = Selection.collapsed(
Position( Position(
path: previous.path, path: previousTextNode.path,
offset: previous.toRawString().length, offset: previousTextNode.toRawString().length,
), ),
); );
break;
} else {
previous = previous.previous;
}
} }
if (transactionBuilder.operations.isNotEmpty) { if (transactionBuilder.operations.isNotEmpty) {
@ -157,8 +172,8 @@ KeyEventResult _backDeleteToPreviousTextNode(
} }
if (prevIsNumberList) { if (prevIsNumberList) {
makeFollowingNodesIncremental( makeFollowingNodesIncremental(editorState, previousTextNode!.path,
editorState, previous!.path, transactionBuilder.afterSelection!); transactionBuilder.afterSelection!);
} }
return KeyEventResult.handled; return KeyEventResult.handled;

View File

@ -1,9 +1,9 @@
import 'dart:collection';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.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/extensions/path_extensions.dart';
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
import './number_list_helper.dart'; import './number_list_helper.dart';
/// Handle some cases where enter is pressed and shift is not pressed. /// 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. /// 2.2 or insert a empty text node before.
ShortcutEventHandler enterWithoutShiftInTextNodesHandler = ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
(editorState, event) { (editorState, event) {
if (event.logicalKey != LogicalKeyboardKey.enter || event.isShiftPressed) {
return KeyEventResult.ignored;
}
var selection = editorState.service.selectionService.currentSelection.value; var selection = editorState.service.selectionService.currentSelection.value;
var nodes = editorState.service.selectionService.currentSelectedNodes; var nodes = editorState.service.selectionService.currentSelectedNodes;
if (selection == null) { if (selection == null) {
@ -124,7 +120,10 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
TransactionBuilder(editorState) TransactionBuilder(editorState)
..insertNode( ..insertNode(
textNode.path, textNode.path,
TextNode.empty(), textNode.copyWith(
children: LinkedList(),
delta: Delta(),
),
) )
..afterSelection = afterSelection ..afterSelection = afterSelection
..commit(); ..commit();
@ -142,21 +141,25 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
Position(path: nextPath, offset: 0), Position(path: nextPath, offset: 0),
); );
TransactionBuilder(editorState) final transactionBuilder = TransactionBuilder(editorState);
..insertNode( transactionBuilder.insertNode(
textNode.path.next, textNode.path.next,
textNode.copyWith( textNode.copyWith(
attributes: attributes, attributes: attributes,
delta: textNode.delta.slice(selection.end.offset), delta: textNode.delta.slice(selection.end.offset),
), ),
) );
..deleteText( transactionBuilder.deleteText(
textNode, textNode,
selection.start.offset, selection.start.offset,
textNode.toRawString().length - selection.start.offset, textNode.toRawString().length - selection.start.offset,
) );
..afterSelection = afterSelection if (textNode.children.isNotEmpty) {
..commit(); 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, // If the new type of a text node is number list,
// the numbers of the following nodes should be incremental. // 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 { class AppFlowyKeyboard extends StatefulWidget {
const AppFlowyKeyboard({ const AppFlowyKeyboard({
Key? key, Key? key,
this.editable = true,
required this.shortcutEvents, required this.shortcutEvents,
required this.editorState, required this.editorState,
required this.child, required this.child,
@ -50,6 +51,7 @@ class AppFlowyKeyboard extends StatefulWidget {
final EditorState editorState; final EditorState editorState;
final Widget child; final Widget child;
final List<ShortcutEvent> shortcutEvents; final List<ShortcutEvent> shortcutEvents;
final bool editable;
@override @override
State<AppFlowyKeyboard> createState() => _AppFlowyKeyboardState(); State<AppFlowyKeyboard> createState() => _AppFlowyKeyboardState();
@ -62,7 +64,6 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
bool isFocus = true; bool isFocus = true;
@override @override
// TODO: implement shortcutEvents
List<ShortcutEvent> get shortcutEvents => widget.shortcutEvents; List<ShortcutEvent> get shortcutEvents => widget.shortcutEvents;
@override @override
@ -91,8 +92,12 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
@override @override
void enable() { void enable() {
if (widget.editable) {
isFocus = true; isFocus = true;
_focusNode.requestFocus(); _focusNode.requestFocus();
} else {
disable();
}
} }
@override @override

View File

@ -84,6 +84,7 @@ class AppFlowySelection extends StatefulWidget {
Key? key, Key? key,
this.cursorColor = const Color(0xFF00BCF0), this.cursorColor = const Color(0xFF00BCF0),
this.selectionColor = const Color.fromARGB(53, 111, 201, 231), this.selectionColor = const Color.fromARGB(53, 111, 201, 231),
this.editable = true,
required this.editorState, required this.editorState,
required this.child, required this.child,
}) : super(key: key); }) : super(key: key);
@ -92,6 +93,7 @@ class AppFlowySelection extends StatefulWidget {
final Widget child; final Widget child;
final Color cursorColor; final Color cursorColor;
final Color selectionColor; final Color selectionColor;
final bool editable;
@override @override
State<AppFlowySelection> createState() => _AppFlowySelectionState(); State<AppFlowySelection> createState() => _AppFlowySelectionState();
@ -144,6 +146,11 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!widget.editable) {
return Container(
child: widget.child,
);
} else {
return SelectionGestureDetector( return SelectionGestureDetector(
onPanStart: _onPanStart, onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate, onPanUpdate: _onPanUpdate,
@ -154,6 +161,7 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
child: widget.child, child: widget.child,
); );
} }
}
@override @override
ValueNotifier<Selection?> currentSelection = ValueNotifier(null); ValueNotifier<Selection?> currentSelection = ValueNotifier(null);
@ -184,6 +192,10 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
@override @override
void updateSelection(Selection? selection) { void updateSelection(Selection? selection) {
if (!widget.editable) {
return;
}
selectionRects.clear(); selectionRects.clear();
clearSelection(); clearSelection();
@ -323,6 +335,7 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
// compute the selection in range. // compute the selection in range.
if (first != null && last != null) { if (first != null && last != null) {
Log.selection.debug('first = $first, last = $last');
final start = final start =
first.getSelectionInRange(panStartOffset, panEndOffset).start; first.getSelectionInRange(panStartOffset, panEndOffset).start;
final end = last.getSelectionInRange(panStartOffset, panEndOffset).end; final end = last.getSelectionInRange(panStartOffset, panEndOffset).end;
@ -353,6 +366,8 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
final normalizedSelection = selection.normalize; final normalizedSelection = selection.normalize;
assert(normalizedSelection.isBackward); assert(normalizedSelection.isBackward);
Log.selection.debug('update selection areas, $normalizedSelection');
for (var i = 0; i < backwardNodes.length; i++) { for (var i = 0; i < backwardNodes.length; i++) {
final node = backwardNodes[i]; final node = backwardNodes[i];
final selectable = node.selectable; 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/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/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/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/internal_key_event_handlers/whitespace_handler.dart';
import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event.dart'; import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event.dart';
@ -243,4 +244,9 @@ List<ShortcutEvent> builtInShortcutEvents = [
command: 'page down', command: 'page down',
handler: pageDownHandler, 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/keybinding.dart';
import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart'; import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
import 'package:flutter/foundation.dart';
/// Defines the implementation of shortcut event. /// Defines the implementation of shortcut event.
class ShortcutEvent { class ShortcutEvent {
@ -56,7 +57,10 @@ class ShortcutEvent {
String? linuxCommand, String? linuxCommand,
}) { }) {
var matched = false; var matched = false;
if (Platform.isWindows && if (kIsWeb && command != null && command.isNotEmpty) {
this.command = command;
matched = true;
} else if (Platform.isWindows &&
windowsCommand != null && windowsCommand != null &&
windowsCommand.isNotEmpty) { windowsCommand.isNotEmpty) {
this.command = windowsCommand; 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; EditorState get editorState => _editorState;
Node get root => _editorState.document.root; Node get root => _editorState.document.root;
StateTree get document => _editorState.document;
int get documentLength => _editorState.document.root.children.length; int get documentLength => _editorState.document.root.children.length;
Selection? get documentSelection => Selection? get documentSelection =>
_editorState.service.selectionService.currentSelection.value; _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/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_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_service.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart'; import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';

View File

@ -1,10 +1,12 @@
import 'dart:collection';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/render/image/image_node_widget.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/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:network_image_mock/network_image_mock.dart'; import 'package:network_image_mock/network_image_mock.dart';
import '../../infra/test_editor.dart'; import '../../infra/test_editor.dart';
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
void main() async { void main() async {
setUpAll(() { setUpAll(() {
@ -267,6 +269,140 @@ void main() async {
BuiltInAttributeKey.h1, 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 { 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); return Stack(children: children);
}); });
_rootEntry.addEntry(context, this, newEntry, widget.asBarrier); _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) ..tint6 = const Color(0xfff5ffdc)
..tint7 = const Color(0xffddffd6) ..tint7 = const Color(0xffddffd6)
..tint8 = const Color(0xffdefff1) ..tint8 = const Color(0xffdefff1)
..tint9 = const Color(0xffdefff1) ..tint9 = const Color(0xffe1fbff)
..main1 = const Color(0xff00bcf0) ..main1 = const Color(0xff00bcf0)
..main2 = const Color(0xff00b7ea) ..main2 = const Color(0xff00b7ea)
..textColor = _black ..textColor = _black
@ -152,7 +152,8 @@ class AppTheme {
ThemeData get themeData { ThemeData get themeData {
var t = ThemeData( var t = ThemeData(
textTheme: TextTheme(bodyText2: TextStyle(color: textColor)), textTheme: TextTheme(bodyText2: TextStyle(color: textColor)),
textSelectionTheme: TextSelectionThemeData(cursorColor: main2, selectionHandleColor: main2), textSelectionTheme: TextSelectionThemeData(
cursorColor: main2, selectionHandleColor: main2),
primaryIconTheme: IconThemeData(color: hover), primaryIconTheme: IconThemeData(color: hover),
iconTheme: IconThemeData(color: shader1), iconTheme: IconThemeData(color: shader1),
canvasColor: shader6, canvasColor: shader6,
@ -179,7 +180,8 @@ class AppTheme {
toggleableActiveColor: main1); 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 { class ColorUtils {
@ -188,14 +190,18 @@ class ColorUtils {
return hslc.withLightness((hslc.lightness + amt).clamp(0.0, 1.0)).toColor(); 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) { static Color blend(Color dst, Color src, double opacity) {
return Color.fromARGB( return Color.fromARGB(
255, 255,
(dst.red.toDouble() * (1.0 - opacity) + src.red.toDouble() * opacity).toInt(), (dst.red.toDouble() * (1.0 - opacity) + src.red.toDouble() * opacity)
(dst.green.toDouble() * (1.0 - opacity) + src.green.toDouble() * opacity).toInt(), .toInt(),
(dst.blue.toDouble() * (1.0 - opacity) + src.blue.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 overlapBehaviour: providerContext
.read<OverlayDemoConfiguration>() .read<OverlayDemoConfiguration>()
.overlapBehaviour, .overlapBehaviour,
width: 200.0, constraints:
height: 200.0, BoxConstraints.tight(const Size(200, 200)),
); );
}, },
child: const Text('Show List Overlay'), 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/list_overlay.dart';
export 'src/flowy_overlay/option_overlay.dart'; export 'src/flowy_overlay/option_overlay.dart';
export 'src/flowy_overlay/flowy_dialog.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:appflowy_popover/appflowy_popover.dart';
import 'package:flutter/material.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 { class AppFlowyPopover extends StatelessWidget {
final Widget child; final Widget child;
final PopoverController? controller; final PopoverController? controller;
@ -13,6 +16,7 @@ class AppFlowyPopover extends StatelessWidget {
final PopoverMutex? mutex; final PopoverMutex? mutex;
final Offset? offset; final Offset? offset;
final bool asBarrier; final bool asBarrier;
final EdgeInsets margin;
const AppFlowyPopover({ const AppFlowyPopover({
Key? key, Key? key,
@ -26,6 +30,7 @@ class AppFlowyPopover extends StatelessWidget {
this.offset, this.offset,
this.controller, this.controller,
this.asBarrier = false, this.asBarrier = false,
this.margin = const EdgeInsets.all(12),
}) : super(key: key); }) : super(key: key);
@override @override
@ -39,13 +44,44 @@ class AppFlowyPopover extends StatelessWidget {
triggerActions: triggerActions, triggerActions: triggerActions,
popupBuilder: (context) { popupBuilder: (context) {
final child = popupBuilder(context); final child = popupBuilder(context);
debugPrint('$child popover'); debugPrint('Show $child popover');
return OverlayContainer( return _PopoverContainer(
constraints: constraints, constraints: constraints,
child: popupBuilder(context), margin: margin,
child: child,
); );
}, },
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:flowy_infra_ui/src/flowy_overlay/layout.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
export './overlay_container.dart';
/// Specifies how overlay are anchored to the SourceWidget /// Specifies how overlay are anchored to the SourceWidget
enum AnchorDirection { 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: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 { class ListOverlayFooter {
Widget widget; Widget widget;
@ -16,40 +21,48 @@ class ListOverlay extends StatelessWidget {
const ListOverlay({ const ListOverlay({
Key? key, Key? key,
required this.itemBuilder, required this.itemBuilder,
this.itemCount, this.itemCount = 0,
this.controller, this.controller,
this.width = double.infinity, this.constraints = const BoxConstraints(),
this.height = double.infinity,
this.footer, this.footer,
}) : super(key: key); }) : super(key: key);
final IndexedWidgetBuilder itemBuilder; final IndexedWidgetBuilder itemBuilder;
final int? itemCount; final int itemCount;
final ScrollController? controller; final ScrollController? controller;
final double width; final BoxConstraints constraints;
final double height;
final ListOverlayFooter? footer; final ListOverlayFooter? footer;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const padding = EdgeInsets.symmetric(horizontal: 6, vertical: 6); const padding = EdgeInsets.symmetric(horizontal: 6, vertical: 6);
double totalHeight = height + padding.vertical; double totalHeight = constraints.minHeight + padding.vertical;
if (footer != null) { if (footer != null) {
totalHeight = totalHeight + footer!.height + footer!.padding.vertical; 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( return OverlayContainer(
constraints: BoxConstraints.tight(Size(width, totalHeight)), constraints: innerConstraints,
padding: padding, padding: padding,
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: IntrinsicWidth(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
ListView.builder( ...children,
shrinkWrap: true,
itemBuilder: itemBuilder,
itemCount: itemCount,
controller: controller,
),
if (footer != null) if (footer != null)
Padding( Padding(
padding: footer!.padding, padding: footer!.padding,
@ -58,6 +71,7 @@ class ListOverlay extends StatelessWidget {
], ],
), ),
), ),
),
); );
} }
@ -65,10 +79,9 @@ class ListOverlay extends StatelessWidget {
BuildContext context, { BuildContext context, {
required String identifier, required String identifier,
required IndexedWidgetBuilder itemBuilder, required IndexedWidgetBuilder itemBuilder,
int? itemCount, int itemCount = 0,
ScrollController? controller, ScrollController? controller,
double width = double.infinity, BoxConstraints constraints = const BoxConstraints(),
double height = double.infinity,
required BuildContext anchorContext, required BuildContext anchorContext,
AnchorDirection? anchorDirection, AnchorDirection? anchorDirection,
FlowyOverlayDelegate? delegate, FlowyOverlayDelegate? delegate,
@ -82,8 +95,7 @@ class ListOverlay extends StatelessWidget {
itemBuilder: itemBuilder, itemBuilder: itemBuilder,
itemCount: itemCount, itemCount: itemCount,
controller: controller, controller: controller,
width: width, constraints: constraints,
height: height,
footer: footer, footer: footer,
), ),
identifier: identifier, 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 Widget? rightIcon;
final Color hoverColor; final Color hoverColor;
final bool isSelected; final bool isSelected;
final BorderRadius radius;
const FlowyButton({ const FlowyButton({
Key? key, Key? key,
required this.text, required this.text,
@ -22,6 +24,7 @@ class FlowyButton extends StatelessWidget {
this.rightIcon, this.rightIcon,
this.hoverColor = Colors.transparent, this.hoverColor = Colors.transparent,
this.isSelected = false, this.isSelected = false,
this.radius = const BorderRadius.all(Radius.circular(6)),
}) : super(key: key); }) : super(key: key);
@override @override
@ -29,8 +32,10 @@ class FlowyButton extends StatelessWidget {
return InkWell( return InkWell(
onTap: onTap, onTap: onTap,
child: FlowyHover( child: FlowyHover(
style: style: HoverStyle(
HoverStyle(borderRadius: BorderRadius.zero, hoverColor: hoverColor), borderRadius: radius,
hoverColor: hoverColor,
),
onHover: onHover, onHover: onHover,
setSelected: () => isSelected, setSelected: () => isSelected,
builder: (context, onHover) => _render(), builder: (context, onHover) => _render(),

View File

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

View File

@ -1,5 +1,5 @@
pub use crate::entities::view::ViewDataTypePB; pub use crate::entities::view::ViewDataTypePB;
use crate::entities::ViewInfoPB; use crate::entities::{ViewInfoPB, ViewLayoutTypePB};
use crate::manager::{ViewDataProcessor, ViewDataProcessorMap}; use crate::manager::{ViewDataProcessor, ViewDataProcessorMap};
use crate::{ use crate::{
dart_notification::{send_dart_notification, FolderNotification}, dart_notification::{send_dart_notification, FolderNotification},
@ -61,16 +61,28 @@ impl ViewController {
let processor = self.get_data_processor(params.data_type.clone())?; let processor = self.get_data_processor(params.data_type.clone())?;
let user_id = self.user.user_id()?; let user_id = self.user.user_id()?;
if params.view_content_data.is_empty() { if params.view_content_data.is_empty() {
tracing::trace!("Create view with build-in data");
let view_data = processor let view_data = processor
.create_default_view(&user_id, &params.view_id, params.layout.clone()) .create_default_view(&user_id, &params.view_id, params.layout.clone())
.await?; .await?;
params.view_content_data = view_data.to_vec(); params.view_content_data = view_data.to_vec();
} else { } else {
tracing::trace!("Create view with view data");
let delta_data = processor 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?; .await?;
let _ = self 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?; .await?;
}; };
@ -84,6 +96,7 @@ impl ViewController {
&self, &self,
view_id: &str, view_id: &str,
data_type: ViewDataTypePB, data_type: ViewDataTypePB,
layout_type: ViewLayoutTypePB,
delta_data: Bytes, delta_data: Bytes,
) -> Result<(), FlowyError> { ) -> Result<(), FlowyError> {
if delta_data.is_empty() { if delta_data.is_empty() {
@ -91,7 +104,9 @@ impl ViewController {
} }
let user_id = self.user.user_id()?; let user_id = self.user.user_id()?;
let processor = self.get_data_processor(data_type)?; 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(()) Ok(())
} }
@ -218,7 +233,7 @@ impl ViewController {
.await?; .await?;
let processor = self.get_data_processor(view_rev.data_type.clone())?; 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 { let duplicate_params = CreateViewParams {
belong_to_id: view_rev.app_id.clone(), belong_to_id: view_rev.app_id.clone(),
name: format!("{} (copy)", &view_rev.name), name: format!("{} (copy)", &view_rev.name),
@ -226,7 +241,7 @@ impl ViewController {
thumbnail: view_rev.thumbnail, thumbnail: view_rev.thumbnail,
data_type: view_rev.data_type.into(), data_type: view_rev.data_type.into(),
layout: view_rev.layout.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_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 { pub fn send_dart_notification(id: &str, ty: GridNotification) -> DartNotifyBuilder {
DartNotifyBuilder::new(id, ty, OBSERVABLE_CATEGORY) 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)?; let editor = manager.get_grid_editor(&params.grid_id)?;
if let Some(insert_params) = params.insert_group { 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 { 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) { match serde_json::from_str(s) {
Ok(obj) => obj, Ok(obj) => obj,
Err(err) => { 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() $target::default()
} }
} }

View File

@ -1,3 +1,4 @@
use crate::entities::GridLayout;
use crate::services::block_editor::GridBlockRevisionCompactor; use crate::services::block_editor::GridBlockRevisionCompactor;
use crate::services::grid_editor::{GridRevisionCompactor, GridRevisionEditor}; use crate::services::grid_editor::{GridRevisionCompactor, GridRevisionEditor};
use crate::services::grid_view_manager::make_grid_view_rev_manager; use crate::services::grid_view_manager::make_grid_view_rev_manager;
@ -178,10 +179,18 @@ impl GridManager {
pub async fn make_grid_view_data( pub async fn make_grid_view_data(
user_id: &str, user_id: &str,
view_id: &str, view_id: &str,
layout: GridLayout,
grid_manager: Arc<GridManager>, grid_manager: Arc<GridManager>,
build_context: BuildGridContext, build_context: BuildGridContext,
) -> FlowyResult<Bytes> { ) -> 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; let block_id = &block_meta_data.block_id;
// Indexing the block's rows // Indexing the block's rows
block_meta_data.rows.iter().for_each(|row| { 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() // Will replace the grid_id with the value returned by the gen_grid_id()
let grid_id = view_id.to_owned(); 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 // Create grid
let grid_rev_delta = make_grid_delta(&grid_rev); 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?; let _ = grid_manager.create_grid(&grid_id, repeated_revision).await?;
// Create grid view // 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 = make_grid_view_delta(&grid_view);
let grid_view_delta_bytes = grid_view_delta.json_bytes(); let grid_view_delta_bytes = grid_view_delta.json_bytes();
let repeated_revision: RepeatedRevision = let repeated_revision: RepeatedRevision =

View File

@ -19,7 +19,10 @@ impl std::str::FromStr for AnyCellData {
type Err = FlowyError; type Err = FlowyError;
fn from_str(s: &str) -> Result<Self, Self::Err> { 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) Ok(type_option_cell_data)
} }
} }

View File

@ -1,6 +1,7 @@
use crate::entities::FieldType; use crate::entities::FieldType;
use crate::services::cell::{AnyCellData, CellBytes}; use crate::services::cell::{AnyCellData, CellBytes};
use crate::services::field::*; use crate::services::field::*;
use std::fmt::Debug;
use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_error::{ErrorCode, FlowyError, FlowyResult};
use flowy_grid_data_model::revision::{CellRevision, FieldRevision, FieldTypeRevision}; use flowy_grid_data_model::revision::{CellRevision, FieldRevision, FieldTypeRevision};
@ -73,8 +74,12 @@ pub fn apply_cell_data_changeset<C: ToString, T: AsRef<FieldRevision>>(
Ok(AnyCellData::new(s, field_type).json()) Ok(AnyCellData::new(s, field_type).json())
} }
pub fn decode_any_cell_data<T: TryInto<AnyCellData>>(data: T, field_rev: &FieldRevision) -> CellBytes { pub fn decode_any_cell_data<T: TryInto<AnyCellData, Error = FlowyError> + Debug>(
if let Ok(any_cell_data) = data.try_into() { data: T,
field_rev: &FieldRevision,
) -> CellBytes {
match data.try_into() {
Ok(any_cell_data) => {
let AnyCellData { data, field_type } = any_cell_data; let AnyCellData { data, field_type } = any_cell_data;
let to_field_type = field_rev.ty.into(); let to_field_type = field_rev.ty.into();
match try_decode_cell_data(data.into(), field_rev, &field_type, &to_field_type) { match try_decode_cell_data(data.into(), field_rev, &field_type, &to_field_type) {
@ -84,10 +89,14 @@ pub fn decode_any_cell_data<T: TryInto<AnyCellData>>(data: T, field_rev: &FieldR
CellBytes::default() CellBytes::default()
} }
} }
} else { }
tracing::error!("Decode type option data failed"); 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() CellBytes::default()
} }
}
} }
pub fn try_decode_cell_data( pub fn try_decode_cell_data(

View File

@ -24,7 +24,7 @@ mod tests {
assert_date(&type_option, 1647251762, None, "2022-03-14", &field_rev); assert_date(&type_option, 1647251762, None, "2022-03-14", &field_rev);
} }
DateFormat::Local => { 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 // https://docs.rs/chrono/0.4.19/chrono/format/strftime/index.html
pub fn format_str(&self) -> &'static str { pub fn format_str(&self) -> &'static str {
match self { match self {
DateFormat::Local => "%Y/%m/%d", DateFormat::Local => "%m/%d/%Y",
DateFormat::US => "%Y/%m/%d", DateFormat::US => "%Y/%m/%d",
DateFormat::ISO => "%Y-%m-%d", DateFormat::ISO => "%Y-%m-%d",
DateFormat::Friendly => "%b %d,%Y", DateFormat::Friendly => "%b %d,%Y",

View File

@ -179,23 +179,20 @@ impl GridRevisionEditor {
None => Err(ErrorCode::FieldDoesNotExist.into()), None => Err(ErrorCode::FieldDoesNotExist.into()),
Some(field_type) => { Some(field_type) => {
let _ = self.update_field_rev(params, field_type).await?; 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?; let _ = self.notify_did_update_grid_field(&field_id).await?;
Ok(()) Ok(())
} }
} }
} }
// Replaces the field revision with new field revision.
pub async fn replace_field(&self, field_rev: Arc<FieldRevision>) -> FlowyResult<()> { pub async fn replace_field(&self, field_rev: Arc<FieldRevision>) -> FlowyResult<()> {
let field_id = field_rev.id.clone(); let field_id = field_rev.id.clone();
let _ = self let _ = self
.modify(|grid_pad| Ok(grid_pad.replace_field_rev(field_rev.clone())?)) .modify(|grid_pad| Ok(grid_pad.replace_field_rev(field_rev.clone())?))
.await?; .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(_) => {} Ok(_) => {}
Err(e) => tracing::error!("View manager update field failed: {:?}", e), 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<()> { async fn update_field_rev(&self, params: FieldChangesetParams, field_type: FieldType) -> FlowyResult<()> {
let mut is_type_option_changed = false;
let _ = self let _ = self
.modify(|grid| { .modify(|grid| {
let deserializer = TypeOptionJsonDeserializer(field_type); let deserializer = TypeOptionJsonDeserializer(field_type);
@ -319,6 +317,7 @@ impl GridRevisionEditor {
Ok(json_str) => { Ok(json_str) => {
let field_type = field.ty; let field_type = field.ty;
field.insert_type_option_str(&field_type, json_str); field.insert_type_option_str(&field_type, json_str);
is_type_option_changed = true;
is_changed = Some(()) is_changed = Some(())
} }
Err(err) => { Err(err) => {
@ -333,7 +332,11 @@ impl GridRevisionEditor {
}) })
.await?; .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(_) => {} Ok(_) => {}
Err(e) => tracing::error!("View manager update field failed: {:?}", e), Err(e) => tracing::error!("View manager update field failed: {:?}", e),
} }
@ -537,7 +540,7 @@ impl GridRevisionEditor {
self.view_manager.get_filters().await 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 self.view_manager.insert_or_update_group(params).await
} }
@ -673,6 +676,7 @@ impl GridRevisionEditor {
pub async fn duplicate_grid(&self) -> FlowyResult<BuildGridContext> { pub async fn duplicate_grid(&self) -> FlowyResult<BuildGridContext> {
let grid_pad = self.grid_pad.read().await; 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 original_blocks = grid_pad.get_block_meta_revs();
let (duplicated_fields, duplicated_blocks) = grid_pad.duplicate_grid_block_meta().await; 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(), field_revs: duplicated_fields.into_iter().map(Arc::new).collect(),
block_metas: duplicated_blocks, block_metas: duplicated_blocks,
blocks: blocks_meta_data, 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) { pub(crate) async fn will_create_row(&self, row_rev: &mut RowRevision, params: &CreateRowParams) {
if params.group_id.is_none() { if params.group_id.is_none() {
return; return;
@ -277,6 +282,7 @@ impl GridViewRevisionEditor {
Ok(()) Ok(())
} }
#[tracing::instrument(level = "debug", skip_all, err)]
pub(crate) async fn group_by_field(&self, field_id: &str) -> FlowyResult<()> { 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 { if let Some(field_rev) = self.field_delegate.get_field_rev(field_id).await {
let new_group_controller = new_group_controller_with_field_rev( let new_group_controller = new_group_controller_with_field_rev(
@ -374,13 +380,14 @@ impl GridViewRevisionEditor {
async fn new_group_controller( async fn new_group_controller(
user_id: String, user_id: String,
view_id: String, view_id: String,
pad: Arc<RwLock<GridViewRevisionPad>>, view_rev_pad: Arc<RwLock<GridViewRevisionPad>>,
rev_manager: Arc<RevisionManager>, rev_manager: Arc<RevisionManager>,
field_delegate: Arc<dyn GridViewFieldDelegate>, field_delegate: Arc<dyn GridViewFieldDelegate>,
row_delegate: Arc<dyn GridViewRowDelegate>, row_delegate: Arc<dyn GridViewRowDelegate>,
) -> FlowyResult<Box<dyn GroupController>> { ) -> 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 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 // Read the group field or find a new group field
let field_rev = configuration_reader let field_rev = configuration_reader
.get_configuration() .get_configuration()
@ -391,24 +398,24 @@ async fn new_group_controller(
.find(|field_rev| field_rev.id == configuration.field_id) .find(|field_rev| field_rev.id == configuration.field_id)
.cloned() .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( async fn new_group_controller_with_field_rev(
user_id: String, user_id: String,
view_id: String, view_id: String,
pad: Arc<RwLock<GridViewRevisionPad>>, view_rev_pad: Arc<RwLock<GridViewRevisionPad>>,
rev_manager: Arc<RevisionManager>, rev_manager: Arc<RevisionManager>,
field_rev: Arc<FieldRevision>, field_rev: Arc<FieldRevision>,
row_delegate: Arc<dyn GridViewRowDelegate>, row_delegate: Arc<dyn GridViewRowDelegate>,
) -> FlowyResult<Box<dyn GroupController>> { ) -> FlowyResult<Box<dyn GroupController>> {
let configuration_reader = GroupConfigurationReaderImpl(pad.clone()); let configuration_reader = GroupConfigurationReaderImpl(view_rev_pad.clone());
let configuration_writer = GroupConfigurationWriterImpl { let configuration_writer = GroupConfigurationWriterImpl {
user_id, user_id,
rev_manager, rev_manager,
view_pad: pad, view_pad: view_rev_pad,
}; };
let row_revs = row_delegate.gv_row_revs().await; let row_revs = row_delegate.gv_row_revs().await;
make_group_controller(view_id, field_rev, row_revs, configuration_reader, configuration_writer).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]. /// 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) { pub(crate) async fn will_create_row(&self, row_rev: &mut RowRevision, params: &CreateRowParams) {
for view_editor in self.view_editors.iter() { for view_editor in self.view_editors.iter() {
@ -169,9 +175,15 @@ impl GridViewManager {
Ok(()) 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 = self.get_default_view_editor().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?; let _ = view_editor.did_update_field(field_id).await?;
}
Ok(()) Ok(())
} }

View File

@ -14,5 +14,6 @@ pub trait GroupAction: Send + Sync {
fn can_group(&self, content: &str, cell_data: &Self::CellDataType) -> bool; fn can_group(&self, content: &str, cell_data: &Self::CellDataType) -> bool;
fn add_row_if_match(&mut self, row_rev: &RowRevision, cell_data: &Self::CellDataType) -> Vec<GroupChangesetPB>; fn add_row_if_match(&mut self, row_rev: &RowRevision, cell_data: &Self::CellDataType) -> Vec<GroupChangesetPB>;
fn remove_row_if_match(&mut self, row_rev: &RowRevision, cell_data: &Self::CellDataType) -> Vec<GroupChangesetPB>; fn remove_row_if_match(&mut self, row_rev: &RowRevision, cell_data: &Self::CellDataType) -> Vec<GroupChangesetPB>;
// Move row from one group to another
fn move_row(&mut self, cell_data: &Self::CellDataType, context: MoveGroupRowContext) -> Vec<GroupChangesetPB>; fn move_row(&mut self, cell_data: &Self::CellDataType, context: MoveGroupRowContext) -> Vec<GroupChangesetPB>;
} }

View File

@ -86,8 +86,7 @@ where
G: GroupGenerator<Context = GroupContext<C>, TypeOptionType = T>, G: GroupGenerator<Context = GroupContext<C>, TypeOptionType = T>,
{ {
pub async fn new(field_rev: &Arc<FieldRevision>, mut configuration: GroupContext<C>) -> FlowyResult<Self> { 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_rev.ty);
let type_option = field_rev.get_type_option::<T>(field_type_rev);
let groups = G::generate_groups(&field_rev.id, &configuration, &type_option); let groups = G::generate_groups(&field_rev.id, &configuration, &type_option);
let _ = configuration.init_groups(groups, true)?; 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>> { 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_rev = match context.row_rev.cells.get(&self.field_id) {
let cell_bytes = decode_any_cell_data(cell_rev.data.clone(), context.field_rev); 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>()?; let cell_data = cell_bytes.parser::<P>()?;
Ok(self.move_row(&cell_data, context)) Ok(self.move_row(&cell_data, context))
} else { } else {
tracing::warn!("Unexpected moving group row, changes should not be empty");
Ok(vec![]) Ok(vec![])
} }
} }

View File

@ -55,7 +55,7 @@ impl GroupControllerSharedOperation for DefaultGroupController {
_row_rev: &RowRevision, _row_rev: &RowRevision,
_field_rev: &FieldRevision, _field_rev: &FieldRevision,
) -> FlowyResult<Vec<GroupChangesetPB>> { ) -> FlowyResult<Vec<GroupChangesetPB>> {
todo!() Ok(vec![])
} }
fn did_delete_row( fn did_delete_row(
@ -63,7 +63,7 @@ impl GroupControllerSharedOperation for DefaultGroupController {
_row_rev: &RowRevision, _row_rev: &RowRevision,
_field_rev: &FieldRevision, _field_rev: &FieldRevision,
) -> FlowyResult<Vec<GroupChangesetPB>> { ) -> FlowyResult<Vec<GroupChangesetPB>> {
todo!() Ok(vec![])
} }
fn move_group_row(&mut self, _context: MoveGroupRowContext) -> FlowyResult<Vec<GroupChangesetPB>> { 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::entities::{FieldType, GroupChangesetPB, InsertedRowPB, RowPB};
use crate::services::cell::insert_select_option_cell; use crate::services::cell::{insert_checkbox_cell, insert_select_option_cell};
use crate::services::field::{SelectOptionCellDataPB, SelectOptionPB}; use crate::services::field::{SelectOptionCellDataPB, SelectOptionPB, CHECK};
use crate::services::group::configuration::GroupContext; use crate::services::group::configuration::GroupContext;
use crate::services::group::{GeneratedGroup, Group};
use crate::services::group::controller::MoveGroupRowContext; 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>; pub type SelectOptionGroupContext = GroupContext<SelectOptionGroupConfigurationRevision>;
@ -109,12 +110,18 @@ pub fn move_group_row(group: &mut Group, context: &mut MoveGroupRowContext) -> O
// Update the corresponding row's cell content. // Update the corresponding row's cell content.
if from_index.is_none() { if from_index.is_none() {
tracing::debug!("Mark row:{} belong to group:{}", row_rev.id, group.id); let cell_rev = make_inserted_cell_rev(&group.id, field_rev);
let cell_rev = insert_select_option_cell(group.id.clone(), 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); row_changeset.cell_by_field_id.insert(field_rev.id.clone(), cell_rev);
changeset.updated_rows.push(RowPB::from(*row_rev)); changeset.updated_rows.push(RowPB::from(*row_rev));
} }
} }
}
if changeset.is_empty() { if changeset.is_empty() {
None None
} else { } else {
@ -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( pub fn generate_select_option_groups(
_field_id: &str, _field_id: &str,
_group_ctx: &SelectOptionGroupContext, _group_ctx: &SelectOptionGroupContext,

View File

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

View File

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

View File

@ -7,6 +7,7 @@ use flowy_folder::{
event_map::{FolderCouldServiceV1, WorkspaceDatabase, WorkspaceUser}, event_map::{FolderCouldServiceV1, WorkspaceDatabase, WorkspaceUser},
manager::FolderManager, manager::FolderManager,
}; };
use flowy_grid::entities::GridLayout;
use flowy_grid::manager::{make_grid_view_data, GridManager}; use flowy_grid::manager::{make_grid_view_data, GridManager};
use flowy_grid::util::{make_default_board, make_default_grid}; use flowy_grid::util::{make_default_board, make_default_grid};
use flowy_grid_data_model::revision::BuildGridContext; use flowy_grid_data_model::revision::BuildGridContext;
@ -142,7 +143,15 @@ impl ViewDataProcessor for TextBlockViewDataProcessor {
FutureResult::new(async move { manager.init() }) 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 repeated_revision: RepeatedRevision = Revision::initial_revision(user_id, view_id, delta_data).into();
let view_id = view_id.to_string(); let view_id = view_id.to_string();
let manager = self.0.clone(); 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 view_id = view_id.to_string();
let manager = self.0.clone(); let manager = self.0.clone();
FutureResult::new(async move { FutureResult::new(async move {
@ -196,7 +205,9 @@ impl ViewDataProcessor for TextBlockViewDataProcessor {
_user_id: &str, _user_id: &str,
_view_id: &str, _view_id: &str,
data: Vec<u8>, data: Vec<u8>,
layout: ViewLayoutTypePB,
) -> FutureResult<Bytes, FlowyError> { ) -> FutureResult<Bytes, FlowyError> {
debug_assert_eq!(layout, ViewLayoutTypePB::Document);
FutureResult::new(async move { Ok(Bytes::from(data)) }) FutureResult::new(async move { Ok(Bytes::from(data)) })
} }
@ -211,7 +222,13 @@ impl ViewDataProcessor for GridViewDataProcessor {
FutureResult::new(async { Ok(()) }) 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 repeated_revision: RepeatedRevision = Revision::initial_revision(user_id, view_id, delta_data).into();
let view_id = view_id.to_string(); let view_id = view_id.to_string();
let grid_manager = self.0.clone(); 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 view_id = view_id.to_string();
let grid_manager = self.0.clone(); let grid_manager = self.0.clone();
FutureResult::new(async move { FutureResult::new(async move {
@ -246,19 +263,22 @@ impl ViewDataProcessor for GridViewDataProcessor {
view_id: &str, view_id: &str,
layout: ViewLayoutTypePB, layout: ViewLayoutTypePB,
) -> FutureResult<Bytes, FlowyError> { ) -> FutureResult<Bytes, FlowyError> {
let build_context = match layout { let (build_context, layout) = match layout {
ViewLayoutTypePB::Grid => make_default_grid(), ViewLayoutTypePB::Grid => (make_default_grid(), GridLayout::Table),
ViewLayoutTypePB::Board => make_default_board(), ViewLayoutTypePB::Board => (make_default_board(), GridLayout::Board),
ViewLayoutTypePB::Document => { ViewLayoutTypePB::Document => {
return FutureResult::new(async move { return FutureResult::new(async move {
Err(FlowyError::internal().context(format!("Can't handle {:?} layout type", layout))) Err(FlowyError::internal().context(format!("Can't handle {:?} layout type", layout)))
}); });
} }
}; };
let user_id = user_id.to_string(); let user_id = user_id.to_string();
let view_id = view_id.to_string(); let view_id = view_id.to_string();
let grid_manager = self.0.clone(); 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( fn create_view_from_delta_data(
@ -266,15 +286,26 @@ impl ViewDataProcessor for GridViewDataProcessor {
user_id: &str, user_id: &str,
view_id: &str, view_id: &str,
data: Vec<u8>, data: Vec<u8>,
layout: ViewLayoutTypePB,
) -> FutureResult<Bytes, FlowyError> { ) -> FutureResult<Bytes, FlowyError> {
let user_id = user_id.to_string(); let user_id = user_id.to_string();
let view_id = view_id.to_string(); let view_id = view_id.to_string();
let grid_manager = self.0.clone(); 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 { FutureResult::new(async move {
let bytes = Bytes::from(data); let bytes = Bytes::from(data);
let build_context = BuildGridContext::try_from(bytes)?; 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." printMessage "Skipping Rust installation."
fi fi
abvc
# Install sqllite # Install sqllite
printMessage "Installing sqlLite3." printMessage "Installing sqlLite3."
brew install sqlite3 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 { Self {
grid_id: grid_id.to_owned(), grid_id: grid_id.to_owned(),
fields: context.field_revs, fields: field_revs,
blocks: context.block_metas.into_iter().map(Arc::new).collect(), blocks: block_metas.into_iter().map(Arc::new).collect(),
} }
} }
} }
@ -188,6 +192,9 @@ pub struct BuildGridContext {
pub field_revs: Vec<Arc<FieldRevision>>, pub field_revs: Vec<Arc<FieldRevision>>,
pub block_metas: Vec<GridBlockMetaRevision>, pub block_metas: Vec<GridBlockMetaRevision>,
pub blocks: Vec<GridBlockRevision>, pub blocks: Vec<GridBlockRevision>,
// String in JSON format. It can be deserialized into [GridViewRevision]
pub grid_view_revision_data: String,
} }
impl BuildGridContext { impl BuildGridContext {

View File

@ -48,16 +48,20 @@ pub struct GridViewRevision {
} }
impl 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 { GridViewRevision {
view_id, view_id,
grid_id, grid_id,
layout: Default::default(), layout,
filters: Default::default(), filters: Default::default(),
groups: Default::default(), groups: Default::default(),
// row_orders: vec![], // 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)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]

View File

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

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