Merge branch 'main' into workspace-rename-icon

This commit is contained in:
Zack Fu Zi Xiang 2024-03-01 14:03:18 +08:00
commit 06d5d57637
No known key found for this signature in database
120 changed files with 3168 additions and 559 deletions

View File

@ -0,0 +1,139 @@
import 'dart:async';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'relation_cell_bloc.freezed.dart';
class RelationCellBloc extends Bloc<RelationCellEvent, RelationCellState> {
RelationCellBloc({required this.cellController})
: super(RelationCellState.initial()) {
_dispatch();
_startListening();
_init();
}
final RelationCellController cellController;
void Function()? _onCellChangedFn;
@override
Future<void> close() async {
if (_onCellChangedFn != null) {
cellController.removeListener(_onCellChangedFn!);
_onCellChangedFn = null;
}
return super.close();
}
void _dispatch() {
on<RelationCellEvent>(
(event, emit) async {
await event.when(
didUpdateCell: (RelationCellDataPB? cellData) async {
if (cellData == null || cellData.rowIds.isEmpty) {
emit(state.copyWith(rows: const []));
return;
}
final payload = RepeatedRowIdPB(
databaseId: state.relatedDatabaseId,
rowIds: cellData.rowIds,
);
final result =
await DatabaseEventGetRelatedRowDatas(payload).send();
final rows = result.fold(
(data) => data.rows,
(err) {
Log.error(err);
return const <RelatedRowDataPB>[];
},
);
emit(state.copyWith(rows: rows));
},
didUpdateRelationDatabaseId: (databaseId) {
emit(state.copyWith(relatedDatabaseId: databaseId));
},
selectRow: (rowId) async {
await _handleSelectRow(rowId);
},
);
},
);
}
void _startListening() {
_onCellChangedFn = cellController.addListener(
onCellChanged: (data) {
if (!isClosed) {
add(RelationCellEvent.didUpdateCell(data));
}
},
onCellFieldChanged: (field) {
if (!isClosed) {
// hack: SingleFieldListener receives notification before
// FieldController's copy is updated.
Future.delayed(const Duration(milliseconds: 50), () {
final RelationTypeOptionPB typeOption =
cellController.getTypeOption(RelationTypeOptionDataParser());
add(
RelationCellEvent.didUpdateRelationDatabaseId(
typeOption.databaseId,
),
);
});
}
},
);
}
void _init() {
final RelationTypeOptionPB typeOption =
cellController.getTypeOption(RelationTypeOptionDataParser());
add(RelationCellEvent.didUpdateRelationDatabaseId(typeOption.databaseId));
final cellData = cellController.getCellData();
add(RelationCellEvent.didUpdateCell(cellData));
}
Future<void> _handleSelectRow(String rowId) async {
final payload = RelationCellChangesetPB(
viewId: cellController.viewId,
cellId: CellIdPB(
viewId: cellController.viewId,
fieldId: cellController.fieldId,
rowId: cellController.rowId,
),
);
if (state.rows.any((row) => row.rowId == rowId)) {
payload.removedRowIds.add(rowId);
} else {
payload.insertedRowIds.add(rowId);
}
final result = await DatabaseEventUpdateRelationCell(payload).send();
result.fold((l) => null, (err) => Log.error(err));
}
}
@freezed
class RelationCellEvent with _$RelationCellEvent {
const factory RelationCellEvent.didUpdateRelationDatabaseId(
String databaseId,
) = _DidUpdateRelationDatabaseId;
const factory RelationCellEvent.didUpdateCell(RelationCellDataPB? data) =
_DidUpdateCell;
const factory RelationCellEvent.selectRow(String rowId) = _SelectRowId;
}
@freezed
class RelationCellState with _$RelationCellState {
const factory RelationCellState({
required String relatedDatabaseId,
required List<RelatedRowDataPB> rows,
}) = _RelationCellState;
factory RelationCellState.initial() =>
const RelationCellState(relatedDatabaseId: "", rows: []);
}

View File

@ -0,0 +1,77 @@
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:bloc/bloc.dart';
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'relation_row_search_bloc.freezed.dart';
class RelationRowSearchBloc
extends Bloc<RelationRowSearchEvent, RelationRowSearchState> {
RelationRowSearchBloc({
required this.databaseId,
}) : super(RelationRowSearchState.initial()) {
_dispatch();
_init();
}
final String databaseId;
final List<RelatedRowDataPB> allRows = [];
void _dispatch() {
on<RelationRowSearchEvent>(
(event, emit) {
event.when(
didUpdateRowList: (List<RelatedRowDataPB> rowList) {
allRows.clear();
allRows.addAll(rowList);
emit(state.copyWith(filteredRows: allRows));
},
updateFilter: (String filter) => _updateFilter(filter, emit),
);
},
);
}
Future<void> _init() async {
final payload = DatabaseIdPB(value: databaseId);
final result = await DatabaseEventGetRelatedDatabaseRows(payload).send();
result.fold(
(data) => add(RelationRowSearchEvent.didUpdateRowList(data.rows)),
(err) => Log.error(err),
);
}
void _updateFilter(String filter, Emitter<RelationRowSearchState> emit) {
final rows = [...allRows];
if (filter.isNotEmpty) {
rows.retainWhere(
(row) => row.name.toLowerCase().contains(filter.toLowerCase()),
);
}
emit(state.copyWith(filter: filter, filteredRows: rows));
}
}
@freezed
class RelationRowSearchEvent with _$RelationRowSearchEvent {
const factory RelationRowSearchEvent.didUpdateRowList(
List<RelatedRowDataPB> rowList,
) = _DidUpdateRowList;
const factory RelationRowSearchEvent.updateFilter(String filter) =
_UpdateFilter;
}
@freezed
class RelationRowSearchState with _$RelationRowSearchState {
const factory RelationRowSearchState({
required String filter,
required List<RelatedRowDataPB> filteredRows,
}) = _RelationRowSearchState;
factory RelationRowSearchState.initial() => const RelationRowSearchState(
filter: "",
filteredRows: [],
);
}

View File

@ -14,6 +14,7 @@ typedef ChecklistCellController = CellController<ChecklistCellDataPB, String>;
typedef DateCellController = CellController<DateCellDataPB, String>;
typedef TimestampCellController = CellController<TimestampCellDataPB, String>;
typedef URLCellController = CellController<URLCellDataPB, String>;
typedef RelationCellController = CellController<RelationCellDataPB, String>;
CellController makeCellController(
DatabaseController databaseController,
@ -118,6 +119,19 @@ CellController makeCellController(
),
cellDataPersistence: TextCellDataPersistence(),
);
case FieldType.Relation:
return RelationCellController(
viewId: viewId,
fieldController: fieldController,
cellContext: cellContext,
rowCache: rowCache,
cellDataLoader: CellDataLoader(
parser: RelationCellDataParser(),
reloadOnFieldChange: true,
),
cellDataPersistence: TextCellDataPersistence(),
);
}
throw UnimplementedError;
}

View File

@ -133,3 +133,10 @@ class URLCellDataParser implements CellDataParser<URLCellDataPB> {
return URLCellDataPB.fromBuffer(data);
}
}
class RelationCellDataParser implements CellDataParser<RelationCellDataPB> {
@override
RelationCellDataPB? parserData(List<int> data) {
return data.isEmpty ? null : RelationCellDataPB.fromBuffer(data);
}
}

View File

@ -72,3 +72,11 @@ class ChecklistTypeOptionDataParser
return ChecklistTypeOptionPB.fromBuffer(buffer);
}
}
class RelationTypeOptionDataParser
extends TypeOptionParser<RelationTypeOptionPB> {
@override
RelationTypeOptionPB fromBuffer(List<int> buffer) {
return RelationTypeOptionPB.fromBuffer(buffer);
}
}

View File

@ -144,9 +144,9 @@ class GridCreateFilterBloc
fieldId: fieldId,
condition: TextFilterConditionPB.Contains,
);
default:
throw UnimplementedError();
}
return FlowyResult.success(null);
}
@override

View File

@ -9,6 +9,19 @@ import '../../layout/sizes.dart';
typedef SelectFieldCallback = void Function(FieldType);
const List<FieldType> _supportedFieldTypes = [
FieldType.RichText,
FieldType.Number,
FieldType.SingleSelect,
FieldType.MultiSelect,
FieldType.DateTime,
FieldType.Checkbox,
FieldType.Checklist,
FieldType.URL,
FieldType.LastEditedTime,
FieldType.CreatedTime,
];
class FieldTypeList extends StatelessWidget with FlowyOverlayDelegate {
const FieldTypeList({required this.onSelectField, super.key});
@ -16,7 +29,7 @@ class FieldTypeList extends StatelessWidget with FlowyOverlayDelegate {
@override
Widget build(BuildContext context) {
final cells = FieldType.values.map((fieldType) {
final cells = _supportedFieldTypes.map((fieldType) {
return FieldTypeCell(
fieldType: fieldType,
onSelectField: (fieldType) {

View File

@ -9,6 +9,7 @@ import 'checklist.dart';
import 'date.dart';
import 'multi_select.dart';
import 'number.dart';
import 'relation.dart';
import 'rich_text.dart';
import 'single_select.dart';
import 'timestamp.dart';
@ -29,6 +30,7 @@ abstract class TypeOptionEditorFactory {
FieldType.MultiSelect => const MultiSelectTypeOptionEditorFactory(),
FieldType.Checkbox => const CheckboxTypeOptionEditorFactory(),
FieldType.Checklist => const ChecklistTypeOptionEditorFactory(),
FieldType.Relation => const RelationTypeOptionEditorFactory(),
_ => throw UnimplementedError(),
};
}

View File

@ -0,0 +1,160 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:protobuf/protobuf.dart';
import 'builder.dart';
class RelationTypeOptionEditorFactory implements TypeOptionEditorFactory {
const RelationTypeOptionEditorFactory();
@override
Widget? build({
required BuildContext context,
required String viewId,
required FieldPB field,
required PopoverMutex popoverMutex,
required TypeOptionDataCallback onTypeOptionUpdated,
}) {
final typeOption = _parseTypeOptionData(field.typeOptionData);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.only(left: 14, right: 8),
height: GridSize.popoverItemHeight,
alignment: Alignment.centerLeft,
child: FlowyText.regular(
LocaleKeys.grid_relation_relatedDatabasePlaceLabel.tr(),
color: Theme.of(context).hintColor,
fontSize: 11,
),
),
AppFlowyPopover(
mutex: popoverMutex,
triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click,
offset: const Offset(6, 0),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8),
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText(
typeOption.databaseId.isEmpty
? LocaleKeys.grid_relation_relatedDatabasePlaceholder.tr()
: typeOption.databaseId,
color: typeOption.databaseId.isEmpty
? Theme.of(context).hintColor
: null,
overflow: TextOverflow.ellipsis,
),
rightIcon: const FlowySvg(FlowySvgs.more_s),
),
),
popupBuilder: (context) {
return _DatabaseList(
onSelectDatabase: (newDatabaseId) {
final newTypeOption = _updateTypeOption(
typeOption: typeOption,
databaseId: newDatabaseId,
);
onTypeOptionUpdated(newTypeOption.writeToBuffer());
PopoverContainer.of(context).close();
},
currentDatabaseId:
typeOption.databaseId.isEmpty ? null : typeOption.databaseId,
);
},
),
],
);
}
RelationTypeOptionPB _parseTypeOptionData(List<int> data) {
return RelationTypeOptionDataParser().fromBuffer(data);
}
RelationTypeOptionPB _updateTypeOption({
required RelationTypeOptionPB typeOption,
required String databaseId,
}) {
typeOption.freeze();
return typeOption.rebuild((typeOption) {
typeOption.databaseId = databaseId;
});
}
}
class _DatabaseList extends StatefulWidget {
const _DatabaseList({
required this.onSelectDatabase,
required this.currentDatabaseId,
});
final String? currentDatabaseId;
final void Function(String databaseId) onSelectDatabase;
@override
State<_DatabaseList> createState() => _DatabaseListState();
}
class _DatabaseListState extends State<_DatabaseList> {
late Future<FlowyResult<RepeatedDatabaseDescriptionPB, FlowyError>> future;
@override
void initState() {
super.initState();
future = DatabaseEventGetDatabases().send();
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: future,
builder: (context, snapshot) {
final data = snapshot.data;
if (!snapshot.hasData ||
snapshot.connectionState != ConnectionState.done ||
data!.isFailure()) {
return const SizedBox.shrink();
}
final databaseIds = data
.fold<List<DatabaseDescriptionPB>>((l) => l.items, (r) => [])
.map((databaseDescription) {
final databaseId = databaseDescription.databaseId;
return FlowyButton(
onTap: () => widget.onSelectDatabase(databaseId),
text: FlowyText.medium(
databaseId,
overflow: TextOverflow.ellipsis,
),
rightIcon: databaseId == widget.currentDatabaseId
? FlowySvg(
FlowySvgs.check_s,
color: Theme.of(context).colorScheme.primary,
)
: null,
);
}).toList();
return ListView.separated(
shrinkWrap: true,
separatorBuilder: (_, __) =>
VSpace(GridSize.typeOptionSeparatorHeight),
itemCount: databaseIds.length,
itemBuilder: (context, index) => databaseIds[index],
);
},
);
}
}

View File

@ -1,5 +1,6 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/relation_card_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/timestamp_card_cell.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:flutter/widgets.dart';
@ -84,6 +85,12 @@ class CardCellBuilder {
databaseController: databaseController,
cellContext: cellContext,
),
FieldType.Relation => RelationCardCell(
key: key,
style: isStyleOrNull(style),
databaseController: databaseController,
cellContext: cellContext,
),
_ => throw UnimplementedError,
};
}

View File

@ -0,0 +1,82 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'card_cell.dart';
class RelationCardCellStyle extends CardCellStyle {
RelationCardCellStyle({
required super.padding,
required this.textStyle,
required this.wrap,
});
final TextStyle textStyle;
final bool wrap;
}
class RelationCardCell extends CardCell<RelationCardCellStyle> {
const RelationCardCell({
super.key,
required super.style,
required this.databaseController,
required this.cellContext,
});
final DatabaseController databaseController;
final CellContext cellContext;
@override
State<RelationCardCell> createState() => _RelationCellState();
}
class _RelationCellState extends State<RelationCardCell> {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) {
return RelationCellBloc(
cellController: makeCellController(
widget.databaseController,
widget.cellContext,
).as(),
);
},
child: BlocBuilder<RelationCellBloc, RelationCellState>(
builder: (context, state) {
if (state.rows.isEmpty) {
return const SizedBox.shrink();
}
final children = state.rows
.map(
(row) => FlowyText.medium(
row.name,
decoration: TextDecoration.underline,
overflow: TextOverflow.ellipsis,
),
)
.toList();
return Container(
alignment: AlignmentDirectional.topStart,
padding: widget.style.padding,
child: widget.style.wrap
? Wrap(spacing: 4, runSpacing: 4, children: children)
: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: children,
),
),
);
},
),
);
}
}

View File

@ -6,6 +6,7 @@ import '../card_cell_skeleton/checkbox_card_cell.dart';
import '../card_cell_skeleton/checklist_card_cell.dart';
import '../card_cell_skeleton/date_card_cell.dart';
import '../card_cell_skeleton/number_card_cell.dart';
import '../card_cell_skeleton/relation_card_cell.dart';
import '../card_cell_skeleton/select_option_card_cell.dart';
import '../card_cell_skeleton/text_card_cell.dart';
import '../card_cell_skeleton/timestamp_card_cell.dart';
@ -73,5 +74,10 @@ CardCellStyleMap desktopCalendarCardCellStyleMap(BuildContext context) {
decoration: TextDecoration.underline,
),
),
FieldType.Relation: RelationCardCellStyle(
padding: padding,
wrap: true,
textStyle: textStyle,
),
};
}

View File

@ -6,6 +6,7 @@ import '../card_cell_skeleton/checkbox_card_cell.dart';
import '../card_cell_skeleton/checklist_card_cell.dart';
import '../card_cell_skeleton/date_card_cell.dart';
import '../card_cell_skeleton/number_card_cell.dart';
import '../card_cell_skeleton/relation_card_cell.dart';
import '../card_cell_skeleton/select_option_card_cell.dart';
import '../card_cell_skeleton/text_card_cell.dart';
import '../card_cell_skeleton/timestamp_card_cell.dart';
@ -73,5 +74,10 @@ CardCellStyleMap desktopBoardCardCellStyleMap(BuildContext context) {
decoration: TextDecoration.underline,
),
),
FieldType.Relation: RelationCardCellStyle(
padding: padding,
wrap: true,
textStyle: textStyle,
),
};
}

