Merge branch 'main' into main

This commit is contained in:
Lucas.Xu 2022-10-01 21:11:08 +08:00 committed by GitHub
commit b8cbbf7454
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
132 changed files with 2368 additions and 917 deletions

View File

@ -1,5 +1,30 @@
# Release Notes # Release Notes
## Version 0.0.5.3 - 09/26/2022
New features
- Open the next page automatically after deleting the current page
- Refresh the Kanban board after altering a property type
### Bug Fixes
- Fix switch board bug
- Fix delete the Kanban board's row error
- Remove duplicate time format
- Fix can't delete field in property edit panel
- Adjust some display UI issues
## Version 0.0.5.2 - 09/16/2022
New features
- Enable adding a new card to the "No Status" group
- Fix some bugs
### Bug Fixes
- Fix cannot open AppFlowy error
- Fix delete the Kanban board's row error
## Version 0.0.5.1 - 09/14/2022 ## Version 0.0.5.1 - 09/14/2022
New features New features

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",
@ -199,7 +199,8 @@
"delete": "Delete", "delete": "Delete",
"textPlaceholder": "Empty", "textPlaceholder": "Empty",
"copyProperty": "Copied property to clipboard", "copyProperty": "Copied property to clipboard",
"count": "Count" "count": "Count",
"newRow": "New row"
}, },
"selectOption": { "selectOption": {
"create": "Create", "create": "Create",
@ -231,4 +232,4 @@
"create_new_card": "New" "create_new_card": "New"
} }
} }
} }

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

@ -149,7 +149,7 @@
"grid": { "grid": {
"settings": { "settings": {
"filter": "Filtrer", "filter": "Filtrer",
"sortBy": "Trier par", "sortBy": "Filtrer par",
"Properties": "Propriétés" "Properties": "Propriétés"
}, },
"field": { "field": {
@ -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

@ -63,13 +63,13 @@
"deletePermanent": "Apagar permanentemente" "deletePermanent": "Apagar permanentemente"
}, },
"dialogCreatePageNameHint": "Nome da página", "dialogCreatePageNameHint": "Nome da página",
"questionBubble": { "questionBubble": {
"whatsNew": "O que há de novo?", "whatsNew": "O que há de novo?",
"help": "Ajuda e Suporte", "help": "Ajuda e Suporte",
"debug": { "debug": {
"name": "Informação de depuração", "name": "Informação de depuração",
"success": "Informação de depuração copiada para a área de transferência!", "success": "Informação de depuração copiada para a área de transferência!",
"fail": "Falha ao copiar a informação de depuração para a área de transferência" "fail": "Falha ao copiar a informação de depuração para a área de transferência"
} }
}, },
"menuAppHeader": { "menuAppHeader": {
@ -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",
@ -231,4 +231,4 @@
"create_new_card": "Novo" "create_new_card": "Novo"
} }
} }
} }

View File

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

View File

@ -94,7 +94,14 @@
}, },
"tooltip": { "tooltip": {
"lightMode": "切换到亮色模式", "lightMode": "切换到亮色模式",
"darkMode": "切换到暗色模式" "darkMode": "切换到暗色模式",
"openAsPage": "作为页面打开",
"addNewRow": "增加一行",
"openMenu": "点击打开菜单"
},
"sideBar": {
"openSidebar": "打开侧边栏",
"closeSidebar": "关闭侧边栏"
}, },
"notifications": { "notifications": {
"export": { "export": {
@ -149,15 +156,12 @@
"darkLabel": "夜间模式" "darkLabel": "夜间模式"
} }
}, },
"sideBar": {
"openSidebar": "打开侧边栏",
"closeSidebar": "关闭侧边栏"
},
"grid": { "grid": {
"settings": { "settings": {
"filter": "过滤器", "filter": "过滤器",
"sortBy": "排序", "sortBy": "排序",
"Properties": "属性" "Properties": "属性",
"group": "组"
}, },
"field": { "field": {
"hide": "隐藏", "hide": "隐藏",
@ -177,7 +181,7 @@
"includeTime": " 包含时间", "includeTime": " 包含时间",
"dateFormatFriendly": "月 日,年", "dateFormatFriendly": "月 日,年",
"dateFormatISO": "年-月-日", "dateFormatISO": "年-月-日",
"dateFormatLocal": "年/月/日", "dateFormatLocal": "月/日/年",
"dateFormatUS": "年/月/日", "dateFormatUS": "年/月/日",
"timeFormat": " 时间格式", "timeFormat": " 时间格式",
"invalidTimeFormat": "时间格式错误", "invalidTimeFormat": "时间格式错误",
@ -186,13 +190,17 @@
"addSelectOption": "添加一个标签", "addSelectOption": "添加一个标签",
"optionTitle": "标签", "optionTitle": "标签",
"addOption": "添加标签", "addOption": "添加标签",
"editProperty": "编辑列属性" "editProperty": "编辑列属性",
"newColumn": "增加一列",
"deleteFieldPromptMessage": "确定要删除这个属性吗? "
}, },
"row": { "row": {
"duplicate": "复制", "duplicate": "复制",
"delete": "删除", "delete": "删除",
"textPlaceholder": "空", "textPlaceholder": "空",
"copyProperty": "复制列" "copyProperty": "复制列",
"count": "数量",
"newRow": "添加一行"
}, },
"selectOption": { "selectOption": {
"create": "新建", "create": "新建",
@ -218,5 +226,11 @@
"timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwelveHour": "01:00 PM",
"timeHintTextInTwentyFourHour": "13:00" "timeHintTextInTwentyFourHour": "13:00"
} }
},
"board": {
"column": {
"create_new_card": "新建"
},
"menuName": "看板"
} }
} }

View File

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

View File

