mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Merge branch 'main' into workspace-rename-icon
This commit is contained in:
commit
06d5d57637
@ -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: []);
|
||||
}
|
@ -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: [],
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -144,9 +144,9 @@ class GridCreateFilterBloc
|
||||
fieldId: fieldId,
|
||||
condition: TextFilterConditionPB.Contains,
|
||||
);
|
||||
default:
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
return FlowyResult.success(null);
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -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) {
|
||||
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
@ -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],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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) {
|
||||
|
@ -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() => "";
|
||||
}
|
@ -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");
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -75,6 +75,7 @@ class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
|
||||
@override
|
||||
void dispose() {
|
||||
widget.textController.removeListener(_onChanged);
|
||||
focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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 |
@ -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 |
@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
export function Colors() {
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
export default Colors;
|
@ -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;
|
@ -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>
|
||||
);
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
export function UploadImage() {
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
export default UploadImage;
|
@ -0,0 +1,4 @@
|
||||
export * from './Unsplash';
|
||||
export * from './UploadImage';
|
||||
export * from './EmbedLink';
|
||||
export * from './Colors';
|
@ -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;
|
||||
|
@ -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';
|
||||
|
@ -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 }) => {
|
||||
|
@ -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),
|
||||
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -0,0 +1 @@
|
||||
export * from './ImageBlock';
|
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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,
|
||||
|
@ -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';
|
||||
|
@ -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',
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
@ -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 && (
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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'}
|
||||
|
@ -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}
|
||||
|
@ -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();
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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];
|
||||
};
|
@ -1,3 +1,2 @@
|
||||
export * from './shortcuts.hooks';
|
||||
export * from './withShortcuts';
|
||||
export * from './hotkey';
|
||||
|
@ -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)) {
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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'}
|
||||
>
|
||||
|
@ -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() {
|
||||
|
@ -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({
|
||||
|
@ -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;
|
||||
};
|
61
frontend/appflowy_tauri/src/appflowy_app/utils/hotkeys.ts
Normal file
61
frontend/appflowy_tauri/src/appflowy_app/utils/hotkeys.ts
Normal 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(' + ');
|
||||
};
|
@ -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)) {
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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(¤t_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(¤t_workspace.id, "origin".to_owned(), vec![])
|
||||
.await;
|
||||
let relation_grid_view = test
|
||||
.create_grid(¤t_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");
|
||||
}
|
||||
|
@ -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)]
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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::*;
|
||||
|
@ -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 }
|
||||
}
|
||||
}
|
@ -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),
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
},
|
||||
}
|
||||
|
@ -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::*;
|
||||
|
@ -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>,
|
||||
}
|
@ -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(¶ms)
|
||||
// .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(¶ms.database_id).await?;
|
||||
let row_datas = database_editor
|
||||
.get_related_rows(Some(¶ms.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 })
|
||||
}
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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());
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>;
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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::*;
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user