View File

@ -6,6 +6,7 @@ import '../card_cell_skeleton/checkbox_card_cell.dart';
import '../card_cell_skeleton/checklist_card_cell.dart';
import '../card_cell_skeleton/date_card_cell.dart';
import '../card_cell_skeleton/number_card_cell.dart';
import '../card_cell_skeleton/relation_card_cell.dart';
import '../card_cell_skeleton/select_option_card_cell.dart';
import '../card_cell_skeleton/text_card_cell.dart';
import '../card_cell_skeleton/timestamp_card_cell.dart';
@ -72,5 +73,10 @@ CardCellStyleMap mobileBoardCardCellStyleMap(BuildContext context) {
decoration: TextDecoration.underline,
),
),
FieldType.Relation: RelationCardCellStyle(
padding: padding,
textStyle: textStyle,
wrap: true,
),
};
}

View File

@ -0,0 +1,58 @@
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/relation_cell_editor.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../editable_cell_skeleton/relation.dart';
class DesktopGridRelationCellSkin extends IEditableRelationCellSkin {
@override
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
RelationCellBloc bloc,
RelationCellState state,
PopoverController popoverController,
) {
return AppFlowyPopover(
controller: popoverController,
direction: PopoverDirection.bottomWithLeftAligned,
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 400),
margin: EdgeInsets.zero,
onClose: () => cellContainerNotifier.isFocus = false,
popupBuilder: (context) {
return BlocProvider.value(
value: bloc,
child: RelationCellEditor(
selectedRowIds: state.rows.map((row) => row.rowId).toList(),
databaseId: state.relatedDatabaseId,
onSelectRow: (rowId) {
bloc.add(RelationCellEvent.selectRow(rowId));
},
),
);
},
child: Container(
alignment: AlignmentDirectional.centerStart,
padding: GridSize.cellContentInsets,
child: Wrap(
runSpacing: 4.0,
spacing: 4.0,
children: state.rows
.map(
(row) => FlowyText.medium(
row.name,
decoration: TextDecoration.underline,
overflow: TextOverflow.ellipsis,
),
)
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,61 @@
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/relation_cell_editor.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../editable_cell_skeleton/relation.dart';
class DesktopRowDetailRelationCellSkin extends IEditableRelationCellSkin {
@override
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
RelationCellBloc bloc,
RelationCellState state,
PopoverController popoverController,
) {
return AppFlowyPopover(
controller: popoverController,
direction: PopoverDirection.bottomWithLeftAligned,
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 400),
margin: EdgeInsets.zero,
onClose: () => cellContainerNotifier.isFocus = false,
popupBuilder: (context) {
return BlocProvider.value(
value: bloc,
child: BlocBuilder<RelationCellBloc, RelationCellState>(
builder: (context, state) => RelationCellEditor(
selectedRowIds: state.rows.map((row) => row.rowId).toList(),
databaseId: state.relatedDatabaseId,
onSelectRow: (rowId) {
context
.read<RelationCellBloc>()
.add(RelationCellEvent.selectRow(rowId));
},
),
),
);
},
child: Container(
alignment: AlignmentDirectional.centerStart,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Wrap(
runSpacing: 4.0,
spacing: 4.0,
children: state.rows
.map(
(row) => FlowyText.medium(
row.name,
decoration: TextDecoration.underline,
overflow: TextOverflow.ellipsis,
),
)
.toList(),
),
),
);
}
}

View File

@ -13,6 +13,7 @@ import 'editable_cell_skeleton/checkbox.dart';
import 'editable_cell_skeleton/checklist.dart';
import 'editable_cell_skeleton/date.dart';
import 'editable_cell_skeleton/number.dart';
import 'editable_cell_skeleton/relation.dart';
import 'editable_cell_skeleton/select_option.dart';
import 'editable_cell_skeleton/text.dart';
import 'editable_cell_skeleton/timestamp.dart';
@ -106,6 +107,12 @@ class EditableCellBuilder {
skin: IEditableURLCellSkin.fromStyle(style),
key: key,
),
FieldType.Relation => EditableRelationCell(
databaseController: databaseController,
cellContext: cellContext,
skin: IEditableRelationCellSkin.fromStyle(style),
key: key,
),
_ => throw UnimplementedError(),
};
}
@ -186,6 +193,12 @@ class EditableCellBuilder {
skin: skinMap.urlSkin!,
key: key,
),
FieldType.Relation => EditableRelationCell(
databaseController: databaseController,
cellContext: cellContext,
skin: skinMap.relationSkin!,
key: key,
),
_ => throw UnimplementedError(),
};
}
@ -340,6 +353,7 @@ class EditableCellSkinMap {
this.numberSkin,
this.textSkin,
this.urlSkin,
this.relationSkin,
});
final IEditableCheckboxCellSkin? checkboxSkin;
@ -350,6 +364,7 @@ class EditableCellSkinMap {
final IEditableNumberCellSkin? numberSkin;
final IEditableTextCellSkin? textSkin;
final IEditableURLCellSkin? urlSkin;
final IEditableRelationCellSkin? relationSkin;
bool has(FieldType fieldType) {
return switch (fieldType) {

View File

@ -0,0 +1,94 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../desktop_grid/desktop_grid_relation_cell.dart';
import '../desktop_row_detail/desktop_row_detail_relation_cell.dart';
import '../mobile_grid/mobile_grid_relation_cell.dart';
import '../mobile_row_detail/mobile_row_detail_relation_cell.dart';
abstract class IEditableRelationCellSkin {
factory IEditableRelationCellSkin.fromStyle(EditableCellStyle style) {
return switch (style) {
EditableCellStyle.desktopGrid => DesktopGridRelationCellSkin(),
EditableCellStyle.desktopRowDetail => DesktopRowDetailRelationCellSkin(),
EditableCellStyle.mobileGrid => MobileGridRelationCellSkin(),
EditableCellStyle.mobileRowDetail => MobileRowDetailRelationCellSkin(),
};
}
const IEditableRelationCellSkin();
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
RelationCellBloc bloc,
RelationCellState state,
PopoverController popoverController,
);
}
class EditableRelationCell extends EditableCellWidget {
EditableRelationCell({
super.key,
required this.databaseController,
required this.cellContext,
required this.skin,
});
final DatabaseController databaseController;
final CellContext cellContext;
final IEditableRelationCellSkin skin;
@override
GridCellState<EditableRelationCell> createState() => _RelationCellState();
}
class _RelationCellState extends GridCellState<EditableRelationCell> {
final PopoverController _popover = PopoverController();
late final cellBloc = RelationCellBloc(
cellController: makeCellController(
widget.databaseController,
widget.cellContext,
).as(),
);
@override
void dispose() {
cellBloc.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: cellBloc,
child: BlocBuilder<RelationCellBloc, RelationCellState>(
builder: (context, state) {
return widget.skin.build(
context,
widget.cellContainerNotifier,
cellBloc,
state,
_popover,
);
},
),
);
}
@override
void onRequestFocus() {
_popover.show();
widget.cellContainerNotifier.isFocus = true;
}
@override
String? onCopy() => "";
}

View File

@ -0,0 +1,54 @@
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import '../editable_cell_skeleton/relation.dart';
class MobileGridRelationCellSkin extends IEditableRelationCellSkin {
@override
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
RelationCellBloc bloc,
RelationCellState state,
PopoverController popoverController,
) {
return FlowyButton(
radius: BorderRadius.zero,
hoverColor: Colors.transparent,
margin: EdgeInsets.zero,
text: Align(
alignment: AlignmentDirectional.centerStart,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: state.rows
.map(
(row) => FlowyText(
row.name,
fontSize: 15,
decoration: TextDecoration.underline,
),
)
.toList(),
),
),
),
onTap: () {
showMobileBottomSheet(
context,
padding: EdgeInsets.zero,
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
builder: (context) {
return const FlowyText("Coming soon");
},
);
},
);
}
}

View File

@ -0,0 +1,56 @@
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class MobileRowDetailRelationCellSkin extends IEditableRelationCellSkin {
@override
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
RelationCellBloc bloc,
RelationCellState state,
PopoverController popoverController,
) {
return InkWell(
borderRadius: const BorderRadius.all(Radius.circular(14)),
onTap: () => showMobileBottomSheet(
context,
padding: EdgeInsets.zero,
builder: (context) {
return const FlowyText("Coming soon");
},
),
child: Container(
constraints: const BoxConstraints(
minHeight: 48,
minWidth: double.infinity,
),
decoration: BoxDecoration(
border: Border.fromBorderSide(
BorderSide(color: Theme.of(context).colorScheme.outline),
),
borderRadius: const BorderRadius.all(Radius.circular(14)),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13),
child: Wrap(
runSpacing: 4.0,
spacing: 4.0,
children: state.rows
.map(
(row) => FlowyText(
row.name,
fontSize: 16,
decoration: TextDecoration.underline,
overflow: TextOverflow.ellipsis,
),
)
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,148 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../application/cell/bloc/relation_cell_bloc.dart';
import '../../application/cell/bloc/relation_row_search_bloc.dart';
class RelationCellEditor extends StatelessWidget {
const RelationCellEditor({
super.key,
required this.databaseId,
required this.selectedRowIds,
required this.onSelectRow,
});
final String databaseId;
final List<String> selectedRowIds;
final void Function(String rowId) onSelectRow;
@override
Widget build(BuildContext context) {
if (databaseId.isEmpty) {
// no i18n here because UX needs thorough checking.
return const Center(
child: FlowyText(
'''
No database has been selected,
please select one first in the field editor.
''',
maxLines: null,
textAlign: TextAlign.center,
),
);
}
return BlocProvider<RelationRowSearchBloc>(
create: (context) => RelationRowSearchBloc(
databaseId: databaseId,
),
child: BlocBuilder<RelationCellBloc, RelationCellState>(
builder: (context, cellState) {
return BlocBuilder<RelationRowSearchBloc, RelationRowSearchState>(
builder: (context, state) {
final children = state.filteredRows
.map(
(row) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0),
child: FlowyButton(
text: FlowyText.medium(
row.name.trim().isEmpty
? LocaleKeys.grid_title_placeholder.tr()
: row.name,
color: row.name.trim().isEmpty
? Theme.of(context).hintColor
: null,
overflow: TextOverflow.ellipsis,
),
rightIcon: cellState.rows
.map((e) => e.rowId)
.contains(row.rowId)
? FlowySvg(
FlowySvgs.check_s,
color: Theme.of(context).primaryColor,
)
: null,
onTap: () => onSelectRow(row.rowId),
),
),
)
.toList();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(6.0),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0) +
GridSize.typeOptionContentInsets,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText.regular(
LocaleKeys.grid_relation_inRelatedDatabase.tr(),
fontSize: 11,
color: Theme.of(context).hintColor,
),
const HSpace(2.0),
FlowyButton(
useIntrinsicWidth: true,
margin: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
text: FlowyText.regular(
cellState.relatedDatabaseId,
fontSize: 11,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
VSpace(GridSize.typeOptionSeparatorHeight),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0),
child: FlowyTextField(
onChanged: (text) => context
.read<RelationRowSearchBloc>()
.add(RelationRowSearchEvent.updateFilter(text)),
),
),
const VSpace(6.0),
const TypeOptionSeparator(spacing: 0.0),
if (state.filteredRows.isEmpty)
Padding(
padding: const EdgeInsets.all(6.0) +
GridSize.typeOptionContentInsets,
child: FlowyText.regular(
LocaleKeys.grid_relation_emptySearchResult.tr(),
color: Theme.of(context).hintColor,
),
)
else
Flexible(
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 6.0),
separatorBuilder: (context, index) =>
VSpace(GridSize.typeOptionSeparatorHeight),
itemCount: children.length,
itemBuilder: (context, index) => children[index],
),
),
],
);
},
);
},
),
);
}
}

View File

@ -75,6 +75,7 @@ class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
@override
void dispose() {
widget.textController.removeListener(_onChanged);
focusNode.dispose();
super.dispose();
}

View File

@ -20,6 +20,7 @@ extension FieldTypeExtension on FieldType {
FieldType.LastEditedTime =>
LocaleKeys.grid_field_updatedAtFieldName.tr(),
FieldType.CreatedTime => LocaleKeys.grid_field_createdAtFieldName.tr(),
FieldType.Relation => LocaleKeys.grid_field_relationFieldName.tr(),
_ => throw UnimplementedError(),
};
@ -34,6 +35,7 @@ extension FieldTypeExtension on FieldType {
FieldType.Checklist => FlowySvgs.checklist_s,
FieldType.LastEditedTime => FlowySvgs.last_modified_s,
FieldType.CreatedTime => FlowySvgs.created_at_s,
FieldType.Relation => FlowySvgs.relation_s,
_ => throw UnimplementedError(),
};
@ -48,6 +50,7 @@ extension FieldTypeExtension on FieldType {
FieldType.Checklist => const Color(0xFF98F4CD),
FieldType.LastEditedTime => const Color(0xFFFDEDA7),
FieldType.CreatedTime => const Color(0xFFFDEDA7),
FieldType.Relation => const Color(0xFFFDEDA7),
_ => throw UnimplementedError(),
};
}

View File

@ -73,6 +73,7 @@
"slate-history": "^0.100.0",
"slate-react": "^0.101.3",
"ts-results": "^3.3.0",
"unsplash-js": "^7.0.19",
"utf8": "^3.0.0",
"valtio": "^1.12.1",
"yjs": "^13.5.51"

View File

@ -166,6 +166,9 @@ dependencies:
ts-results:
specifier: ^3.3.0
version: 3.3.0
unsplash-js:
specifier: ^7.0.19
version: 7.0.19
utf8:
specifier: ^3.0.0
version: 3.0.0
@ -6853,6 +6856,11 @@ packages:
engines: {node: '>= 10.0.0'}
dev: true
/unsplash-js@7.0.19:
resolution: {integrity: sha512-j6qT2floy5Q2g2d939FJpwey1yw/GpQecFiSouyJtsHQPj3oqmqq3K4rI+GF8vU1zwGCT7ZwIGQd2dtCQLjYJw==}
engines: {node: '>=10'}
dev: false
/update-browserslist-db@1.0.11(browserslist@4.21.5):
resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==}
hasBin: true

View File