@ -36,21 +36,21 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
super(BoardState.initial(view.id)) { super(BoardState.initial(view.id)) {
boardController = AppFlowyBoardController( boardController = AppFlowyBoardController(
onMoveGroup: ( onMoveGroup: (
fromColumnId, fromGroupId,
fromIndex, fromIndex,
toColumnId, toGroupId,
toIndex, toIndex,
) { ) {
_moveGroup(fromColumnId, toColumnId); _moveGroup(fromGroupId, toGroupId);
}, },
onMoveGroupItem: ( onMoveGroupItem: (
columnId, groupId,
fromIndex, fromIndex,
toIndex, toIndex,
) { ) {
final fromRow = groupControllers[columnId]?.rowAtIndex(fromIndex); final fromRow = groupControllers[groupId]?.rowAtIndex(fromIndex);
final toRow = groupControllers[columnId]?.rowAtIndex(toIndex); final toRow = groupControllers[groupId]?.rowAtIndex(toIndex);
_moveRow(fromRow, columnId, toRow); _moveRow(fromRow, groupId, toRow);
}, },
onMoveGroupItemToGroup: ( onMoveGroupItemToGroup: (
fromGroupId, fromGroupId,

View File

@ -68,9 +68,11 @@ class GridPluginDisplay extends PluginDisplay {
@override @override
Widget buildWidget(PluginContext context) { Widget buildWidget(PluginContext context) {
notifier.isDeleted.addListener(() { notifier.isDeleted.addListener(() {
if (notifier.isDeleted.value) { notifier.isDeleted.value.fold(() => null, (deletedView) {
context.onDeleted(view); if (deletedView.hasIndex()) {
} context.onDeleted(view, deletedView.index);
}
});
}); });
return BoardPage(key: ValueKey(view.id), view: view); return BoardPage(key: ValueKey(view.id), view: view);

View File

@ -69,7 +69,6 @@ class BoardContent extends StatefulWidget {
class _BoardContentState extends State<BoardContent> { class _BoardContentState extends State<BoardContent> {
late AppFlowyBoardScrollController scrollManager; late AppFlowyBoardScrollController scrollManager;
final Map<String, ValueKey> cardKeysCache = {};
final config = AppFlowyBoardConfig( final config = AppFlowyBoardConfig(
groupBackgroundColor: HexColor.fromHex('#F7F8FC'), groupBackgroundColor: HexColor.fromHex('#F7F8FC'),
@ -104,8 +103,8 @@ class _BoardContentState extends State<BoardContent> {
Widget _buildBoard(BuildContext context) { Widget _buildBoard(BuildContext context) {
return ChangeNotifierProvider.value( return ChangeNotifierProvider.value(
value: Provider.of<AppearanceSettingModel>(context, listen: true), value: Provider.of<AppearanceSetting>(context, listen: true),
child: Selector<AppearanceSettingModel, AppTheme>( child: Selector<AppearanceSetting, AppTheme>(
selector: (ctx, notifier) => notifier.theme, selector: (ctx, notifier) => notifier.theme,
builder: (ctx, theme, child) => Expanded( builder: (ctx, theme, child) => Expanded(
child: AppFlowyBoard( child: AppFlowyBoard(
@ -139,7 +138,7 @@ class _BoardContentState extends State<BoardContent> {
.read<BoardBloc>() .read<BoardBloc>()
.add(BoardEvent.endEditRow(editingRow.row.id)); .add(BoardEvent.endEditRow(editingRow.row.id));
} else { } else {
scrollManager.scrollToBottom(editingRow.columnId, () { scrollManager.scrollToBottom(editingRow.columnId, (boardContext) {
context context
.read<BoardBloc>() .read<BoardBloc>()
.add(BoardEvent.endEditRow(editingRow.row.id)); .add(BoardEvent.endEditRow(editingRow.row.id));
@ -247,15 +246,8 @@ class _BoardContentState extends State<BoardContent> {
); );
final groupItemId = columnItem.id + group.id; final groupItemId = columnItem.id + group.id;
ValueKey? key = cardKeysCache[groupItemId];
if (key == null) {
final newKey = ValueKey(groupItemId);
cardKeysCache[groupItemId] = newKey;
key = newKey;
}
return AppFlowyGroupCard( return AppFlowyGroupCard(
key: key, key: ValueKey(groupItemId),
margin: config.cardPadding, margin: config.cardPadding,
decoration: _makeBoxDecoration(context), decoration: _makeBoxDecoration(context),
child: BoardCard( child: BoardCard(
@ -331,8 +323,8 @@ class _ToolbarBlocAdaptor extends StatelessWidget {
); );
return ChangeNotifierProvider.value( return ChangeNotifierProvider.value(
value: Provider.of<AppearanceSettingModel>(context, listen: true), value: Provider.of<AppearanceSetting>(context, listen: true),
child: Selector<AppearanceSettingModel, AppTheme>( child: Selector<AppearanceSetting, AppTheme>(
selector: (ctx, notifier) => notifier.theme, selector: (ctx, notifier) => notifier.theme,
builder: (ctx, theme, child) { builder: (ctx, theme, child) {
return BoardToolbar(toolbarContext: toolbarContext); return BoardToolbar(toolbarContext: toolbarContext);

View File

@ -106,8 +106,11 @@ class _SettingItem extends StatelessWidget {
height: 30, height: 30,
child: FlowyButton( child: FlowyButton(
isSelected: isSelected, isSelected: isSelected,
text: FlowyText.medium(action.title(), text: FlowyText.medium(
fontSize: 12, color: theme.textColor), action.title(),
fontSize: 12,
color: theme.textColor,
),
hoverColor: theme.hover, hoverColor: theme.hover,
onTap: () { onTap: () {
context context

View File

@ -74,15 +74,26 @@ class DocumentPlugin extends Plugin<int> {
class DocumentPluginDisplay extends PluginDisplay with NavigationItem { class DocumentPluginDisplay extends PluginDisplay with NavigationItem {
final ViewPluginNotifier notifier; final ViewPluginNotifier notifier;
ViewPB get view => notifier.view; ViewPB get view => notifier.view;
int? deletedViewIndex;
DocumentPluginDisplay({required this.notifier, Key? key}); DocumentPluginDisplay({required this.notifier, Key? key});
@override @override
Widget buildWidget(PluginContext context) => DocumentPage( Widget buildWidget(PluginContext context) {
view: view, notifier.isDeleted.addListener(() {
onDeleted: () => context.onDeleted(view), notifier.isDeleted.value.fold(() => null, (deletedView) {
key: ValueKey(view.id), if (deletedView.hasIndex()) {
); deletedViewIndex = deletedView.index;
}
});
});
return DocumentPage(
view: view,
onDeleted: () => context.onDeleted(view, deletedViewIndex),
key: ValueKey(view.id),
);
}
@override @override
Widget get leftBarItem => ViewLeftBarItem(view: view); Widget get leftBarItem => ViewLeftBarItem(view: view);
@ -120,8 +131,8 @@ class DocumentShareButton extends StatelessWidget {
child: BlocBuilder<DocShareBloc, DocShareState>( child: BlocBuilder<DocShareBloc, DocShareState>(
builder: (context, state) { builder: (context, state) {
return ChangeNotifierProvider.value( return ChangeNotifierProvider.value(
value: Provider.of<AppearanceSettingModel>(context, listen: true), value: Provider.of<AppearanceSetting>(context, listen: true),
child: Selector<AppearanceSettingModel, Locale>( child: Selector<AppearanceSetting, Locale>(
selector: (ctx, notifier) => notifier.locale, selector: (ctx, notifier) => notifier.locale,
builder: (ctx, _, child) => ConstrainedBox( builder: (ctx, _, child) => ConstrainedBox(
constraints: const BoxConstraints.expand( constraints: const BoxConstraints.expand(
@ -195,9 +206,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;

View File

@ -134,7 +134,7 @@ class _DocumentPageState extends State<DocumentPage> {
Widget _renderToolbar(quill.QuillController controller) { Widget _renderToolbar(quill.QuillController controller) {
return ChangeNotifierProvider.value( return ChangeNotifierProvider.value(
value: Provider.of<AppearanceSettingModel>(context, listen: true), value: Provider.of<AppearanceSetting>(context, listen: true),
child: EditorToolbar.basic( child: EditorToolbar.basic(
controller: controller, controller: controller,
), ),

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

@ -11,7 +11,10 @@ class NumberFormatBloc extends Bloc<NumberFormatEvent, NumberFormatState> {
event.map(setFilter: (_SetFilter value) { event.map(setFilter: (_SetFilter value) {
final List<NumberFormat> formats = List.from(NumberFormat.values); final List<NumberFormat> formats = List.from(NumberFormat.values);
if (value.filter.isNotEmpty) { if (value.filter.isNotEmpty) {
formats.retainWhere((element) => element.title().toLowerCase().contains(value.filter.toLowerCase())); formats.retainWhere((element) => element
.title()
.toLowerCase()
.contains(value.filter.toLowerCase()));
} }
emit(state.copyWith(formats: formats, filter: value.filter)); emit(state.copyWith(formats: formats, filter: value.filter));
}); });
@ -91,7 +94,7 @@ extension NumberFormatExtension on NumberFormat {
case NumberFormat.Percent: case NumberFormat.Percent:
return "Percent"; return "Percent";
case NumberFormat.PhilippinePeso: case NumberFormat.PhilippinePeso:
return "Percent"; return "PhilippinePeso";
case NumberFormat.Pound: case NumberFormat.Pound:
return "Pound"; return "Pound";
case NumberFormat.Rand: case NumberFormat.Rand:

View File

@ -70,9 +70,11 @@ class GridPluginDisplay extends PluginDisplay {
@override @override
Widget buildWidget(PluginContext context) { Widget buildWidget(PluginContext context) {
notifier.isDeleted.addListener(() { notifier.isDeleted.addListener(() {
if (notifier.isDeleted.value) { notifier.isDeleted.value.fold(() => null, (deletedView) {
context.onDeleted(view); if (deletedView.hasIndex()) {
} context.onDeleted(view, deletedView.index);
}
});
}); });
return GridPage(key: ValueKey(view.id), view: view); return GridPage(key: ValueKey(view.id), view: view);

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

@ -97,7 +97,6 @@ class GridURLCell extends GridCellWidget {
class _GridURLCellState extends GridCellState<GridURLCell> { class _GridURLCellState extends GridCellState<GridURLCell> {
final _popoverController = PopoverController(); final _popoverController = PopoverController();
GridURLCellController? _cellContext;
late URLCellBloc _cellBloc; late URLCellBloc _cellBloc;
@override @override
@ -132,6 +131,7 @@ class _GridURLCellState extends GridCellState<GridURLCell> {
controller: _popoverController, controller: _popoverController,
constraints: BoxConstraints.loose(const Size(300, 160)), constraints: BoxConstraints.loose(const Size(300, 160)),
direction: PopoverDirection.bottomWithLeftAligned, direction: PopoverDirection.bottomWithLeftAligned,
triggerActions: PopoverTriggerFlags.none,
offset: const Offset(0, 20), offset: const Offset(0, 20),
child: SizedBox.expand( child: SizedBox.expand(
child: GestureDetector( child: GestureDetector(
@ -144,7 +144,8 @@ class _GridURLCellState extends GridCellState<GridURLCell> {
), ),
popupBuilder: (BuildContext popoverContext) { popupBuilder: (BuildContext popoverContext) {
return URLEditorPopover( return URLEditorPopover(
cellController: _cellContext!, cellController: widget.cellControllerBuilder.build()
as GridURLCellController,
); );
}, },
onClose: () { onClose: () {
@ -166,17 +167,13 @@ class _GridURLCellState extends GridCellState<GridURLCell> {
final uri = Uri.parse(url); final uri = Uri.parse(url);
if (url.isNotEmpty && await canLaunchUrl(uri)) { if (url.isNotEmpty && await canLaunchUrl(uri)) {
await launchUrl(uri); await launchUrl(uri);
} else {
_cellContext =
widget.cellControllerBuilder.build() as GridURLCellController;
widget.onCellEditing.value = true;
_popoverController.show();
} }
} }
@override @override
void requestBeginFocus() { void requestBeginFocus() {
_openUrlOrEdit(_cellBloc.state.url); widget.onCellEditing.value = true;
_popoverController.show();
} }
@override @override

View File

@ -1,4 +1,6 @@
import 'package:app_flowy/generated/locale_keys.g.dart';
import 'package:app_flowy/plugins/grid/application/grid_bloc.dart'; import 'package:app_flowy/plugins/grid/application/grid_bloc.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';
import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/button.dart';
@ -13,7 +15,7 @@ class GridAddRowButton extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = context.watch<AppTheme>(); final theme = context.watch<AppTheme>();
return FlowyButton( return FlowyButton(
text: const FlowyText.medium('New row', fontSize: 12), text: FlowyText.medium(LocaleKeys.grid_row_newRow.tr(), fontSize: 12),
hoverColor: theme.shader6, hoverColor: theme.shader6,
onTap: () => context.read<GridBloc>().add(const GridEvent.createRow()), onTap: () => context.read<GridBloc>().add(const GridEvent.createRow()),
leftIcon: svgWidget("home/add", color: theme.iconColor), leftIcon: svgWidget("home/add", color: theme.iconColor),

View File

@ -33,8 +33,8 @@ class MenuTrash extends StatelessWidget {
Widget _render(BuildContext context) { Widget _render(BuildContext context) {
return Row(children: [ return Row(children: [
ChangeNotifierProvider.value( ChangeNotifierProvider.value(
value: Provider.of<AppearanceSettingModel>(context, listen: true), value: Provider.of<AppearanceSetting>(context, listen: true),
child: Selector<AppearanceSettingModel, AppTheme>( child: Selector<AppearanceSetting, AppTheme>(
selector: (ctx, notifier) => notifier.theme, selector: (ctx, notifier) => notifier.theme,
builder: (ctx, theme, child) => SizedBox( builder: (ctx, theme, child) => SizedBox(
width: 16, width: 16,
@ -44,8 +44,8 @@ class MenuTrash extends StatelessWidget {
), ),
const HSpace(6), const HSpace(6),
ChangeNotifierProvider.value( ChangeNotifierProvider.value(
value: Provider.of<AppearanceSettingModel>(context, listen: true), value: Provider.of<AppearanceSetting>(context, listen: true),
child: Selector<AppearanceSettingModel, Locale>( child: Selector<AppearanceSetting, Locale>(
selector: (ctx, notifier) => notifier.locale, selector: (ctx, notifier) => notifier.locale,
builder: (ctx, _, child) => builder: (ctx, _, child) =>
FlowyText.medium(LocaleKeys.trash_text.tr(), fontSize: 12), FlowyText.medium(LocaleKeys.trash_text.tr(), fontSize: 12),

View File

@ -1,15 +1,16 @@
import 'package:app_flowy/startup/plugin/plugin.dart'; import 'package:app_flowy/startup/plugin/plugin.dart';
import 'package:app_flowy/workspace/application/view/view_listener.dart'; import 'package:app_flowy/workspace/application/view/view_listener.dart';
import 'package:dartz/dartz.dart';
import 'package:flowy_sdk/log.dart'; import 'package:flowy_sdk/log.dart';
import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class ViewPluginNotifier extends PluginNotifier { class ViewPluginNotifier extends PluginNotifier<Option<DeletedViewPB>> {
final ViewListener? _viewListener; final ViewListener? _viewListener;
ViewPB view; ViewPB view;
@override @override
final ValueNotifier<bool> isDeleted = ValueNotifier(false); final ValueNotifier<Option<DeletedViewPB>> isDeleted = ValueNotifier(none());
@override @override
final ValueNotifier<int> isDisplayChanged = ValueNotifier(0); final ValueNotifier<int> isDisplayChanged = ValueNotifier(0);
@ -27,9 +28,7 @@ class ViewPluginNotifier extends PluginNotifier {
); );
}, onViewMoveToTrash: (result) { }, onViewMoveToTrash: (result) {
result.fold( result.fold(
(deletedView) { (deletedView) => isDeleted.value = some(deletedView),
isDeleted.value = true;
},
(err) => Log.error(err), (err) => Log.error(err),
); );
}); });

View File

@ -32,9 +32,9 @@ abstract class Plugin<T> {
} }
} }
abstract class PluginNotifier { abstract class PluginNotifier<T> {
/// Notify if the plugin get deleted /// Notify if the plugin get deleted
ValueNotifier<bool> get isDeleted; ValueNotifier<T> get isDeleted;
/// Notify if the [PluginDisplay]'s content was changed /// Notify if the [PluginDisplay]'s content was changed
ValueNotifier<int> get isDisplayChanged; ValueNotifier<int> get isDisplayChanged;
@ -67,7 +67,7 @@ abstract class PluginDisplay with NavigationItem {
class PluginContext { class PluginContext {
// calls when widget of the plugin get deleted // calls when widget of the plugin get deleted
final Function(ViewPB) onDeleted; final Function(ViewPB, int?) onDeleted;
PluginContext({required this.onDeleted}); PluginContext({required this.onDeleted});
} }

View File

@ -17,8 +17,8 @@ class InitAppWidgetTask extends LaunchTask {
@override @override
Future<void> initialize(LaunchContext context) async { Future<void> initialize(LaunchContext context) async {
final widget = context.getIt<EntryPoint>().create(); final widget = context.getIt<EntryPoint>().create();
final setting = await UserSettingsService().getAppearanceSettings(); final setting = await SettingsFFIService().getAppearanceSetting();
final settingModel = AppearanceSettingModel(setting); final settingModel = AppearanceSetting(setting);
final app = ApplicationWidget( final app = ApplicationWidget(
settingModel: settingModel, settingModel: settingModel,
child: widget, child: widget,
@ -58,7 +58,7 @@ class InitAppWidgetTask extends LaunchTask {
class ApplicationWidget extends StatelessWidget { class ApplicationWidget extends StatelessWidget {
final Widget child; final Widget child;
final AppearanceSettingModel settingModel; final AppearanceSetting settingModel;
const ApplicationWidget({ const ApplicationWidget({
Key? key, Key? key,
@ -75,10 +75,10 @@ class ApplicationWidget extends StatelessWidget {
const minWidth = 600.0; const minWidth = 600.0;
setWindowMinSize(const Size(minWidth, minWidth / ratio)); setWindowMinSize(const Size(minWidth, minWidth / ratio));
settingModel.readLocaleWhenAppLaunch(context); settingModel.readLocaleWhenAppLaunch(context);
AppTheme theme = context.select<AppearanceSettingModel, AppTheme>( AppTheme theme = context.select<AppearanceSetting, AppTheme>(
(value) => value.theme, (value) => value.theme,
); );
Locale locale = context.select<AppearanceSettingModel, Locale>( Locale locale = context.select<AppearanceSetting, Locale>(
(value) => value.locale, (value) => value.locale,
); );

View File

@ -4,8 +4,8 @@ import 'package:flowy_sdk/flowy_sdk.dart';
import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-user/user_setting.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-user/user_setting.pb.dart';
class UserSettingsService { class SettingsFFIService {
Future<AppearanceSettingsPB> getAppearanceSettings() async { Future<AppearanceSettingsPB> getAppearanceSetting() async {
final result = await UserEventGetAppearanceSetting().send(); final result = await UserEventGetAppearanceSetting().send();
return result.fold( return result.fold(
@ -18,7 +18,8 @@ class UserSettingsService {
); );
} }
Future<Either<Unit, FlowyError>> setAppearanceSettings(AppearanceSettingsPB settings) { Future<Either<Unit, FlowyError>> setAppearanceSetting(
return UserEventSetAppearanceSetting(settings).send(); AppearanceSettingsPB setting) {
return UserEventSetAppearanceSetting(setting).send();
} }
} }

View File

@ -8,72 +8,114 @@ import 'package:flowy_sdk/protobuf/flowy-user/user_setting.pb.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
class AppearanceSettingModel extends ChangeNotifier with EquatableMixin { /// [AppearanceSetting] is used to modify the appear setting of AppFlowy application. Including the [Locale], [AppTheme], etc.
AppearanceSettingsPB setting; class AppearanceSetting extends ChangeNotifier with EquatableMixin {
final AppearanceSettingsPB _setting;
AppTheme _theme; AppTheme _theme;
Locale _locale; Locale _locale;
Timer? _saveOperation; Timer? _debounceSaveOperation;
AppearanceSettingModel(this.setting) AppearanceSetting(AppearanceSettingsPB setting)
: _theme = AppTheme.fromName(name: setting.theme), : _setting = setting,
_locale = _theme = AppTheme.fromName(name: setting.theme),
Locale(setting.locale.languageCode, setting.locale.countryCode); _locale = Locale(
setting.locale.languageCode,
setting.locale.countryCode,
);
/// Returns the current [AppTheme]
AppTheme get theme => _theme; AppTheme get theme => _theme;
/// Returns the current [Locale]
Locale get locale => _locale; Locale get locale => _locale;
Future<void> save() async { /// Updates the current theme and notify the listeners the theme was changed.
_saveOperation?.cancel(); /// Do nothing if the passed in themeType equal to the current theme type.
_saveOperation = Timer(const Duration(seconds: 2), () async { ///
await UserSettingsService().setAppearanceSettings(setting); void setTheme(ThemeType themeType) {
}); if (_theme.ty == themeType) {
} return;
@override
List<Object> get props {
return [setting.hashCode];
}
void swapTheme() {
final themeType =
(_theme.ty == ThemeType.light ? ThemeType.dark : ThemeType.light);
if (_theme.ty != themeType) {
_theme = AppTheme.fromType(themeType);
setting.theme = themeTypeToString(themeType);
notifyListeners();
save();
} }
_theme = AppTheme.fromType(themeType);
_setting.theme = themeTypeToString(themeType);
_saveAppearSetting();
notifyListeners();
} }
/// Updates the current locale and notify the listeners the locale was changed
/// Fallback to [en] locale If the newLocale is not supported.
///
void setLocale(BuildContext context, Locale newLocale) { void setLocale(BuildContext context, Locale newLocale) {
if (!context.supportedLocales.contains(newLocale)) { if (!context.supportedLocales.contains(newLocale)) {
Log.warn("Unsupported locale: $newLocale"); Log.warn("Unsupported locale: $newLocale, Fallback to locale: en");
newLocale = const Locale('en'); newLocale = const Locale('en');
Log.debug("Fallback to locale: $newLocale");
} }
context.setLocale(newLocale); context.setLocale(newLocale);
if (_locale != newLocale) { if (_locale != newLocale) {
_locale = newLocale; _locale = newLocale;
setting.locale.languageCode = _locale.languageCode; _setting.locale.languageCode = _locale.languageCode;
setting.locale.countryCode = _locale.countryCode ?? ""; _setting.locale.countryCode = _locale.countryCode ?? "";
_saveAppearSetting();
notifyListeners(); notifyListeners();
save();
} }
} }
void readLocaleWhenAppLaunch(BuildContext context) { /// Saves key/value setting to disk.
if (setting.resetAsDefault) { /// Removes the key if the passed in value is null
setting.resetAsDefault = false; void setKeyValue(String key, String? value) {
save(); if (key.isEmpty) {
Log.warn("The key should not be empty");
return;
}
if (value == null) {
_setting.settingKeyValue.remove(key);
}
if (_setting.settingKeyValue[key] != value) {
if (value == null) {
_setting.settingKeyValue.remove(key);
} else {
_setting.settingKeyValue[key] = value;
}
_saveAppearSetting();
notifyListeners();
}
}
/// Called when the application launch.
/// Uses the device locale when open the application for the first time
void readLocaleWhenAppLaunch(BuildContext context) {
if (_setting.resetToDefault) {
_setting.resetToDefault = false;
_saveAppearSetting();
setLocale(context, context.deviceLocale); setLocale(context, context.deviceLocale);
return; return;
} }
// when opening app the first time
setLocale(context, _locale); setLocale(context, _locale);
} }
Future<void> _saveAppearSetting() async {
_debounceSaveOperation?.cancel();
_debounceSaveOperation = Timer(
const Duration(seconds: 1),
() {
SettingsFFIService().setAppearanceSetting(_setting).then((result) {
result.fold((l) => null, (error) => Log.error(error));
});
},
);
}
@override
List<Object> get props {
return [_setting.hashCode];
}
} }

View File

@ -16,7 +16,7 @@ typedef UpdateViewNotifiedValue = Either<ViewPB, FlowyError>;
// Restore the view from trash // Restore the view from trash
typedef RestoreViewNotifiedValue = Either<ViewPB, FlowyError>; typedef RestoreViewNotifiedValue = Either<ViewPB, FlowyError>;
// Move the view to trash // Move the view to trash
typedef MoveToTrashNotifiedValue = Either<ViewIdPB, FlowyError>; typedef MoveToTrashNotifiedValue = Either<DeletedViewPB, FlowyError>;
class ViewListener { class ViewListener {
StreamSubscription<SubscribeObject>? _subscription; StreamSubscription<SubscribeObject>? _subscription;
@ -98,8 +98,8 @@ class ViewListener {
break; break;
case FolderNotification.ViewMoveToTrash: case FolderNotification.ViewMoveToTrash:
result.fold( result.fold(
(payload) => (payload) => _moveToTrashNotifier.value =
_moveToTrashNotifier.value = left(ViewIdPB.fromBuffer(payload)), left(DeletedViewPB.fromBuffer(payload)),
(error) => _moveToTrashNotifier.value = right(error), (error) => _moveToTrashNotifier.value = right(error),
); );
break; break;

View File

@ -34,19 +34,6 @@ class HomeScreen extends StatefulWidget {
} }
class _HomeScreenState extends State<HomeScreen> { class _HomeScreenState extends State<HomeScreen> {
ViewPB? initialView;
@override
void initState() {
super.initState();
}
@override
void didUpdateWidget(covariant HomeScreen oldWidget) {
initialView = null;
super.didUpdateWidget(oldWidget);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiBlocProvider( return MultiBlocProvider(
@ -129,26 +116,29 @@ class _HomeScreenState extends State<HomeScreen> {
required BuildContext context, required BuildContext context,
required HomeState state}) { required HomeState state}) {
final workspaceSetting = state.workspaceSetting; final workspaceSetting = state.workspaceSetting;
if (initialView == null && workspaceSetting.hasLatestView()) {
initialView = workspaceSetting.latestView;
final plugin = makePlugin(
pluginType: initialView!.pluginType,
data: initialView,
);
getIt<HomeStackManager>().setPlugin(plugin);
}
final homeMenu = HomeMenu( final homeMenu = HomeMenu(
user: widget.user, user: widget.user,
workspaceSetting: workspaceSetting, workspaceSetting: workspaceSetting,
collapsedNotifier: getIt<HomeStackManager>().collapsedNotifier, collapsedNotifier: getIt<HomeStackManager>().collapsedNotifier,
); );
final latestView = // Only open the last opened view if the [HomeStackManager] current opened
workspaceSetting.hasLatestView() ? workspaceSetting.latestView : null; // plugin is blank and the last opened view is not null.
if (getIt<MenuSharedState>().latestOpenView == null) { //
/// AppFlowy will open the view that the last time the user opened it. The _buildHomeMenu will get called when AppFlowy's screen resizes. So we only set the latestOpenView when it's null. // All opened widgets that display on the home screen are in the form
getIt<MenuSharedState>().latestOpenView = latestView; // of plugins. There is a list of built-in plugins defined in the
// [PluginType] enum, including board, grid and trash.
if (getIt<HomeStackManager>().plugin.ty == PluginType.blank) {
// Open the last opened view.
if (workspaceSetting.hasLatestView()) {
final view = workspaceSetting.latestView;
final plugin = makePlugin(
pluginType: view.pluginType,
data: view,
);
getIt<HomeStackManager>().setPlugin(plugin);
getIt<MenuSharedState>().latestOpenView = view;
}
} }
return FocusTraversalGroup(child: RepaintBoundary(child: homeMenu)); return FocusTraversalGroup(child: RepaintBoundary(child: homeMenu));
@ -261,14 +251,18 @@ class HomeScreenStackAdaptor extends HomeStackDelegate {
}); });
@override @override
void didDeleteStackWidget(ViewPB view) { void didDeleteStackWidget(ViewPB view, int? index) {
final homeService = HomeService(); final homeService = HomeService();
homeService.readApp(appId: view.appId).then((result) { homeService.readApp(appId: view.appId).then((result) {
result.fold( result.fold(
(appPB) { (appPB) {
final List<ViewPB> views = appPB.belongings.items; final List<ViewPB> views = appPB.belongings.items;
if (views.isNotEmpty) { if (views.isNotEmpty) {
final lastView = views.last; var lastView = views.last;
if (index != null && index != 0 && views.length > index - 1) {
lastView = views[index - 1];
}
final plugin = makePlugin( final plugin = makePlugin(
pluginType: lastView.pluginType, pluginType: lastView.pluginType,
data: lastView, data: lastView,

View File

@ -18,7 +18,7 @@ import 'home_layout.dart';
typedef NavigationCallback = void Function(String id); typedef NavigationCallback = void Function(String id);
abstract class HomeStackDelegate { abstract class HomeStackDelegate {
void didDeleteStackWidget(ViewPB view); void didDeleteStackWidget(ViewPB view, int? index);
} }
class HomeStack extends StatelessWidget { class HomeStack extends StatelessWidget {
@ -41,9 +41,11 @@ class HomeStack extends StatelessWidget {
child: Container( child: Container(
color: theme.surface, color: theme.surface,
child: FocusTraversalGroup( child: FocusTraversalGroup(
child: getIt<HomeStackManager>().stackWidget(onDeleted: (view) { child: getIt<HomeStackManager>().stackWidget(
delegate.didDeleteStackWidget(view); onDeleted: (view, index) {
}), delegate.didDeleteStackWidget(view, index);
},
),
), ),
), ),
), ),
@ -144,6 +146,7 @@ class HomeStackManager {
} }
PublishNotifier<bool> get collapsedNotifier => _notifier.collapsedNotifier; PublishNotifier<bool> get collapsedNotifier => _notifier.collapsedNotifier;
Plugin get plugin => _notifier.plugin;
void setPlugin(Plugin newPlugin) { void setPlugin(Plugin newPlugin) {
_notifier.plugin = newPlugin; _notifier.plugin = newPlugin;
@ -167,7 +170,7 @@ class HomeStackManager {
); );
} }
Widget stackWidget({required Function(ViewPB) onDeleted}) { Widget stackWidget({required Function(ViewPB, int?) onDeleted}) {
return MultiProvider( return MultiProvider(
providers: [ChangeNotifierProvider.value(value: _notifier)], providers: [ChangeNotifierProvider.value(value: _notifier)],
child: Consumer(builder: (ctx, HomeStackNotifier notifier, child) { child: Consumer(builder: (ctx, HomeStackNotifier notifier, child) {

View File

@ -61,13 +61,20 @@ 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(
height: 80, minWidth: 120,
maxWidth: 280,
minHeight: items.length * (CreateItem.height),
maxHeight: items.length * (CreateItem.height),
),
); );
} }
} }
class CreateItem extends StatelessWidget { class CreateItem extends StatelessWidget {
static const double height = 30;
static const double verticalPadding = 6;
final PluginBuilder pluginBuilder; final PluginBuilder pluginBuilder;
final Function(PluginBuilder) onSelected; final Function(PluginBuilder) onSelected;
const CreateItem({ const CreateItem({
@ -86,11 +93,20 @@ class CreateItem extends StatelessWidget {
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () => onSelected(pluginBuilder), onTap: () => onSelected(pluginBuilder),
child: FlowyText.medium( child: ConstrainedBox(
pluginBuilder.menuName, constraints: const BoxConstraints(
color: theme.textColor, minWidth: 120,
fontSize: 12, minHeight: CreateItem.height,
).padding(horizontal: 10, vertical: 6), ),
child: Align(
alignment: Alignment.centerLeft,
child: FlowyText.medium(
pluginBuilder.menuName,
color: theme.textColor,
fontSize: 12,
).padding(horizontal: 10),
),
),
), ),
); );
} }

View File

@ -89,8 +89,7 @@ class _MenuAppState extends State<MenuApp> {
hasIcon: false, hasIcon: false,
), ),
header: ChangeNotifierProvider.value( header: ChangeNotifierProvider.value(
value: value: Provider.of<AppearanceSetting>(context, listen: true),
Provider.of<AppearanceSettingModel>(context, listen: true),
child: MenuAppHeader(widget.app), child: MenuAppHeader(widget.app),
), ),
expanded: ViewSection(appViewData: viewDataContext), expanded: ViewSection(appViewData: viewDataContext),

View File

@ -53,7 +53,7 @@ class ViewSectionItem extends StatelessWidget {
_handleAction(context, action); _handleAction(context, action);
}, },
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 2),
child: InkWell( child: InkWell(
onTap: () => onSelected(context.read<ViewBloc>().state.view), onTap: () => onSelected(context.read<ViewBloc>().state.view),
child: FlowyHover( child: FlowyHover(
@ -73,13 +73,18 @@ class ViewSectionItem extends StatelessWidget {
BuildContext context, bool onHover, ViewState state, Color iconColor) { BuildContext context, bool onHover, ViewState state, Color iconColor) {
List<Widget> children = [ List<Widget> children = [
SizedBox( SizedBox(
width: 16, width: 16,
height: 16, height: 16,
child: state.view.renderThumbnail(iconColor: iconColor)), child: state.view.renderThumbnail(iconColor: iconColor),
),
const HSpace(2), const HSpace(2),
Expanded( Expanded(
child: FlowyText.regular(state.view.name, child: FlowyText.regular(
fontSize: 12, overflow: TextOverflow.clip)), state.view.name,
fontSize: 12,
overflow: TextOverflow.clip,
),
),
]; ];
if (onHover || state.isEditing) { if (onHover || state.isEditing) {

View File

@ -33,8 +33,7 @@ class SettingsDialog extends StatelessWidget {
..add(const SettingsDialogEvent.initial()), ..add(const SettingsDialogEvent.initial()),
child: BlocBuilder<SettingsDialogBloc, SettingsDialogState>( child: BlocBuilder<SettingsDialogBloc, SettingsDialogState>(
builder: (context, state) => ChangeNotifierProvider.value( builder: (context, state) => ChangeNotifierProvider.value(
value: Provider.of<AppearanceSettingModel>(context, value: Provider.of<AppearanceSetting>(context, listen: true),
listen: true),
child: FlowyDialog( child: FlowyDialog(
title: Text( title: Text(
LocaleKeys.settings_title.tr(), LocaleKeys.settings_title.tr(),

View File

@ -13,7 +13,7 @@ class SettingsAppearanceView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = context.watch<AppTheme>(); final theme = context.read<AppTheme>();
return SingleChildScrollView( return SingleChildScrollView(
child: Column( child: Column(
@ -30,9 +30,7 @@ class SettingsAppearanceView extends StatelessWidget {
), ),
Toggle( Toggle(
value: theme.isDark, value: theme.isDark,
onChanged: (val) { onChanged: (_) => setTheme(context),
context.read<AppearanceSettingModel>().swapTheme();
},
style: ToggleStyle.big(theme), style: ToggleStyle.big(theme),
), ),
Text( Text(
@ -48,4 +46,13 @@ class SettingsAppearanceView extends StatelessWidget {
), ),
); );
} }
void setTheme(BuildContext context) {
final theme = context.read<AppTheme>();
if (theme.isDark) {
context.read<AppearanceSetting>().setTheme(ThemeType.light);
} else {
context.read<AppearanceSetting>().setTheme(ThemeType.dark);
}
}
} }

View File

@ -13,7 +13,7 @@ class SettingsLanguageView extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
context.watch<AppTheme>(); context.watch<AppTheme>();
return ChangeNotifierProvider.value( return ChangeNotifierProvider.value(
value: Provider.of<AppearanceSettingModel>(context, listen: true), value: Provider.of<AppearanceSetting>(context, listen: true),
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -43,7 +43,8 @@ class LanguageSelectorDropdown extends StatefulWidget {
}) : super(key: key); }) : super(key: key);
@override @override
State<LanguageSelectorDropdown> createState() => _LanguageSelectorDropdownState(); State<LanguageSelectorDropdown> createState() =>
_LanguageSelectorDropdownState();
} }
class _LanguageSelectorDropdownState extends State<LanguageSelectorDropdown> { class _LanguageSelectorDropdownState extends State<LanguageSelectorDropdown> {
@ -77,10 +78,10 @@ class _LanguageSelectorDropdownState extends State<LanguageSelectorDropdown> {
), ),
child: DropdownButtonHideUnderline( child: DropdownButtonHideUnderline(
child: DropdownButton<Locale>( child: DropdownButton<Locale>(
value: context.read<AppearanceSettingModel>().locale, value: context.read<AppearanceSetting>().locale,
onChanged: (val) { onChanged: (val) {
setState(() { setState(() {
context.read<AppearanceSettingModel>().setLocale(context, val!); context.read<AppearanceSetting>().setLocale(context, val!);
}); });
}, },
icon: const Visibility( icon: const Visibility(

View File

@ -111,9 +111,6 @@ class QuestionBubbleActionSheet
required this.onSelected, required this.onSelected,
}); });
@override
double get maxWidth => 170;
@override @override
double get itemHeight => 22; double get itemHeight => 22;
@ -142,7 +139,7 @@ class QuestionBubbleActionSheet
@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),
); );
} }
@ -174,11 +171,14 @@ 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", FlowyText(
fontSize: 12, color: theme.shader4), "$appName $version.$buildNumber",
fontSize: 12,
color: theme.shader4,
),
], ],
).padding( ).padding(
horizontal: ActionListSizes.itemHPadding + ActionListSizes.padding, horizontal: ActionListSizes.itemHPadding + ActionListSizes.hPadding,
); );
} else { } else {
return const CircularProgressIndicator(); return const CircularProgressIndicator();

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
.map(
(action) => ActionCell<T>(
action: action,
itemHeight: itemHeight,
onSelected: (action) {
FlowyOverlay.of(buildContext).remove(identifier);
selectCallback(dartz.some(action));
},
),
)
.toList();
ListOverlay.showWithAnchor( ListOverlay.showWithAnchor(
buildContext, buildContext,
identifier: identifier, identifier: identifier,
itemCount: widgets.length, itemCount: items.length,
itemBuilder: (context, index) => widgets[index], itemBuilder: (context, index) {
final action = items[index];
return ActionCell<T>(
action: action,
itemHeight: itemHeight,
onSelected: (action) {
FlowyOverlay.of(buildContext).remove(identifier);
selectCallback(dartz.some(action));
},
);
},
anchorContext: anchorContext ?? buildContext, anchorContext: anchorContext ?? buildContext,
anchorDirection: anchorDirection, anchorDirection: anchorDirection,
width: maxWidth, constraints: BoxConstraints(
height: widgets.length * (itemHeight + ActionListSizes.padding * 2), minHeight: items.length * (itemHeight + ActionListSizes.vPadding * 2),
maxHeight: items.length * (itemHeight + ActionListSizes.vPadding * 2),
maxWidth: maxWidth,
minWidth: minWidth,
),
delegate: delegate, delegate: delegate,
anchorOffset: anchorOffset, anchorOffset: anchorOffset,
footer: footer, footer: footer,
@ -66,7 +69,8 @@ abstract class ActionItem {
class ActionListSizes { class ActionListSizes {
static double itemHPadding = 10; static double itemHPadding = 10;
static double itemHeight = 20; static double itemHeight = 20;
static double padding = 6; static double vPadding = 6;
static double hPadding = 10;
} }
class ActionCell<T extends ActionItem> extends StatelessWidget { class ActionCell<T extends ActionItem> extends StatelessWidget {
@ -93,7 +97,7 @@ 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 (icon != null) icon, if (icon != null) icon,
HSpace(ActionListSizes.itemHPadding), HSpace(ActionListSizes.itemHPadding),
@ -101,8 +105,8 @@ class ActionCell<T extends ActionItem> extends StatelessWidget {
], ],
), ),
).padding( ).padding(
horizontal: ActionListSizes.padding, horizontal: ActionListSizes.hPadding,
vertical: ActionListSizes.padding, vertical: ActionListSizes.vPadding,
), ),
), ),
); );

View File

@ -1,3 +1,5 @@
# 0.0.8
* Enable drag and drop group
# 0.0.7 # 0.0.7
* Rename some classes * Rename some classes
* Add documentation * Add documentation
@ -7,7 +9,7 @@
# 0.0.5 # 0.0.5
* Optimize insert card animation * Optimize insert card animation
* Enable insert card at the end of the column * Enable insert card at the end of the group
* Fix some bugs * Fix some bugs
# 0.0.4 # 0.0.4
@ -24,6 +26,5 @@
# 0.0.1 # 0.0.1
* Support drag and drop column * Support drag and drop group items from one to another
* Support drag and drop column items from one to another

View File

@ -34,7 +34,7 @@ class _MyAppState extends State<MyApp> {
appBar: AppBar( appBar: AppBar(
title: const Text('AppFlowy Board'), title: const Text('AppFlowy Board'),
), ),
body: _examples[_currentIndex], body: Container(color: Colors.white, child: _examples[_currentIndex]),
bottomNavigationBar: BottomNavigationBar( bottomNavigationBar: BottomNavigationBar(
fixedColor: _bottomNavigationColor, fixedColor: _bottomNavigationColor,
showSelectedLabels: true, showSelectedLabels: true,

View File

@ -21,8 +21,11 @@ class _MultiBoardListExampleState extends State<MultiBoardListExample> {
}, },
); );
late AppFlowyBoardScrollController boardController;
@override @override
void initState() { void initState() {
boardController = AppFlowyBoardScrollController();
final group1 = AppFlowyGroupData(id: "To Do", name: "To Do", items: [ final group1 = AppFlowyGroupData(id: "To Do", name: "To Do", items: [
TextItem("Card 1"), TextItem("Card 1"),
TextItem("Card 2"), TextItem("Card 2"),
@ -67,12 +70,16 @@ class _MultiBoardListExampleState extends State<MultiBoardListExample> {
child: _buildCard(groupItem), child: _buildCard(groupItem),
); );
}, },
boardScrollController: boardController,
footerBuilder: (context, columnData) { footerBuilder: (context, columnData) {
return AppFlowyGroupFooter( return AppFlowyGroupFooter(
icon: const Icon(Icons.add, size: 20), icon: const Icon(Icons.add, size: 20),
title: const Text('New'), title: const Text('New'),
height: 50, height: 50,
margin: config.groupItemPadding, margin: config.groupItemPadding,
onAddButtonClick: () {
boardController.scrollToBottom(columnData.id, (p0) {});
},
); );
}, },
headerBuilder: (context, columnData) { headerBuilder: (context, columnData) {

View File

@ -8,25 +8,28 @@ class Log {
static void info(String? message) { static void info(String? message) {
if (enableLog) { if (enableLog) {
debugPrint('[Info]=> $message'); debugPrint('AppFlowyBoard: [Info]=> $message');
} }
} }
static void debug(String? message) { static void debug(String? message) {
if (enableLog) { if (enableLog) {
debugPrint('🐛[Debug] - ${DateTime.now().second}=> $message'); debugPrint(
'AppFlowyBoard: 🐛[Debug] - ${DateTime.now().second}=> $message');
} }
} }
static void warn(String? message) { static void warn(String? message) {
if (enableLog) { if (enableLog) {
debugPrint('🐛[Warn] - ${DateTime.now().second} => $message'); debugPrint(
'AppFlowyBoard: 🐛[Warn] - ${DateTime.now().second} => $message');
} }
} }
static void trace(String? message) { static void trace(String? message) {
if (enableLog) { if (enableLog) {
debugPrint('❗️[Trace] - ${DateTime.now().second}=> $message'); debugPrint(
'AppFlowyBoard: ❗️[Trace] - ${DateTime.now().second}=> $message');
} }
} }
} }

View File

@ -13,10 +13,8 @@ import '../rendering/board_overlay.dart';
class AppFlowyBoardScrollController { class AppFlowyBoardScrollController {
AppFlowyBoardState? _groupState; AppFlowyBoardState? _groupState;
void scrollToBottom(String groupId, VoidCallback? completed) { void scrollToBottom(String groupId, void Function(BuildContext)? completed) {
_groupState _groupState?.reorderFlexActionMap[groupId]?.scrollToBottom(completed);
?.getReorderFlexState(groupId: groupId)
?.scrollToBottom(completed);
} }
} }
@ -133,7 +131,7 @@ class AppFlowyBoard extends StatelessWidget {
dataController: controller, dataController: controller,
scrollController: scrollController, scrollController: scrollController,
scrollManager: boardScrollController, scrollManager: boardScrollController,
columnsState: _groupState, groupState: _groupState,
background: background, background: background,
delegate: _phantomController, delegate: _phantomController,
groupConstraints: groupConstraints, groupConstraints: groupConstraints,
@ -158,7 +156,7 @@ class _AppFlowyBoardContent extends StatefulWidget {
final ReorderFlexConfig reorderFlexConfig; final ReorderFlexConfig reorderFlexConfig;
final BoxConstraints groupConstraints; final BoxConstraints groupConstraints;
final AppFlowyBoardScrollController? scrollManager; final AppFlowyBoardScrollController? scrollManager;
final AppFlowyBoardState columnsState; final AppFlowyBoardState groupState;
final AppFlowyBoardCardBuilder cardBuilder; final AppFlowyBoardCardBuilder cardBuilder;
final AppFlowyBoardHeaderBuilder? headerBuilder; final AppFlowyBoardHeaderBuilder? headerBuilder;
final AppFlowyBoardFooterBuilder? footerBuilder; final AppFlowyBoardFooterBuilder? footerBuilder;
@ -171,7 +169,7 @@ class _AppFlowyBoardContent extends StatefulWidget {
required this.delegate, required this.delegate,
required this.dataController, required this.dataController,
required this.scrollManager, required this.scrollManager,
required this.columnsState, required this.groupState,
this.scrollController, this.scrollController,
this.background, this.background,
required this.groupConstraints, required this.groupConstraints,
@ -192,8 +190,6 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> {
GlobalKey(debugLabel: '$_AppFlowyBoardContent overlay key'); GlobalKey(debugLabel: '$_AppFlowyBoardContent overlay key');
late BoardOverlayEntry _overlayEntry; late BoardOverlayEntry _overlayEntry;
final Map<String, GlobalObjectKey> _reorderFlexKeys = {};
@override @override
void initState() { void initState() {
_overlayEntry = BoardOverlayEntry( _overlayEntry = BoardOverlayEntry(
@ -202,7 +198,7 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> {
reorderFlexId: widget.dataController.identifier, reorderFlexId: widget.dataController.identifier,
acceptedReorderFlexId: widget.dataController.groupIds, acceptedReorderFlexId: widget.dataController.groupIds,
delegate: widget.delegate, delegate: widget.delegate,
columnsState: widget.columnsState, columnsState: widget.groupState,
); );
final reorderFlex = ReorderFlex( final reorderFlex = ReorderFlex(
@ -212,7 +208,7 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> {
dataSource: widget.dataController, dataSource: widget.dataController,
direction: Axis.horizontal, direction: Axis.horizontal,
interceptor: interceptor, interceptor: interceptor,
reorderable: false, reorderable: true,
children: _buildColumns(), children: _buildColumns(),
); );
@ -257,18 +253,16 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> {
dataController: widget.dataController, dataController: widget.dataController,
); );
if (_reorderFlexKeys[columnData.id] == null) { final reorderFlexAction = ReorderFlexActionImpl();
_reorderFlexKeys[columnData.id] = GlobalObjectKey(columnData.id); widget.groupState.reorderFlexActionMap[columnData.id] =
} reorderFlexAction;
GlobalObjectKey reorderFlexKey = _reorderFlexKeys[columnData.id]!;
return ChangeNotifierProvider.value( return ChangeNotifierProvider.value(
key: ValueKey(columnData.id), key: ValueKey(columnData.id),
value: widget.dataController.getGroupController(columnData.id), value: widget.dataController.getGroupController(columnData.id),
child: Consumer<AppFlowyGroupController>( child: Consumer<AppFlowyGroupController>(
builder: (context, value, child) { builder: (context, value, child) {
final boardColumn = AppFlowyBoardGroup( final boardColumn = AppFlowyBoardGroup(
reorderFlexKey: reorderFlexKey,
// key: PageStorageKey<String>(columnData.id), // key: PageStorageKey<String>(columnData.id),
margin: _marginFromIndex(columnIndex), margin: _marginFromIndex(columnIndex),
itemMargin: widget.config.groupItemPadding, itemMargin: widget.config.groupItemPadding,
@ -281,11 +275,11 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> {
onReorder: widget.dataController.moveGroupItem, onReorder: widget.dataController.moveGroupItem,
cornerRadius: widget.config.cornerRadius, cornerRadius: widget.config.cornerRadius,
backgroundColor: widget.config.groupBackgroundColor, backgroundColor: widget.config.groupBackgroundColor,
dragStateStorage: widget.columnsState, dragStateStorage: widget.groupState,
dragTargetIndexKeyStorage: widget.columnsState, dragTargetKeys: widget.groupState,
reorderFlexAction: reorderFlexAction,
); );
widget.columnsState.addGroup(columnData.id, boardColumn);
return ConstrainedBox( return ConstrainedBox(
constraints: widget.groupConstraints, constraints: widget.groupConstraints,
child: boardColumn, child: boardColumn,
@ -356,71 +350,61 @@ class AppFlowyGroupContext {
} }
class AppFlowyBoardState extends DraggingStateStorage class AppFlowyBoardState extends DraggingStateStorage
with ReorderDragTargetIndexKeyStorage { with ReorderDragTargeKeys {
final Map<String, DraggingState> groupDragStates = {};
final Map<String, Map<String, GlobalObjectKey>> groupDragTargetKeys = {};
/// Quick access to the [AppFlowyBoardGroup], the [GlobalKey] is bind to the /// Quick access to the [AppFlowyBoardGroup], the [GlobalKey] is bind to the
/// AppFlowyBoardGroup's [ReorderFlex] widget. /// AppFlowyBoardGroup's [ReorderFlex] widget.
final Map<String, GlobalKey> groupReorderFlexKeys = {}; final Map<String, ReorderFlexActionImpl> reorderFlexActionMap = {};
final Map<String, DraggingState> groupDragStates = {};
final Map<String, Map<String, GlobalObjectKey>> groupDragDragTargets = {};
void addGroup(String groupId, AppFlowyBoardGroup groupWidget) {
groupReorderFlexKeys[groupId] = groupWidget.reorderFlexKey;
}
ReorderFlexState? getReorderFlexState({required String groupId}) {
final flexGlobalKey = groupReorderFlexKeys[groupId];
if (flexGlobalKey == null) return null;
if (flexGlobalKey.currentState is! ReorderFlexState) return null;
final state = flexGlobalKey.currentState as ReorderFlexState;
return state;
}
ReorderFlex? getReorderFlex({required String groupId}) {
final flexGlobalKey = groupReorderFlexKeys[groupId];
if (flexGlobalKey == null) return null;
if (flexGlobalKey.currentWidget is! ReorderFlex) return null;
final widget = flexGlobalKey.currentWidget as ReorderFlex;
return widget;
}
@override @override
DraggingState? read(String reorderFlexId) { DraggingState? readState(String reorderFlexId) {
return groupDragStates[reorderFlexId]; return groupDragStates[reorderFlexId];
} }
@override @override
void write(String reorderFlexId, DraggingState state) { void insertState(String reorderFlexId, DraggingState state) {
Log.trace('$reorderFlexId Write dragging state: $state'); Log.trace('$reorderFlexId Write dragging state: $state');
groupDragStates[reorderFlexId] = state; groupDragStates[reorderFlexId] = state;
} }
@override @override
void remove(String reorderFlexId) { void removeState(String reorderFlexId) {
groupDragStates.remove(reorderFlexId); groupDragStates.remove(reorderFlexId);
} }
@override @override
void addKey( void insertDragTarget(
String reorderFlexId, String reorderFlexId,
String key, String key,
GlobalObjectKey<State<StatefulWidget>> value, GlobalObjectKey<State<StatefulWidget>> value,
) { ) {
Map<String, GlobalObjectKey>? group = groupDragDragTargets[reorderFlexId]; Map<String, GlobalObjectKey>? group = groupDragTargetKeys[reorderFlexId];
if (group == null) { if (group == null) {
group = {}; group = {};
groupDragDragTargets[reorderFlexId] = group; groupDragTargetKeys[reorderFlexId] = group;
} }
group[key] = value; group[key] = value;
} }
@override @override
GlobalObjectKey<State<StatefulWidget>>? readKey( GlobalObjectKey<State<StatefulWidget>>? getDragTarget(
String reorderFlexId, String key) { String reorderFlexId,
Map<String, GlobalObjectKey>? group = groupDragDragTargets[reorderFlexId]; String key,
) {
Map<String, GlobalObjectKey>? group = groupDragTargetKeys[reorderFlexId];
if (group != null) { if (group != null) {
return group[key]; return group[key];
} else { } else {
return null; return null;
} }
} }
@override
void removeDragTarget(String reorderFlexId) {
groupDragTargetKeys.remove(reorderFlexId);
}
} }
class ReorderFlexActionImpl extends ReorderFlexAction {}

View File

@ -217,15 +217,15 @@ class AppFlowyBoardController extends ChangeNotifier
final fromGroupItem = fromGroupController.removeAt(fromGroupIndex); final fromGroupItem = fromGroupController.removeAt(fromGroupIndex);
if (toGroupController.items.length > toGroupIndex) { if (toGroupController.items.length > toGroupIndex) {
assert(toGroupController.items[toGroupIndex] is PhantomGroupItem); assert(toGroupController.items[toGroupIndex] is PhantomGroupItem);
}
toGroupController.replace(toGroupIndex, fromGroupItem); toGroupController.replace(toGroupIndex, fromGroupItem);
onMoveGroupItemToGroup?.call( onMoveGroupItemToGroup?.call(
fromGroupId, fromGroupId,
fromGroupIndex, fromGroupIndex,
toGroupId, toGroupId,
toGroupIndex, toGroupIndex,
); );
}
} }
@override @override

View File

@ -90,21 +90,21 @@ class AppFlowyBoardGroup extends StatefulWidget {
final DraggingStateStorage? dragStateStorage; final DraggingStateStorage? dragStateStorage;
final ReorderDragTargetIndexKeyStorage? dragTargetIndexKeyStorage; final ReorderDragTargeKeys? dragTargetKeys;
final GlobalObjectKey reorderFlexKey; final ReorderFlexAction? reorderFlexAction;
const AppFlowyBoardGroup({ const AppFlowyBoardGroup({
Key? key, Key? key,
required this.reorderFlexKey,
this.headerBuilder, this.headerBuilder,
this.footerBuilder, this.footerBuilder,
required this.cardBuilder, required this.cardBuilder,
required this.onReorder, required this.onReorder,
required this.dataSource, required this.dataSource,
required this.phantomController, required this.phantomController,
this.reorderFlexAction,
this.dragStateStorage, this.dragStateStorage,
this.dragTargetIndexKeyStorage, this.dragTargetKeys,
this.scrollController, this.scrollController,
this.onDragStarted, this.onDragStarted,
this.onDragEnded, this.onDragEnded,
@ -112,7 +112,7 @@ class AppFlowyBoardGroup extends StatefulWidget {
this.itemMargin = EdgeInsets.zero, this.itemMargin = EdgeInsets.zero,
this.cornerRadius = 0.0, this.cornerRadius = 0.0,
this.backgroundColor = Colors.transparent, this.backgroundColor = Colors.transparent,
}) : config = const ReorderFlexConfig(setStateWhenEndDrag: false), }) : config = const ReorderFlexConfig(),
super(key: key); super(key: key);
@override @override
@ -146,9 +146,9 @@ class _AppFlowyBoardGroupState extends State<AppFlowyBoardGroup> {
); );
Widget reorderFlex = ReorderFlex( Widget reorderFlex = ReorderFlex(
key: widget.reorderFlexKey, key: ValueKey(widget.groupId),
dragStateStorage: widget.dragStateStorage, dragStateStorage: widget.dragStateStorage,
dragTargetIndexKeyStorage: widget.dragTargetIndexKeyStorage, dragTargetKeys: widget.dragTargetKeys,
scrollController: widget.scrollController, scrollController: widget.scrollController,
config: widget.config, config: widget.config,
onDragStarted: (index) { onDragStarted: (index) {
@ -168,6 +168,7 @@ class _AppFlowyBoardGroupState extends State<AppFlowyBoardGroup> {
}, },
dataSource: widget.dataSource, dataSource: widget.dataSource,
interceptor: interceptor, interceptor: interceptor,
reorderFlexAction: widget.reorderFlexAction,
children: children, children: children,
); );

View File

@ -41,7 +41,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
void updateGroupName(String newName) { void updateGroupName(String newName) {
if (groupData.headerData.groupName != newName) { if (groupData.headerData.groupName != newName) {
groupData.headerData.groupName = newName; groupData.headerData.groupName = newName;
notifyListeners(); _notify();
} }
} }
@ -56,7 +56,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
Log.debug('[$AppFlowyGroupController] $groupData remove item at $index'); Log.debug('[$AppFlowyGroupController] $groupData remove item at $index');
final item = groupData._items.removeAt(index); final item = groupData._items.removeAt(index);
if (notify) { if (notify) {
notifyListeners(); _notify();
} }
return item; return item;
} }
@ -81,7 +81,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
'[$AppFlowyGroupController] $groupData move item from $fromIndex to $toIndex'); '[$AppFlowyGroupController] $groupData move item from $fromIndex to $toIndex');
final item = groupData._items.removeAt(fromIndex); final item = groupData._items.removeAt(fromIndex);
groupData._items.insert(toIndex, item); groupData._items.insert(toIndex, item);
notifyListeners(); _notify();
return true; return true;
} }
@ -102,7 +102,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
groupData._items.add(item); groupData._items.add(item);
} }
if (notify) notifyListeners(); if (notify) _notify();
return true; return true;
} }
} }
@ -112,7 +112,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
return false; return false;
} else { } else {
groupData._items.add(item); groupData._items.add(item);
if (notify) notifyListeners(); if (notify) _notify();
return true; return true;
} }
} }
@ -124,6 +124,8 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
Log.debug('[$AppFlowyGroupController] $groupData add $newItem'); Log.debug('[$AppFlowyGroupController] $groupData add $newItem');
} else { } else {
if (index >= groupData._items.length) { if (index >= groupData._items.length) {
Log.warn(
'[$AppFlowyGroupController] unexpected items length, index should less than the count of the items. Index: $index, items count: ${items.length}');
return; return;
} }
@ -133,7 +135,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
'[$AppFlowyGroupController] $groupData replace $removedItem with $newItem at $index'); '[$AppFlowyGroupController] $groupData replace $removedItem with $newItem at $index');
} }
notifyListeners(); _notify();
} }
void replaceOrInsertItem(AppFlowyGroupItem newItem) { void replaceOrInsertItem(AppFlowyGroupItem newItem) {
@ -141,10 +143,10 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
if (index != -1) { if (index != -1) {
groupData._items.removeAt(index); groupData._items.removeAt(index);
groupData._items.insert(index, newItem); groupData._items.insert(index, newItem);
notifyListeners(); _notify();
} else { } else {
groupData._items.add(newItem); groupData._items.add(newItem);
notifyListeners(); _notify();
} }
} }
@ -152,6 +154,10 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
return groupData._items.indexWhere((element) => element.id == item.id) != return groupData._items.indexWhere((element) => element.id == item.id) !=
-1; -1;
} }
void _notify() {
notifyListeners();
}
} }
/// [AppFlowyGroupData] represents the data of each group of the Board. /// [AppFlowyGroupData] represents the data of each group of the Board.

View File

@ -22,6 +22,8 @@ class FlexDragTargetData extends DragTargetData {
Size? get feedbackSize => _state.feedbackSize; Size? get feedbackSize => _state.feedbackSize;
bool get isDragging => _state.isDragging();
final String dragTargetId; final String dragTargetId;
Offset dragTargetOffset = Offset.zero; Offset dragTargetOffset = Offset.zero;
@ -48,47 +50,28 @@ class FlexDragTargetData extends DragTargetData {
bool isOverlapWithWidgets(List<GlobalObjectKey> widgetKeys) { bool isOverlapWithWidgets(List<GlobalObjectKey> widgetKeys) {
final renderBox = dragTargetIndexKey.currentContext?.findRenderObject(); final renderBox = dragTargetIndexKey.currentContext?.findRenderObject();
if (renderBox == null) return false; if (renderBox == null) return false;
if (renderBox is! RenderBox) return false; if (renderBox is! RenderBox) return false;
final size = feedbackSize ?? Size.zero; final size = feedbackSize ?? Size.zero;
final Rect rect = dragTargetOffset & size;
final Rect dragTargetRect = renderBox.localToGlobal(Offset.zero) & size;
for (final widgetKey in widgetKeys) { for (final widgetKey in widgetKeys) {
final renderObject = widgetKey.currentContext?.findRenderObject(); final renderObject = widgetKey.currentContext?.findRenderObject();
if (renderObject != null && renderObject is RenderBox) { if (renderObject != null && renderObject is RenderBox) {
Rect widgetRect = Rect widgetRect =
renderObject.localToGlobal(Offset.zero) & renderObject.size; renderObject.localToGlobal(Offset.zero) & renderObject.size;
// return rect.overlaps(widgetRect); return dragTargetRect.overlaps(widgetRect);
if (rect.right <= widgetRect.left || widgetRect.right <= rect.left) {
return false;
}
if (rect.bottom <= widgetRect.top || widgetRect.bottom <= rect.top) {
return false;
}
return true;
} }
} }
// final HitTestResult result = HitTestResult();
// WidgetsBinding.instance.hitTest(result, position);
// for (final HitTestEntry entry in result.path) {
// final HitTestTarget target = entry.target;
// if (target is RenderMetaData) {
// print(target.metaData);
// }
// print(target);
// }
return false; return false;
} }
} }
abstract class DraggingStateStorage { abstract class DraggingStateStorage {
void write(String reorderFlexId, DraggingState state); void insertState(String reorderFlexId, DraggingState state);
void remove(String reorderFlexId); void removeState(String reorderFlexId);
DraggingState? read(String reorderFlexId); DraggingState? readState(String reorderFlexId);
} }
class DraggingState { class DraggingState {
@ -113,7 +96,7 @@ class DraggingState {
int currentIndex = -1; int currentIndex = -1;
/// The widget to move the dragging widget too after the current index. /// The widget to move the dragging widget too after the current index.
int nextIndex = 0; int nextIndex = -1;
/// Whether or not we are currently scrolling this view to show a widget. /// Whether or not we are currently scrolling this view to show a widget.
bool scrolling = false; bool scrolling = false;
@ -149,6 +132,7 @@ class DraggingState {
dragStartIndex = -1; dragStartIndex = -1;
phantomIndex = -1; phantomIndex = -1;
currentIndex = -1; currentIndex = -1;
nextIndex = -1;
_draggingWidget = null; _draggingWidget = null;
} }

View File

@ -1,3 +1,4 @@
import 'package:appflowy_board/src/utils/log.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -72,11 +73,15 @@ class ReorderDragTarget<T extends DragTargetData> extends StatefulWidget {
final ReorderFlexDraggableTargetBuilder? draggableTargetBuilder; final ReorderFlexDraggableTargetBuilder? draggableTargetBuilder;
final AnimationController insertAnimationController; final AnimationController insertAnimationController;
final AnimationController deleteAnimationController; final AnimationController deleteAnimationController;
final bool useMoveAnimation; final bool useMoveAnimation;
final bool draggable; final bool draggable;
final double draggingOpacity;
const ReorderDragTarget({ const ReorderDragTarget({
Key? key, Key? key,
required this.child, required this.child,
@ -93,6 +98,7 @@ class ReorderDragTarget<T extends DragTargetData> extends StatefulWidget {
this.onAccept, this.onAccept,
this.onLeave, this.onLeave,
this.draggableTargetBuilder, this.draggableTargetBuilder,
this.draggingOpacity = 0.3,
}) : super(key: key); }) : super(key: key);
@override @override
@ -163,6 +169,7 @@ class _ReorderDragTargetState<T extends DragTargetData>
feedback: feedbackBuilder, feedback: feedbackBuilder,
childWhenDragging: IgnorePointerWidget( childWhenDragging: IgnorePointerWidget(
useIntrinsicSize: !widget.useMoveAnimation, useIntrinsicSize: !widget.useMoveAnimation,
opacity: widget.draggingOpacity,
child: widget.child, child: widget.child,
), ),
onDragStarted: () { onDragStarted: () {
@ -184,8 +191,9 @@ class _ReorderDragTargetState<T extends DragTargetData>
/// When the drag does not end inside a DragTarget widget, the /// When the drag does not end inside a DragTarget widget, the
/// drag fails, but we still reorder the widget to the last position it /// drag fails, but we still reorder the widget to the last position it
/// had been dragged to. /// had been dragged to.
onDraggableCanceled: (Velocity velocity, Offset offset) => onDraggableCanceled: (Velocity velocity, Offset offset) {
widget.onDragEnded(widget.dragTargetData), widget.onDragEnded(widget.dragTargetData);
},
child: widget.child, child: widget.child,
); );
@ -193,7 +201,10 @@ class _ReorderDragTargetState<T extends DragTargetData>
} }
Widget _buildDraggableFeedback( Widget _buildDraggableFeedback(
BuildContext context, BoxConstraints constraints, Widget child) { BuildContext context,
BoxConstraints constraints,
Widget child,
) {
return Transform( return Transform(
transform: Matrix4.rotationZ(0), transform: Matrix4.rotationZ(0),
alignment: FractionalOffset.topLeft, alignment: FractionalOffset.topLeft,
@ -203,7 +214,7 @@ class _ReorderDragTargetState<T extends DragTargetData>
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
child: ConstrainedBox( child: ConstrainedBox(
constraints: constraints, constraints: constraints,
child: Opacity(opacity: 0.3, child: child), child: Opacity(opacity: widget.draggingOpacity, child: child),
), ),
), ),
); );
@ -221,8 +232,11 @@ class DragTargetAnimation {
// where the widget used to be. // where the widget used to be.
late AnimationController phantomController; late AnimationController phantomController;
// Uses to simulate the insert animation when card was moved from on group to
// another group. Check out the [FakeDragTarget].
late AnimationController insertController; late AnimationController insertController;
// Used to remove the phantom
late AnimationController deleteController; late AnimationController deleteController;
DragTargetAnimation({ DragTargetAnimation({
@ -238,7 +252,7 @@ class DragTargetAnimation {
value: 0, vsync: vsync, duration: reorderAnimationDuration); value: 0, vsync: vsync, duration: reorderAnimationDuration);
insertController = AnimationController( insertController = AnimationController(
value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 200)); value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 100));
deleteController = AnimationController( deleteController = AnimationController(
value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 1)); value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 1));
@ -269,8 +283,11 @@ class DragTargetAnimation {
class IgnorePointerWidget extends StatelessWidget { class IgnorePointerWidget extends StatelessWidget {
final Widget? child; final Widget? child;
final bool useIntrinsicSize; final bool useIntrinsicSize;
final double opacity;
const IgnorePointerWidget({ const IgnorePointerWidget({
required this.child, required this.child,
required this.opacity,
this.useIntrinsicSize = false, this.useIntrinsicSize = false,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -281,11 +298,10 @@ class IgnorePointerWidget extends StatelessWidget {
? child ? child
: SizedBox(width: 0.0, height: 0.0, child: child); : SizedBox(width: 0.0, height: 0.0, child: child);
final opacity = useIntrinsicSize ? 0.3 : 0.0;
return IgnorePointer( return IgnorePointer(
ignoring: true, ignoring: true,
child: Opacity( child: Opacity(
opacity: opacity, opacity: useIntrinsicSize ? opacity : 0.0,
child: sizedChild, child: sizedChild,
), ),
); );
@ -295,8 +311,10 @@ class IgnorePointerWidget extends StatelessWidget {
class AbsorbPointerWidget extends StatelessWidget { class AbsorbPointerWidget extends StatelessWidget {
final Widget? child; final Widget? child;
final bool useIntrinsicSize; final bool useIntrinsicSize;
final double opacity;
const AbsorbPointerWidget({ const AbsorbPointerWidget({
required this.child, required this.child,
required this.opacity,
this.useIntrinsicSize = false, this.useIntrinsicSize = false,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -307,10 +325,9 @@ class AbsorbPointerWidget extends StatelessWidget {
? child ? child
: SizedBox(width: 0.0, height: 0.0, child: child); : SizedBox(width: 0.0, height: 0.0, child: child);
final opacity = useIntrinsicSize ? 0.3 : 0.0;
return AbsorbPointer( return AbsorbPointer(
child: Opacity( child: Opacity(
opacity: opacity, opacity: useIntrinsicSize ? opacity : 0.0,
child: sizedChild, child: sizedChild,
), ),
); );
@ -423,7 +440,6 @@ abstract class FakeDragTargetEventData {
} }
class FakeDragTarget<T extends DragTargetData> extends StatefulWidget { class FakeDragTarget<T extends DragTargetData> extends StatefulWidget {
final Duration animationDuration;
final FakeDragTargetEventTrigger eventTrigger; final FakeDragTargetEventTrigger eventTrigger;
final FakeDragTargetEventData eventData; final FakeDragTargetEventData eventData;
final DragTargetOnStarted onDragStarted; final DragTargetOnStarted onDragStarted;
@ -442,7 +458,6 @@ class FakeDragTarget<T extends DragTargetData> extends StatefulWidget {
required this.insertAnimationController, required this.insertAnimationController,
required this.deleteAnimationController, required this.deleteAnimationController,
required this.child, required this.child,
this.animationDuration = const Duration(milliseconds: 250),
}) : super(key: key); }) : super(key: key);
@override @override
@ -468,6 +483,7 @@ class _FakeDragTargetState<T extends DragTargetData>
// }); // });
widget.eventTrigger.fakeOnDragEnded(() { widget.eventTrigger.fakeOnDragEnded(() {
Log.trace("[$FakeDragTarget] on drag end");
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onDragEnded(widget.eventData.dragTargetData as T); widget.onDragEnded(widget.eventData.dragTargetData as T);
}); });
@ -476,6 +492,13 @@ class _FakeDragTargetState<T extends DragTargetData>
super.initState(); super.initState();
} }
@override
void dispose() {
widget.insertAnimationController
.removeStatusListener(_onInsertedAnimationStatusChanged);
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (simulateDragging) { if (simulateDragging) {
@ -483,6 +506,7 @@ class _FakeDragTargetState<T extends DragTargetData>
sizeFactor: widget.deleteAnimationController, sizeFactor: widget.deleteAnimationController,
axis: Axis.vertical, axis: Axis.vertical,
child: AbsorbPointerWidget( child: AbsorbPointerWidget(
opacity: 0.3,
child: widget.child, child: widget.child,
), ),
); );
@ -492,6 +516,7 @@ class _FakeDragTargetState<T extends DragTargetData>
axis: Axis.vertical, axis: Axis.vertical,
child: AbsorbPointerWidget( child: AbsorbPointerWidget(
useIntrinsicSize: true, useIntrinsicSize: true,
opacity: 0.3,
child: widget.child, child: widget.child,
), ),
); );
@ -503,14 +528,18 @@ class _FakeDragTargetState<T extends DragTargetData>
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
simulateDragging = true; if (widget.onWillAccept(widget.eventData.dragTargetData as T)) {
widget.deleteAnimationController.reverse(from: 1.0); Log.trace("[$FakeDragTarget] on drag start");
widget.onWillAccept(widget.eventData.dragTargetData as T); simulateDragging = true;
widget.onDragStarted( widget.deleteAnimationController.reverse(from: 1.0);
widget.child, widget.onDragStarted(
widget.eventData.index, widget.child,
widget.eventData.feedbackSize, widget.eventData.index,
); widget.eventData.feedbackSize,
);
} else {
Log.trace("[$FakeDragTarget] cancel start drag");
}
}); });
}); });
} }

View File

@ -35,7 +35,7 @@ abstract class DragTargetInterceptor {
abstract class OverlapDragTargetDelegate { abstract class OverlapDragTargetDelegate {
void cancel(); void cancel();
void moveTo( void dragTargetDidMoveToReorderFlex(
String reorderFlexId, String reorderFlexId,
FlexDragTargetData dragTargetData, FlexDragTargetData dragTargetData,
int dragTargetIndex, int dragTargetIndex,
@ -81,7 +81,7 @@ class OverlappingDragTargetInterceptor extends DragTargetInterceptor {
delegate.cancel(); delegate.cancel();
} else { } else {
// Ignore the event if the dragTarget overlaps with the other column's dragTargets. // Ignore the event if the dragTarget overlaps with the other column's dragTargets.
final columnKeys = columnsState.groupDragDragTargets[dragTargetId]; final columnKeys = columnsState.groupDragTargetKeys[dragTargetId];
if (columnKeys != null) { if (columnKeys != null) {
final keys = columnKeys.values.toList(); final keys = columnKeys.values.toList();
if (dragTargetData.isOverlapWithWidgets(keys)) { if (dragTargetData.isOverlapWithWidgets(keys)) {
@ -99,10 +99,10 @@ class OverlappingDragTargetInterceptor extends DragTargetInterceptor {
if (index != -1) { if (index != -1) {
Log.trace( Log.trace(
'[$OverlappingDragTargetInterceptor] move to $dragTargetId at $index'); '[$OverlappingDragTargetInterceptor] move to $dragTargetId at $index');
delegate.moveTo(dragTargetId, dragTargetData, index); delegate.dragTargetDidMoveToReorderFlex(
dragTargetId, dragTargetData, index);
columnsState columnsState.reorderFlexActionMap[dragTargetId]
.getReorderFlexState(groupId: dragTargetId)
?.resetDragTargetIndex(index); ?.resetDragTargetIndex(index);
} }
}); });
@ -153,7 +153,7 @@ class CrossReorderFlexDragTargetInterceptor extends DragTargetInterceptor {
/// it means the dragTarget is dragging on the top of its own list. /// it means the dragTarget is dragging on the top of its own list.
/// Otherwise, it means the dargTarget was moved to another list. /// Otherwise, it means the dargTarget was moved to another list.
Log.trace( Log.trace(
"[$CrossReorderFlexDragTargetInterceptor] $reorderFlexId accept ${dragTargetData.reorderFlexId} ${reorderFlexId != dragTargetData.reorderFlexId}"); "[$CrossReorderFlexDragTargetInterceptor] $reorderFlexId should accept ${dragTargetData.reorderFlexId} : ${reorderFlexId != dragTargetData.reorderFlexId}");
return reorderFlexId != dragTargetData.reorderFlexId; return reorderFlexId != dragTargetData.reorderFlexId;
} else { } else {
Log.trace( Log.trace(

View File

@ -31,27 +31,43 @@ abstract class ReoderFlexItem {
String get id; String get id;
} }
abstract class ReorderDragTargetIndexKeyStorage { /// Cache each dragTarget's key.
void addKey(String reorderFlexId, String key, GlobalObjectKey value); /// For the moment, the key is used to locate the render object that will
GlobalObjectKey? readKey(String reorderFlexId, String key); /// be passed in the [ScrollPosition]'s [ensureVisible] function.
///
abstract class ReorderDragTargeKeys {
void insertDragTarget(
String reorderFlexId,
String key,
GlobalObjectKey value,
);
GlobalObjectKey? getDragTarget(
String reorderFlexId,
String key,
);
void removeDragTarget(String reorderFlexId);
}
abstract class ReorderFlexAction {
void Function(void Function(BuildContext)?)? _scrollToBottom;
void Function(void Function(BuildContext)?) get scrollToBottom =>
_scrollToBottom!;
void Function(int)? _resetDragTargetIndex;
void Function(int) get resetDragTargetIndex => _resetDragTargetIndex!;
} }
class ReorderFlexConfig { class ReorderFlexConfig {
/// The opacity of the dragging widget /// The opacity of the dragging widget
final double draggingWidgetOpacity = 0.3; final double draggingWidgetOpacity = 0.4;
// How long an animation to reorder an element // How long an animation to reorder an element
final Duration reorderAnimationDuration = const Duration(milliseconds: 300); final Duration reorderAnimationDuration = const Duration(milliseconds: 200);
// How long an animation to scroll to an off-screen element // How long an animation to scroll to an off-screen element
final Duration scrollAnimationDuration = const Duration(milliseconds: 300); final Duration scrollAnimationDuration = const Duration(milliseconds: 200);
/// Determines if setSatte method needs to be called when the drag is complete.
/// Default value is [true].
///
/// If the [ReorderFlex] needs to be rebuild after the [ReorderFlex] end dragging,
/// the [setStateWhenEndDrag] should set to [true].
final bool setStateWhenEndDrag;
final bool useMoveAnimation; final bool useMoveAnimation;
@ -59,7 +75,6 @@ class ReorderFlexConfig {
const ReorderFlexConfig({ const ReorderFlexConfig({
this.useMoveAnimation = true, this.useMoveAnimation = true,
this.setStateWhenEndDrag = true,
}) : useMovePlaceholder = !useMoveAnimation; }) : useMovePlaceholder = !useMoveAnimation;
} }
@ -86,9 +101,12 @@ class ReorderFlex extends StatefulWidget {
final DragTargetInterceptor? interceptor; final DragTargetInterceptor? interceptor;
/// Save the [DraggingState] if the current [ReorderFlex] get reinitialize.
final DraggingStateStorage? dragStateStorage; final DraggingStateStorage? dragStateStorage;
final ReorderDragTargetIndexKeyStorage? dragTargetIndexKeyStorage; final ReorderDragTargeKeys? dragTargetKeys;
final ReorderFlexAction? reorderFlexAction;
final bool reorderable; final bool reorderable;
@ -101,10 +119,11 @@ class ReorderFlex extends StatefulWidget {
required this.onReorder, required this.onReorder,
this.reorderable = true, this.reorderable = true,
this.dragStateStorage, this.dragStateStorage,
this.dragTargetIndexKeyStorage, this.dragTargetKeys,
this.onDragStarted, this.onDragStarted,
this.onDragEnded, this.onDragEnded,
this.interceptor, this.interceptor,
this.reorderFlexAction,
this.direction = Axis.vertical, this.direction = Axis.vertical,
}) : assert(children.every((Widget w) => w.key != null), }) : assert(children.every((Widget w) => w.key != null),
'All child must have a key.'), 'All child must have a key.'),
@ -117,7 +136,7 @@ class ReorderFlex extends StatefulWidget {
} }
class ReorderFlexState extends State<ReorderFlex> class ReorderFlexState extends State<ReorderFlex>
with ReorderFlexMinxi, TickerProviderStateMixin<ReorderFlex> { with ReorderFlexMixin, TickerProviderStateMixin<ReorderFlex> {
/// Controls scrolls and measures scroll progress. /// Controls scrolls and measures scroll progress.
late ScrollController _scrollController; late ScrollController _scrollController;
@ -139,22 +158,31 @@ class ReorderFlexState extends State<ReorderFlex>
void initState() { void initState() {
_notifier = ReorderFlexNotifier(); _notifier = ReorderFlexNotifier();
final flexId = widget.reorderFlexId; final flexId = widget.reorderFlexId;
dragState = widget.dragStateStorage?.read(flexId) ?? dragState = widget.dragStateStorage?.readState(flexId) ??
DraggingState(widget.reorderFlexId); DraggingState(widget.reorderFlexId);
Log.trace('[DragTarget] init dragState: $dragState'); Log.trace('[DragTarget] init dragState: $dragState');
widget.dragStateStorage?.remove(flexId); widget.dragStateStorage?.removeState(flexId);
_animation = DragTargetAnimation( _animation = DragTargetAnimation(
reorderAnimationDuration: widget.config.reorderAnimationDuration, reorderAnimationDuration: widget.config.reorderAnimationDuration,
entranceAnimateStatusChanged: (status) { entranceAnimateStatusChanged: (status) {
if (status == AnimationStatus.completed) { if (status == AnimationStatus.completed) {
if (dragState.nextIndex == -1) return;
setState(() => _requestAnimationToNextIndex()); setState(() => _requestAnimationToNextIndex());
} }
}, },
vsync: this, vsync: this,
); );
widget.reorderFlexAction?._scrollToBottom = (fn) {
scrollToBottom(fn);
};
widget.reorderFlexAction?._resetDragTargetIndex = (index) {
resetDragTargetIndex(index);
};
super.initState(); super.initState();
} }
@ -191,7 +219,7 @@ class ReorderFlexState extends State<ReorderFlex>
final indexKey = GlobalObjectKey(child.key!); final indexKey = GlobalObjectKey(child.key!);
// Save the index key for quick access // Save the index key for quick access
widget.dragTargetIndexKeyStorage?.addKey( widget.dragTargetKeys?.insertDragTarget(
widget.reorderFlexId, widget.reorderFlexId,
item.id, item.id,
indexKey, indexKey,
@ -243,8 +271,12 @@ class ReorderFlexState extends State<ReorderFlex>
/// [childIndex]: the index of the child in a list /// [childIndex]: the index of the child in a list
Widget _wrap(Widget child, int childIndex, GlobalObjectKey indexKey) { Widget _wrap(Widget child, int childIndex, GlobalObjectKey indexKey) {
return Builder(builder: (context) { return Builder(builder: (context) {
final ReorderDragTarget dragTarget = final ReorderDragTarget dragTarget = _buildDragTarget(
_buildDragTarget(context, child, childIndex, indexKey); context,
child,
childIndex,
indexKey,
);
int shiftedIndex = childIndex; int shiftedIndex = childIndex;
if (dragState.isOverlapWithPhantom()) { if (dragState.isOverlapWithPhantom()) {
@ -349,6 +381,15 @@ class ReorderFlexState extends State<ReorderFlex>
}); });
} }
static ReorderFlexState of(BuildContext context) {
if (context is StatefulElement && context.state is ReorderFlexState) {
return context.state as ReorderFlexState;
}
final ReorderFlexState? result =
context.findAncestorStateOfType<ReorderFlexState>();
return result!;
}
ReorderDragTarget _buildDragTarget( ReorderDragTarget _buildDragTarget(
BuildContext builderContext, BuildContext builderContext,
Widget child, Widget child,
@ -371,18 +412,24 @@ class ReorderFlexState extends State<ReorderFlex>
"[DragTarget] Group:[${widget.dataSource.identifier}] start dragging item at $draggingIndex"); "[DragTarget] Group:[${widget.dataSource.identifier}] start dragging item at $draggingIndex");
_startDragging(draggingWidget, draggingIndex, size); _startDragging(draggingWidget, draggingIndex, size);
widget.onDragStarted?.call(draggingIndex); widget.onDragStarted?.call(draggingIndex);
widget.dragStateStorage?.remove(widget.reorderFlexId); widget.dragStateStorage?.removeState(widget.reorderFlexId);
}, },
onDragMoved: (dragTargetData, offset) { onDragMoved: (dragTargetData, offset) {
dragTargetData.dragTargetOffset = offset; dragTargetData.dragTargetOffset = offset;
}, },
onDragEnded: (dragTargetData) { onDragEnded: (dragTargetData) {
if (!mounted) return; if (!mounted) {
Log.warn(
"[DragTarget]: Group:[${widget.dataSource.identifier}] end dragging but current widget was unmounted");
return;
}
Log.debug( Log.debug(
"[DragTarget]: Group:[${widget.dataSource.identifier}] end dragging"); "[DragTarget]: Group:[${widget.dataSource.identifier}] end dragging");
_notifier.updateDragTargetIndex(-1);
onDragEnded() { _notifier.updateDragTargetIndex(-1);
_animation.insertController.stop();
setState(() {
if (dragTargetData.reorderFlexId == widget.reorderFlexId) { if (dragTargetData.reorderFlexId == widget.reorderFlexId) {
_onReordered( _onReordered(
dragState.dragStartIndex, dragState.dragStartIndex,
@ -391,13 +438,7 @@ class ReorderFlexState extends State<ReorderFlex>
} }
dragState.endDragging(); dragState.endDragging();
widget.onDragEnded?.call(); widget.onDragEnded?.call();
} });
if (widget.config.setStateWhenEndDrag) {
setState(() => onDragEnded());
} else {
onDragEnded();
}
}, },
onWillAccept: (FlexDragTargetData dragTargetData) { onWillAccept: (FlexDragTargetData dragTargetData) {
// Do not receive any events if the Insert item is animating. // Do not receive any events if the Insert item is animating.
@ -405,19 +446,23 @@ class ReorderFlexState extends State<ReorderFlex>
return false; return false;
} }
assert(widget.dataSource.items.length > dragTargetIndex); if (dragTargetData.isDragging) {
if (_interceptDragTarget(dragTargetData, (interceptor) { assert(widget.dataSource.items.length > dragTargetIndex);
interceptor.onWillAccept( if (_interceptDragTarget(dragTargetData, (interceptor) {
context: builderContext, interceptor.onWillAccept(
reorderFlexState: this, context: builderContext,
dragTargetData: dragTargetData, reorderFlexState: this,
dragTargetId: reorderFlexItem.id, dragTargetData: dragTargetData,
dragTargetIndex: dragTargetIndex, dragTargetId: reorderFlexItem.id,
); dragTargetIndex: dragTargetIndex,
})) { );
return true; })) {
return true;
} else {
return handleOnWillAccept(builderContext, dragTargetIndex);
}
} else { } else {
return handleOnWillAccept(builderContext, dragTargetIndex); return false;
} }
}, },
onAccept: (dragTargetData) { onAccept: (dragTargetData) {
@ -438,6 +483,7 @@ class ReorderFlexState extends State<ReorderFlex>
draggableTargetBuilder: widget.interceptor?.draggableTargetBuilder, draggableTargetBuilder: widget.interceptor?.draggableTargetBuilder,
useMoveAnimation: widget.config.useMoveAnimation, useMoveAnimation: widget.config.useMoveAnimation,
draggable: widget.reorderable, draggable: widget.reorderable,
draggingOpacity: widget.config.draggingWidgetOpacity,
child: child, child: child,
); );
} }
@ -485,8 +531,12 @@ class ReorderFlexState extends State<ReorderFlex>
} }
void resetDragTargetIndex(int dragTargetIndex) { void resetDragTargetIndex(int dragTargetIndex) {
if (dragTargetIndex > widget.dataSource.items.length) {
return;
}
dragState.setStartDraggingIndex(dragTargetIndex); dragState.setStartDraggingIndex(dragTargetIndex);
widget.dragStateStorage?.write( widget.dragStateStorage?.insertState(
widget.reorderFlexId, widget.reorderFlexId,
dragState, dragState,
); );
@ -521,6 +571,9 @@ class ReorderFlexState extends State<ReorderFlex>
} }
void _onReordered(int fromIndex, int toIndex) { void _onReordered(int fromIndex, int toIndex) {
if (toIndex == -1) return;
if (fromIndex == -1) return;
if (fromIndex != toIndex) { if (fromIndex != toIndex) {
widget.onReorder.call(fromIndex, toIndex); widget.onReorder.call(fromIndex, toIndex);
} }
@ -577,46 +630,46 @@ class ReorderFlexState extends State<ReorderFlex>
} }
} }
void scrollToBottom(VoidCallback? completed) { void scrollToBottom(void Function(BuildContext)? completed) {
if (_scrolling) { if (_scrolling) {
completed?.call(); completed?.call(context);
return; return;
} }
if (widget.dataSource.items.isNotEmpty) { if (widget.dataSource.items.isNotEmpty) {
final item = widget.dataSource.items.last; final item = widget.dataSource.items.last;
final indexKey = widget.dragTargetIndexKeyStorage?.readKey( final dragTargetKey = widget.dragTargetKeys?.getDragTarget(
widget.reorderFlexId, widget.reorderFlexId,
item.id, item.id,
); );
if (indexKey == null) { if (dragTargetKey == null) {
completed?.call(); completed?.call(context);
return; return;
} }
final indexContext = indexKey.currentContext; final dragTargetContext = dragTargetKey.currentContext;
if (indexContext == null || _scrollController.hasClients == false) { if (dragTargetContext == null || _scrollController.hasClients == false) {
completed?.call(); completed?.call(context);
return; return;
} }
final renderObject = indexContext.findRenderObject(); final dragTargetRenderObject = dragTargetContext.findRenderObject();
if (renderObject != null) { if (dragTargetRenderObject != null) {
_scrolling = true; _scrolling = true;
_scrollController.position _scrollController.position
.ensureVisible( .ensureVisible(
renderObject, dragTargetRenderObject,
alignment: 0.5, alignment: 0.5,
duration: const Duration(milliseconds: 120), duration: const Duration(milliseconds: 120),
) )
.then((value) { .then((value) {
setState(() { setState(() {
_scrolling = false; _scrolling = false;
completed?.call(); completed?.call(context);
}); });
}); });
} else { } else {
completed?.call(); completed?.call(context);
} }
} }
} }

View File

@ -3,7 +3,7 @@ import 'package:flutter/widgets.dart';
import '../transitions.dart'; import '../transitions.dart';
import 'drag_target.dart'; import 'drag_target.dart';
mixin ReorderFlexMinxi { mixin ReorderFlexMixin {
@protected @protected
Widget makeAppearingWidget( Widget makeAppearingWidget(
Widget child, Widget child,

View File

@ -94,7 +94,8 @@ class BoardPhantomController extends OverlapDragTargetDelegate
/// Remove the phantom in the group if it contains phantom /// Remove the phantom in the group if it contains phantom
void _removePhantom(String groupId) { void _removePhantom(String groupId) {
if (delegate.removePhantom(groupId)) { final didRemove = delegate.removePhantom(groupId);
if (didRemove) {
phantomState.notifyDidRemovePhantom(groupId); phantomState.notifyDidRemovePhantom(groupId);
phantomState.removeGroupListener(groupId); phantomState.removeGroupListener(groupId);
} }
@ -195,7 +196,7 @@ class BoardPhantomController extends OverlapDragTargetDelegate
} }
@override @override
void moveTo( void dragTargetDidMoveToReorderFlex(
String reorderFlexId, String reorderFlexId,
FlexDragTargetData dragTargetData, FlexDragTargetData dragTargetData,
int dragTargetIndex, int dragTargetIndex,

View File

@ -1,50 +1,54 @@
import 'package:appflowy_board/src/utils/log.dart';
import 'phantom_controller.dart'; import 'phantom_controller.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class GroupPhantomState { class GroupPhantomState {
final _states = <String, GroupState>{}; final _groupStates = <String, GroupState>{};
final _groupIsDragging = <String, bool>{};
void setGroupIsDragging(String groupId, bool isDragging) { void setGroupIsDragging(String groupId, bool isDragging) {
_stateWithId(groupId).isDragging = isDragging; _groupIsDragging[groupId] = isDragging;
} }
bool isDragging(String groupId) { bool isDragging(String groupId) {
return _stateWithId(groupId).isDragging; return _groupIsDragging[groupId] ?? false;
} }
void addGroupListener(String groupId, PassthroughPhantomListener listener) { void addGroupListener(String groupId, PassthroughPhantomListener listener) {
_stateWithId(groupId).notifier.addListener( if (_groupStates[groupId] == null) {
onInserted: (index) => listener.onInserted?.call(index), Log.debug("[$GroupPhantomState] add group listener: $groupId");
onDeleted: () => listener.onDragEnded?.call(), _groupStates[groupId] = GroupState();
); _groupStates[groupId]?.notifier.addListener(
onInserted: (index) => listener.onInserted?.call(index),
onDeleted: () => listener.onDragEnded?.call(),
);
}
} }
void removeGroupListener(String groupId) { void removeGroupListener(String groupId) {
_stateWithId(groupId).notifier.dispose(); Log.debug("[$GroupPhantomState] remove group listener: $groupId");
_states.remove(groupId); final groupState = _groupStates.remove(groupId);
groupState?.dispose();
} }
void notifyDidInsertPhantom(String groupId, int index) { void notifyDidInsertPhantom(String groupId, int index) {
_stateWithId(groupId).notifier.insert(index); _groupStates[groupId]?.notifier.insert(index);
} }
void notifyDidRemovePhantom(String groupId) { void notifyDidRemovePhantom(String groupId) {
_stateWithId(groupId).notifier.remove(); Log.debug("[$GroupPhantomState] $groupId remove phantom");
} _groupStates[groupId]?.notifier.remove();
GroupState _stateWithId(String groupId) {
var state = _states[groupId];
if (state == null) {
state = GroupState();
_states[groupId] = state;
}
return state;
} }
} }
class GroupState { class GroupState {
bool isDragging = false; bool isDragging = false;
final notifier = PassthroughPhantomNotifier(); final notifier = PassthroughPhantomNotifier();
void dispose() {
notifier.dispose();
}
} }
abstract class PassthroughPhantomListener { abstract class PassthroughPhantomListener {

View File

@ -1,6 +1,6 @@
name: appflowy_board name: appflowy_board
description: AppFlowyBoard is a board-style widget that consists of multi-groups. It supports drag and drop between different groups. description: AppFlowyBoard is a board-style widget that consists of multi-groups. It supports drag and drop between different groups.
version: 0.0.7 version: 0.0.8
homepage: https://github.com/AppFlowy-IO/AppFlowy homepage: https://github.com/AppFlowy-IO/AppFlowy
repository: https://github.com/AppFlowy-IO/AppFlowy/tree/main/frontend/app_flowy/packages/appflowy_board repository: https://github.com/AppFlowy-IO/AppFlowy/tree/main/frontend/app_flowy/packages/appflowy_board

View File

@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:example/plugin/code_block_node_widget.dart';
import 'package:flutter/foundation.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';
@ -116,9 +117,17 @@ class _MyHomePageState extends State<MyHomePage> {
editorState: _editorState!, editorState: _editorState!,
editorStyle: _editorStyle, editorStyle: _editorStyle,
editable: true, editable: true,
customBuilders: {
'text/code_block': CodeBlockNodeWidgetBuilder(),
},
shortcutEvents: [ shortcutEvents: [
enterInCodeBlock,
ignoreKeysInCodeBlock,
underscoreToItalic, underscoreToItalic,
], ],
selectionMenuItems: [
codeBlockItem,
],
), ),
); );
} else { } else {

View File

@ -0,0 +1,277 @@
import 'dart:collection';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:highlight/highlight.dart' as highlight;
import 'package:highlight/languages/all.dart';
ShortcutEvent enterInCodeBlock = ShortcutEvent(
key: 'Enter in code block',
command: 'enter',
handler: _enterInCodeBlockHandler,
);
ShortcutEvent ignoreKeysInCodeBlock = ShortcutEvent(
key: 'White space in code block',
command: 'space,slash,shift+underscore',
handler: _ignorekHandler,
);
ShortcutEventHandler _enterInCodeBlockHandler = (editorState, event) {
final selection = editorState.service.selectionService.currentSelection.value;
final nodes = editorState.service.selectionService.currentSelectedNodes;
final codeBlockNode =
nodes.whereType<TextNode>().where((node) => node.id == 'text/code_block');
if (codeBlockNode.length != 1 || selection == null) {
return KeyEventResult.ignored;
}
if (selection.isCollapsed) {
TransactionBuilder(editorState)
..insertText(codeBlockNode.first, selection.end.offset, '\n')
..commit();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
};
ShortcutEventHandler _ignorekHandler = (editorState, event) {
final nodes = editorState.service.selectionService.currentSelectedNodes;
final codeBlockNodes =
nodes.whereType<TextNode>().where((node) => node.id == 'text/code_block');
if (codeBlockNodes.length == 1) {
return KeyEventResult.skipRemainingHandlers;
}
return KeyEventResult.ignored;
};
SelectionMenuItem codeBlockItem = SelectionMenuItem(
name: 'Code Block',
icon: const Icon(Icons.abc),
keywords: ['code block'],
handler: (editorState, _, __) {
final selection =
editorState.service.selectionService.currentSelection.value;
final textNodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>();
if (selection == null || textNodes.isEmpty) {
return;
}
if (textNodes.first.toRawString().isEmpty) {
TransactionBuilder(editorState)
..updateNode(textNodes.first, {
'subtype': 'code_block',
'theme': 'vs',
'language': null,
})
..afterSelection = selection
..commit();
} else {
TransactionBuilder(editorState)
..insertNode(
selection.end.path.next,
TextNode(
type: 'text',
children: LinkedList(),
attributes: {
'subtype': 'code_block',
'theme': 'vs',
'language': null,
},
delta: Delta()..insert('\n'),
),
)
..afterSelection = selection
..commit();
}
},
);
class CodeBlockNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
@override
Widget build(NodeWidgetContext<TextNode> context) {
return _CodeBlockNodeWidge(
key: context.node.key,
textNode: context.node,
editorState: context.editorState,
);
}
@override
NodeValidator<Node> get nodeValidator => (node) {
return node is TextNode && node.attributes['theme'] is String;
};
}
class _CodeBlockNodeWidge extends StatefulWidget {
const _CodeBlockNodeWidge({
Key? key,
required this.textNode,
required this.editorState,
}) : super(key: key);
final TextNode textNode;
final EditorState editorState;
@override
State<_CodeBlockNodeWidge> createState() => __CodeBlockNodeWidgeState();
}
class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge>
with SelectableMixin, DefaultSelectable {
final _richTextKey = GlobalKey(debugLabel: 'code_block_text');
final _padding = const EdgeInsets.only(left: 20, top: 20, bottom: 20);
String? get _language => widget.textNode.attributes['language'] as String?;
String? _detectLanguage;
@override
SelectableMixin<StatefulWidget> get forward =>
_richTextKey.currentState as SelectableMixin;
@override
GlobalKey<State<StatefulWidget>>? get iconKey => null;
@override
Offset get baseOffset => super.baseOffset + _padding.topLeft;
@override
Widget build(BuildContext context) {
return Stack(
children: [
_buildCodeBlock(context),
_buildSwitchCodeButton(context),
],
);
}
Widget _buildCodeBlock(BuildContext context) {
final result = highlight.highlight.parse(
widget.textNode.toRawString(),
language: _language,
autoDetection: _language == null,
);
_detectLanguage = _language ?? result.language;
final code = result.nodes;
final codeTextSpan = _convert(code!);
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
color: Colors.grey.withOpacity(0.1),
),
padding: _padding,
width: MediaQuery.of(context).size.width,
child: FlowyRichText(
key: _richTextKey,
textNode: widget.textNode,
editorState: widget.editorState,
textSpanDecorator: (textSpan) => TextSpan(
style: widget.editorState.editorStyle.textStyle.defaultTextStyle,
children: codeTextSpan,
),
),
);
}
Widget _buildSwitchCodeButton(BuildContext context) {
return Positioned(
top: -5,
right: 0,
child: DropdownButton<String>(
value: _detectLanguage,
onChanged: (value) {
TransactionBuilder(widget.editorState)
..updateNode(widget.textNode, {
'language': value,
})
..commit();
},
items: allLanguages.keys.map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(
value,
style: const TextStyle(fontSize: 12.0),
),
);
}).toList(growable: false),
),
);
}
// Copy from flutter.highlight package.
// https://github.com/git-touch/highlight.dart/blob/master/flutter_highlight/lib/flutter_highlight.dart
List<TextSpan> _convert(List<highlight.Node> nodes) {
List<TextSpan> spans = [];
var currentSpans = spans;
List<List<TextSpan>> stack = [];
_traverse(highlight.Node node) {
if (node.value != null) {
currentSpans.add(node.className == null
? TextSpan(text: node.value)
: TextSpan(
text: node.value,
style: _builtInCodeBlockTheme[node.className!]));
} else if (node.children != null) {
List<TextSpan> tmp = [];
currentSpans.add(TextSpan(
children: tmp, style: _builtInCodeBlockTheme[node.className!]));
stack.add(currentSpans);
currentSpans = tmp;
for (var n in node.children!) {
_traverse(n);
if (n == node.children!.last) {
currentSpans = stack.isEmpty ? spans : stack.removeLast();
}
}
}
}
for (var node in nodes) {
_traverse(node);
}
return spans;
}
}
const _builtInCodeBlockTheme = {
'root':
TextStyle(backgroundColor: Color(0xffffffff), color: Color(0xff000000)),
'comment': TextStyle(color: Color(0xff007400)),
'quote': TextStyle(color: Color(0xff007400)),
'tag': TextStyle(color: Color(0xffaa0d91)),
'attribute': TextStyle(color: Color(0xffaa0d91)),
'keyword': TextStyle(color: Color(0xffaa0d91)),
'selector-tag': TextStyle(color: Color(0xffaa0d91)),
'literal': TextStyle(color: Color(0xffaa0d91)),
'name': TextStyle(color: Color(0xffaa0d91)),
'variable': TextStyle(color: Color(0xff3F6E74)),
'template-variable': TextStyle(color: Color(0xff3F6E74)),
'code': TextStyle(color: Color(0xffc41a16)),
'string': TextStyle(color: Color(0xffc41a16)),
'meta-string': TextStyle(color: Color(0xffc41a16)),
'regexp': TextStyle(color: Color(0xff0E0EFF)),
'link': TextStyle(color: Color(0xff0E0EFF)),
'title': TextStyle(color: Color(0xff1c00cf)),
'symbol': TextStyle(color: Color(0xff1c00cf)),
'bullet': TextStyle(color: Color(0xff1c00cf)),
'number': TextStyle(color: Color(0xff1c00cf)),
'section': TextStyle(color: Color(0xff643820)),
'meta': TextStyle(color: Color(0xff643820)),
'type': TextStyle(color: Color(0xff5c2699)),
'built_in': TextStyle(color: Color(0xff5c2699)),
'builtin-name': TextStyle(color: Color(0xff5c2699)),
'params': TextStyle(color: Color(0xff5c2699)),
'attr': TextStyle(color: Color(0xff836C28)),
'subst': TextStyle(color: Color(0xff000000)),
'formula': TextStyle(
backgroundColor: Color(0xffeeeeee), fontStyle: FontStyle.italic),
'addition': TextStyle(backgroundColor: Color(0xffbaeeba)),
'deletion': TextStyle(backgroundColor: Color(0xffffc8bd)),
'selector-id': TextStyle(color: Color(0xff9b703f)),
'selector-class': TextStyle(color: Color(0xff9b703f)),
'doctag': TextStyle(fontWeight: FontWeight.bold),
'strong': TextStyle(fontWeight: FontWeight.bold),
'emphasis': TextStyle(fontStyle: FontStyle.italic),
};

View File

@ -24,7 +24,7 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
SPEC CHECKSUMS: SPEC CHECKSUMS:
FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424 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

View File

@ -43,6 +43,7 @@ dependencies:
sdk: flutter sdk: flutter
file_picker: ^5.0.1 file_picker: ^5.0.1
universal_html: ^2.0.8 universal_html: ^2.0.8
highlight: ^0.7.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@ -28,4 +28,8 @@ export 'src/service/shortcut_event/keybinding.dart';
export 'src/service/shortcut_event/shortcut_event.dart'; export 'src/service/shortcut_event/shortcut_event.dart';
export 'src/service/shortcut_event/shortcut_event_handler.dart'; export 'src/service/shortcut_event/shortcut_event_handler.dart';
export 'src/extensions/attributes_extension.dart'; export 'src/extensions/attributes_extension.dart';
export 'src/extensions/path_extensions.dart';
export 'src/render/rich_text/default_selectable.dart';
export 'src/render/rich_text/flowy_rich_text.dart';
export 'src/render/selection_menu/selection_menu_widget.dart';
export 'src/l10n/l10n.dart'; export 'src/l10n/l10n.dart';

View File

@ -0,0 +1,35 @@
{
"@@locale": "nl-NL",
"bold": "Vet",
"@bold": {},
"bulletedList": "Opsommingstekens",
"@bulletedList": {},
"checkbox": "Selectievakje",
"@checkbox": {},
"embedCode": "Invoegcode",
"@embedCode": {},
"heading1": "H1",
"@heading1": {},
"heading2": "H2",
"@heading2": {},
"heading3": "H3",
"@heading3": {},
"highlight": "Highlight",
"@highlight": {},
"image": "Afbeelding",
"@image": {},
"italic": "Cursief",
"@italic": {},
"link": "",
"@link": {},
"numberedList": "Nummering",
"@numberedList": {},
"quote": "Quote",
"@quote": {},
"strikethrough": "Doorhalen",
"@strikethrough": {},
"text": "Tekst",
"@text": {},
"underline": "Onderstrepen",
"@underline": {}
}

View File

@ -1,35 +1,35 @@
{ {
"@@locale": "pt-BR", "@@locale": "pt-BR",
"bold": "", "bold": "Negrito",
"@bold": {}, "@bold": {},
"bulletedList": "", "bulletedList": "Lista de marcadores",
"@bulletedList": {}, "@bulletedList": {},
"checkbox": "", "checkbox": "Caixa de seleção",
"@checkbox": {}, "@checkbox": {},
"embedCode": "", "embedCode": "Código incorporado",
"@embedCode": {}, "@embedCode": {},
"heading1": "", "heading1": "H1",
"@heading1": {}, "@heading1": {},
"heading2": "", "heading2": "H2",
"@heading2": {}, "@heading2": {},
"heading3": "", "heading3": "H3",
"@heading3": {}, "@heading3": {},
"highlight": "", "highlight": "Destacar",
"@highlight": {}, "@highlight": {},
"image": "", "image": "Imagem",
"@image": {}, "@image": {},
"italic": "", "italic": "Itálico",
"@italic": {}, "@italic": {},
"link": "", "link": "Link",
"@link": {}, "@link": {},
"numberedList": "", "numberedList": "Lista numerada",
"@numberedList": {}, "@numberedList": {},
"quote": "", "quote": "Citar",
"@quote": {}, "@quote": {},
"strikethrough": "", "strikethrough": "Rasurar",
"@strikethrough": {}, "@strikethrough": {},
"text": "", "text": "Texto",
"@text": {}, "@text": {},
"underline": "", "underline": "Sublinhar",
"@underline": {} "@underline": {}
} }

View File

@ -0,0 +1,34 @@
import 'dart:async';
import 'package:appflowy_editor/src/commands/text_command_infra.dart';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/path.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
import 'package:flutter/widgets.dart';
Future<void> insertContextInText(
EditorState editorState,
int index,
String content, {
Path? path,
TextNode? textNode,
}) async {
final result = getTextNodeToBeFormatted(
editorState,
path: path,
textNode: textNode,
);
final completer = Completer<void>();
TransactionBuilder(editorState)
..insertText(result, index, content)
..commit();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
completer.complete();
});
return completer.future;
}

View File

@ -0,0 +1,83 @@
import 'package:appflowy_editor/src/commands/format_text.dart';
import 'package:appflowy_editor/src/commands/text_command_infra.dart';
import 'package:appflowy_editor/src/document/attributes.dart';
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/path.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/editor_state.dart';
Future<void> formatBuiltInTextAttributes(
EditorState editorState,
String key,
Attributes attributes, {
Selection? selection,
Path? path,
TextNode? textNode,
}) async {
final result = getTextNodeToBeFormatted(
editorState,
path: path,
textNode: textNode,
);
if (BuiltInAttributeKey.globalStyleKeys.contains(key)) {
// remove all the existing style
final newAttributes = result.attributes
..removeWhere((key, value) {
if (BuiltInAttributeKey.globalStyleKeys.contains(key)) {
return true;
}
return false;
})
..addAll(attributes)
..addAll({
BuiltInAttributeKey.subtype: key,
});
return updateTextNodeAttributes(
editorState,
newAttributes,
textNode: textNode,
);
} else if (BuiltInAttributeKey.partialStyleKeys.contains(key)) {
return updateTextNodeDeltaAttributes(
editorState,
selection,
attributes,
textNode: textNode,
);
}
}
Future<void> formatTextToCheckbox(
EditorState editorState,
bool check, {
Path? path,
TextNode? textNode,
}) async {
return formatBuiltInTextAttributes(
editorState,
BuiltInAttributeKey.checkbox,
{
BuiltInAttributeKey.checkbox: check,
},
path: path,
textNode: textNode,
);
}
Future<void> formatLinkInText(
EditorState editorState,
String? link, {
Path? path,
TextNode? textNode,
}) async {
return formatBuiltInTextAttributes(
editorState,
BuiltInAttributeKey.href,
{
BuiltInAttributeKey.href: link,
},
path: path,
textNode: textNode,
);
}

View File

@ -0,0 +1,67 @@
import 'dart:async';
import 'package:appflowy_editor/src/commands/text_command_infra.dart';
import 'package:appflowy_editor/src/document/attributes.dart';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/path.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
import 'package:flutter/widgets.dart';
Future<void> updateTextNodeAttributes(
EditorState editorState,
Attributes attributes, {
Path? path,
TextNode? textNode,
}) async {
final result = getTextNodeToBeFormatted(
editorState,
path: path,
textNode: textNode,
);
final completer = Completer<void>();
TransactionBuilder(editorState)
..updateNode(result, attributes)
..commit();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
completer.complete();
});
return completer.future;
}
Future<void> updateTextNodeDeltaAttributes(
EditorState editorState,
Selection? selection,
Attributes attributes, {
Path? path,
TextNode? textNode,
}) {
final result = getTextNodeToBeFormatted(
editorState,
path: path,
textNode: textNode,
);
final newSelection = getSelection(editorState, selection: selection);
final completer = Completer<void>();
TransactionBuilder(editorState)
..formatText(
result,
newSelection.startIndex,
newSelection.length,
attributes,
)
..commit();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
completer.complete();
});
return completer.future;
}

View File

@ -0,0 +1,43 @@
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/path.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/editor_state.dart';
// get formatted [TextNode]
TextNode getTextNodeToBeFormatted(
EditorState editorState, {
Path? path,
TextNode? textNode,
}) {
final currentSelection =
editorState.service.selectionService.currentSelection.value;
TextNode result;
if (textNode != null) {
result = textNode;
} else if (path != null) {
result = editorState.document.nodeAtPath(path) as TextNode;
} else if (currentSelection != null && currentSelection.isCollapsed) {
result = editorState.document.nodeAtPath(currentSelection.start.path)
as TextNode;
} else {
throw Exception('path and textNode cannot be null at the same time');
}
return result;
}
Selection getSelection(
EditorState editorState, {
Selection? selection,
}) {
final currentSelection =
editorState.service.selectionService.currentSelection.value;
Selection result;
if (selection != null) {
result = selection;
} else if (currentSelection != null) {
result = currentSelection;
} else {
throw Exception('path and textNode cannot be null at the same time');
}
return result;
}

View File

@ -93,12 +93,14 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
} }
void updateAttributes(Attributes attributes) { void updateAttributes(Attributes attributes) {
bool shouldNotifyParent = _attributes['subtype'] != attributes['subtype']; final oldAttributes = {..._attributes};
_attributes = composeAttributes(_attributes, attributes) ?? {}; _attributes = composeAttributes(_attributes, attributes) ?? {};
// Notifies the new attributes // Notifies the new attributes
// if attributes contains 'subtype', should notify parent to rebuild node // if attributes contains 'subtype', should notify parent to rebuild node
// else, just notify current node. // else, just notify current node.
bool shouldNotifyParent =
_attributes['subtype'] != oldAttributes['subtype'];
shouldNotifyParent ? parent?.notifyListeners() : notifyListeners(); shouldNotifyParent ? parent?.notifyListeners() : notifyListeners();
} }

View File

@ -53,6 +53,10 @@ class Selection {
Selection get reversed => copyWith(start: end, end: start); Selection get reversed => copyWith(start: end, end: start);
int get startIndex => normalize.start.offset;
int get endIndex => normalize.end.offset;
int get length => endIndex - startIndex;
Selection collapse({bool atStart = false}) { Selection collapse({bool atStart = false}) {
if (atStart) { if (atStart) {
return Selection(start: start, end: start); return Selection(start: start, end: start);

View File

@ -72,6 +72,8 @@ class EditorState {
// TODO: only for testing. // TODO: only for testing.
bool disableSealTimer = false; bool disableSealTimer = false;
bool editable = true;
Selection? get cursorSelection { Selection? get cursorSelection {
return _cursorSelection; return _cursorSelection;
} }
@ -112,6 +114,9 @@ class EditorState {
/// should record the transaction in undo/redo stack. /// should record the transaction in undo/redo stack.
apply(Transaction transaction, apply(Transaction transaction,
[ApplyOptions options = const ApplyOptions()]) { [ApplyOptions options = const ApplyOptions()]) {
if (!editable) {
return;
}
// TODO: validate the transation. // TODO: validate the transation.
for (final op in transaction.operations) { for (final op in transaction.operations) {
_applyOperation(op); _applyOperation(op);

View File

@ -54,6 +54,11 @@ extension TextNodeExtension on TextNode {
return value == true; return value == true;
}); });
bool allSatisfyCodeInSelection(Selection selection) =>
allSatisfyInSelection(selection, BuiltInAttributeKey.code, (value) {
return value == true;
});
bool allSatisfyInSelection( bool allSatisfyInSelection(
Selection selection, Selection selection,
String styleKey, String styleKey,

View File

@ -31,6 +31,7 @@ class _LinkMenuState extends State<LinkMenu> {
void initState() { void initState() {
super.initState(); super.initState();
_textEditingController.text = widget.linkText ?? ''; _textEditingController.text = widget.linkText ?? '';
_focusNode.requestFocus();
_focusNode.addListener(_onFocusChange); _focusNode.addListener(_onFocusChange);
} }

View File

@ -1,15 +1,8 @@
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/document/node.dart'; import 'package:appflowy_editor/src/commands/format_built_in_text.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart'; import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart'; import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart';
import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart';
import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
import 'package:appflowy_editor/src/service/render_plugin_service.dart';
import 'package:appflowy_editor/src/extensions/attributes_extension.dart';
import 'package:appflowy_editor/src/extensions/text_style_extension.dart'; import 'package:appflowy_editor/src/extensions/text_style_extension.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -81,8 +74,12 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
padding: iconPadding, padding: iconPadding,
name: check ? 'check' : 'uncheck', name: check ? 'check' : 'uncheck',
), ),
onTap: () { onTap: () async {
formatCheckbox(widget.editorState, !check); await formatTextToCheckbox(
widget.editorState,
!check,
textNode: widget.textNode,
);
}, },
), ),
Flexible( Flexible(

View File

@ -18,6 +18,8 @@ import 'package:appflowy_editor/src/extensions/attributes_extension.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart'; import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart'; import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
const _kRichTextDebugMode = false;
typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan); typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan);
class FlowyRichText extends StatefulWidget { class FlowyRichText extends StatefulWidget {
@ -261,6 +263,17 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
), ),
); );
} }
if (_kRichTextDebugMode) {
textSpans.add(
TextSpan(
text: '${widget.textNode.path}',
style: const TextStyle(
backgroundColor: Colors.red,
fontSize: 16.0,
),
),
);
}
return TextSpan( return TextSpan(
children: textSpans, children: textSpans,
); );

View File

@ -37,7 +37,7 @@ class SelectionMenuItemWidget extends StatelessWidget {
: MaterialStateProperty.all(Colors.transparent), : MaterialStateProperty.all(Colors.transparent),
), ),
label: Text( label: Text(
item.name, item.name(),
textAlign: TextAlign.left, textAlign: TextAlign.left,
style: const TextStyle( style: const TextStyle(
color: Colors.black, color: Colors.black,

View File

@ -124,7 +124,7 @@ List<SelectionMenuItem> get defaultSelectionMenuItems =>
_defaultSelectionMenuItems; _defaultSelectionMenuItems;
final List<SelectionMenuItem> _defaultSelectionMenuItems = [ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
SelectionMenuItem( SelectionMenuItem(
name: AppFlowyEditorLocalizations.current.text, name: () => AppFlowyEditorLocalizations.current.text,
icon: _selectionMenuIcon('text'), icon: _selectionMenuIcon('text'),
keywords: ['text'], keywords: ['text'],
handler: (editorState, _, __) { handler: (editorState, _, __) {
@ -132,7 +132,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
}, },
), ),
SelectionMenuItem( SelectionMenuItem(
name: AppFlowyEditorLocalizations.current.heading1, name: () => AppFlowyEditorLocalizations.current.heading1,
icon: _selectionMenuIcon('h1'), icon: _selectionMenuIcon('h1'),
keywords: ['heading 1, h1'], keywords: ['heading 1, h1'],
handler: (editorState, _, __) { handler: (editorState, _, __) {
@ -140,7 +140,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
}, },
), ),
SelectionMenuItem( SelectionMenuItem(
name: AppFlowyEditorLocalizations.current.heading2, name: () => AppFlowyEditorLocalizations.current.heading2,
icon: _selectionMenuIcon('h2'), icon: _selectionMenuIcon('h2'),
keywords: ['heading 2, h2'], keywords: ['heading 2, h2'],
handler: (editorState, _, __) { handler: (editorState, _, __) {
@ -148,7 +148,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
}, },
), ),
SelectionMenuItem( SelectionMenuItem(
name: AppFlowyEditorLocalizations.current.heading3, name: () => AppFlowyEditorLocalizations.current.heading3,
icon: _selectionMenuIcon('h3'), icon: _selectionMenuIcon('h3'),
keywords: ['heading 3, h3'], keywords: ['heading 3, h3'],
handler: (editorState, _, __) { handler: (editorState, _, __) {
@ -156,13 +156,13 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
}, },
), ),
SelectionMenuItem( SelectionMenuItem(
name: AppFlowyEditorLocalizations.current.image, name: () => AppFlowyEditorLocalizations.current.image,
icon: _selectionMenuIcon('image'), icon: _selectionMenuIcon('image'),
keywords: ['image'], keywords: ['image'],
handler: showImageUploadMenu, handler: showImageUploadMenu,
), ),
SelectionMenuItem( SelectionMenuItem(
name: AppFlowyEditorLocalizations.current.bulletedList, name: () => AppFlowyEditorLocalizations.current.bulletedList,
icon: _selectionMenuIcon('bulleted_list'), icon: _selectionMenuIcon('bulleted_list'),
keywords: ['bulleted list', 'list', 'unordered list'], keywords: ['bulleted list', 'list', 'unordered list'],
handler: (editorState, _, __) { handler: (editorState, _, __) {
@ -170,7 +170,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
}, },
), ),
SelectionMenuItem( SelectionMenuItem(
name: AppFlowyEditorLocalizations.current.checkbox, name: () => AppFlowyEditorLocalizations.current.checkbox,
icon: _selectionMenuIcon('checkbox'), icon: _selectionMenuIcon('checkbox'),
keywords: ['todo list', 'list', 'checkbox list'], keywords: ['todo list', 'list', 'checkbox list'],
handler: (editorState, _, __) { handler: (editorState, _, __) {
@ -178,7 +178,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
}, },
), ),
SelectionMenuItem( SelectionMenuItem(
name: AppFlowyEditorLocalizations.current.quote, name: () => AppFlowyEditorLocalizations.current.quote,
icon: _selectionMenuIcon('quote'), icon: _selectionMenuIcon('quote'),
keywords: ['quote', 'refer'], keywords: ['quote', 'refer'],
handler: (editorState, _, __) { handler: (editorState, _, __) {

View File

@ -6,27 +6,54 @@ import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
typedef SelectionMenuItemHandler = void Function(
EditorState editorState,
SelectionMenuService menuService,
BuildContext context,
);
/// Selection Menu Item /// Selection Menu Item
class SelectionMenuItem { class SelectionMenuItem {
SelectionMenuItem({ SelectionMenuItem({
required this.name, required this.name,
required this.icon, required this.icon,
required this.keywords, required this.keywords,
required this.handler, required SelectionMenuItemHandler handler,
}); }) {
this.handler = (editorState, menuService, context) {
_deleteToSlash(editorState);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
handler(editorState, menuService, context);
});
};
}
final String name; final String Function() name;
final Widget icon; final Widget icon;
/// Customizes keywords for item. /// Customizes keywords for item.
/// ///
/// The keywords are used to quickly retrieve items. /// The keywords are used to quickly retrieve items.
final List<String> keywords; final List<String> keywords;
final void Function( late final SelectionMenuItemHandler handler;
EditorState editorState,
SelectionMenuService menuService, void _deleteToSlash(EditorState editorState) {
BuildContext context, final selectionService = editorState.service.selectionService;
) handler; final selection = selectionService.currentSelection.value;
final nodes = selectionService.currentSelectedNodes;
if (selection != null && nodes.length == 1) {
final node = nodes.first as TextNode;
final end = selection.start.offset;
final start = node.toRawString().substring(0, end).lastIndexOf('/');
TransactionBuilder(editorState)
..deleteText(
node,
start,
selection.start.offset - start,
)
..commit();
}
}
} }
class SelectionMenuWidget extends StatefulWidget { class SelectionMenuWidget extends StatefulWidget {
@ -204,11 +231,8 @@ class _SelectionMenuWidgetState extends State<SelectionMenuWidget> {
if (event.logicalKey == LogicalKeyboardKey.enter) { if (event.logicalKey == LogicalKeyboardKey.enter) {
if (0 <= _selectedIndex && _selectedIndex < _showingItems.length) { if (0 <= _selectedIndex && _selectedIndex < _showingItems.length) {
_deleteLastCharacters(length: keyword.length + 1); _showingItems[_selectedIndex]
WidgetsBinding.instance.addPostFrameCallback((timeStamp) { .handler(widget.editorState, widget.menuService, context);
_showingItems[_selectedIndex]
.handler(widget.editorState, widget.menuService, context);
});
return KeyEventResult.handled; return KeyEventResult.handled;
} }
} else if (event.logicalKey == LogicalKeyboardKey.escape) { } else if (event.logicalKey == LogicalKeyboardKey.escape) {

View File

@ -1,4 +1,5 @@
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/commands/format_built_in_text.dart';
import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart'; import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart'; import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/render/link_menu/link_menu.dart'; import 'package:appflowy_editor/src/render/link_menu/link_menu.dart';
@ -8,7 +9,6 @@ import 'package:appflowy_editor/src/service/default_text_operations/format_rich_
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:rich_clipboard/rich_clipboard.dart'; import 'package:rich_clipboard/rich_clipboard.dart';
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
typedef ToolbarItemEventHandler = void Function( typedef ToolbarItemEventHandler = void Function(
EditorState editorState, BuildContext context); EditorState editorState, BuildContext context);
@ -120,7 +120,7 @@ List<ToolbarItem> defaultToolbarItems = [
name: 'toolbar/bold', name: 'toolbar/bold',
color: isHighlight ? Colors.lightBlue : null, color: isHighlight ? Colors.lightBlue : null,
), ),
validator: _showInTextSelection, validator: _showInBuiltInTextSelection,
highlightCallback: (editorState) => _allSatisfy( highlightCallback: (editorState) => _allSatisfy(
editorState, editorState,
BuiltInAttributeKey.bold, BuiltInAttributeKey.bold,
@ -136,7 +136,7 @@ List<ToolbarItem> defaultToolbarItems = [
name: 'toolbar/italic', name: 'toolbar/italic',
color: isHighlight ? Colors.lightBlue : null, color: isHighlight ? Colors.lightBlue : null,
), ),
validator: _showInTextSelection, validator: _showInBuiltInTextSelection,
highlightCallback: (editorState) => _allSatisfy( highlightCallback: (editorState) => _allSatisfy(
editorState, editorState,
BuiltInAttributeKey.italic, BuiltInAttributeKey.italic,
@ -152,7 +152,7 @@ List<ToolbarItem> defaultToolbarItems = [
name: 'toolbar/underline', name: 'toolbar/underline',
color: isHighlight ? Colors.lightBlue : null, color: isHighlight ? Colors.lightBlue : null,
), ),
validator: _showInTextSelection, validator: _showInBuiltInTextSelection,
highlightCallback: (editorState) => _allSatisfy( highlightCallback: (editorState) => _allSatisfy(
editorState, editorState,
BuiltInAttributeKey.underline, BuiltInAttributeKey.underline,
@ -168,7 +168,7 @@ List<ToolbarItem> defaultToolbarItems = [
name: 'toolbar/strikethrough', name: 'toolbar/strikethrough',
color: isHighlight ? Colors.lightBlue : null, color: isHighlight ? Colors.lightBlue : null,
), ),
validator: _showInTextSelection, validator: _showInBuiltInTextSelection,
highlightCallback: (editorState) => _allSatisfy( highlightCallback: (editorState) => _allSatisfy(
editorState, editorState,
BuiltInAttributeKey.strikethrough, BuiltInAttributeKey.strikethrough,
@ -184,7 +184,7 @@ List<ToolbarItem> defaultToolbarItems = [
name: 'toolbar/code', name: 'toolbar/code',
color: isHighlight ? Colors.lightBlue : null, color: isHighlight ? Colors.lightBlue : null,
), ),
validator: _showInTextSelection, validator: _showInBuiltInTextSelection,
highlightCallback: (editorState) => _allSatisfy( highlightCallback: (editorState) => _allSatisfy(
editorState, editorState,
BuiltInAttributeKey.code, BuiltInAttributeKey.code,
@ -248,7 +248,7 @@ List<ToolbarItem> defaultToolbarItems = [
name: 'toolbar/highlight', name: 'toolbar/highlight',
color: isHighlight ? Colors.lightBlue : null, color: isHighlight ? Colors.lightBlue : null,
), ),
validator: _showInTextSelection, validator: _showInBuiltInTextSelection,
highlightCallback: (editorState) => _allSatisfy( highlightCallback: (editorState) => _allSatisfy(
editorState, editorState,
BuiltInAttributeKey.backgroundColor, BuiltInAttributeKey.backgroundColor,
@ -262,13 +262,22 @@ List<ToolbarItem> defaultToolbarItems = [
]; ];
ToolbarItemValidator _onlyShowInSingleTextSelection = (editorState) { ToolbarItemValidator _onlyShowInSingleTextSelection = (editorState) {
final result = _showInBuiltInTextSelection(editorState);
if (!result) {
return false;
}
final nodes = editorState.service.selectionService.currentSelectedNodes; final nodes = editorState.service.selectionService.currentSelectedNodes;
return (nodes.length == 1 && nodes.first is TextNode); return (nodes.length == 1 && nodes.first is TextNode);
}; };
ToolbarItemValidator _showInTextSelection = (editorState) { ToolbarItemValidator _showInBuiltInTextSelection = (editorState) {
final nodes = editorState.service.selectionService.currentSelectedNodes final nodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>(); .whereType<TextNode>()
.where(
(textNode) =>
BuiltInAttributeKey.globalStyleKeys.contains(textNode.subtype) ||
textNode.subtype == null,
);
return nodes.isNotEmpty; return nodes.isNotEmpty;
}; };
@ -337,11 +346,8 @@ void showLinkMenu(
onOpenLink: () async { onOpenLink: () async {
await safeLaunchUrl(linkText); await safeLaunchUrl(linkText);
}, },
onSubmitted: (text) { onSubmitted: (text) async {
TransactionBuilder(editorState) await formatLinkInText(editorState, text, textNode: textNode);
..formatText(
textNode, index, length, {BuiltInAttributeKey.href: text})
..commit();
_dismissLinkMenu(); _dismissLinkMenu();
}, },
onCopyLink: () { onCopyLink: () {
@ -369,6 +375,7 @@ void showLinkMenu(
Overlay.of(context)?.insert(_linkMenuOverlay!); Overlay.of(context)?.insert(_linkMenuOverlay!);
editorState.service.scrollService?.disable(); editorState.service.scrollService?.disable();
editorState.service.keyboardService?.disable();
editorState.service.selectionService.currentSelection editorState.service.selectionService.currentSelection
.addListener(_dismissLinkMenu); .addListener(_dismissLinkMenu);
} }

View File

@ -103,13 +103,17 @@ bool formatTextNodes(EditorState editorState, Attributes attributes) {
final builder = TransactionBuilder(editorState); final builder = TransactionBuilder(editorState);
for (final textNode in textNodes) { for (final textNode in textNodes) {
var newAttributes = {...textNode.attributes};
for (final globalStyleKey in BuiltInAttributeKey.globalStyleKeys) {
if (newAttributes.keys.contains(globalStyleKey)) {
newAttributes[globalStyleKey] = null;
}
}
newAttributes.addAll(attributes);
builder builder
..updateNode( ..updateNode(
textNode, textNode,
Attributes.fromIterable( newAttributes,
BuiltInAttributeKey.globalStyleKeys,
value: (_) => null,
)..addAll(attributes),
) )
..afterSelection = Selection.collapsed( ..afterSelection = Selection.collapsed(
Position( Position(

View File

@ -72,6 +72,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
editorState.selectionMenuItems = widget.selectionMenuItems; editorState.selectionMenuItems = widget.selectionMenuItems;
editorState.editorStyle = widget.editorStyle; editorState.editorStyle = widget.editorStyle;
editorState.service.renderPluginService = _createRenderPlugin(); editorState.service.renderPluginService = _createRenderPlugin();
editorState.editable = widget.editable;
} }
@override @override
@ -84,6 +85,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
} }
editorState.editorStyle = widget.editorStyle; editorState.editorStyle = widget.editorStyle;
editorState.editable = widget.editable;
services = null; services = null;
} }
@ -118,8 +120,8 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
key: editorState.service.keyboardServiceKey, key: editorState.service.keyboardServiceKey,
editable: widget.editable, editable: widget.editable,
shortcutEvents: [ shortcutEvents: [
...builtInShortcutEvents,
...widget.shortcutEvents, ...widget.shortcutEvents,
...builtInShortcutEvents,
], ],
editorState: editorState, editorState: editorState,
child: FlowyToolbar( child: FlowyToolbar(

View File

@ -297,7 +297,11 @@ class _AppFlowyInputState extends State<AppFlowyInput>
_updateCaretPosition(textNodes.first, selection); _updateCaretPosition(textNodes.first, selection);
} }
} else { } else {
// close(); // https://github.com/flutter/flutter/issues/104944
// Disable IME for the Web.
if (kIsWeb) {
close();
}
} }
} }

View File

@ -117,12 +117,17 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
makeFollowingNodesIncremental(editorState, insertPath, afterSelection, makeFollowingNodesIncremental(editorState, insertPath, afterSelection,
beginNum: prevNumber); beginNum: prevNumber);
} else { } else {
bool needCopyAttributes = ![
BuiltInAttributeKey.heading,
BuiltInAttributeKey.quote,
].contains(subtype);
TransactionBuilder(editorState) TransactionBuilder(editorState)
..insertNode( ..insertNode(
textNode.path, textNode.path,
textNode.copyWith( textNode.copyWith(
children: LinkedList(), children: LinkedList(),
delta: Delta(), delta: Delta(),
attributes: needCopyAttributes ? null : {},
), ),
) )
..afterSelection = afterSelection ..afterSelection = afterSelection
@ -173,7 +178,9 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
Attributes _attributesFromPreviousLine(TextNode textNode) { Attributes _attributesFromPreviousLine(TextNode textNode) {
final prevAttributes = textNode.attributes; final prevAttributes = textNode.attributes;
final subType = textNode.subtype; final subType = textNode.subtype;
if (subType == null || subType == BuiltInAttributeKey.heading) { if (subType == null ||
subType == BuiltInAttributeKey.heading ||
subType == BuiltInAttributeKey.quote) {
return {}; return {};
} }

View File

@ -0,0 +1,126 @@
import "dart:math";
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/extensions/text_node_extensions.dart';
import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
import 'package:flutter/material.dart';
bool _isCodeStyle(TextNode textNode, int index) {
return textNode.allSatisfyCodeInSelection(Selection.single(
path: textNode.path, startOffset: index, endOffset: index + 1));
}
// enter escape mode when start two backquote
bool _isEscapeBackquote(String text, List<int> backquoteIndexes) {
if (backquoteIndexes.length >= 2) {
final firstBackquoteIndex = backquoteIndexes[0];
final secondBackquoteIndex = backquoteIndexes[1];
return firstBackquoteIndex == secondBackquoteIndex - 1;
}
return false;
}
// find all the index of `, exclusion in code style.
List<int> _findBackquoteIndexes(String text, TextNode textNode) {
final backquoteIndexes = <int>[];
for (var i = 0; i < text.length; i++) {
if (text[i] == '`' && _isCodeStyle(textNode, i) == false) {
backquoteIndexes.add(i);
}
}
return backquoteIndexes;
}
/// To denote a word or phrase as code, enclose it in backticks (`).
/// If the word or phrase you want to denote as code includes one or more
/// backticks, you can escape it by enclosing the word or phrase in double
/// backticks (``).
ShortcutEventHandler backquoteToCodeHandler = (editorState, event) {
final selectionService = editorState.service.selectionService;
final selection = selectionService.currentSelection.value;
final textNodes = selectionService.currentSelectedNodes.whereType<TextNode>();
if (selection == null || !selection.isSingle || textNodes.length != 1) {
return KeyEventResult.ignored;
}
final textNode = textNodes.first;
final selectionText = textNode
.toRawString()
.substring(selection.start.offset, selection.end.offset);
// toggle code style when selected some text
if (selectionText.length > 0) {
formatEmbedCode(editorState);
return KeyEventResult.handled;
}
final text = textNode.toRawString().substring(0, selection.end.offset);
final backquoteIndexes = _findBackquoteIndexes(text, textNode);
if (backquoteIndexes.isEmpty) {
return KeyEventResult.ignored;
}
final endIndex = selection.end.offset;
if (_isEscapeBackquote(text, backquoteIndexes)) {
final firstBackquoteIndex = backquoteIndexes[0];
final secondBackquoteIndex = backquoteIndexes[1];
final lastBackquoteIndex = backquoteIndexes[backquoteIndexes.length - 1];
if (secondBackquoteIndex == lastBackquoteIndex ||
secondBackquoteIndex == lastBackquoteIndex - 1 ||
lastBackquoteIndex != endIndex - 1) {
// ``(`),```(`),``...`...(`) should ignored
return KeyEventResult.ignored;
}
TransactionBuilder(editorState)
..deleteText(textNode, lastBackquoteIndex, 1)
..deleteText(textNode, firstBackquoteIndex, 2)
..formatText(
textNode,
firstBackquoteIndex,
endIndex - firstBackquoteIndex - 3,
{
BuiltInAttributeKey.code: true,
},
)
..afterSelection = Selection.collapsed(
Position(
path: textNode.path,
offset: endIndex - 3,
),
)
..commit();
return KeyEventResult.handled;
}
// handle single backquote
final startIndex = backquoteIndexes[0];
if (startIndex == endIndex - 1) {
return KeyEventResult.ignored;
}
// delete the backquote.
// update the style of the text surround by ` ` to code.
// and update the cursor position.
TransactionBuilder(editorState)
..deleteText(textNode, startIndex, 1)
..formatText(
textNode,
startIndex,
endIndex - startIndex - 1,
{
BuiltInAttributeKey.code: true,
},
)
..afterSelection = Selection.collapsed(
Position(
path: textNode.path,
offset: endIndex - 1,
),
)
..commit();
return KeyEventResult.handled;
};

View File

@ -0,0 +1,21 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/commands/edit_text.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
ShortcutEventHandler spaceOnWebHandler = (editorState, event) {
final selection = editorState.service.selectionService.currentSelection.value;
final textNodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>()
.toList(growable: false);
if (selection == null ||
!selection.isCollapsed ||
!kIsWeb ||
textNodes.length != 1) {
return KeyEventResult.ignored;
}
insertContextInText(editorState, selection.startIndex, ' ');
return KeyEventResult.handled;
};

View File

@ -13,12 +13,19 @@ ShortcutEventHandler tabHandler = (editorState, event) {
final textNode = textNodes.first; final textNode = textNodes.first;
final previous = textNode.previous; final previous = textNode.previous;
if (textNode.subtype != BuiltInAttributeKey.bulletedList ||
previous == null || if (textNode.subtype != BuiltInAttributeKey.bulletedList) {
previous.subtype != BuiltInAttributeKey.bulletedList) { TransactionBuilder(editorState)
..insertText(textNode, selection.end.offset, ' ' * 4)
..commit();
return KeyEventResult.handled; return KeyEventResult.handled;
} }
if (previous == null ||
previous.subtype != BuiltInAttributeKey.bulletedList) {
return KeyEventResult.ignored;
}
final path = previous.path + [previous.children.length]; final path = previous.path + [previous.children.length];
final afterSelection = Selection( final afterSelection = Selection(
start: selection.start.copyWith(path: path), start: selection.start.copyWith(path: path),

View File

@ -124,6 +124,8 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
final result = shortcutEvent.handler(widget.editorState, event); final result = shortcutEvent.handler(widget.editorState, event);
if (result == KeyEventResult.handled) { if (result == KeyEventResult.handled) {
return KeyEventResult.handled; return KeyEventResult.handled;
} else if (result == KeyEventResult.skipRemainingHandlers) {
return KeyEventResult.skipRemainingHandlers;
} }
continue; continue;
} }

View File

@ -5,14 +5,17 @@ import 'package:appflowy_editor/src/service/internal_key_event_handlers/backspac
import 'package:appflowy_editor/src/service/internal_key_event_handlers/copy_paste_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/copy_paste_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/page_up_down_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/page_up_down_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/redo_undo_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/redo_undo_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/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/space_on_web_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/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';
import 'package:flutter/foundation.dart';
// //
List<ShortcutEvent> builtInShortcutEvents = [ List<ShortcutEvent> builtInShortcutEvents = [
@ -260,4 +263,18 @@ List<ShortcutEvent> builtInShortcutEvents = [
command: 'shift+underscore', command: 'shift+underscore',
handler: doubleUnderscoresToBold, handler: doubleUnderscoresToBold,
), ),
key: 'Backquote to code',
command: 'backquote',
handler: backquoteToCodeHandler,
),
// https://github.com/flutter/flutter/issues/104944
// Workaround: Using space editing on the web platform often results in errors,
// so adding a shortcut event to handle the space input instead of using the
// `input_service`.
if (kIsWeb)
ShortcutEvent(
key: 'Space on the Web',
command: 'space',
handler: spaceOnWebHandler,
),
]; ];

View File

@ -38,14 +38,17 @@ class _FlowyToolbarState extends State<FlowyToolbar>
@override @override
void showInOffset(Offset offset, LayerLink layerLink) { void showInOffset(Offset offset, LayerLink layerLink) {
hide(); hide();
final items = _filterItems(defaultToolbarItems);
if (items.isEmpty) {
return;
}
_toolbarOverlay = OverlayEntry( _toolbarOverlay = OverlayEntry(
builder: (context) => ToolbarWidget( builder: (context) => ToolbarWidget(
key: _toolbarWidgetKey, key: _toolbarWidgetKey,
editorState: widget.editorState, editorState: widget.editorState,
layerLink: layerLink, layerLink: layerLink,
offset: offset, offset: offset,
items: _filterItems(defaultToolbarItems), items: items,
), ),
); );
Overlay.of(context)?.insert(_toolbarOverlay!); Overlay.of(context)?.insert(_toolbarOverlay!);
@ -102,9 +105,4 @@ class _FlowyToolbarState extends State<FlowyToolbar>
} }
return dividedItems; return dividedItems;
} }
// List<ToolbarItem> _highlightItems(
// List<ToolbarItem> items,
// Selection selection,
// ) {}
} }

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';
@ -13,29 +12,40 @@ void main() async {
}); });
group('selection_menu_widget.dart', () { group('selection_menu_widget.dart', () {
// const i = defaultSelectionMenuItems.length; for (var i = 0; i < defaultSelectionMenuItems.length; i += 1) {
// testWidgets('Selects number.$i item in selection menu with enter', (
// Because the `defaultSelectionMenuItems` uses localization, tester) async {
// and the MaterialApp has not been initialized at the time of getting the value, final editor = await _prepare(tester);
// it will crash. for (var j = 0; j < i; j++) {
// await editor.pressLogicKey(LogicalKeyboardKey.arrowDown);
// Use const value temporarily instead. }
const i = 7;
testWidgets('Selects number.$i item in selection menu', (tester) async {
final editor = await _prepare(tester);
for (var j = 0; j < i; j++) {
await editor.pressLogicKey(LogicalKeyboardKey.arrowDown);
}
await editor.pressLogicKey(LogicalKeyboardKey.enter); await editor.pressLogicKey(LogicalKeyboardKey.enter);
expect( expect(
find.byType(SelectionMenuWidget, skipOffstage: false), find.byType(SelectionMenuWidget, skipOffstage: false),
findsNothing, findsNothing,
); );
if (defaultSelectionMenuItems[i].name != 'Image') { if (defaultSelectionMenuItems[i].name() != 'Image') {
await _testDefaultSelectionMenuItems(i, editor); await _testDefaultSelectionMenuItems(i, editor);
} }
}); });
testWidgets('Selects number.$i item in selection menu with click', (
tester) async {
final editor = await _prepare(tester);
await tester.tap(find.byType(SelectionMenuItemWidget).at(i));
await tester.pumpAndSettle();
expect(
find.byType(SelectionMenuWidget, skipOffstage: false),
findsNothing,
);
if (defaultSelectionMenuItems[i].name() != 'Image') {
await _testDefaultSelectionMenuItems(i, editor);
}
});
}
testWidgets('Search item in selection menu util no results', testWidgets('Search item in selection menu util no results',
(tester) async { (tester) async {
@ -138,23 +148,27 @@ Future<void> _testDefaultSelectionMenuItems(
int index, EditorWidgetTester editor) async { int index, EditorWidgetTester editor) async {
expect(editor.documentLength, 4); expect(editor.documentLength, 4);
expect(editor.documentSelection, Selection.single(path: [2], startOffset: 0)); expect(editor.documentSelection, Selection.single(path: [2], startOffset: 0));
expect((editor.nodeAtPath([1]) as TextNode).toRawString(), 'Welcome to Appflowy 😁');
final node = editor.nodeAtPath([2]); final node = editor.nodeAtPath([2]);
final item = defaultSelectionMenuItems[index]; final item = defaultSelectionMenuItems[index];
if (item.name == 'Text') { final itemName = item.name();
if (itemName == 'Text') {
expect(node?.subtype == null, true); expect(node?.subtype == null, true);
} else if (item.name == 'Heading 1') { } else if (itemName == 'Heading 1') {
expect(node?.subtype, BuiltInAttributeKey.heading); expect(node?.subtype, BuiltInAttributeKey.heading);
expect(node?.attributes.heading, BuiltInAttributeKey.h1); expect(node?.attributes.heading, BuiltInAttributeKey.h1);
} else if (item.name == 'Heading 2') { } else if (itemName == 'Heading 2') {
expect(node?.subtype, BuiltInAttributeKey.heading); expect(node?.subtype, BuiltInAttributeKey.heading);
expect(node?.attributes.heading, BuiltInAttributeKey.h2); expect(node?.attributes.heading, BuiltInAttributeKey.h2);
} else if (item.name == 'Heading 3') { } else if (itemName == 'Heading 3') {
expect(node?.subtype, BuiltInAttributeKey.heading); expect(node?.subtype, BuiltInAttributeKey.heading);
expect(node?.attributes.heading, BuiltInAttributeKey.h3); expect(node?.attributes.heading, BuiltInAttributeKey.h3);
} else if (item.name == 'Bulleted list') { } else if (itemName == 'Bulleted list') {
expect(node?.subtype, BuiltInAttributeKey.bulletedList); expect(node?.subtype, BuiltInAttributeKey.bulletedList);
} else if (item.name == 'Checkbox') { } else if (itemName == 'Checkbox') {
expect(node?.subtype, BuiltInAttributeKey.checkbox); expect(node?.subtype, BuiltInAttributeKey.checkbox);
expect(node?.attributes.check, false); expect(node?.attributes.check, false);
} else if (itemName == 'Quote') {
expect(node?.subtype, BuiltInAttributeKey.quote);
} }
} }

View File

@ -2,7 +2,6 @@ import 'package:appflowy_editor/appflowy_editor.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 '../../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(() {
@ -171,13 +170,27 @@ Future<void> _testStyleNeedToBeCopy(WidgetTester tester, String style) async {
LogicalKeyboardKey.enter, LogicalKeyboardKey.enter,
); );
expect(editor.documentSelection, Selection.single(path: [4], startOffset: 0)); expect(editor.documentSelection, Selection.single(path: [4], startOffset: 0));
expect(editor.nodeAtPath([4])?.subtype, style);
await editor.pressLogicKey( if ([BuiltInAttributeKey.heading, BuiltInAttributeKey.quote]
LogicalKeyboardKey.enter, .contains(style)) {
); expect(editor.nodeAtPath([4])?.subtype, null);
expect(editor.documentSelection, Selection.single(path: [4], startOffset: 0));
expect(editor.nodeAtPath([4])?.subtype, null); await editor.pressLogicKey(
LogicalKeyboardKey.enter,
);
expect(
editor.documentSelection, Selection.single(path: [5], startOffset: 0));
expect(editor.nodeAtPath([5])?.subtype, null);
} else {
expect(editor.nodeAtPath([4])?.subtype, style);
await editor.pressLogicKey(
LogicalKeyboardKey.enter,
);
expect(
editor.documentSelection, Selection.single(path: [4], startOffset: 0));
expect(editor.nodeAtPath([4])?.subtype, null);
}
} }
Future<void> _testMultipleSelection( Future<void> _testMultipleSelection(

View File

@ -0,0 +1,154 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/extensions/text_node_extensions.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('markdown_syntax_to_styled_text.dart', () {
group('convert single backquote to code', () {
Future<void> insertBackquote(
EditorWidgetTester editor, {
int repeat = 1,
}) async {
for (var i = 0; i < repeat; i++) {
await editor.pressLogicKey(
LogicalKeyboardKey.backquote,
);
}
}
testWidgets('`AppFlowy` to code AppFlowy', (tester) async {
const text = '`AppFlowy';
final editor = tester.editor..insertTextNode('');
await editor.startTesting();
await editor.updateSelection(
Selection.single(path: [0], startOffset: 0),
);
final textNode = editor.nodeAtPath([0]) as TextNode;
for (var i = 0; i < text.length; i++) {
await editor.insertText(textNode, text[i], i);
}
await insertBackquote(editor);
final allCode = textNode.allSatisfyCodeInSelection(
Selection.single(
path: [0],
startOffset: 0,
endOffset: textNode.toRawString().length,
),
);
expect(allCode, true);
expect(textNode.toRawString(), 'AppFlowy');
});
testWidgets('App`Flowy` to code AppFlowy', (tester) async {
const text = 'App`Flowy';
final editor = tester.editor..insertTextNode('');
await editor.startTesting();
await editor.updateSelection(
Selection.single(path: [0], startOffset: 0),
);
final textNode = editor.nodeAtPath([0]) as TextNode;
for (var i = 0; i < text.length; i++) {
await editor.insertText(textNode, text[i], i);
}
await insertBackquote(editor);
final allCode = textNode.allSatisfyCodeInSelection(
Selection.single(
path: [0],
startOffset: 3,
endOffset: textNode.toRawString().length,
),
);
expect(allCode, true);
expect(textNode.toRawString(), 'AppFlowy');
});
testWidgets('`` nothing changes', (tester) async {
const text = '`';
final editor = tester.editor..insertTextNode('');
await editor.startTesting();
await editor.updateSelection(
Selection.single(path: [0], startOffset: 0),
);
final textNode = editor.nodeAtPath([0]) as TextNode;
for (var i = 0; i < text.length; i++) {
await editor.insertText(textNode, text[i], i);
}
await insertBackquote(editor);
final allCode = textNode.allSatisfyCodeInSelection(
Selection.single(
path: [0],
startOffset: 0,
endOffset: textNode.toRawString().length,
),
);
expect(allCode, false);
expect(textNode.toRawString(), text);
});
});
group('convert double backquote to code', () {
Future<void> insertBackquote(
EditorWidgetTester editor, {
int repeat = 1,
}) async {
for (var i = 0; i < repeat; i++) {
await editor.pressLogicKey(
LogicalKeyboardKey.backquote,
);
}
}
testWidgets('```AppFlowy`` to code `AppFlowy', (tester) async {
const text = '```AppFlowy`';
final editor = tester.editor..insertTextNode('');
await editor.startTesting();
await editor.updateSelection(
Selection.single(path: [0], startOffset: 0),
);
final textNode = editor.nodeAtPath([0]) as TextNode;
for (var i = 0; i < text.length; i++) {
await editor.insertText(textNode, text[i], i);
}
await insertBackquote(editor);
final allCode = textNode.allSatisfyCodeInSelection(
Selection.single(
path: [0],
startOffset: 1,
endOffset: textNode.toRawString().length,
),
);
expect(allCode, true);
expect(textNode.toRawString(), '`AppFlowy');
});
testWidgets('```` nothing changes', (tester) async {
const text = '```';
final editor = tester.editor..insertTextNode('');
await editor.startTesting();
await editor.updateSelection(
Selection.single(path: [0], startOffset: 0),
);
final textNode = editor.nodeAtPath([0]) as TextNode;
for (var i = 0; i < text.length; i++) {
await editor.insertText(textNode, text[i], i);
}
await insertBackquote(editor);
final allCode = textNode.allSatisfyCodeInSelection(
Selection.single(
path: [0],
startOffset: 0,
endOffset: textNode.toRawString().length,
),
);
expect(allCode, false);
expect(textNode.toRawString(), text);
});
});
});
}

View File

@ -0,0 +1,45 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/foundation.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('space_on_web_handler.dart', () {
testWidgets('Presses space key on web', (tester) async {
if (!kIsWeb) return;
const count = 10;
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor;
for (var i = 0; i < count; i++) {
editor.insertTextNode(text);
}
await editor.startTesting();
for (var i = 0; i < count; i++) {
await editor.updateSelection(
Selection.single(path: [i], startOffset: 1),
);
await editor.pressLogicKey(LogicalKeyboardKey.space);
expect(
(editor.nodeAtPath([i]) as TextNode).toRawString(),
'W elcome to Appflowy 😁',
);
}
for (var i = 0; i < count; i++) {
await editor.updateSelection(
Selection.single(path: [i], startOffset: text.length + 1),
);
await editor.pressLogicKey(LogicalKeyboardKey.space);
expect(
(editor.nodeAtPath([i]) as TextNode).toRawString(),
'W elcome to Appflowy 😁 ',
);
}
});
});
}

View File

@ -15,23 +15,24 @@ void main() async {
..insertTextNode(text) ..insertTextNode(text)
..insertTextNode(text); ..insertTextNode(text);
await editor.startTesting(); await editor.startTesting();
final document = editor.document;
var selection = Selection.single(path: [0], startOffset: 0); var selection = Selection.single(path: [0], startOffset: 0);
await editor.updateSelection(selection); await editor.updateSelection(selection);
await editor.pressLogicKey(LogicalKeyboardKey.tab); await editor.pressLogicKey(LogicalKeyboardKey.tab);
// nothing happens expect(
expect(editor.documentSelection, selection); editor.documentSelection,
expect(editor.document.toJson(), document.toJson()); Selection.single(path: [0], startOffset: 4),
);
selection = Selection.single(path: [1], startOffset: 0); selection = Selection.single(path: [1], startOffset: 0);
await editor.updateSelection(selection); await editor.updateSelection(selection);
await editor.pressLogicKey(LogicalKeyboardKey.tab); await editor.pressLogicKey(LogicalKeyboardKey.tab);
// nothing happens expect(
expect(editor.documentSelection, selection); editor.documentSelection,
expect(editor.document.toJson(), document.toJson()); Selection.single(path: [1], startOffset: 4),
);
}); });
testWidgets('press tab in bulleted list', (tester) async { testWidgets('press tab in bulleted list', (tester) async {
@ -63,7 +64,10 @@ void main() async {
await editor.pressLogicKey(LogicalKeyboardKey.tab); await editor.pressLogicKey(LogicalKeyboardKey.tab);
// nothing happens // nothing happens
expect(editor.documentSelection, selection); expect(
editor.documentSelection,
Selection.single(path: [0], startOffset: 0),
);
expect(editor.document.toJson(), document.toJson()); expect(editor.document.toJson(), document.toJson());
// Before // Before

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

@ -30,7 +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), this.margin = const EdgeInsets.all(6),
}) : super(key: key); }) : super(key: key);
@override @override

View File

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; 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/theme.dart';
@ -19,46 +21,55 @@ 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(
child: Column( scrollDirection: Axis.horizontal,
children: [ child: IntrinsicWidth(
ListView.builder( child: Column(
shrinkWrap: true, mainAxisSize: MainAxisSize.max,
itemBuilder: itemBuilder, children: [
itemCount: itemCount, ...children,
controller: controller, if (footer != null)
), Padding(
if (footer != null) padding: footer!.padding,
Padding( child: footer!.widget,
padding: footer!.padding, ),
child: footer!.widget, ],
), ),
],
), ),
), ),
); );
@ -68,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,
@ -85,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,
@ -122,7 +131,9 @@ class OverlayContainer extends StatelessWidget {
child: Container( child: Container(
padding: padding, padding: padding,
decoration: FlowyDecoration.decoration( decoration: FlowyDecoration.decoration(
theme.surface, theme.shadowColor.withOpacity(0.15)), theme.surface,
theme.shadowColor.withOpacity(0.15),
),
constraints: constraints, constraints: constraints,
child: child, child: child,
), ),

View File

@ -1444,9 +1444,9 @@ checksum = "eabb4a44450da02c90444cf74558da904edde8fb4e9035a9a6a4e15445af0bd7"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.11.2" version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]] [[package]]
name = "heck" name = "heck"
@ -1610,9 +1610,9 @@ checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.8.1" version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f647032dfaa1f8b6dc29bd3edb7bbef4861b8b8007ebb118d6db284fd59f6ee" checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"hashbrown", "hashbrown",

View File

@ -206,6 +206,15 @@ impl std::convert::From<&str> for ViewIdPB {
} }
} }
#[derive(Default, ProtoBuf, Clone, Debug)]
pub struct DeletedViewPB {
#[pb(index = 1)]
pub view_id: String,
#[pb(index = 2, one_of)]
pub index: Option<i32>,
}
impl std::ops::Deref for ViewIdPB { impl std::ops::Deref for ViewIdPB {
type Target = str; type Target = str;

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