@ -109,6 +109,22 @@ export interface MathEquationNode extends Element {
} & BlockData;
}
export enum ImageType {
Internal = 1,
External = 2,
}
export interface ImageNode extends Element {
type: EditorNodeType.ImageBlock;
blockId: string;
data: {
url?: string;
width?: number;
image_type?: ImageType;
height?: number;
} & BlockData;
}
export interface FormulaNode extends Element {
type: EditorInlineNodeType.Formula;
data: string;

View File

@ -0,0 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="11.8274" cy="5.82739" r="1.5" stroke="#333333"/>
<path d="M10.5008 5.38471L6.24097 4.78992" stroke="#333333"/>
<path d="M4.86475 6.24121L6.02777 10.1009" stroke="#333333"/>
<circle cx="7" cy="11" r="1.5" stroke="#333333"/>
<circle cx="5" cy="5" r="1.5" stroke="#333333"/>
<path d="M10.9011 7.14258L8.1484 10.0447" stroke="#333333"/>
</svg>

After

Width:  |  Height:  |  Size: 448 B

View File

@ -0,0 +1,3 @@
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.5 1H12.5C13.3284 1 14 1.67157 14 2.5V12.5C14 13.3284 13.3284 14 12.5 14H2.5C1.67157 14 1 13.3284 1 12.5V2.5C1 1.67157 1.67157 1 2.5 1ZM2.5 2C2.22386 2 2 2.22386 2 2.5V8.3636L3.6818 6.6818C3.76809 6.59551 3.88572 6.54797 4.00774 6.55007C4.12975 6.55216 4.24568 6.60372 4.32895 6.69293L7.87355 10.4901L10.6818 7.6818C10.8575 7.50607 11.1425 7.50607 11.3182 7.6818L13 9.3636V2.5C13 2.22386 12.7761 2 12.5 2H2.5ZM2 12.5V9.6364L3.98887 7.64753L7.5311 11.4421L8.94113 13H2.5C2.22386 13 2 12.7761 2 12.5ZM12.5 13H10.155L8.48336 11.153L11 8.6364L13 10.6364V12.5C13 12.7761 12.7761 13 12.5 13ZM6.64922 5.5C6.64922 5.03013 7.03013 4.64922 7.5 4.64922C7.96987 4.64922 8.35078 5.03013 8.35078 5.5C8.35078 5.96987 7.96987 6.35078 7.5 6.35078C7.03013 6.35078 6.64922 5.96987 6.64922 5.5ZM7.5 3.74922C6.53307 3.74922 5.74922 4.53307 5.74922 5.5C5.74922 6.46693 6.53307 7.25078 7.5 7.25078C8.46693 7.25078 9.25078 6.46693 9.25078 5.5C9.25078 4.53307 8.46693 3.74922 7.5 3.74922Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,7 @@
import React from 'react';
export function Colors() {
return <div></div>;
}
export default Colors;

View File

@ -0,0 +1,61 @@
import React, { useCallback, useState } from 'react';
import TextField from '@mui/material/TextField';
import { useTranslation } from 'react-i18next';
import { pattern } from '$app/utils/open_url';
import Button from '@mui/material/Button';
export function EmbedLink({ onDone, onEscape }: { onDone?: (value: string) => void; onEscape?: () => void }) {
const { t } = useTranslation();
const [value, setValue] = useState('');
const [error, setError] = useState(false);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setValue(value);
setError(!pattern.test(value));
},
[setValue, setError]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !error && value) {
e.preventDefault();
e.stopPropagation();
onDone?.(value);
}
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onEscape?.();
}
},
[error, onDone, onEscape, value]
);
return (
<div tabIndex={0} onKeyDown={handleKeyDown} className={'flex flex-col items-center gap-4 px-4 pb-4'}>
<TextField
error={error}
autoFocus
onKeyDown={handleKeyDown}
size={'small'}
spellCheck={false}
onChange={handleChange}
helperText={error ? t('editor.incorrectLink') : ''}
value={value}
placeholder={t('document.imageBlock.embedLink.placeholder')}
fullWidth
/>
<Button variant={'contained'} className={'w-3/5'} onClick={() => onDone?.(value)} disabled={error || !value}>
{t('document.imageBlock.embedLink.label')}
</Button>
</div>
);
}
export default EmbedLink;

View File

@ -0,0 +1,154 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { createApi } from 'unsplash-js';
import TextField from '@mui/material/TextField';
import { useTranslation } from 'react-i18next';
import Typography from '@mui/material/Typography';
import debounce from 'lodash-es/debounce';
import { CircularProgress } from '@mui/material';
import { open } from '@tauri-apps/api/shell';
const unsplash = createApi({
accessKey: '1WxD1JpMOUX86lZKKob4Ca0LMZPyO2rUmAgjpWm9Ids',
});
const SEARCH_DEBOUNCE_TIME = 500;
export function Unsplash({ onDone, onEscape }: { onDone?: (value: string) => void; onEscape?: () => void }) {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>('');
const [photos, setPhotos] = useState<
{
thumb: string;
regular: string;
alt: string | null;
id: string;
user: {
name: string;
link: string;
};
}[]
>([]);
const [searchValue, setSearchValue] = useState('');
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setSearchValue(value);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onEscape?.();
}
},
[onEscape]
);
const debounceSearchPhotos = useMemo(() => {
return debounce(async (searchValue: string) => {
const request = searchValue
? unsplash.search.getPhotos({ query: searchValue ?? undefined, perPage: 32 })
: unsplash.photos.list({ perPage: 32 });
setError('');
setLoading(true);
await request.then((result) => {
if (result.errors) {
setError(result.errors[0]);
} else {
setPhotos(
result.response.results.map((photo) => ({
id: photo.id,
thumb: photo.urls.thumb,
regular: photo.urls.regular,
alt: photo.alt_description,
user: {
name: photo.user.name,
link: photo.user.links.html,
},
}))
);
}
setLoading(false);
});
}, SEARCH_DEBOUNCE_TIME);
}, []);
useEffect(() => {
void debounceSearchPhotos(searchValue);
return () => {
debounceSearchPhotos.cancel();
};
}, [debounceSearchPhotos, searchValue]);
return (
<div tabIndex={0} onKeyDown={handleKeyDown} className={'flex min-h-[200px] flex-col gap-4 px-4 pb-4'}>
<TextField
autoFocus
onKeyDown={handleKeyDown}
size={'small'}
spellCheck={false}
onChange={handleChange}
value={searchValue}
placeholder={t('document.imageBlock.searchForAnImage')}
fullWidth
/>
{loading ? (
<div className={'flex h-[120px] w-full items-center justify-center gap-2 text-xs'}>
<CircularProgress size={24} />
<div className={'text-xs text-text-caption'}>{t('editor.loading')}</div>
</div>
) : error ? (
<Typography className={'flex h-[120px] w-full items-center justify-center gap-2 text-xs text-function-error'}>
{error}
</Typography>
) : (
<div className={'flex flex-col gap-4'}>
{photos.length > 0 ? (
<>
<div className={'flex w-full flex-1 flex-wrap gap-2'}>
{photos.map((photo) => (
<div key={photo.id} className={'flex cursor-pointer flex-col gap-2'}>
<img
onClick={() => {
onDone?.(photo.regular);
}}
src={photo.thumb}
alt={photo.alt ?? ''}
className={'h-20 w-32 rounded object-cover hover:opacity-80'}
/>
<div className={'w-32 truncate text-xs text-text-caption'}>
by{' '}
<span
onClick={() => {
void open(photo.user.link);
}}
className={'underline hover:text-function-info'}
>
{photo.user.name}
</span>
</div>
</div>
))}
</div>
<Typography className={'w-full text-center text-xs text-text-caption'}>
{t('findAndReplace.searchMore')}
</Typography>
</>
) : (
<Typography className={'flex h-[120px] w-full items-center justify-center gap-2 text-xs text-text-caption'}>
{t('findAndReplace.noResult')}
</Typography>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,7 @@
import React from 'react';
export function UploadImage() {
return <div></div>;
}
export default UploadImage;

View File

@ -0,0 +1,4 @@
export * from './Unsplash';
export * from './UploadImage';
export * from './EmbedLink';
export * from './Colors';

View File

@ -30,7 +30,9 @@ function getOffsetLeft(
height: number;
width: number;
},
horizontal: number | 'center' | 'left' | 'right'
paperWidth: number,
horizontal: number | 'center' | 'left' | 'right',
transformHorizontal: number | 'center' | 'left' | 'right'
) {
let offset = 0;
@ -42,6 +44,12 @@ function getOffsetLeft(
offset = rect.width;
}
if (transformHorizontal === 'center') {
offset -= paperWidth / 2;
} else if (transformHorizontal === 'right') {
offset -= paperWidth;
}
return offset;
}
@ -50,7 +58,9 @@ function getOffsetTop(
height: number;
width: number;
},
vertical: number | 'center' | 'bottom' | 'top'
papertHeight: number,
vertical: number | 'center' | 'bottom' | 'top',
transformVertical: number | 'center' | 'bottom' | 'top'
) {
let offset = 0;
@ -62,6 +72,12 @@ function getOffsetTop(
offset = rect.height;
}
if (transformVertical === 'center') {
offset -= papertHeight / 2;
} else if (transformVertical === 'bottom') {
offset -= papertHeight;
}
return offset;
}
@ -122,8 +138,12 @@ const usePopoverAutoPosition = ({
};
// calculate new paper width
const newLeft = anchorRect.left + getOffsetLeft(anchorRect, initialAnchorOrigin.horizontal);
const newTop = anchorRect.top + getOffsetTop(anchorRect, initialAnchorOrigin.vertical);
const newLeft =
anchorRect.left +
getOffsetLeft(anchorRect, newPaperWidth, initialAnchorOrigin.horizontal, initialTransformOrigin.horizontal);
const newTop =
anchorRect.top +
getOffsetTop(anchorRect, newPaperHeight, initialAnchorOrigin.vertical, initialTransformOrigin.vertical);
let isExceedViewportRight = false;
let isExceedViewportBottom = false;

View File

@ -17,6 +17,7 @@ export const PropertyTypeText = ({ type }: { type: FieldType }) => {
[FieldType.Checklist]: t('grid.field.checklistFieldName'),
[FieldType.LastEditedTime]: t('grid.field.updatedAtFieldName'),
[FieldType.CreatedTime]: t('grid.field.createdAtFieldName'),
[FieldType.Relation]: t('grid.field.relationFieldName'),
};
return map[type] || 'unknown';

View File

@ -9,6 +9,7 @@ import { ReactComponent as ChecklistSvg } from '$app/assets/database/field-type-
import { ReactComponent as CheckboxSvg } from '$app/assets/database/field-type-checkbox.svg';
import { ReactComponent as URLSvg } from '$app/assets/database/field-type-url.svg';
import { ReactComponent as LastEditedTimeSvg } from '$app/assets/database/field-type-last-edited-time.svg';
import { ReactComponent as RelationSvg } from '$app/assets/database/field-type-relation.svg';
export const FieldTypeSvgMap: Record<FieldType, FC<React.SVGProps<SVGSVGElement>>> = {
[FieldType.RichText]: TextSvg,
@ -21,6 +22,7 @@ export const FieldTypeSvgMap: Record<FieldType, FC<React.SVGProps<SVGSVGElement>
[FieldType.Checklist]: ChecklistSvg,
[FieldType.LastEditedTime]: LastEditedTimeSvg,
[FieldType.CreatedTime]: LastEditedTimeSvg,
[FieldType.Relation]: RelationSvg,
};
export const ProppertyTypeSvg: FC<{ type: FieldType; className?: string }> = memo(({ type, ...props }) => {

View File

@ -30,6 +30,7 @@ import {
ToggleListNode,
inlineNodeTypes,
FormulaNode,
ImageNode,
} from '$app/application/document/document.types';
import cloneDeep from 'lodash-es/cloneDeep';
import { generateId } from '$app/components/editor/provider/utils/convert';
@ -39,6 +40,7 @@ export const EmbedTypes: string[] = [
EditorNodeType.DividerBlock,
EditorNodeType.EquationBlock,
EditorNodeType.GridBlock,
EditorNodeType.ImageBlock,
];
export const CustomEditor = {
@ -120,7 +122,7 @@ export const CustomEditor = {
at: path,
});
Transforms.insertNodes(editor, cloneNode, { at: path });
return;
return cloneNode;
}
const isListType = LIST_TYPES.includes(cloneNode.type as EditorNodeType);
@ -148,6 +150,8 @@ export const CustomEditor = {
if (selection) {
editor.select(selection);
}
return cloneNode;
},
tabForward,
tabBackward,
@ -346,6 +350,19 @@ export const CustomEditor = {
Transforms.setNodes(editor, newProperties, { at: path });
},
setImageBlockData(editor: ReactEditor, node: Element, newData: ImageNode['data']) {
const path = ReactEditor.findPath(editor, node);
const data = node.data || {};
const newProperties = {
data: {
...data,
...newData,
},
} as Partial<Element>;
Transforms.setNodes(editor, newProperties, { at: path });
},
cloneBlock(editor: ReactEditor, block: Element): Element {
const cloneNode: Element = {
...cloneDeep(block),

View File

@ -0,0 +1,163 @@
import React, { useMemo, useState } from 'react';
import { ImageNode } from '$app/application/document/document.types';
import { ReactComponent as CopyIcon } from '$app/assets/copy.svg';
import { ReactComponent as AlignLeftIcon } from '$app/assets/align-left.svg';
import { ReactComponent as AlignCenterIcon } from '$app/assets/align-center.svg';
import { ReactComponent as AlignRightIcon } from '$app/assets/align-right.svg';
import { ReactComponent as DeleteIcon } from '$app/assets/delete.svg';
import { useTranslation } from 'react-i18next';
import { IconButton } from '@mui/material';
import { notify } from '$app/components/_shared/notify';
import { CustomEditor } from '$app/components/editor/command';
import { useSlateStatic } from 'slate-react';
import Popover from '@mui/material/Popover';
import Tooltip from '@mui/material/Tooltip';
enum ImageAction {
Copy = 'copy',
AlignLeft = 'left',
AlignCenter = 'center',
AlignRight = 'right',
Delete = 'delete',
}
function ImageActions({ node }: { node: ImageNode }) {
const { t } = useTranslation();
const align = node.data.align;
const editor = useSlateStatic();
const [alignAnchorEl, setAlignAnchorEl] = useState<null | HTMLElement>(null);
const alignOptions = useMemo(() => {
return [
{
key: ImageAction.AlignLeft,
Icon: AlignLeftIcon,
onClick: () => {
CustomEditor.setImageBlockData(editor, node, { align: 'left' });
setAlignAnchorEl(null);
},
},
{
key: ImageAction.AlignCenter,
Icon: AlignCenterIcon,
onClick: () => {
CustomEditor.setImageBlockData(editor, node, { align: 'center' });
setAlignAnchorEl(null);
},
},
{
key: ImageAction.AlignRight,
Icon: AlignRightIcon,
onClick: () => {
CustomEditor.setImageBlockData(editor, node, { align: 'right' });
setAlignAnchorEl(null);
},
},
];
}, [editor, node]);
const options = useMemo(() => {
return [
{
key: ImageAction.Copy,
Icon: CopyIcon,
tooltip: t('button.copyLink'),
onClick: () => {
if (!node.data.url) return;
void navigator.clipboard.writeText(node.data.url);
notify.success(t('message.copy.success'));
},
},
(!align || align === 'left') && {
key: ImageAction.AlignLeft,
Icon: AlignLeftIcon,
tooltip: t('button.align'),
onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
setAlignAnchorEl(e.currentTarget);
},
},
align === 'center' && {
key: ImageAction.AlignCenter,
Icon: AlignCenterIcon,
tooltip: t('button.align'),
onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
setAlignAnchorEl(e.currentTarget);
},
},
align === 'right' && {
key: ImageAction.AlignRight,
Icon: AlignRightIcon,
tooltip: t('button.align'),
onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
setAlignAnchorEl(e.currentTarget);
},
},
{
key: ImageAction.Delete,
Icon: DeleteIcon,
tooltip: t('button.delete'),
onClick: () => {
CustomEditor.deleteNode(editor, node);
},
},
].filter(Boolean) as {
key: ImageAction;
Icon: React.FC<React.SVGProps<SVGSVGElement>>;
tooltip: string;
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
}[];
}, [align, node, t, editor]);
return (
<div className={'absolute right-1 top-1 flex items-center justify-between rounded bg-bg-body shadow-lg'}>
{options.map((option) => {
const { key, Icon, tooltip, onClick } = option;
return (
<Tooltip disableInteractive={true} placement={'top'} title={tooltip} key={key}>
<IconButton
size={'small'}
className={'bg-transparent p-2 text-icon-primary hover:text-fill-default'}
onClick={onClick}
>
<Icon />
</IconButton>
</Tooltip>
);
})}
{!!alignAnchorEl && (
<Popover
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
open={!!alignAnchorEl}
anchorEl={alignAnchorEl}
onClose={() => setAlignAnchorEl(null)}
>
{alignOptions.map((option) => {
const { key, Icon, onClick } = option;
return (
<IconButton
key={key}
size={'small'}
style={{
color: align === key ? 'var(--fill-default)' : undefined,
}}
className={'bg-transparent p-2 text-icon-primary hover:text-fill-default'}
onClick={onClick}
>
<Icon />
</IconButton>
);
})}
</Popover>
)}
</div>
);
}
export default ImageActions;

View File

@ -0,0 +1,49 @@
import React, { forwardRef, memo, useCallback, useRef } from 'react';
import { EditorElementProps, ImageNode } from '$app/application/document/document.types';
import { ReactEditor, useSelected, useSlateStatic } from 'slate-react';
import ImageRender from '$app/components/editor/components/blocks/image/ImageRender';
import ImageEmpty from '$app/components/editor/components/blocks/image/ImageEmpty';
export const ImageBlock = memo(
forwardRef<HTMLDivElement, EditorElementProps<ImageNode>>(({ node, children, className, ...attributes }, ref) => {
const selected = useSelected();
const { url, align } = node.data;
const containerRef = useRef<HTMLDivElement>(null);
const editor = useSlateStatic();
const onFocusNode = useCallback(() => {
ReactEditor.focus(editor);
const path = ReactEditor.findPath(editor, node);
editor.select(path);
}, [editor, node]);
return (
<div
{...attributes}
ref={containerRef}
onClick={() => {
if (!selected) onFocusNode();
}}
className={`${className} image-block relative w-full cursor-pointer py-1`}
>
<div ref={ref} className={'absolute left-0 top-0 h-full w-full caret-transparent'}>
{children}
</div>
<div
contentEditable={false}
className={`flex w-full select-none ${url ? '' : 'rounded border'} ${
selected ? 'border-fill-list-hover' : 'border-line-divider'
} ${align === 'center' ? 'justify-center' : align === 'right' ? 'justify-end' : 'justify-start'}`}
>
{url ? (
<ImageRender selected={selected} node={node} />
) : (
<ImageEmpty node={node} onEscape={onFocusNode} containerRef={containerRef} />
)}
</div>
</div>
);
})
);
export default ImageBlock;

View File

@ -0,0 +1,63 @@
import React, { useEffect } from 'react';
import { ReactComponent as ImageIcon } from '$app/assets/image.svg';
import { useTranslation } from 'react-i18next';
import UploadPopover from '$app/components/editor/components/blocks/image/UploadPopover';
import { EditorNodeType, ImageNode } from '$app/application/document/document.types';
import { useEditorBlockDispatch, useEditorBlockState } from '$app/components/editor/stores/block';
function ImageEmpty({
containerRef,
onEscape,
node,
}: {
containerRef: React.RefObject<HTMLDivElement>;
onEscape: () => void;
node: ImageNode;
}) {
const { t } = useTranslation();
const state = useEditorBlockState(EditorNodeType.ImageBlock);
const open = Boolean(state?.popoverOpen && state?.blockId === node.blockId && containerRef.current);
const { openPopover, closePopover } = useEditorBlockDispatch();
useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
const handleClick = () => {
openPopover(EditorNodeType.ImageBlock, node.blockId);
};
container.addEventListener('click', handleClick);
return () => {
container.removeEventListener('click', handleClick);
};
}, [containerRef, node.blockId, openPopover]);
return (
<>
<div
className={
'flex h-[48px] w-full cursor-pointer items-center gap-[10px] bg-content-blue-50 px-4 text-text-caption'
}
>
<ImageIcon />
{t('document.plugins.image.addAnImage')}
</div>
{open && (
<UploadPopover
anchorEl={containerRef.current}
open={open}
node={node}
onClose={() => {
closePopover(EditorNodeType.ImageBlock);
onEscape();
}}
/>
)}
</>
);
}
export default ImageEmpty;

View File

@ -0,0 +1,91 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { ImageNode } from '$app/application/document/document.types';
import { useTranslation } from 'react-i18next';
import { CircularProgress } from '@mui/material';
import { ErrorOutline } from '@mui/icons-material';
import ImageResizer from '$app/components/editor/components/blocks/image/ImageResizer';
import { CustomEditor } from '$app/components/editor/command';
import { useSlateStatic } from 'slate-react';
import ImageActions from '$app/components/editor/components/blocks/image/ImageActions';
function ImageRender({ selected, node }: { selected: boolean; node: ImageNode }) {
const [loading, setLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
const editor = useSlateStatic();
const { url, width: imageWidth } = node.data;
const { t } = useTranslation();
const blockId = node.blockId;
const [showActions, setShowActions] = useState(false);
const [initialWidth, setInitialWidth] = useState<number | null>(null);
const handleWidthChange = useCallback(
(newWidth: number) => {
CustomEditor.setImageBlockData(editor, node, {
width: newWidth,
});
},
[editor, node]
);
useEffect(() => {
if (!loading && !hasError && initialWidth === null && imgRef.current) {
setInitialWidth(imgRef.current.offsetWidth);
}
}, [hasError, initialWidth, loading]);
return (
<>
<div
onMouseEnter={() => {
setShowActions(true);
}}
onMouseLeave={() => {
setShowActions(false);
}}
className={'relative'}
>
<img
ref={imgRef}
draggable={false}
loading={'lazy'}
onLoad={() => {
setHasError(false);
setLoading(false);
}}
onError={() => {
setHasError(true);
setLoading(false);
}}
src={url}
alt={`image-${blockId}`}
className={'object-cover'}
style={{ width: loading || hasError ? '0' : imageWidth ?? '100%', opacity: selected ? 0.8 : 1 }}
/>
{initialWidth && <ImageResizer width={imageWidth ?? initialWidth} onWidthChange={handleWidthChange} />}
{showActions && <ImageActions node={node} />}
</div>
{loading && (
<div className={'flex h-[48px] w-full items-center justify-center gap-2 rounded bg-gray-100'}>
<CircularProgress size={24} />
<div className={'text-text-caption'}>{t('editor.loading')}</div>
</div>
)}
{hasError && (
<div
className={
'flex h-[48px] w-full items-center justify-center gap-2 rounded border border-function-error bg-red-50'
}
>
<ErrorOutline className={'text-function-error'} />
<div className={'text-function-error'}>{t('editor.imageLoadFailed')}</div>
</div>
)}
</>
);
}
export default ImageRender;

View File

@ -0,0 +1,54 @@
import React, { useCallback, useRef } from 'react';
const MIN_WIDTH = 80;
function ImageResizer({ width, onWidthChange }: { width: number; onWidthChange: (newWidth: number) => void }) {
const originalWidth = useRef(width);
const startX = useRef(0);
const onResize = useCallback(
(e: MouseEvent) => {
e.preventDefault();
const diff = e.clientX - startX.current;
const newWidth = originalWidth.current + diff;
if (newWidth < MIN_WIDTH) {
return;
}
onWidthChange(newWidth);
},
[onWidthChange]
);
const onResizeEnd = useCallback(() => {
document.removeEventListener('mousemove', onResize);
document.removeEventListener('mouseup', onResizeEnd);
}, [onResize]);
const onResizeStart = useCallback(
(e: React.MouseEvent) => {
startX.current = e.clientX;
document.addEventListener('mousemove', onResize);
document.addEventListener('mouseup', onResizeEnd);
},
[onResize, onResizeEnd]
);
return (
<div
onMouseDown={onResizeStart}
onMouseUp={() => {
originalWidth.current = width;
}}
style={{
right: '2px',
}}
className={'image-resizer'}
>
<div className={'resize-handle'} />
</div>
);
}
export default ImageResizer;

View File

@ -0,0 +1,189 @@
import React, { useCallback, useMemo, SyntheticEvent, useState } from 'react';
import Popover, { PopoverOrigin } from '@mui/material/Popover/Popover';
import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks';
import { PopoverCommonProps } from '$app/components/editor/components/tools/popover';
import { TabPanel, ViewTab, ViewTabs } from '$app/components/database/components/tab_bar/ViewTabs';
import { useTranslation } from 'react-i18next';
import { EmbedLink, Unsplash } from '$app/components/_shared/image_upload';
import SwipeableViews from 'react-swipeable-views';
import { CustomEditor } from '$app/components/editor/command';
import { useSlateStatic } from 'slate-react';
import { ImageNode, ImageType } from '$app/application/document/document.types';
enum TAB_KEY {
UPLOAD = 'upload',
EMBED_LINK = 'embed_link',
UNSPLASH = 'unsplash',
}
const initialOrigin: {
transformOrigin: PopoverOrigin;
anchorOrigin: PopoverOrigin;
} = {
transformOrigin: {
vertical: 'top',
horizontal: 'center',
},
anchorOrigin: {
vertical: 'bottom',
horizontal: 'center',
},
};
function UploadPopover({
open,
anchorEl,
onClose,
node,
}: {
open: boolean;
anchorEl: HTMLDivElement | null;
onClose: () => void;
node: ImageNode;
}) {
const editor = useSlateStatic();
const { t } = useTranslation();
const { transformOrigin, anchorOrigin, isEntered, paperHeight, paperWidth } = usePopoverAutoPosition({
initialPaperWidth: 433,
initialPaperHeight: 300,
anchorEl,
initialAnchorOrigin: initialOrigin.anchorOrigin,
initialTransformOrigin: initialOrigin.transformOrigin,
open,
});
const tabOptions = useMemo(() => {
return [
// {
// label: t('button.upload'),
// key: TAB_KEY.UPLOAD,
// Component: UploadImage,
// },
{
label: t('document.imageBlock.embedLink.label'),
key: TAB_KEY.EMBED_LINK,
Component: EmbedLink,
onDone: (link: string) => {
CustomEditor.setImageBlockData(editor, node, {
url: link,
image_type: ImageType.External,
});
onClose();
},
},
{
key: TAB_KEY.UNSPLASH,
label: t('document.imageBlock.unsplash.label'),
Component: Unsplash,
onDone: (link: string) => {
CustomEditor.setImageBlockData(editor, node, {
url: link,
image_type: ImageType.External,
});
onClose();
},
},
];
}, [editor, node, onClose, t]);
const [tabValue, setTabValue] = useState<TAB_KEY>(tabOptions[0].key);
const handleTabChange = useCallback((_: SyntheticEvent, newValue: string) => {
setTabValue(newValue as TAB_KEY);
}, []);
const selectedIndex = tabOptions.findIndex((tab) => tab.key === tabValue);
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onClose();
}
if (e.key === 'Tab') {
e.preventDefault();
e.stopPropagation();
setTabValue((prev) => {
const currentIndex = tabOptions.findIndex((tab) => tab.key === prev);
const nextIndex = (currentIndex + 1) % tabOptions.length;
return tabOptions[nextIndex]?.key ?? tabOptions[0].key;
});
}
},
[onClose, tabOptions]
);
return (
<Popover
{...PopoverCommonProps}
disableAutoFocus={false}
open={open && isEntered}
anchorEl={anchorEl}
transformOrigin={transformOrigin}
anchorOrigin={anchorOrigin}
onClose={onClose}
onMouseDown={(e) => {
e.stopPropagation();
}}
onKeyDown={onKeyDown}
PaperProps={{
style: {
padding: 0,
},
}}
>
<div
style={{
maxWidth: paperWidth,
maxHeight: paperHeight,
overflow: 'hidden',
}}
className={'flex flex-col gap-4'}
>
<ViewTabs
value={tabValue}
onChange={handleTabChange}
scrollButtons={false}
variant='scrollable'
allowScrollButtonsMobile
className={'min-h-[38px] border-b border-line-divider px-2'}
>
{tabOptions.map((tab) => {
const { key, label } = tab;
return <ViewTab key={key} iconPosition='start' color='inherit' label={label} value={key} />;
})}
</ViewTabs>
<div className={'h-full w-full flex-1 overflow-y-auto overflow-x-hidden'}>
<SwipeableViews
slideStyle={{
overflow: 'hidden',
height: '100%',
}}
axis={'x'}
index={selectedIndex}
>
{tabOptions.map((tab, index) => {
const { key, Component, onDone } = tab;
return (
<TabPanel className={'flex h-full w-full flex-col'} key={key} index={index} value={selectedIndex}>
<Component onDone={onDone} onEscape={onClose} />
</TabPanel>
);
})}
</SwipeableViews>
</div>
</div>
</Popover>
);
}
export default UploadPopover;

View File

@ -0,0 +1 @@
export * from './ImageBlock';

View File

@ -1,10 +1,11 @@
import { forwardRef, memo, useEffect, useRef, useState } from 'react';
import { EditorElementProps, MathEquationNode } from '$app/application/document/document.types';
import { forwardRef, memo, useEffect, useRef } from 'react';
import { EditorElementProps, EditorNodeType, MathEquationNode } from '$app/application/document/document.types';
import KatexMath from '$app/components/_shared/katex_math/KatexMath';
import { useTranslation } from 'react-i18next';
import { FunctionsOutlined } from '@mui/icons-material';
import EditPopover from '$app/components/editor/components/blocks/math_equation/EditPopover';
import { ReactEditor, useSelected, useSlateStatic } from 'slate-react';
import { useEditorBlockDispatch, useEditorBlockState } from '$app/components/editor/stores/block';
export const MathEquation = memo(
forwardRef<HTMLDivElement, EditorElementProps<MathEquationNode>>(
@ -12,7 +13,9 @@ export const MathEquation = memo(
const formula = node.data.formula;
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const { openPopover, closePopover } = useEditorBlockDispatch();
const state = useEditorBlockState(EditorNodeType.EquationBlock);
const open = Boolean(state?.popoverOpen && state?.blockId === node.blockId && containerRef.current);
const selected = useSelected();
@ -26,7 +29,7 @@ export const MathEquation = memo(
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
setOpen(true);
openPopover(EditorNodeType.EquationBlock, node.blockId);
}
};
@ -37,7 +40,7 @@ export const MathEquation = memo(
return () => {
slateDom.removeEventListener('keydown', handleKeyDown);
};
}, [editor, selected]);
}, [editor, node.blockId, openPopover, selected]);
return (
<>
@ -45,9 +48,9 @@ export const MathEquation = memo(
{...attributes}
ref={containerRef}
onClick={() => {
setOpen(true);
openPopover(EditorNodeType.EquationBlock, node.blockId);
}}
className={`${className} relative w-full cursor-pointer py-2`}
className={`${className} math-equation-block relative w-full cursor-pointer py-2`}
>
<div
contentEditable={false}
@ -71,7 +74,7 @@ export const MathEquation = memo(
{open && (
<EditPopover
onClose={() => {
setOpen(false);
closePopover(EditorNodeType.EquationBlock);
}}
node={node}
open={open}

View File

@ -21,6 +21,7 @@ import {
EditorInlineBlockStateProvider,
} from '$app/components/editor/stores';
import CommandPanel from '../tools/command_panel/CommandPanel';
import { EditorBlockStateProvider } from '$app/components/editor/stores/block';
function Editor({ sharedType, disableFocus }: { sharedType: Y.XmlText; id: string; disableFocus?: boolean }) {
const { editor, initialValue, handleOnClickEnd, ...props } = useEditor(sharedType);
@ -33,6 +34,7 @@ function Editor({ sharedType, disableFocus }: { sharedType: Y.XmlText; id: strin
decorateState,
slashState,
inlineBlockState,
blockState,
} = useInitialEditorState(editor);
const decorate = useCallback(
@ -60,24 +62,26 @@ function Editor({ sharedType, disableFocus }: { sharedType: Y.XmlText; id: strin
return (
<EditorSelectedBlockProvider value={selectedBlocks}>
<DecorateStateProvider value={decorateState}>
<EditorInlineBlockStateProvider value={inlineBlockState}>
<SlashStateProvider value={slashState}>
<Slate editor={editor} initialValue={initialValue}>
<BlockActionsToolbar />
<SelectionToolbar />
<EditorBlockStateProvider value={blockState}>
<EditorInlineBlockStateProvider value={inlineBlockState}>
<SlashStateProvider value={slashState}>
<Slate editor={editor} initialValue={initialValue}>
<BlockActionsToolbar />
<SelectionToolbar />
<CustomEditable
{...props}
disableFocus={disableFocus}
onKeyDown={onKeyDown}
decorate={decorate}
className={'px-16 caret-text-title outline-none focus:outline-none'}
/>
<CommandPanel />
<div onClick={handleOnClickEnd} className={'relative bottom-0 left-0 h-10 w-full cursor-text'} />
</Slate>
</SlashStateProvider>
</EditorInlineBlockStateProvider>
<CustomEditable
{...props}
disableFocus={disableFocus}
onKeyDown={onKeyDown}
decorate={decorate}
className={'px-16 caret-text-title outline-none focus:outline-none'}
/>
<CommandPanel />
<div onClick={handleOnClickEnd} className={'relative bottom-0 left-0 h-10 w-full cursor-text'} />
</Slate>
</SlashStateProvider>
</EditorInlineBlockStateProvider>
</EditorBlockStateProvider>
</DecorateStateProvider>
</EditorSelectedBlockProvider>
);

View File

@ -21,6 +21,8 @@ import { Callout } from '$app/components/editor/components/blocks/callout';
import { Mention } from '$app/components/editor/components/inline_nodes/mention';
import { GridBlock } from '$app/components/editor/components/blocks/database';
import { MathEquation } from '$app/components/editor/components/blocks/math_equation';
import { ImageBlock } from '$app/components/editor/components/blocks/image';
import { Text as TextComponent } from '../blocks/text';
import { Page } from '../blocks/page';
import { useElementState } from '$app/components/editor/components/editor/Element.hooks';
@ -68,6 +70,8 @@ function Element({ element, attributes, children }: RenderElementProps) {
return GridBlock;
case EditorNodeType.EquationBlock:
return MathEquation;
case EditorNodeType.ImageBlock:
return ImageBlock;
default:
return UnSupportBlock;
}

View File

@ -13,8 +13,8 @@ import KeyboardNavigation, {
KeyboardNavigationOption,
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
import isHotkey from 'is-hotkey';
import LinkEditInput, { pattern } from '$app/components/editor/components/inline_nodes/link/LinkEditInput';
import { openUrl } from '$app/utils/open_url';
import LinkEditInput from '$app/components/editor/components/inline_nodes/link/LinkEditInput';
import { openUrl, pattern } from '$app/utils/open_url';
function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaultHref: string }) {
const editor = useSlateStatic();

View File

@ -1,8 +1,7 @@
import React, { useEffect, useState } from 'react';
import { TextField } from '@mui/material';
import { useTranslation } from 'react-i18next';
export const pattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w.-]*)*\/?$/;
import { pattern } from '$app/utils/open_url';
function LinkEditInput({
link,

View File

@ -12,7 +12,7 @@ import KeyboardNavigation, {
KeyboardNavigationOption,
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
import { Color } from '$app/components/editor/components/tools/block_actions/color';
import { getModifier } from '$app/utils/get_modifier';
import { getModifier } from '$app/utils/hotkeys';
import isHotkey from 'is-hotkey';
import { EditorNodeType } from '$app/application/document/document.types';

View File

@ -14,14 +14,18 @@ import { ReactComponent as NumberedListIcon } from '$app/assets/numbers.svg';
import { ReactComponent as QuoteIcon } from '$app/assets/quote.svg';
import { ReactComponent as ToggleListIcon } from '$app/assets/show-menu.svg';
import { ReactComponent as GridIcon } from '$app/assets/grid.svg';
import { ReactComponent as ImageIcon } from '$app/assets/image.svg';
import { DataObjectOutlined, FunctionsOutlined, HorizontalRuleOutlined, MenuBookOutlined } from '@mui/icons-material';
import { CustomEditor } from '$app/components/editor/command';
import { randomEmoji } from '$app/utils/emoji';
import { KeyboardNavigationOption } from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
import { YjsEditor } from '@slate-yjs/core';
import { useEditorBlockDispatch } from '$app/components/editor/stores/block';
enum SlashCommandPanelTab {
BASIC = 'basic',
MEDIA = 'media',
DATABASE = 'database',
ADVANCED = 'advanced',
}
@ -40,6 +44,7 @@ export enum SlashOptionType {
Code,
Grid,
MathEquation,
Image,
}
const slashOptionGroup = [
{
@ -55,11 +60,20 @@ const slashOptionGroup = [
SlashOptionType.Quote,
SlashOptionType.ToggleList,
SlashOptionType.Divider,
SlashOptionType.Callout,
],
},
{
key: SlashCommandPanelTab.MEDIA,
options: [SlashOptionType.Code, SlashOptionType.Image],
},
{
key: SlashCommandPanelTab.DATABASE,
options: [SlashOptionType.Grid],
},
{
key: SlashCommandPanelTab.ADVANCED,
options: [SlashOptionType.Callout, SlashOptionType.Code, SlashOptionType.Grid, SlashOptionType.MathEquation],
options: [SlashOptionType.MathEquation],
},
];
@ -78,6 +92,7 @@ const slashOptionMapToEditorNodeType = {
[SlashOptionType.Code]: EditorNodeType.CodeBlock,
[SlashOptionType.Grid]: EditorNodeType.GridBlock,
[SlashOptionType.MathEquation]: EditorNodeType.EquationBlock,
[SlashOptionType.Image]: EditorNodeType.ImageBlock,
};
const headingTypeToLevelMap: Record<string, number> = {
@ -95,6 +110,7 @@ export function useSlashCommandPanel({
searchText: string;
closePanel: (deleteText?: boolean) => void;
}) {
const { openPopover } = useEditorBlockDispatch();
const { t } = useTranslation();
const editor = useSlate();
const onConfirm = useCallback(
@ -127,6 +143,12 @@ export function useSlashCommandPanel({
});
}
if (nodeType === EditorNodeType.ImageBlock) {
Object.assign(data, {
url: '',
});
}
closePanel(true);
const newNode = getBlock(editor);
@ -145,12 +167,20 @@ export function useSlashCommandPanel({
editor.select(nextPath);
}
CustomEditor.turnToBlock(editor, {
const turnIntoBlock = CustomEditor.turnToBlock(editor, {
type: nodeType,
data,
});
setTimeout(() => {
if (turnIntoBlock && turnIntoBlock.blockId) {
if (turnIntoBlock.type === EditorNodeType.ImageBlock || turnIntoBlock.type === EditorNodeType.EquationBlock) {
openPopover(turnIntoBlock.type, turnIntoBlock.blockId);
}
}
}, 0);
},
[editor, closePanel]
[editor, closePanel, openPopover]
);
const typeToLabelIconMap = useMemo(() => {
@ -212,6 +242,10 @@ export function useSlashCommandPanel({
label: t('document.plugins.mathEquation.name'),
Icon: FunctionsOutlined,
},
[SlashOptionType.Image]: {
label: t('editor.image'),
Icon: ImageIcon,
},
};
}, [t]);
@ -219,6 +253,8 @@ export function useSlashCommandPanel({
return {
[SlashCommandPanelTab.BASIC]: 'Basic',
[SlashCommandPanelTab.ADVANCED]: 'Advanced',
[SlashCommandPanelTab.MEDIA]: 'Media',
[SlashCommandPanelTab.DATABASE]: 'Database',
};
}, []);

View File

@ -22,12 +22,15 @@ function SelectionActions({
isAcrossBlocks,
storeSelection,
restoreSelection,
isIncludeRoot,
}: {
storeSelection: () => void;
restoreSelection: () => void;
isAcrossBlocks: boolean;
visible: boolean;
isIncludeRoot: boolean;
}) {
if (isIncludeRoot) return null;
return (
<div className={'flex w-fit flex-grow items-center gap-1'}>
{!isAcrossBlocks && (

View File

@ -14,6 +14,7 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
const [isAcrossBlocks, setIsAcrossBlocks] = useState(false);
const [visible, setVisible] = useState(false);
const isFocusedEditor = useFocused();
const isIncludeRoot = CustomEditor.selectionIncludeRoot(editor);
// paint the selection when the editor is blurred
const { add: addDecorate, clear: clearDecorate, getStaticState } = useDecorateDispatch();
@ -61,12 +62,6 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
return;
}
// Close toolbar when selection include root
if (CustomEditor.selectionIncludeRoot(editor)) {
closeToolbar();
return;
}
const position = getSelectionPosition(editor);
if (!position) {
@ -123,7 +118,7 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
closeToolbar();
};
if (!isFocusedEditor || !selection || SlateRange.isCollapsed(selection)) {
if (isIncludeRoot || !isFocusedEditor || !selection || SlateRange.isCollapsed(selection)) {
close();
return;
}
@ -205,5 +200,6 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
restoreSelection,
storeSelection,
isAcrossBlocks,
isIncludeRoot,
};
}

View File

@ -6,7 +6,7 @@ import withErrorBoundary from '$app/components/_shared/error_boundary/withError'
const Toolbar = memo(() => {
const ref = useRef<HTMLDivElement | null>(null);
const { visible, restoreSelection, storeSelection, isAcrossBlocks } = useSelectionToolbar(ref);
const { visible, restoreSelection, storeSelection, isAcrossBlocks, isIncludeRoot } = useSelectionToolbar(ref);
return (
<div
@ -20,6 +20,7 @@ const Toolbar = memo(() => {
}}
>
<SelectionActions
isIncludeRoot={isIncludeRoot}
isAcrossBlocks={isAcrossBlocks}
storeSelection={storeSelection}
restoreSelection={restoreSelection}

View File

@ -1,4 +1,4 @@
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import Tooltip from '@mui/material/Tooltip';
import { ReactComponent as AlignLeftSvg } from '$app/assets/align-left.svg';
import { ReactComponent as AlignCenterSvg } from '$app/assets/align-center.svg';
@ -6,15 +6,16 @@ import { ReactComponent as AlignRightSvg } from '$app/assets/align-right.svg';
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
import { useTranslation } from 'react-i18next';
import { CustomEditor } from '$app/components/editor/command';
import { useSlateStatic } from 'slate-react';
import { ReactEditor, useSlateStatic } from 'slate-react';
import { IconButton } from '@mui/material';
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
import { createHotkey, HOT_KEY_NAME } from '$app/utils/hotkeys';
export function Align() {
const { t } = useTranslation();
const editor = useSlateStatic();
const align = CustomEditor.getAlign(editor);
const [open, setOpen] = React.useState(false);
const [open, setOpen] = useState(false);
const handleClose = useCallback(() => {
setOpen(false);
@ -60,6 +61,36 @@ export function Align() {
}
}, []);
useEffect(() => {
const editorDom = ReactEditor.toDOMNode(editor, editor);
const handleShortcut = (e: KeyboardEvent) => {
if (createHotkey(HOT_KEY_NAME.ALIGN_LEFT)(e)) {
e.preventDefault();
e.stopPropagation();
CustomEditor.toggleAlign(editor, 'left');
return;
}
if (createHotkey(HOT_KEY_NAME.ALIGN_CENTER)(e)) {
e.preventDefault();
e.stopPropagation();
CustomEditor.toggleAlign(editor, 'center');
return;
}
if (createHotkey(HOT_KEY_NAME.ALIGN_RIGHT)(e)) {
e.preventDefault();
e.stopPropagation();
CustomEditor.toggleAlign(editor, 'right');
return;
}
};
editorDom.addEventListener('keydown', handleShortcut);
return () => {
editorDom.removeEventListener('keydown', handleShortcut);
};
}, [editor]);
return (
<Tooltip
placement={'bottom'}

View File

@ -1,18 +1,18 @@
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo } from 'react';
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
import { useTranslation } from 'react-i18next';
import { useSlateStatic } from 'slate-react';
import { ReactEditor, useSlateStatic } from 'slate-react';
import { CustomEditor } from '$app/components/editor/command';
import { ReactComponent as BoldSvg } from '$app/assets/bold.svg';
import { EditorMarkFormat } from '$app/application/document/document.types';
import { getHotKey } from '$app/components/editor/plugins/shortcuts';
import { createHotkey, createHotKeyLabel, HOT_KEY_NAME } from '$app/utils/hotkeys';
export function Bold() {
const { t } = useTranslation();
const editor = useSlateStatic();
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Bold);
const modifier = useMemo(() => getHotKey(EditorMarkFormat.Bold).modifier, []);
const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.BOLD), []);
const onClick = useCallback(() => {
CustomEditor.toggleMark(editor, {
key: EditorMarkFormat.Bold,
@ -20,6 +20,26 @@ export function Bold() {
});
}, [editor]);
useEffect(() => {
const editorDom = ReactEditor.toDOMNode(editor, editor);
const handleShortcut = (e: KeyboardEvent) => {
if (createHotkey(HOT_KEY_NAME.BOLD)(e)) {
e.preventDefault();
e.stopPropagation();
CustomEditor.toggleMark(editor, {
key: EditorMarkFormat.Bold,
value: true,
});
return;
}
};
editorDom.addEventListener('keydown', handleShortcut);
return () => {
editorDom.removeEventListener('keydown', handleShortcut);
};
}, [editor]);
return (
<ActionButton
onClick={onClick}

View File

@ -4,12 +4,12 @@ import { useTranslation } from 'react-i18next';
import { ReactEditor, useSlateStatic } from 'slate-react';
import { CustomEditor } from '$app/components/editor/command';
import { ReactComponent as LinkSvg } from '$app/assets/link.svg';
import { Editor } from 'slate';
import { Editor, Range } from 'slate';
import { EditorMarkFormat } from '$app/application/document/document.types';
import { useDecorateDispatch, useDecorateState } from '$app/components/editor/stores';
import { LinkEditPopover } from '$app/components/editor/components/inline_nodes/link';
import isHotkey from 'is-hotkey';
import { getModifier } from '$app/utils/get_modifier';
import { getModifier } from '$app/utils/hotkeys';
export function Href() {
const { t } = useTranslation();
@ -69,6 +69,7 @@ export function Href() {
const editorDom = ReactEditor.toDOMNode(editor, editor);
const handleShortcut = (e: KeyboardEvent) => {
if (isHotkey('mod+k', e)) {
if (editor.selection && Range.isCollapsed(editor.selection)) return;
e.preventDefault();
e.stopPropagation();
onClick();

View File

@ -1,17 +1,17 @@
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo } from 'react';
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
import { useTranslation } from 'react-i18next';
import { useSlateStatic } from 'slate-react';
import { ReactEditor, useSlateStatic } from 'slate-react';
import { CustomEditor } from '$app/components/editor/command';
import { ReactComponent as CodeSvg } from '$app/assets/inline-code.svg';
import { EditorMarkFormat } from '$app/application/document/document.types';
import { getHotKey } from '$app/components/editor/plugins/shortcuts';
import { createHotkey, createHotKeyLabel, HOT_KEY_NAME } from '$app/utils/hotkeys';
export function InlineCode() {
const { t } = useTranslation();
const editor = useSlateStatic();
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Code);
const modifier = useMemo(() => getHotKey(EditorMarkFormat.Code).modifier, []);
const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.CODE), []);
const onClick = useCallback(() => {
CustomEditor.toggleMark(editor, {
@ -20,6 +20,26 @@ export function InlineCode() {
});
}, [editor]);
useEffect(() => {
const editorDom = ReactEditor.toDOMNode(editor, editor);
const handleShortcut = (e: KeyboardEvent) => {
if (createHotkey(HOT_KEY_NAME.CODE)(e)) {
e.preventDefault();
e.stopPropagation();
CustomEditor.toggleMark(editor, {
key: EditorMarkFormat.Code,
value: true,
});
return;
}
};
editorDom.addEventListener('keydown', handleShortcut);
return () => {
editorDom.removeEventListener('keydown', handleShortcut);
};
}, [editor]);
return (
<ActionButton
onClick={onClick}

View File

@ -1,17 +1,17 @@
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo } from 'react';
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
import { useTranslation } from 'react-i18next';
import { useSlateStatic } from 'slate-react';
import { ReactEditor, useSlateStatic } from 'slate-react';
import { CustomEditor } from '$app/components/editor/command';
import { ReactComponent as ItalicSvg } from '$app/assets/italic.svg';
import { EditorMarkFormat } from '$app/application/document/document.types';
import { getHotKey } from '$app/components/editor/plugins/shortcuts';
import { createHotkey, createHotKeyLabel, HOT_KEY_NAME } from '$app/utils/hotkeys';
export function Italic() {
const { t } = useTranslation();
const editor = useSlateStatic();
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Italic);
const modifier = useMemo(() => getHotKey(EditorMarkFormat.Italic).modifier, []);
const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.ITALIC), []);
const onClick = useCallback(() => {
CustomEditor.toggleMark(editor, {
@ -20,6 +20,25 @@ export function Italic() {
});
}, [editor]);
useEffect(() => {
const editorDom = ReactEditor.toDOMNode(editor, editor);
const handleShortcut = (e: KeyboardEvent) => {
if (createHotkey(HOT_KEY_NAME.ITALIC)(e)) {
e.preventDefault();
e.stopPropagation();
CustomEditor.toggleMark(editor, {
key: EditorMarkFormat.Italic,
value: true,
});
return;
}
};
editorDom.addEventListener('keydown', handleShortcut);
return () => {
editorDom.removeEventListener('keydown', handleShortcut);
};
}, [editor]);
return (
<ActionButton
onClick={onClick}

View File

@ -1,17 +1,17 @@
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo } from 'react';
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
import { useTranslation } from 'react-i18next';
import { useSlateStatic } from 'slate-react';
import { ReactEditor, useSlateStatic } from 'slate-react';
import { CustomEditor } from '$app/components/editor/command';
import { ReactComponent as StrikeThroughSvg } from '$app/assets/strikethrough.svg';
import { EditorMarkFormat } from '$app/application/document/document.types';
import { getHotKey } from '$app/components/editor/plugins/shortcuts';
import { createHotkey, createHotKeyLabel, HOT_KEY_NAME } from '$app/utils/hotkeys';
export function StrikeThrough() {
const { t } = useTranslation();
const editor = useSlateStatic();
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.StrikeThrough);
const modifier = useMemo(() => getHotKey(EditorMarkFormat.StrikeThrough).modifier, []);
const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.STRIKETHROUGH), []);
const onClick = useCallback(() => {
CustomEditor.toggleMark(editor, {
@ -20,6 +20,26 @@ export function StrikeThrough() {
});
}, [editor]);
useEffect(() => {
const editorDom = ReactEditor.toDOMNode(editor, editor);
const handleShortcut = (e: KeyboardEvent) => {
if (createHotkey(HOT_KEY_NAME.STRIKETHROUGH)(e)) {
e.preventDefault();
e.stopPropagation();
CustomEditor.toggleMark(editor, {
key: EditorMarkFormat.StrikeThrough,
value: true,
});
return;
}
};
editorDom.addEventListener('keydown', handleShortcut);
return () => {
editorDom.removeEventListener('keydown', handleShortcut);
};
}, [editor]);
return (
<ActionButton
onClick={onClick}

View File

@ -1,17 +1,17 @@
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo } from 'react';
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
import { useTranslation } from 'react-i18next';
import { useSlateStatic } from 'slate-react';
import { ReactEditor, useSlateStatic } from 'slate-react';
import { CustomEditor } from '$app/components/editor/command';
import { ReactComponent as UnderlineSvg } from '$app/assets/underline.svg';
import { EditorMarkFormat } from '$app/application/document/document.types';
import { getHotKey } from '$app/components/editor/plugins/shortcuts';
import { createHotkey, createHotKeyLabel, HOT_KEY_NAME } from '$app/utils/hotkeys';
export function Underline() {
const { t } = useTranslation();
const editor = useSlateStatic();
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Underline);
const modifier = useMemo(() => getHotKey(EditorMarkFormat.Underline).modifier, []);
const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.UNDERLINE), []);
const onClick = useCallback(() => {
CustomEditor.toggleMark(editor, {
@ -20,6 +20,26 @@ export function Underline() {
});
}, [editor]);
useEffect(() => {
const editorDom = ReactEditor.toDOMNode(editor, editor);
const handleShortcut = (e: KeyboardEvent) => {
if (createHotkey(HOT_KEY_NAME.UNDERLINE)(e)) {
e.preventDefault();
e.stopPropagation();
CustomEditor.toggleMark(editor, {
key: EditorMarkFormat.Underline,
value: true,
});
return;
}
};
editorDom.addEventListener('keydown', handleShortcut);
return () => {
editorDom.removeEventListener('keydown', handleShortcut);
};
}, [editor]);
return (
<ActionButton
onClick={onClick}

View File

@ -136,4 +136,23 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
.grid-block .grid-scroll-container::-webkit-scrollbar {
width: 0;
height: 0;
}
.image-resizer {
@apply absolute w-[10px] top-0 z-10 flex h-full cursor-col-resize items-center justify-end;
.resize-handle {
@apply h-1/4 w-1/2 transform transition-all duration-500 select-none rounded-full border border-white opacity-0;
background: var(--fill-toolbar);
}
&:hover {
.resize-handle {
@apply opacity-90;
}
}
}
.image-block, .math-equation-block {
::selection {
@apply bg-transparent;
}
}

View File

@ -1,72 +0,0 @@
import { EditorMarkFormat } from '$app/application/document/document.types';
import { getModifier } from '$app/utils/get_modifier';
/**
* Hotkeys shortcuts
* @description
* - bold: Mod+b
* - italic: Mod+i
* - underline: Mod+u
* - strikethrough: Mod+Shift+s
* - code: Mod+Shift+c
*/
export const getHotKeys: () => {
[key: string]: { modifier: string; hotkey: string; markKey: EditorMarkFormat; markValue: string | boolean };
} = () => {
const modifier = getModifier();
return {
[EditorMarkFormat.Bold]: {
hotkey: 'mod+b',
modifier: `${modifier} + B`,
markKey: EditorMarkFormat.Bold,
markValue: true,
},
[EditorMarkFormat.Italic]: {
hotkey: 'mod+i',
modifier: `${modifier} + I`,
markKey: EditorMarkFormat.Italic,
markValue: true,
},
[EditorMarkFormat.Underline]: {
hotkey: 'mod+u',
modifier: `${modifier} + U`,
markKey: EditorMarkFormat.Underline,
markValue: true,
},
[EditorMarkFormat.StrikeThrough]: {
hotkey: 'mod+shift+s',
modifier: `${modifier} + Shift + S`,
markKey: EditorMarkFormat.StrikeThrough,
markValue: true,
},
[EditorMarkFormat.Code]: {
hotkey: 'mod+shift+c',
modifier: `${modifier} + Shift + C`,
markKey: EditorMarkFormat.Code,
markValue: true,
},
'align-left': {
hotkey: 'control+shift+l',
modifier: `Ctrl + Shift + L`,
markKey: EditorMarkFormat.Align,
markValue: 'left',
},
'align-center': {
hotkey: 'control+shift+e',
modifier: `Ctrl + Shift + E`,
markKey: EditorMarkFormat.Align,
markValue: 'center',
},
'align-right': {
hotkey: 'control+shift+r',
modifier: `Ctrl + Shift + R`,
markKey: EditorMarkFormat.Align,
markValue: 'right',
},
};
};
export const getHotKey = (key: EditorMarkFormat) => {
return getHotKeys()[key];
};

View File

@ -1,3 +1,2 @@
export * from './shortcuts.hooks';
export * from './withShortcuts';
export * from './hotkey';

View File

@ -1,28 +1,14 @@
import { ReactEditor } from 'slate-react';
import { useCallback, KeyboardEvent } from 'react';
import {
EditorMarkFormat,
EditorNodeType,
TodoListNode,
ToggleListNode,
} from '$app/application/document/document.types';
import { EditorNodeType, TodoListNode, ToggleListNode } from '$app/application/document/document.types';
import isHotkey from 'is-hotkey';
import { getBlock } from '$app/components/editor/plugins/utils';
import { SOFT_BREAK_TYPES } from '$app/components/editor/plugins/constants';
import { CustomEditor } from '$app/components/editor/command';
import { getHotKeys } from '$app/components/editor/plugins/shortcuts/hotkey';
/**
* Hotkeys shortcuts
* @description [getHotKeys] is defined in [hotkey.ts]
* - bold: Mod+b
* - italic: Mod+i
* - underline: Mod+u
* - strikethrough: Mod+Shift+s
* - code: Mod+Shift+c
* - align left: Mod+Shift+l
* - align center: Mod+Shift+e
* - align right: Mod+Shift+r
* - indent: Tab
* - outdent: Shift+Tab
* - split block: Enter
@ -33,24 +19,6 @@ import { getHotKeys } from '$app/components/editor/plugins/shortcuts/hotkey';
export function useShortcuts(editor: ReactEditor) {
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
Object.entries(getHotKeys()).forEach(([_, item]) => {
if (isHotkey(item.hotkey, e)) {
e.stopPropagation();
e.preventDefault();
if (CustomEditor.selectionIncludeRoot(editor)) return;
if (item.markKey === EditorMarkFormat.Align) {
CustomEditor.toggleAlign(editor, item.markValue as string);
return;
}
CustomEditor.toggleMark(editor, {
key: item.markKey,
value: item.markValue,
});
return;
}
});
const node = getBlock(editor);
if (isHotkey('Escape', e)) {

View File

@ -0,0 +1,70 @@
import { createContext, useCallback, useContext, useMemo } from 'react';
import { proxy, useSnapshot } from 'valtio';
import { EditorNodeType } from '$app/application/document/document.types';
export interface EditorBlockState {
[EditorNodeType.ImageBlock]: {
popoverOpen: boolean;
blockId?: string;
};
[EditorNodeType.EquationBlock]: {
popoverOpen: boolean;
blockId?: string;
};
}
const initialState = {
[EditorNodeType.ImageBlock]: {
popoverOpen: false,
blockId: undefined,
},
[EditorNodeType.EquationBlock]: {
popoverOpen: false,
blockId: undefined,
},
};
export const EditorBlockStateContext = createContext<EditorBlockState>(initialState);
export const EditorBlockStateProvider = EditorBlockStateContext.Provider;
export function useEditorInitialBlockState() {
const state = useMemo(() => {
return proxy({
...initialState,
});
}, []);
return state;
}
export function useEditorBlockState(key: EditorNodeType.ImageBlock | EditorNodeType.EquationBlock) {
const context = useContext(EditorBlockStateContext);
return useSnapshot(context[key]);
}
export function useEditorBlockDispatch() {
const context = useContext(EditorBlockStateContext);
const openPopover = useCallback(
(key: EditorNodeType.ImageBlock | EditorNodeType.EquationBlock, blockId: string) => {
context[key].popoverOpen = true;
context[key].blockId = blockId;
},
[context]
);
const closePopover = useCallback(
(key: EditorNodeType.ImageBlock | EditorNodeType.EquationBlock) => {
context[key].popoverOpen = false;
context[key].blockId = undefined;
},
[context]
);
return {
openPopover,
closePopover,
};
}

View File

@ -3,6 +3,7 @@ import { useInitialDecorateState } from '$app/components/editor/stores/decorate'
import { useInitialSelectedBlocks } from '$app/components/editor/stores/selected';
import { useInitialSlashState } from '$app/components/editor/stores/slash';
import { useInitialEditorInlineBlockState } from '$app/components/editor/stores/inline_node';
import { useEditorInitialBlockState } from '$app/components/editor/stores/block';
export * from './decorate';
export * from './selected';
@ -14,6 +15,7 @@ export function useInitialEditorState(editor: ReactEditor) {
const selectedBlocks = useInitialSelectedBlocks(editor);
const slashState = useInitialSlashState();
const inlineBlockState = useInitialEditorInlineBlockState();
const blockState = useEditorInitialBlockState();
return {
selectedBlocks,
@ -21,5 +23,6 @@ export function useInitialEditorState(editor: ReactEditor) {
decorateState,
slashState,
inlineBlockState,
blockState,
};
}

View File

@ -1,20 +1,8 @@
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { createContext, useEffect, useMemo, useState } from 'react';
import { proxySet, subscribeKey } from 'valtio/utils';
import { ReactEditor } from 'slate-react';
import { Element } from 'slate';
export function useSelectedBlocksSize() {
const selectedBlocks = useContext(EditorSelectedBlockContext);
const [selectedLength, setSelectedLength] = useState(0);
useEffect(() => {
subscribeKey(selectedBlocks, 'size', (v) => setSelectedLength(v));
}, [selectedBlocks]);
return selectedLength;
}
export function useInitialSelectedBlocks(editor: ReactEditor) {
const selectedBlocks = useMemo(() => proxySet([]), []);
const [selectedLength, setSelectedLength] = useState(0);

View File

@ -36,7 +36,7 @@ function Layout({ children }: { children: ReactNode }) {
<TopBar />
<div
style={{
height: 'calc(100vh - 64px - 48px)',
height: 'calc(100vh - 64px)',
}}
className={'appflowy-layout appflowy-scroll-container select-none overflow-y-auto overflow-x-hidden'}
>

View File

@ -5,7 +5,7 @@ import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { sidebarActions } from '$app_reducers/sidebar/slice';
import { ReactComponent as ShowMenuIcon } from '$app/assets/show-menu.svg';
import { useTranslation } from 'react-i18next';
import { getModifier } from '$app/utils/get_modifier';
import { getModifier } from '$app/utils/hotkeys';
import isHotkey from 'is-hotkey';
function CollapseMenuButton() {

View File

@ -10,7 +10,7 @@ import RenameDialog from '../../_shared/confirm_dialog/RenameDialog';
import { Page } from '$app_reducers/pages/slice';
import DeleteDialog from '$app/components/layout/nested_page/DeleteDialog';
import OperationMenu from '$app/components/layout/nested_page/OperationMenu';
import { getModifier } from '$app/utils/get_modifier';
import { getModifier } from '$app/utils/hotkeys';
import isHotkey from 'is-hotkey';
function MoreButton({

View File

@ -1,12 +0,0 @@
export const isMac = () => {
return navigator.userAgent.includes('Mac OS X');
};
const MODIFIERS = {
control: 'Ctrl',
meta: '⌘',
};
export const getModifier = () => {
return isMac() ? MODIFIERS.meta : MODIFIERS.control;
};

View File

@ -0,0 +1,61 @@
import isHotkey from 'is-hotkey';
export const isMac = () => {
return navigator.userAgent.includes('Mac OS X');
};
const MODIFIERS = {
control: 'Ctrl',
meta: '⌘',
};
export const getModifier = () => {
return isMac() ? MODIFIERS.meta : MODIFIERS.control;
};
export enum HOT_KEY_NAME {
ALIGN_LEFT = 'align-left',
ALIGN_CENTER = 'align-center',
ALIGN_RIGHT = 'align-right',
BOLD = 'bold',
ITALIC = 'italic',
UNDERLINE = 'underline',
STRIKETHROUGH = 'strikethrough',
CODE = 'code',
}
const defaultHotKeys = {
[HOT_KEY_NAME.ALIGN_LEFT]: 'control+shift+l',
[HOT_KEY_NAME.ALIGN_CENTER]: 'control+shift+e',
[HOT_KEY_NAME.ALIGN_RIGHT]: 'control+shift+r',
[HOT_KEY_NAME.BOLD]: 'mod+b',
[HOT_KEY_NAME.ITALIC]: 'mod+i',
[HOT_KEY_NAME.UNDERLINE]: 'mod+u',
[HOT_KEY_NAME.STRIKETHROUGH]: 'mod+shift+s',
[HOT_KEY_NAME.CODE]: 'mod+shift+c',
};
const replaceModifier = (hotkey: string) => {
return hotkey.replace('mod', getModifier()).replace('control', 'ctrl');
};
export const createHotkey = (hotkeyName: HOT_KEY_NAME, customHotKeys?: Record<HOT_KEY_NAME, string>) => {
const keys = customHotKeys || defaultHotKeys;
const hotkey = keys[hotkeyName];
return (event: KeyboardEvent) => {
return isHotkey(hotkey, event);
};
};
export const createHotKeyLabel = (hotkeyName: HOT_KEY_NAME, customHotKeys?: Record<HOT_KEY_NAME, string>) => {
const keys = customHotKeys || defaultHotKeys;
const hotkey = replaceModifier(keys[hotkeyName]);
return hotkey
.split('+')
.map((key) => {
return key === ' ' ? 'Space' : key.charAt(0).toUpperCase() + key.slice(1);
})
.join(' + ');
};

View File

@ -1,6 +1,6 @@
import { open as openWindow } from '@tauri-apps/api/shell';
export const pattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w.-]*)*\/?$/;
export const pattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})(\S*)*\/?(\?[=&\w.%-]*)?(#[\w.\-!~*'()]*)?$/;
export function openUrl(str: string) {
if (pattern.test(str)) {

View File

@ -245,7 +245,9 @@
"Cancel": "Cancel",
"clear": "Clear",
"remove": "Remove",
"dontRemove": "Don't remove"
"dontRemove": "Don't remove",
"copyLink": "Copy Link",
"align": "Align"
},
"label": {
"welcome": "Welcome!",
@ -588,6 +590,7 @@
"multiSelectFieldName": "Multiselect",
"urlFieldName": "URL",
"checklistFieldName": "Checklist",
"relationFieldName": "Relation",
"numberFormat": "Number format",
"dateFormat": "Date format",
"includeTime": "Include time",
@ -688,6 +691,12 @@
"hideComplete": "Hide completed tasks",
"showComplete": "Show all tasks"
},
"relation": {
"relatedDatabasePlaceLabel": "Related Database",
"relatedDatabasePlaceholder": "None",
"inRelatedDatabase": "In",
"emptySearchResult": "No records found"
},
"menuName": "Grid",
"referencedGridPrefix": "View of",
"calculate": "Calculate",
@ -1154,7 +1163,8 @@
"replace": "Replace",
"replaceAll": "Replace all",
"noResult": "No results",
"caseSensitive": "Case sensitive"
"caseSensitive": "Case sensitive",
"searchMore": "Search to find more results"
},
"error": {
"weAreSorry": "We're sorry",

View File

@ -335,6 +335,16 @@ impl EventIntegrationTest {
ChecklistCellDataPB::try_from(Bytes::from(cell.data)).unwrap()
}
pub async fn get_relation_cell(
&self,
view_id: &str,
field_id: &str,
row_id: &str,
) -> RelationCellDataPB {
let cell = self.get_cell(view_id, row_id, field_id).await;
RelationCellDataPB::try_from(Bytes::from(cell.data)).unwrap_or_default()
}
pub async fn update_checklist_cell(
&self,
changeset: ChecklistCellDataChangesetPB,
@ -469,4 +479,33 @@ impl EventIntegrationTest {
.parse::<RepeatedCalendarEventPB>()
.items
}
pub async fn update_relation_cell(
&self,
changeset: RelationCellChangesetPB,
) -> Option<FlowyError> {
EventBuilder::new(self.clone())
.event(DatabaseEvent::UpdateRelationCell)
.payload(changeset)
.async_send()
.await
.error()
}
pub async fn get_related_row_data(
&self,
database_id: String,
row_ids: Vec<String>,
) -> Vec<RelatedRowDataPB> {
EventBuilder::new(self.clone())
.event(DatabaseEvent::GetRelatedRowDatas)
.payload(RepeatedRowIdPB {
database_id,
row_ids,
})
.async_send()
.await
.parse::<RepeatedRelatedRowDataPB>()
.rows
}
}

View File

@ -7,7 +7,7 @@ use event_integration::EventIntegrationTest;
use flowy_database2::entities::{
CellChangesetPB, CellIdPB, CheckboxCellDataPB, ChecklistCellDataChangesetPB, DatabaseLayoutPB,
DatabaseSettingChangesetPB, DatabaseViewIdPB, DateCellChangesetPB, FieldType,
OrderObjectPositionPB, SelectOptionCellDataPB, UpdateRowMetaChangesetPB,
OrderObjectPositionPB, RelationCellChangesetPB, SelectOptionCellDataPB, UpdateRowMetaChangesetPB,
};
use lib_infra::util::timestamp;
@ -778,3 +778,99 @@ async fn create_calendar_event_test() {
let events = test.get_all_calendar_events(&calendar_view.id).await;
assert_eq!(events.len(), 1);
}
#[tokio::test]
async fn update_relation_cell_test() {
let test = EventIntegrationTest::new_with_guest_user().await;
let current_workspace = test.get_current_workspace().await;
let grid_view = test
.create_grid(&current_workspace.id, "my grid view".to_owned(), vec![])
.await;
let relation_field = test.create_field(&grid_view.id, FieldType::Relation).await;
let database = test.get_database(&grid_view.id).await;
// update the relation cell
let changeset = RelationCellChangesetPB {
view_id: grid_view.id.clone(),
cell_id: CellIdPB {
view_id: grid_view.id.clone(),
field_id: relation_field.id.clone(),
row_id: database.rows[0].id.clone(),
},
inserted_row_ids: vec![
"row1rowid".to_string(),
"row2rowid".to_string(),
"row3rowid".to_string(),
],
..Default::default()
};
test.update_relation_cell(changeset).await;
// get the cell
let cell = test
.get_relation_cell(&grid_view.id, &relation_field.id, &database.rows[0].id)
.await;
assert_eq!(cell.row_ids.len(), 3);
}
#[tokio::test]
async fn get_detailed_relation_cell_data() {
let test = EventIntegrationTest::new_with_guest_user().await;
let current_workspace = test.get_current_workspace().await;
let origin_grid_view = test
.create_grid(&current_workspace.id, "origin".to_owned(), vec![])
.await;
let relation_grid_view = test
.create_grid(&current_workspace.id, "relation grid".to_owned(), vec![])
.await;
let relation_field = test
.create_field(&relation_grid_view.id, FieldType::Relation)
.await;
let origin_database = test.get_database(&origin_grid_view.id).await;
let origin_fields = test.get_all_database_fields(&origin_grid_view.id).await;
let linked_row = origin_database.rows[0].clone();
test
.update_cell(CellChangesetPB {
view_id: origin_grid_view.id.clone(),
row_id: linked_row.id.clone(),
field_id: origin_fields.items[0].id.clone(),
cell_changeset: "hello world".to_string(),
})
.await;
let new_database = test.get_database(&relation_grid_view.id).await;
// update the relation cell
let changeset = RelationCellChangesetPB {
view_id: relation_grid_view.id.clone(),
cell_id: CellIdPB {
view_id: relation_grid_view.id.clone(),
field_id: relation_field.id.clone(),
row_id: new_database.rows[0].id.clone(),
},
inserted_row_ids: vec![linked_row.id.clone()],
..Default::default()
};
test.update_relation_cell(changeset).await;
// get the cell
let cell = test
.get_relation_cell(
&relation_grid_view.id,
&relation_field.id,
&new_database.rows[0].id,
)
.await;
// using the row ids, get the row data
let rows = test
.get_related_row_data(origin_database.id.clone(), cell.row_ids)
.await;
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].name, "hello world");
}

View File

@ -69,6 +69,12 @@ impl AsRef<str> for DatabaseIdPB {
}
}
#[derive(Clone, ProtoBuf, Default, Debug)]
pub struct RepeatedDatabaseIdPB {
#[pb(index = 1)]
pub value: Vec<DatabaseIdPB>,
}
#[derive(Clone, ProtoBuf, Default, Debug, Validate)]
pub struct DatabaseViewIdPB {
#[pb(index = 1)]

View File

@ -473,6 +473,7 @@ pub enum FieldType {
Checklist = 7,
LastEditedTime = 8,
CreatedTime = 9,
Relation = 10,
}
impl Display for FieldType {
@ -509,8 +510,9 @@ impl FieldType {
FieldType::Checkbox => "Checkbox",
FieldType::URL => "URL",
FieldType::Checklist => "Checklist",
FieldType::LastEditedTime => "Last edited time",
FieldType::LastEditedTime => "Last modified",
FieldType::CreatedTime => "Created time",
FieldType::Relation => "Relation",
};
s.to_string()
}
@ -559,6 +561,10 @@ impl FieldType {
matches!(self, FieldType::Checklist)
}
pub fn is_relation(&self) -> bool {
matches!(self, FieldType::Relation)
}
pub fn can_be_group(&self) -> bool {
self.is_select_option() || self.is_checkbox() || self.is_url()
}

View File

@ -3,6 +3,7 @@ mod checklist_filter;
mod date_filter;
mod filter_changeset;
mod number_filter;
mod relation_filter;
mod select_option_filter;
mod text_filter;
mod util;
@ -12,6 +13,7 @@ pub use checklist_filter::*;
pub use date_filter::*;
pub use filter_changeset::*;
pub use number_filter::*;
pub use relation_filter::*;
pub use select_option_filter::*;
pub use text_filter::*;
pub use util::*;

View File

@ -0,0 +1,24 @@
use flowy_derive::ProtoBuf;
use crate::services::filter::{Filter, FromFilterString};
#[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)]
pub struct RelationFilterPB {
#[pb(index = 1)]
pub condition: i64,
}
impl FromFilterString for RelationFilterPB {
fn from_filter(_filter: &Filter) -> Self
where
Self: Sized,
{
RelationFilterPB { condition: 0 }
}
}
impl From<&Filter> for RelationFilterPB {
fn from(_filter: &Filter) -> Self {
RelationFilterPB { condition: 0 }
}
}

View File

@ -1,3 +1,5 @@
use std::str::FromStr;
use flowy_derive::{ProtoBuf, ProtoBuf_Enum};
use flowy_error::ErrorCode;
@ -48,7 +50,7 @@ impl FromFilterString for SelectOptionFilterPB {
where
Self: Sized,
{
let ids = SelectOptionIds::from(filter.content.clone());
let ids = SelectOptionIds::from_str(&filter.content).unwrap_or_default();
SelectOptionFilterPB {
condition: SelectOptionConditionPB::try_from(filter.condition as u8)
.unwrap_or(SelectOptionConditionPB::OptionIs),
@ -59,7 +61,7 @@ impl FromFilterString for SelectOptionFilterPB {
impl std::convert::From<&Filter> for SelectOptionFilterPB {
fn from(filter: &Filter) -> Self {
let ids = SelectOptionIds::from(filter.content.clone());
let ids = SelectOptionIds::from_str(&filter.content).unwrap_or_default();
SelectOptionFilterPB {
condition: SelectOptionConditionPB::try_from(filter.condition as u8)
.unwrap_or(SelectOptionConditionPB::OptionIs),

View File

@ -11,7 +11,7 @@ use validator::Validate;
use crate::entities::parser::NotEmptyStr;
use crate::entities::{
CheckboxFilterPB, ChecklistFilterPB, DateFilterContentPB, DateFilterPB, FieldType,
NumberFilterPB, SelectOptionFilterPB, TextFilterPB,
NumberFilterPB, RelationFilterPB, SelectOptionFilterPB, TextFilterPB,
};
use crate::services::field::SelectOptionIds;
use crate::services::filter::Filter;
@ -44,6 +44,7 @@ impl std::convert::From<&Filter> for FilterPB {
FieldType::Checklist => ChecklistFilterPB::from(filter).try_into().unwrap(),
FieldType::Checkbox => CheckboxFilterPB::from(filter).try_into().unwrap(),
FieldType::URL => TextFilterPB::from(filter).try_into().unwrap(),
FieldType::Relation => RelationFilterPB::from(filter).try_into().unwrap(),
};
Self {
id: filter.id.clone(),
@ -186,6 +187,10 @@ impl TryInto<UpdateFilterParams> for UpdateFilterPayloadPB {
condition = filter.condition as u8;
content = SelectOptionIds::from(filter.option_ids).to_string();
},
FieldType::Relation => {
let filter = RelationFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?;
condition = filter.condition as u8;
},
}
Ok(UpdateFilterParams {

View File

@ -14,8 +14,9 @@ macro_rules! impl_into_field_type {
7 => FieldType::Checklist,
8 => FieldType::LastEditedTime,
9 => FieldType::CreatedTime,
10 => FieldType::Relation,
_ => {
tracing::error!("🔴Can't parser FieldType from value: {}", ty);
tracing::error!("🔴Can't parse FieldType from value: {}", ty);
FieldType::RichText
},
}
@ -34,7 +35,7 @@ macro_rules! impl_into_field_visibility {
1 => FieldVisibility::HideWhenEmpty,
2 => FieldVisibility::AlwaysHidden,
_ => {
tracing::error!("🔴Can't parser FieldVisibility from value: {}", ty);
tracing::error!("🔴Can't parse FieldVisibility from value: {}", ty);
FieldVisibility::AlwaysShown
},
}

View File

@ -2,6 +2,7 @@ mod checkbox_entities;
mod checklist_entities;
mod date_entities;
mod number_entities;
mod relation_entities;
mod select_option_entities;
mod text_entities;
mod timestamp_entities;
@ -11,6 +12,7 @@ pub use checkbox_entities::*;
pub use checklist_entities::*;
pub use date_entities::*;
pub use number_entities::*;
pub use relation_entities::*;
pub use select_option_entities::*;
pub use text_entities::*;
pub use timestamp_entities::*;

View File

@ -0,0 +1,87 @@
use flowy_derive::ProtoBuf;
use crate::entities::CellIdPB;
use crate::services::field::{RelationCellData, RelationTypeOption};
#[derive(Debug, Clone, Default, ProtoBuf)]
pub struct RelationCellDataPB {
#[pb(index = 1)]
pub row_ids: Vec<String>,
}
impl From<RelationCellData> for RelationCellDataPB {
fn from(data: RelationCellData) -> Self {
Self {
row_ids: data.row_ids.into_iter().map(Into::into).collect(),
}
}
}
impl From<RelationCellDataPB> for RelationCellData {
fn from(data: RelationCellDataPB) -> Self {
Self {
row_ids: data.row_ids.into_iter().map(Into::into).collect(),
}
}
}
#[derive(Debug, Clone, Default, ProtoBuf)]
pub struct RelationCellChangesetPB {
#[pb(index = 1)]
pub view_id: String,
#[pb(index = 2)]
pub cell_id: CellIdPB,
#[pb(index = 3)]
pub inserted_row_ids: Vec<String>,
#[pb(index = 4)]
pub removed_row_ids: Vec<String>,
}
#[derive(Clone, Debug, Default, ProtoBuf)]
pub struct RelationTypeOptionPB {
#[pb(index = 1)]
pub database_id: String,
}
impl From<RelationTypeOption> for RelationTypeOptionPB {
fn from(value: RelationTypeOption) -> Self {
RelationTypeOptionPB {
database_id: value.database_id,
}
}
}
impl From<RelationTypeOptionPB> for RelationTypeOption {
fn from(value: RelationTypeOptionPB) -> Self {
RelationTypeOption {
database_id: value.database_id,
}
}
}
#[derive(Debug, Clone, Default, ProtoBuf)]
pub struct RelatedRowDataPB {
#[pb(index = 1)]
pub row_id: String,
#[pb(index = 2)]
pub name: String,
}
#[derive(Debug, Clone, Default, ProtoBuf)]
pub struct RepeatedRelatedRowDataPB {
#[pb(index = 1)]
pub rows: Vec<RelatedRowDataPB>,
}
#[derive(Debug, Default, Clone, ProtoBuf)]
pub struct RepeatedRowIdPB {
#[pb(index = 1)]
pub database_id: String,
#[pb(index = 2)]
pub row_ids: Vec<String>,
}

View File

@ -13,7 +13,8 @@ use crate::entities::*;
use crate::manager::DatabaseManager;
use crate::services::cell::CellBuilder;
use crate::services::field::{
type_option_data_from_pb, ChecklistCellChangeset, DateCellChangeset, SelectOptionCellChangeset,
type_option_data_from_pb, ChecklistCellChangeset, DateCellChangeset, RelationCellChangeset,
SelectOptionCellChangeset,
};
use crate::services::field_settings::FieldSettingsChangesetParams;
use crate::services::group::GroupChangeset;
@ -978,3 +979,81 @@ pub(crate) async fn remove_calculation_handler(
Ok(())
}
pub(crate) async fn get_related_database_ids_handler(
_data: AFPluginData<DatabaseViewIdPB>,
_manager: AFPluginState<Weak<DatabaseManager>>,
) -> FlowyResult<()> {
Ok(())
}
#[tracing::instrument(level = "debug", skip_all, err)]
pub(crate) async fn update_relation_cell_handler(
data: AFPluginData<RelationCellChangesetPB>,
manager: AFPluginState<Weak<DatabaseManager>>,
) -> FlowyResult<()> {
let manager = upgrade_manager(manager)?;
let params: RelationCellChangesetPB = data.into_inner();
let view_id = parser::NotEmptyStr::parse(params.view_id)
.map_err(|_| flowy_error::ErrorCode::DatabaseViewIdIsEmpty)?
.0;
let cell_id: CellIdParams = params.cell_id.try_into()?;
let params = RelationCellChangeset {
inserted_row_ids: params
.inserted_row_ids
.into_iter()
.map(Into::into)
.collect(),
removed_row_ids: params.removed_row_ids.into_iter().map(Into::into).collect(),
};
let database_editor = manager.get_database_with_view_id(&view_id).await?;
// // get the related database
// let related_database_id = database_editor
// .get_related_database_id(&cell_id.field_id)
// .await?;
// let related_database_editor = manager.get_database(&related_database_id).await?;
// // validate the changeset contents
// related_database_editor
// .validate_row_ids_exist(&params)
// .await?;
// update the cell in the database
database_editor
.update_cell_with_changeset(
&view_id,
cell_id.row_id,
&cell_id.field_id,
BoxAny::new(params),
)
.await?;
Ok(())
}
pub(crate) async fn get_related_row_datas_handler(
data: AFPluginData<RepeatedRowIdPB>,
manager: AFPluginState<Weak<DatabaseManager>>,
) -> DataResult<RepeatedRelatedRowDataPB, FlowyError> {
let manager = upgrade_manager(manager)?;
let params: RepeatedRowIdPB = data.into_inner();
let database_editor = manager.get_database(&params.database_id).await?;
let row_datas = database_editor
.get_related_rows(Some(&params.row_ids))
.await?;
data_result_ok(RepeatedRelatedRowDataPB { rows: row_datas })
}
pub(crate) async fn get_related_database_rows_handler(
data: AFPluginData<DatabaseIdPB>,
manager: AFPluginState<Weak<DatabaseManager>>,
) -> DataResult<RepeatedRelatedRowDataPB, FlowyError> {
let manager = upgrade_manager(manager)?;
let database_id = data.into_inner().value;
let database_editor = manager.get_database(&database_id).await?;
let row_datas = database_editor.get_related_rows(None).await?;
data_result_ok(RepeatedRelatedRowDataPB { rows: row_datas })
}

View File

@ -83,6 +83,11 @@ pub fn init(database_manager: Weak<DatabaseManager>) -> AFPlugin {
.event(DatabaseEvent::GetAllCalculations, get_all_calculations_handler)
.event(DatabaseEvent::UpdateCalculation, update_calculation_handler)
.event(DatabaseEvent::RemoveCalculation, remove_calculation_handler)
// Relation
.event(DatabaseEvent::GetRelatedDatabaseIds, get_related_database_ids_handler)
.event(DatabaseEvent::UpdateRelationCell, update_relation_cell_handler)
.event(DatabaseEvent::GetRelatedRowDatas, get_related_row_datas_handler)
.event(DatabaseEvent::GetRelatedDatabaseRows, get_related_database_rows_handler)
}
/// [DatabaseEvent] defines events that are used to interact with the Grid. You could check [this](https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/architecture/backend/protobuf)
@ -342,4 +347,22 @@ pub enum DatabaseEvent {
#[event(input = "RemoveCalculationChangesetPB")]
RemoveCalculation = 165,
/// Currently unused. Get a list of database ids that this database relates
/// to.
#[event(input = "DatabaseViewIdPB", output = "RepeatedDatabaseIdPB")]
GetRelatedDatabaseIds = 170,
/// Updates a relation cell, adding or removing links to rows in another
/// database
#[event(input = "RelationCellChangesetPB")]
UpdateRelationCell = 171,
/// Get the names of the linked rows in a relation cell.
#[event(input = "RepeatedRowIdPB", output = "RepeatedRelatedRowDataPB")]
GetRelatedRowDatas = 172,
/// Get the names of all the rows in a related database.
#[event(input = "DatabaseIdPB", output = "RepeatedRelatedRowDataPB")]
GetRelatedDatabaseRows = 173,
}

View File

@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::str::FromStr;
use collab_database::fields::Field;
use collab_database::rows::{get_field_type_from_cell, Cell, Cells};
@ -94,13 +95,8 @@ pub fn get_cell_protobuf(
let from_field_type = from_field_type.unwrap();
let to_field_type = FieldType::from(field.field_type);
match try_decode_cell_str_to_cell_protobuf(
cell,
&from_field_type,
&to_field_type,
field,
cell_cache,
) {
match try_decode_cell_to_cell_protobuf(cell, &from_field_type, &to_field_type, field, cell_cache)
{
Ok(cell_bytes) => cell_bytes,
Err(e) => {
tracing::error!("Decode cell data failed, {:?}", e);
@ -125,7 +121,7 @@ pub fn get_cell_protobuf(
///
/// returns: CellBytes
///
pub fn try_decode_cell_str_to_cell_protobuf(
pub fn try_decode_cell_to_cell_protobuf(
cell: &Cell,
from_field_type: &FieldType,
to_field_type: &FieldType,
@ -136,7 +132,7 @@ pub fn try_decode_cell_str_to_cell_protobuf(
.get_type_option_cell_data_handler(to_field_type)
{
None => Ok(CellProtobufBlob::default()),
Some(handler) => handler.handle_cell_str(cell, from_field_type, field),
Some(handler) => handler.handle_cell_protobuf(cell, from_field_type, field),
}
}
@ -245,13 +241,6 @@ pub fn delete_select_option_cell(option_ids: Vec<String>, field: &Field) -> Cell
apply_cell_changeset(BoxAny::new(changeset), None, field, None).unwrap()
}
/// Deserialize the String into cell specific data type.
pub trait FromCellString {
fn from_cell_str(s: &str) -> FlowyResult<Self>
where
Self: Sized;
}
pub struct CellBuilder<'a> {
cells: Cells,
field_maps: HashMap<String, &'a Field>,
@ -290,12 +279,12 @@ impl<'a> CellBuilder<'a> {
tracing::warn!("Shouldn't insert cell data to cell whose field type is LastEditedTime or CreatedTime");
},
FieldType::SingleSelect | FieldType::MultiSelect => {
if let Ok(ids) = SelectOptionIds::from_cell_str(&cell_str) {
if let Ok(ids) = SelectOptionIds::from_str(&cell_str) {
cells.insert(field_id, insert_select_option_cell(ids.into_inner(), field));
}
},
FieldType::Checkbox => {
if let Ok(value) = CheckboxCellDataPB::from_cell_str(&cell_str) {
if let Ok(value) = CheckboxCellDataPB::from_str(&cell_str) {
cells.insert(field_id, insert_checkbox_cell(value.is_checked, field));
}
},
@ -303,10 +292,13 @@ impl<'a> CellBuilder<'a> {
cells.insert(field_id, insert_url_cell(cell_str, field));
},
FieldType::Checklist => {
if let Ok(ids) = SelectOptionIds::from_cell_str(&cell_str) {
if let Ok(ids) = SelectOptionIds::from_str(&cell_str) {
cells.insert(field_id, insert_select_option_cell(ids.into_inner(), field));
}
},
FieldType::Relation => {
cells.insert(field_id, (&RelationCellData::from(cell_str)).into());
},
}
}
}

View File

@ -1,109 +1,6 @@
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use flowy_error::{internal_error, FlowyError, FlowyResult};
use crate::entities::FieldType;
/// TypeCellData is a generic CellData, you can parse the type_cell_data according to the field_type.
/// The `data` is encoded by JSON format. You can use `IntoCellData` to decode the opaque data to
/// concrete cell type.
/// TypeCellData -> IntoCellData<T> -> T
///
/// The `TypeCellData` is the same as the cell data that was saved to disk except it carries the
/// field_type. The field_type indicates the cell data original `FieldType`. The field_type will
/// be changed if the current Field's type switch from one to another.
///
#[derive(Debug, Serialize, Deserialize)]
pub struct TypeCellData {
#[serde(rename = "data")]
pub cell_str: String,
pub field_type: FieldType,
}
impl TypeCellData {
pub fn from_field_type(field_type: &FieldType) -> TypeCellData {
Self {
cell_str: "".to_string(),
field_type: *field_type,
}
}
pub fn from_json_str(s: &str) -> FlowyResult<Self> {
let type_cell_data: TypeCellData = serde_json::from_str(s).map_err(|err| {
let msg = format!("Deserialize {} to type cell data failed.{}", s, err);
FlowyError::internal().with_context(msg)
})?;
Ok(type_cell_data)
}
pub fn into_inner(self) -> String {
self.cell_str
}
}
impl std::convert::TryFrom<String> for TypeCellData {
type Error = FlowyError;
fn try_from(value: String) -> Result<Self, Self::Error> {
TypeCellData::from_json_str(&value)
}
}
impl ToString for TypeCellData {
fn to_string(&self) -> String {
self.cell_str.clone()
}
}
impl TypeCellData {
pub fn new(cell_str: String, field_type: FieldType) -> Self {
TypeCellData {
cell_str,
field_type,
}
}
pub fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| "".to_owned())
}
pub fn is_number(&self) -> bool {
self.field_type == FieldType::Number
}
pub fn is_text(&self) -> bool {
self.field_type == FieldType::RichText
}
pub fn is_checkbox(&self) -> bool {
self.field_type == FieldType::Checkbox
}
pub fn is_date(&self) -> bool {
self.field_type == FieldType::DateTime
}
pub fn is_single_select(&self) -> bool {
self.field_type == FieldType::SingleSelect
}
pub fn is_multi_select(&self) -> bool {
self.field_type == FieldType::MultiSelect
}
pub fn is_checklist(&self) -> bool {
self.field_type == FieldType::Checklist
}
pub fn is_url(&self) -> bool {
self.field_type == FieldType::URL
}
pub fn is_select_option(&self) -> bool {
self.field_type == FieldType::MultiSelect || self.field_type == FieldType::SingleSelect
}
}
use flowy_error::{internal_error, FlowyResult};
/// The data is encoded by protobuf or utf8. You should choose the corresponding decode struct to parse it.
///
@ -116,13 +13,8 @@ impl TypeCellData {
#[derive(Default, Debug)]
pub struct CellProtobufBlob(pub Bytes);
pub trait DecodedCellData {
type Object;
fn is_empty(&self) -> bool;
}
pub trait CellProtobufBlobParser {
type Object: DecodedCellData;
type Object;
fn parser(bytes: &Bytes) -> FlowyResult<Self::Object>;
}

View File

@ -26,8 +26,8 @@ use crate::services::database_view::{
};
use crate::services::field::{
default_type_option_data_from_type, select_type_option_from_field, transform_type_option,
type_option_data_from_pb, ChecklistCellChangeset, SelectOptionCellChangeset, SelectOptionIds,
TimestampCellData, TypeOptionCellDataHandler, TypeOptionCellExt,
type_option_data_from_pb, ChecklistCellChangeset, RelationTypeOption, SelectOptionCellChangeset,
SelectOptionIds, StrCellData, TimestampCellData, TypeOptionCellDataHandler, TypeOptionCellExt,
};
use crate::services::field_settings::{
default_field_settings_by_layout_map, FieldSettings, FieldSettingsChangesetParams,
@ -1238,6 +1238,61 @@ impl DatabaseEditor {
Ok(())
}
pub async fn get_related_database_id(&self, field_id: &str) -> FlowyResult<String> {
let mut field = self
.database
.lock()
.get_fields(Some(vec![field_id.to_string()]));
let field = field.pop().ok_or(FlowyError::internal())?;
let type_option = field
.get_type_option::<RelationTypeOption>(FieldType::Relation)
.ok_or(FlowyError::record_not_found())?;
Ok(type_option.database_id)
}
pub async fn get_related_rows(
&self,
row_ids: Option<&Vec<String>>,
) -> FlowyResult<Vec<RelatedRowDataPB>> {
let primary_field = self.database.lock().fields.get_primary_field().unwrap();
let handler =
TypeOptionCellExt::new_with_cell_data_cache(&primary_field, Some(self.cell_cache.clone()))
.get_type_option_cell_data_handler(&FieldType::RichText)
.ok_or(FlowyError::internal())?;
let row_data = {
let database = self.database.lock();
let mut rows = database.get_database_rows();
if let Some(row_ids) = row_ids {
rows.retain(|row| row_ids.contains(&row.id));
}
rows
.iter()
.map(|row| {
let title = database
.get_cell(&primary_field.id, &row.id)
.cell
.and_then(|cell| {
handler
.get_cell_data(&cell, &FieldType::RichText, &primary_field)
.ok()
})
.and_then(|cell_data| cell_data.unbox_or_none())
.unwrap_or_else(|| StrCellData("".to_string()));
RelatedRowDataPB {
row_id: row.id.to_string(),
name: title.0,
}
})
.collect::<Vec<_>>()
};
Ok(row_data)
}
fn get_auto_updated_fields(&self, view_id: &str) -> Vec<Field> {
self
.database

View File

@ -1,6 +1,6 @@
mod field_builder;
mod field_operation;
mod type_options;
pub mod type_options;
pub use field_builder::*;
pub use field_operation::*;

View File

@ -1,11 +1,12 @@
#[cfg(test)]
mod tests {
use std::str::FromStr;
use collab_database::fields::Field;
use crate::entities::CheckboxCellDataPB;
use crate::entities::FieldType;
use crate::services::cell::CellDataDecoder;
use crate::services::cell::FromCellString;
use crate::services::field::type_options::checkbox_type_option::*;
use crate::services::field::FieldBuilder;
@ -43,7 +44,7 @@ mod tests {
assert_eq!(
type_option
.decode_cell(
&CheckboxCellDataPB::from_cell_str(input_str).unwrap().into(),
&CheckboxCellDataPB::from_str(input_str).unwrap().into(),
field_type,
field
)

View File

@ -124,12 +124,8 @@ impl TypeOptionCellDataFilter for CheckboxTypeOption {
fn apply_filter(
&self,
filter: &<Self as TypeOption>::CellFilter,
field_type: &FieldType,
cell_data: &<Self as TypeOption>::CellData,
) -> bool {
if !field_type.is_checkbox() {
return true;
}
filter.is_visible(cell_data)
}
}

View File

@ -7,7 +7,7 @@ use collab_database::rows::{new_cell_builder, Cell};
use flowy_error::{FlowyError, FlowyResult};
use crate::entities::{CheckboxCellDataPB, FieldType};
use crate::services::cell::{CellProtobufBlobParser, DecodedCellData, FromCellString};
use crate::services::cell::CellProtobufBlobParser;
use crate::services::field::{TypeOptionCellData, CELL_DATA};
pub const CHECK: &str = "Yes";
@ -22,7 +22,7 @@ impl TypeOptionCellData for CheckboxCellDataPB {
impl From<&Cell> for CheckboxCellDataPB {
fn from(cell: &Cell) -> Self {
let value = cell.get_str_value(CELL_DATA).unwrap_or_default();
CheckboxCellDataPB::from_cell_str(&value).unwrap_or_default()
CheckboxCellDataPB::from_str(&value).unwrap_or_default()
}
}
@ -49,15 +49,6 @@ impl FromStr for CheckboxCellDataPB {
}
}
impl FromCellString for CheckboxCellDataPB {
fn from_cell_str(s: &str) -> FlowyResult<Self>
where
Self: Sized,
{
Self::from_str(s)
}
}
impl ToString for CheckboxCellDataPB {
fn to_string(&self) -> String {
if self.is_checked {
@ -68,14 +59,6 @@ impl ToString for CheckboxCellDataPB {
}
}
impl DecodedCellData for CheckboxCellDataPB {
type Object = CheckboxCellDataPB;
fn is_empty(&self) -> bool {
false
}
}
pub struct CheckboxCellDataParser();
impl CellProtobufBlobParser for CheckboxCellDataParser {
type Object = CheckboxCellDataPB;

View File

@ -178,12 +178,8 @@ impl TypeOptionCellDataFilter for ChecklistTypeOption {
fn apply_filter(
&self,
filter: &<Self as TypeOption>::CellFilter,
field_type: &FieldType,
cell_data: &<Self as TypeOption>::CellData,
) -> bool {
if !field_type.is_checklist() {
return true;
}
let selected_options = cell_data.selected_options();
filter.is_visible(&cell_data.options, &selected_options)
}

View File

@ -342,12 +342,8 @@ impl TypeOptionCellDataFilter for DateTypeOption {
fn apply_filter(
&self,
filter: &<Self as TypeOption>::CellFilter,
field_type: &FieldType,
cell_data: &<Self as TypeOption>::CellData,
) -> bool {
if !field_type.is_date() {
return true;
}
filter.is_visible(cell_data).unwrap_or(true)
}
}

View File

@ -11,7 +11,7 @@ use strum_macros::EnumIter;
use flowy_error::{internal_error, FlowyResult};
use crate::entities::{DateCellDataPB, FieldType};
use crate::services::cell::{CellProtobufBlobParser, DecodedCellData, FromCellString};
use crate::services::cell::CellProtobufBlobParser;
use crate::services::field::{TypeOptionCellData, CELL_DATA};
#[derive(Clone, Debug, Default)]
@ -196,16 +196,6 @@ impl<'de> serde::Deserialize<'de> for DateCellData {
}
}
impl FromCellString for DateCellData {
fn from_cell_str(s: &str) -> FlowyResult<Self>
where
Self: Sized,
{
let result: DateCellData = serde_json::from_str(s).unwrap();
Ok(result)
}
}
impl ToString for DateCellData {
fn to_string(&self) -> String {
serde_json::to_string(self).unwrap()
@ -288,14 +278,6 @@ impl TimeFormat {
}
}
impl DecodedCellData for DateCellDataPB {
type Object = DateCellDataPB;
fn is_empty(&self) -> bool {
self.date.is_empty()
}
}
pub struct DateCellDataParser();
impl CellProtobufBlobParser for DateCellDataParser {
type Object = DateCellDataPB;

View File

@ -2,6 +2,7 @@ pub mod checkbox_type_option;
pub mod checklist_type_option;
pub mod date_type_option;
pub mod number_type_option;
pub mod relation_type_option;
pub mod selection_type_option;
pub mod text_type_option;
pub mod timestamp_type_option;
@ -14,6 +15,7 @@ pub use checkbox_type_option::*;
pub use checklist_type_option::*;
pub use date_type_option::*;
pub use number_type_option::*;
pub use relation_type_option::*;
pub use selection_type_option::*;
pub use text_type_option::*;
pub use timestamp_type_option::*;

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