mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: support incremental updates for textblock's delta. (#3216)
* feat: support incremental to update textblock's delta * fix: update test code * fix: remove console * fix: update test * feat: integrate increamental delta in Flutter * fix: delete quill editor * fix: delete quill editor * feat: add csharp in codeblock (#3371) * chore: pt-PT & pt-BR translation updated (#3353) * chore: Ensure Cargo.lock Is Updated Alongside Changes to Cargo.toml (#3361) * ci: add cargo check workflow * ci: test cargo.toml * fix: update test * fix: code review * fix: update cargo.toml and cargo.lock * fix: code review * fix: rust format --------- Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io> Co-authored-by: Mayur Mahajan <47064215+MayurSMahajan@users.noreply.github.com> Co-authored-by: Carlos Silva <mtbf99@gmail.com>
This commit is contained in:
parent
9565173baf
commit
c7af04b317
1
.github/workflows/tauri_ci.yaml
vendored
1
.github/workflows/tauri_ci.yaml
vendored
@ -94,6 +94,7 @@ jobs:
|
||||
mkdir dist
|
||||
pnpm install
|
||||
cargo make --cwd .. tauri_build
|
||||
pnpm test
|
||||
pnpm test:errors
|
||||
|
||||
- name: Check for uncommitted changes
|
||||
|
@ -5,7 +5,6 @@ import 'package:appflowy/plugins/document/application/document_data_pb_extension
|
||||
import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart';
|
||||
import 'package:appflowy/plugins/trash/application/trash_service.dart';
|
||||
import 'package:appflowy/user/application/user_service.dart';
|
||||
import 'package:appflowy/util/json_print.dart';
|
||||
import 'package:appflowy/workspace/application/doc/doc_listener.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_listener.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart';
|
||||
@ -122,10 +121,7 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
|
||||
/// subscribe to the document content change
|
||||
void _onDocumentChanged() {
|
||||
_documentListener.start(
|
||||
didReceiveUpdate: (docEvent) {
|
||||
// todo: integrate the document change to the editor
|
||||
// prettyPrintJson(docEvent.toProto3Json());
|
||||
},
|
||||
didReceiveUpdate: syncDocumentDataPB,
|
||||
);
|
||||
}
|
||||
|
||||
@ -143,10 +139,6 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
|
||||
}
|
||||
|
||||
Future<void> _initAppFlowyEditorState(DocumentDataPB data) async {
|
||||
if (kDebugMode) {
|
||||
prettyPrintJson(data.toProto3Json());
|
||||
}
|
||||
|
||||
final document = data.toDocument();
|
||||
if (document == null) {
|
||||
assert(false, 'document is null');
|
||||
@ -213,6 +205,24 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
void syncDocumentDataPB(DocEventPB docEvent) {
|
||||
// prettyPrintJson(docEvent.toProto3Json());
|
||||
// todo: integrate the document change to the editor
|
||||
// for (final event in docEvent.events) {
|
||||
// for (final blockEvent in event.event) {
|
||||
// switch (blockEvent.command) {
|
||||
// case DeltaTypePB.Inserted:
|
||||
// break;
|
||||
// case DeltaTypePB.Updated:
|
||||
// break;
|
||||
// case DeltaTypePB.Removed:
|
||||
// break;
|
||||
// default:
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
|
@ -1,9 +1,8 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-document2/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:dartz/dartz.dart';
|
||||
|
||||
class DocumentService {
|
||||
// unused now.
|
||||
@ -46,4 +45,42 @@ class DocumentService {
|
||||
final result = await DocumentEventApplyAction(payload).send();
|
||||
return result.swap();
|
||||
}
|
||||
|
||||
/// Creates a new external text.
|
||||
///
|
||||
/// Normally, it's used to the block that needs sync long text.
|
||||
///
|
||||
/// the delta parameter is the json representation of the delta.
|
||||
Future<Either<FlowyError, Unit>> createExternalText({
|
||||
required String documentId,
|
||||
required String textId,
|
||||
String? delta,
|
||||
}) async {
|
||||
final payload = TextDeltaPayloadPB(
|
||||
documentId: documentId,
|
||||
textId: textId,
|
||||
delta: delta,
|
||||
);
|
||||
final result = await DocumentEventCreateText(payload).send();
|
||||
return result.swap();
|
||||
}
|
||||
|
||||
/// Updates the external text.
|
||||
///
|
||||
/// this function is compatible with the [createExternalText] function.
|
||||
///
|
||||
/// the delta parameter is the json representation of the delta too.
|
||||
Future<Either<FlowyError, Unit>> updateExternalText({
|
||||
required String documentId,
|
||||
required String textId,
|
||||
String? delta,
|
||||
}) async {
|
||||
final payload = TextDeltaPayloadPB(
|
||||
documentId: documentId,
|
||||
textId: textId,
|
||||
delta: delta,
|
||||
);
|
||||
final result = await DocumentEventApplyTextDeltaEvent(payload).send();
|
||||
return result.swap();
|
||||
}
|
||||
}
|
||||
|
@ -3,10 +3,27 @@ import 'dart:convert';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart'
|
||||
show Document, Node, Attributes, Delta, ParagraphBlockKeys, NodeIterator;
|
||||
show
|
||||
Document,
|
||||
Node,
|
||||
Attributes,
|
||||
Delta,
|
||||
ParagraphBlockKeys,
|
||||
NodeIterator,
|
||||
NodeExternalValues;
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:nanoid/nanoid.dart';
|
||||
|
||||
class ExternalValues extends NodeExternalValues {
|
||||
const ExternalValues({
|
||||
required this.externalId,
|
||||
required this.externalType,
|
||||
});
|
||||
|
||||
final String externalId;
|
||||
final String externalType;
|
||||
}
|
||||
|
||||
extension DocumentDataPBFromTo on DocumentDataPB {
|
||||
static DocumentDataPB? fromDocument(Document document) {
|
||||
final startNode = document.first;
|
||||
@ -84,24 +101,51 @@ extension DocumentDataPBFromTo on DocumentDataPB {
|
||||
children.addAll(childrenIds.map((e) => buildNode(e)).whereNotNull());
|
||||
}
|
||||
|
||||
return block?.toNode(children: children);
|
||||
return block?.toNode(
|
||||
children: children,
|
||||
meta: meta,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension BlockToNode on BlockPB {
|
||||
Node toNode({
|
||||
Iterable<Node>? children,
|
||||
required MetaPB meta,
|
||||
}) {
|
||||
return Node(
|
||||
final node = Node(
|
||||
id: id,
|
||||
type: ty,
|
||||
attributes: _dataAdapter(ty, data),
|
||||
attributes: _dataAdapter(ty, data, meta),
|
||||
children: children ?? [],
|
||||
);
|
||||
node.externalValues = ExternalValues(
|
||||
externalId: externalId,
|
||||
externalType: externalType,
|
||||
);
|
||||
return node;
|
||||
}
|
||||
|
||||
Attributes _dataAdapter(String ty, String data) {
|
||||
Attributes _dataAdapter(String ty, String data, MetaPB meta) {
|
||||
final map = Attributes.from(jsonDecode(data));
|
||||
|
||||
// it used in the delta case now.
|
||||
final externalType = this.externalType;
|
||||
final externalId = this.externalId;
|
||||
if (externalType.isNotEmpty && externalId.isNotEmpty) {
|
||||
// the 'text' type is the only type that is supported now.
|
||||
if (externalType == 'text') {
|
||||
final deltaString = meta.textMap[externalId];
|
||||
if (deltaString != null) {
|
||||
final delta = jsonDecode(deltaString);
|
||||
map.putIfAbsent(
|
||||
'delta',
|
||||
() => delta,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final adapter = {
|
||||
ParagraphBlockKeys.type: (Attributes map) => map
|
||||
..putIfAbsent(
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy/plugins/document/application/doc_service.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
|
||||
@ -15,6 +16,7 @@ import 'package:appflowy_editor/appflowy_editor.dart'
|
||||
PathExtensions,
|
||||
Node,
|
||||
Path,
|
||||
Delta,
|
||||
composeAttributes;
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:nanoid/nanoid.dart';
|
||||
@ -32,28 +34,66 @@ class TransactionAdapter {
|
||||
final DocumentService documentService;
|
||||
final String documentId;
|
||||
|
||||
final bool _enableDebug = false;
|
||||
|
||||
Future<void> apply(Transaction transaction, EditorState editorState) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
Log.debug('transaction => ${transaction.toJson()}');
|
||||
final actions = transaction.operations
|
||||
.map((op) => op.toBlockAction(editorState))
|
||||
.map((op) => op.toBlockAction(editorState, documentId))
|
||||
.whereNotNull()
|
||||
.expand((element) => element)
|
||||
.toList(growable: false); // avoid lazy evaluation
|
||||
Log.debug('actions => $actions');
|
||||
final textActions = actions.where(
|
||||
(e) =>
|
||||
e.textDeltaType != TextDeltaType.none && e.textDeltaPayloadPB != null,
|
||||
);
|
||||
final actionCostTime = stopwatch.elapsedMilliseconds;
|
||||
for (final textAction in textActions) {
|
||||
final payload = textAction.textDeltaPayloadPB!;
|
||||
final type = textAction.textDeltaType;
|
||||
if (type == TextDeltaType.create) {
|
||||
await documentService.createExternalText(
|
||||
documentId: payload.documentId,
|
||||
textId: payload.textId,
|
||||
delta: payload.delta,
|
||||
);
|
||||
Log.debug('create external text: ${payload.delta}');
|
||||
} else if (type == TextDeltaType.update) {
|
||||
await documentService.updateExternalText(
|
||||
documentId: payload.documentId,
|
||||
textId: payload.textId,
|
||||
delta: payload.delta,
|
||||
);
|
||||
Log.debug('update external text: ${payload.delta}');
|
||||
}
|
||||
}
|
||||
final blockActions =
|
||||
actions.map((e) => e.blockActionPB).toList(growable: false);
|
||||
await documentService.applyAction(
|
||||
documentId: documentId,
|
||||
actions: actions,
|
||||
actions: blockActions,
|
||||
);
|
||||
final elapsed = stopwatch.elapsedMilliseconds;
|
||||
stopwatch.stop();
|
||||
if (_enableDebug) {
|
||||
Log.debug(
|
||||
'apply transaction cost: total $elapsed ms, converter action $actionCostTime ms, apply action ${elapsed - actionCostTime} ms',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension BlockAction on Operation {
|
||||
List<BlockActionPB> toBlockAction(EditorState editorState) {
|
||||
List<BlockActionWrapper> toBlockAction(
|
||||
EditorState editorState,
|
||||
String documentId,
|
||||
) {
|
||||
final op = this;
|
||||
if (op is InsertOperation) {
|
||||
return op.toBlockAction(editorState);
|
||||
return op.toBlockAction(editorState, documentId);
|
||||
} else if (op is UpdateOperation) {
|
||||
return op.toBlockAction(editorState);
|
||||
return op.toBlockAction(editorState, documentId);
|
||||
} else if (op is DeleteOperation) {
|
||||
return op.toBlockAction(editorState);
|
||||
}
|
||||
@ -62,12 +102,13 @@ extension BlockAction on Operation {
|
||||
}
|
||||
|
||||
extension on InsertOperation {
|
||||
List<BlockActionPB> toBlockAction(
|
||||
EditorState editorState, {
|
||||
List<BlockActionWrapper> toBlockAction(
|
||||
EditorState editorState,
|
||||
String documentId, {
|
||||
Node? previousNode,
|
||||
}) {
|
||||
Path currentPath = path;
|
||||
final List<BlockActionPB> actions = [];
|
||||
final List<BlockActionWrapper> actions = [];
|
||||
for (final node in nodes) {
|
||||
final parentId = node.parent?.id ??
|
||||
editorState.getNodeAtPath(currentPath.parent)?.id ??
|
||||
@ -82,22 +123,58 @@ extension on InsertOperation {
|
||||
} else {
|
||||
assert(prevId.isNotEmpty && prevId != node.id);
|
||||
}
|
||||
|
||||
// create the external text if the node contains the delta in its data.
|
||||
final delta = node.delta;
|
||||
TextDeltaPayloadPB? textDeltaPayloadPB;
|
||||
if (delta != null) {
|
||||
final textId = nanoid(6);
|
||||
|
||||
textDeltaPayloadPB = TextDeltaPayloadPB(
|
||||
documentId: documentId,
|
||||
textId: textId,
|
||||
delta: jsonEncode(node.delta!.toJson()),
|
||||
);
|
||||
|
||||
// sync the text id to the node
|
||||
node.externalValues = ExternalValues(
|
||||
externalId: textId,
|
||||
externalType: 'text',
|
||||
);
|
||||
}
|
||||
|
||||
// remove the delta from the data when the incremental update is stable.
|
||||
final payload = BlockActionPayloadPB()
|
||||
..block = node.toBlock(childrenId: nanoid(10))
|
||||
..block = node.toBlock(childrenId: nanoid(6))
|
||||
..parentId = parentId
|
||||
..prevId = prevId;
|
||||
|
||||
// pass the external text id to the payload.
|
||||
if (textDeltaPayloadPB != null) {
|
||||
payload.textId = textDeltaPayloadPB.textId;
|
||||
}
|
||||
|
||||
assert(payload.block.childrenId.isNotEmpty);
|
||||
final blockActionPB = BlockActionPB()
|
||||
..action = BlockActionTypePB.Insert
|
||||
..payload = payload;
|
||||
|
||||
actions.add(
|
||||
BlockActionPB()
|
||||
..action = BlockActionTypePB.Insert
|
||||
..payload = payload,
|
||||
BlockActionWrapper(
|
||||
blockActionPB: blockActionPB,
|
||||
textDeltaPayloadPB: textDeltaPayloadPB,
|
||||
textDeltaType: TextDeltaType.create,
|
||||
),
|
||||
);
|
||||
if (node.children.isNotEmpty) {
|
||||
Node? prevChild;
|
||||
for (final child in node.children) {
|
||||
actions.addAll(
|
||||
InsertOperation(currentPath + child.path, [child])
|
||||
.toBlockAction(editorState, previousNode: prevChild),
|
||||
InsertOperation(currentPath + child.path, [child]).toBlockAction(
|
||||
editorState,
|
||||
documentId,
|
||||
previousNode: prevChild,
|
||||
),
|
||||
);
|
||||
prevChild = child;
|
||||
}
|
||||
@ -110,8 +187,11 @@ extension on InsertOperation {
|
||||
}
|
||||
|
||||
extension on UpdateOperation {
|
||||
List<BlockActionPB> toBlockAction(EditorState editorState) {
|
||||
final List<BlockActionPB> actions = [];
|
||||
List<BlockActionWrapper> toBlockAction(
|
||||
EditorState editorState,
|
||||
String documentId,
|
||||
) {
|
||||
final List<BlockActionWrapper> actions = [];
|
||||
|
||||
// if the attributes are both empty, we don't need to update
|
||||
if (const DeepCollectionEquality().equals(attributes, oldAttributes)) {
|
||||
@ -125,23 +205,74 @@ extension on UpdateOperation {
|
||||
final parentId =
|
||||
node.parent?.id ?? editorState.getNodeAtPath(path.parent)?.id ?? '';
|
||||
assert(parentId.isNotEmpty);
|
||||
|
||||
// create the external text if the node contains the delta in its data.
|
||||
final prevDelta = oldAttributes['delta'];
|
||||
final delta = attributes['delta'];
|
||||
final diff = prevDelta != null && delta != null
|
||||
? Delta.fromJson(prevDelta).diff(
|
||||
Delta.fromJson(delta),
|
||||
)
|
||||
: null;
|
||||
|
||||
final payload = BlockActionPayloadPB()
|
||||
..block = node.toBlock(
|
||||
parentId: parentId,
|
||||
attributes: composeAttributes(oldAttributes, attributes),
|
||||
)
|
||||
..parentId = parentId;
|
||||
actions.add(
|
||||
BlockActionPB()
|
||||
..action = BlockActionTypePB.Update
|
||||
..payload = payload,
|
||||
);
|
||||
final blockActionPB = BlockActionPB()
|
||||
..action = BlockActionTypePB.Update
|
||||
..payload = payload;
|
||||
|
||||
final textId = (node.externalValues as ExternalValues?)?.externalId;
|
||||
if (textId == null || textId.isEmpty) {
|
||||
// to be compatible with the old version, we create a new text id if the text id is empty.
|
||||
final textId = nanoid(6);
|
||||
final textDeltaPayloadPB = delta == null
|
||||
? null
|
||||
: TextDeltaPayloadPB(
|
||||
documentId: documentId,
|
||||
textId: textId,
|
||||
delta: jsonEncode(delta),
|
||||
);
|
||||
|
||||
node.externalValues = ExternalValues(
|
||||
externalId: textId,
|
||||
externalType: 'text',
|
||||
);
|
||||
|
||||
actions.add(
|
||||
BlockActionWrapper(
|
||||
blockActionPB: blockActionPB,
|
||||
textDeltaPayloadPB: textDeltaPayloadPB,
|
||||
textDeltaType: TextDeltaType.create,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final textDeltaPayloadPB = delta == null
|
||||
? null
|
||||
: TextDeltaPayloadPB(
|
||||
documentId: documentId,
|
||||
textId: textId,
|
||||
delta: jsonEncode(diff),
|
||||
);
|
||||
|
||||
actions.add(
|
||||
BlockActionWrapper(
|
||||
blockActionPB: blockActionPB,
|
||||
textDeltaPayloadPB: textDeltaPayloadPB,
|
||||
textDeltaType: TextDeltaType.update,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
|
||||
extension on DeleteOperation {
|
||||
List<BlockActionPB> toBlockAction(EditorState editorState) {
|
||||
List<BlockActionWrapper> toBlockAction(EditorState editorState) {
|
||||
final List<BlockActionPB> actions = [];
|
||||
for (final node in nodes) {
|
||||
final parentId =
|
||||
@ -158,6 +289,26 @@ extension on DeleteOperation {
|
||||
..payload = payload,
|
||||
);
|
||||
}
|
||||
return actions;
|
||||
return actions
|
||||
.map((e) => BlockActionWrapper(blockActionPB: e))
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
|
||||
enum TextDeltaType {
|
||||
none,
|
||||
create,
|
||||
update,
|
||||
}
|
||||
|
||||
class BlockActionWrapper {
|
||||
BlockActionWrapper({
|
||||
required this.blockActionPB,
|
||||
this.textDeltaType = TextDeltaType.none,
|
||||
this.textDeltaPayloadPB,
|
||||
});
|
||||
|
||||
final BlockActionPB blockActionPB;
|
||||
final TextDeltaPayloadPB? textDeltaPayloadPB;
|
||||
final TextDeltaType textDeltaType;
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
// import 'dart:convert';
|
||||
// import 'package:appflowy_backend/log.dart';
|
||||
// const JsonEncoder _encoder = JsonEncoder.withIndent(' ');
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
|
||||
const JsonEncoder _encoder = JsonEncoder.withIndent(' ');
|
||||
void prettyPrintJson(Object? object) {
|
||||
// Log.trace(_encoder.convert(object));
|
||||
Log.trace(_encoder.convert(object));
|
||||
}
|
||||
|
@ -44,7 +44,6 @@ dependencies:
|
||||
git:
|
||||
url: https://github.com/AppFlowy-IO/appflowy-board.git
|
||||
ref: a183c57
|
||||
# appflowy_editor: 1.2.3
|
||||
appflowy_editor:
|
||||
git:
|
||||
url: https://github.com/AppFlowy-IO/appflowy-editor.git
|
||||
|
@ -1,7 +1,7 @@
|
||||
import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart';
|
||||
|
||||
void main() {
|
||||
group('TransactionAdapter', () {
|
||||
@ -24,81 +24,81 @@ void main() {
|
||||
expect(transaction.operations.length, 1);
|
||||
expect(transaction.operations[0] is InsertOperation, true);
|
||||
|
||||
final actions = transaction.operations[0].toBlockAction(editorState);
|
||||
final actions = transaction.operations[0].toBlockAction(editorState, '');
|
||||
|
||||
expect(actions.length, 7);
|
||||
for (final action in actions) {
|
||||
expect(action.action, BlockActionTypePB.Insert);
|
||||
expect(action.blockActionPB.action, BlockActionTypePB.Insert);
|
||||
}
|
||||
|
||||
expect(
|
||||
actions[0].payload.parentId,
|
||||
actions[0].blockActionPB.payload.parentId,
|
||||
editorState.document.root.id,
|
||||
reason: '0 - parent id',
|
||||
);
|
||||
expect(
|
||||
actions[0].payload.prevId,
|
||||
actions[0].blockActionPB.payload.prevId,
|
||||
editorState.document.root.children.first.id,
|
||||
reason: '0 - prev id',
|
||||
);
|
||||
expect(
|
||||
actions[1].payload.parentId,
|
||||
actions[0].payload.block.id,
|
||||
actions[1].blockActionPB.payload.parentId,
|
||||
actions[0].blockActionPB.payload.block.id,
|
||||
reason: '1 - parent id',
|
||||
);
|
||||
expect(
|
||||
actions[1].payload.prevId,
|
||||
actions[1].blockActionPB.payload.prevId,
|
||||
'',
|
||||
reason: '1 - prev id',
|
||||
);
|
||||
expect(
|
||||
actions[2].payload.parentId,
|
||||
actions[1].payload.block.id,
|
||||
actions[2].blockActionPB.payload.parentId,
|
||||
actions[1].blockActionPB.payload.block.id,
|
||||
reason: '2 - parent id',
|
||||
);
|
||||
expect(
|
||||
actions[2].payload.prevId,
|
||||
actions[2].blockActionPB.payload.prevId,
|
||||
'',
|
||||
reason: '2 - prev id',
|
||||
);
|
||||
expect(
|
||||
actions[3].payload.parentId,
|
||||
actions[0].payload.block.id,
|
||||
actions[3].blockActionPB.payload.parentId,
|
||||
actions[0].blockActionPB.payload.block.id,
|
||||
reason: '3 - parent id',
|
||||
);
|
||||
expect(
|
||||
actions[3].payload.prevId,
|
||||
actions[1].payload.block.id,
|
||||
actions[3].blockActionPB.payload.prevId,
|
||||
actions[1].blockActionPB.payload.block.id,
|
||||
reason: '3 - prev id',
|
||||
);
|
||||
expect(
|
||||
actions[4].payload.parentId,
|
||||
actions[0].payload.block.id,
|
||||
actions[4].blockActionPB.payload.parentId,
|
||||
actions[0].blockActionPB.payload.block.id,
|
||||
reason: '4 - parent id',
|
||||
);
|
||||
expect(
|
||||
actions[4].payload.prevId,
|
||||
actions[3].payload.block.id,
|
||||
actions[4].blockActionPB.payload.prevId,
|
||||
actions[3].blockActionPB.payload.block.id,
|
||||
reason: '4 - prev id',
|
||||
);
|
||||
expect(
|
||||
actions[5].payload.parentId,
|
||||
actions[4].payload.block.id,
|
||||
actions[5].blockActionPB.payload.parentId,
|
||||
actions[4].blockActionPB.payload.block.id,
|
||||
reason: '5 - parent id',
|
||||
);
|
||||
expect(
|
||||
actions[5].payload.prevId,
|
||||
actions[5].blockActionPB.payload.prevId,
|
||||
'',
|
||||
reason: '5 - prev id',
|
||||
);
|
||||
expect(
|
||||
actions[6].payload.parentId,
|
||||
actions[0].payload.block.id,
|
||||
actions[6].blockActionPB.payload.parentId,
|
||||
actions[0].blockActionPB.payload.block.id,
|
||||
reason: '6 - parent id',
|
||||
);
|
||||
expect(
|
||||
actions[6].payload.prevId,
|
||||
actions[4].payload.block.id,
|
||||
actions[6].blockActionPB.payload.prevId,
|
||||
actions[4].blockActionPB.payload.block.id,
|
||||
reason: '6 - prev id',
|
||||
);
|
||||
});
|
||||
@ -120,31 +120,31 @@ void main() {
|
||||
expect(transaction.operations.length, 1);
|
||||
expect(transaction.operations[0] is InsertOperation, true);
|
||||
|
||||
final actions = transaction.operations[0].toBlockAction(editorState);
|
||||
final actions = transaction.operations[0].toBlockAction(editorState, '');
|
||||
|
||||
expect(actions.length, 2);
|
||||
for (final action in actions) {
|
||||
expect(action.action, BlockActionTypePB.Insert);
|
||||
expect(action.blockActionPB.action, BlockActionTypePB.Insert);
|
||||
}
|
||||
|
||||
expect(
|
||||
actions[0].payload.parentId,
|
||||
actions[0].blockActionPB.payload.parentId,
|
||||
editorState.document.root.children.first.id,
|
||||
reason: '0 - parent id',
|
||||
);
|
||||
expect(
|
||||
actions[0].payload.prevId,
|
||||
actions[0].blockActionPB.payload.prevId,
|
||||
'',
|
||||
reason: '0 - prev id',
|
||||
);
|
||||
expect(
|
||||
actions[1].payload.parentId,
|
||||
actions[1].blockActionPB.payload.parentId,
|
||||
editorState.document.root.children.first.id,
|
||||
reason: '1 - parent id',
|
||||
);
|
||||
expect(
|
||||
actions[1].payload.prevId,
|
||||
actions[0].payload.block.id,
|
||||
actions[1].blockActionPB.payload.prevId,
|
||||
actions[0].blockActionPB.payload.block.id,
|
||||
reason: '1 - prev id',
|
||||
);
|
||||
});
|
||||
|
4
frontend/appflowy_tauri/.gitignore
vendored
4
frontend/appflowy_tauri/.gitignore
vendored
@ -25,4 +25,6 @@ dist-ssr
|
||||
|
||||
**/src/services/backend/models/
|
||||
**/src/services/backend/events/
|
||||
**/src/appflowy_app/i18n/translations/
|
||||
**/src/appflowy_app/i18n/translations/
|
||||
|
||||
coverage
|
18
frontend/appflowy_tauri/jest.config.cjs
Normal file
18
frontend/appflowy_tauri/jest.config.cjs
Normal file
@ -0,0 +1,18 @@
|
||||
const { compilerOptions } = require('./tsconfig.json');
|
||||
const { pathsToModuleNameMapper } = require("ts-jest");
|
||||
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>'],
|
||||
modulePaths: [compilerOptions.baseUrl],
|
||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths),
|
||||
"transform": {
|
||||
"(.*)/node_modules/nanoid/.+\\.(j|t)sx?$": "ts-jest"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!nanoid/.*)"
|
||||
],
|
||||
"testRegex": "(/__tests__/.*\.(test|spec))\\.(jsx?|tsx?)$",
|
||||
};
|
@ -14,7 +14,8 @@
|
||||
"tauri:clean": "cargo make --cwd .. tauri_clean",
|
||||
"tauri:dev": "pnpm sync:i18n && tauri dev",
|
||||
"sync:i18n": "node scripts/i18n/index.cjs",
|
||||
"css:variables": "node style-dictionary/config.cjs"
|
||||
"css:variables": "node style-dictionary/config.cjs",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "^1.1.2",
|
||||
@ -70,6 +71,7 @@
|
||||
"@tauri-apps/cli": "^1.2.2",
|
||||
"@types/google-protobuf": "^3.15.6",
|
||||
"@types/is-hotkey": "^0.1.7",
|
||||
"@types/jest": "^29.5.3",
|
||||
"@types/katex": "^0.16.0",
|
||||
"@types/node": "^18.7.10",
|
||||
"@types/prismjs": "^1.26.0",
|
||||
@ -86,17 +88,22 @@
|
||||
"@typescript-eslint/parser": "^5.51.0",
|
||||
"@vitejs/plugin-react": "^3.0.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"babel-jest": "^29.6.2",
|
||||
"eslint": "^8.34.0",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"jest-environment-jsdom": "^29.6.2",
|
||||
"postcss": "^8.4.21",
|
||||
"prettier": "2.8.4",
|
||||
"prettier-plugin-tailwindcss": "^0.2.2",
|
||||
"style-dictionary": "^3.8.0",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"tsconfig-paths-jest": "^0.0.1",
|
||||
"typescript": "^4.6.4",
|
||||
"uuid": "^9.0.0",
|
||||
"vite": "^4.0.0",
|
||||
"vite-plugin-svgr": "^3.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
40
frontend/appflowy_tauri/src-tauri/Cargo.lock
generated
40
frontend/appflowy_tauri/src-tauri/Cargo.lock
generated
@ -140,7 +140,7 @@ checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6"
|
||||
[[package]]
|
||||
name = "appflowy-integrate"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"collab",
|
||||
@ -729,7 +729,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@ -748,7 +748,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-database"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@ -777,7 +777,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-define"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"collab",
|
||||
@ -789,7 +789,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-derive"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -801,12 +801,13 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-document"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"collab",
|
||||
"collab-derive",
|
||||
"collab-persistence",
|
||||
"lib0",
|
||||
"nanoid",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
@ -820,7 +821,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-folder"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@ -840,7 +841,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-persistence"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bincode",
|
||||
@ -861,16 +862,17 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-plugins"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"collab",
|
||||
"collab-define",
|
||||
"collab-persistence",
|
||||
"collab-sync",
|
||||
"collab-sync-protocol",
|
||||
"collab-ws",
|
||||
"futures-util",
|
||||
"lib0",
|
||||
"parking_lot",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
@ -887,23 +889,15 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "collab-sync"
|
||||
name = "collab-sync-protocol"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"collab",
|
||||
"futures-util",
|
||||
"lib0",
|
||||
"md5",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"y-sync",
|
||||
"yrs",
|
||||
]
|
||||
@ -911,7 +905,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-user"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"collab",
|
||||
@ -927,10 +921,10 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-ws"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"collab-sync",
|
||||
"collab-sync-protocol",
|
||||
"futures-util",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
@ -34,15 +34,15 @@ default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
[patch.crates-io]
|
||||
collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
|
||||
collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
|
||||
collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
|
||||
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
|
||||
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
|
||||
appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
|
||||
collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
|
||||
collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
|
||||
collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
|
||||
collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
|
||||
collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
|
||||
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
|
||||
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
|
||||
appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
|
||||
collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
|
||||
collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
|
||||
collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
|
||||
collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
|
||||
|
||||
#collab = { path = "../../../../AppFlowy-Collab/collab" }
|
||||
#collab-folder = { path = "../../../../AppFlowy-Collab/collab-folder" }
|
||||
|
@ -40,11 +40,10 @@ export function useRangeKeyDown() {
|
||||
},
|
||||
handler: (e: KeyboardEvent) => {
|
||||
if (!controller) return;
|
||||
const insertDelta = new Delta().insert(e.key);
|
||||
dispatch(
|
||||
deleteRangeAndInsertThunk({
|
||||
controller,
|
||||
insertDelta,
|
||||
insertChar: e.key,
|
||||
})
|
||||
);
|
||||
},
|
||||
@ -104,6 +103,7 @@ export function useRangeKeyDown() {
|
||||
handler: (e: KeyboardEvent) => {
|
||||
if (!controller) return;
|
||||
const format = parseFormat(e);
|
||||
|
||||
if (!format) return;
|
||||
dispatch(
|
||||
toggleFormatThunk({
|
||||
@ -122,19 +122,25 @@ export function useRangeKeyDown() {
|
||||
if (!rangeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { anchor, focus } = rangeRef.current;
|
||||
|
||||
if (!anchor || !focus) return;
|
||||
|
||||
if (anchor.id === focus.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
const filteredEvents = interceptEvents.filter((event) => event.canHandle(e));
|
||||
const lastIndex = filteredEvents.length - 1;
|
||||
|
||||
if (lastIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastEvent = filteredEvents[lastIndex];
|
||||
|
||||
if (!lastEvent) return;
|
||||
e.preventDefault();
|
||||
lastEvent.handler(e);
|
||||
|
@ -22,11 +22,11 @@ import {
|
||||
SlashCommandOptionKey,
|
||||
} from '$app/interfaces/document';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { triggerSlashCommandActionThunk } from '$app_reducers/document/async-actions/menu';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import { slashCommandActions } from '$app_reducers/document/slice';
|
||||
import { Keyboard } from '$app/constants/document/keyboard';
|
||||
import { selectOptionByUpDown } from '$app/utils/document/menu';
|
||||
import { turnToBlockThunk } from '$app_reducers/document/async-actions';
|
||||
|
||||
function BlockSlashMenu({
|
||||
id,
|
||||
@ -48,13 +48,11 @@ function BlockSlashMenu({
|
||||
async (type: BlockType, data?: BlockData<any>) => {
|
||||
if (!controller) return;
|
||||
await dispatch(
|
||||
triggerSlashCommandActionThunk({
|
||||
turnToBlockThunk({
|
||||
controller,
|
||||
id,
|
||||
props: {
|
||||
type,
|
||||
data,
|
||||
},
|
||||
type,
|
||||
data,
|
||||
})
|
||||
);
|
||||
onClose?.();
|
||||
|
@ -1,11 +1,9 @@
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { slashCommandActions } from '$app_reducers/document/slice';
|
||||
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||
import Delta from 'quill-delta';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import { useSubscribeSlashState } from '$app/components/document/_shared/SubscribeSlash.hooks';
|
||||
import { getDeltaText } from '$app/utils/document/delta';
|
||||
import { useSubscribePanelSearchText } from '$app/components/document/_shared/usePanelSearchText';
|
||||
|
||||
export function useBlockSlash() {
|
||||
const dispatch = useAppDispatch();
|
||||
@ -68,28 +66,12 @@ export function useSubscribeSlash() {
|
||||
const slashCommandState = useSubscribeSlashState();
|
||||
const visible = slashCommandState.isSlashCommand;
|
||||
const blockId = slashCommandState.blockId;
|
||||
const rightDistanceRef = useRef<number>(0);
|
||||
|
||||
const { node } = useSubscribeNode(blockId || '');
|
||||
|
||||
const slashText = useMemo(() => {
|
||||
if (!node) return '';
|
||||
const delta = new Delta(node.data.delta);
|
||||
const length = delta.length();
|
||||
const slicedDelta = delta.slice(0, length - rightDistanceRef.current);
|
||||
|
||||
return getDeltaText(slicedDelta);
|
||||
}, [node]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
rightDistanceRef.current = new Delta(node.data.delta).length();
|
||||
}, [visible]);
|
||||
const { searchText } = useSubscribePanelSearchText({ blockId: '', open: visible });
|
||||
|
||||
return {
|
||||
visible,
|
||||
blockId,
|
||||
slashText,
|
||||
slashText: searchText,
|
||||
hoverOption: slashCommandState.hoverOption,
|
||||
};
|
||||
}
|
||||
|
@ -1,36 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Delta, { Op } from 'quill-delta';
|
||||
import { getDeltaText } from '$app/utils/document/delta';
|
||||
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useAppSelector } from '$app/stores/store';
|
||||
import { Page } from '$app_reducers/pages/slice';
|
||||
|
||||
export function useSubscribeMentionSearchText({ blockId, open }: { blockId: string; open: boolean }) {
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const beforeOpenDeltaRef = useRef<Op[]>([]);
|
||||
const { node } = useSubscribeNode(blockId);
|
||||
const handleSearch = useCallback((newDelta: Delta) => {
|
||||
const diff = new Delta(beforeOpenDeltaRef.current).diff(newDelta);
|
||||
const text = getDeltaText(diff);
|
||||
|
||||
setSearchText(text);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
handleSearch(new Delta(node?.data?.delta));
|
||||
}, [handleSearch, node?.data?.delta, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
beforeOpenDeltaRef.current = node?.data?.delta;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
return {
|
||||
searchText,
|
||||
};
|
||||
}
|
||||
export function useMentionPopoverProps({ open }: { open: boolean }) {
|
||||
const [anchorPosition, setAnchorPosition] = useState<
|
||||
| {
|
||||
@ -43,12 +14,14 @@ export function useMentionPopoverProps({ open }: { open: boolean }) {
|
||||
const getPosition = useCallback(() => {
|
||||
const range = document.getSelection()?.getRangeAt(0);
|
||||
const rangeRect = range?.getBoundingClientRect();
|
||||
|
||||
return rangeRect;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const position = getPosition();
|
||||
|
||||
if (!position) return;
|
||||
setAnchorPosition({
|
||||
top: position.top + position.height || 0,
|
||||
@ -75,10 +48,9 @@ export function useLoadRecentPages(searchText: string) {
|
||||
return page;
|
||||
})
|
||||
.filter((page) => {
|
||||
const text = searchText.slice(1, searchText.length);
|
||||
if (!text) return true;
|
||||
return page.name.toLowerCase().includes(text.toLowerCase());
|
||||
return page.name.toLowerCase().includes(searchText.toLowerCase());
|
||||
});
|
||||
|
||||
setRecentPages(recentPages);
|
||||
}, [pages, searchText]);
|
||||
|
||||
|
@ -1,16 +1,18 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useSubscribeMentionState } from '$app/components/document/_shared/SubscribeMention.hooks';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { mentionActions } from '$app_reducers/document/mention_slice';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import { useMentionPopoverProps, useSubscribeMentionSearchText } from '$app/components/document/Mention/Mention.hooks';
|
||||
import { useMentionPopoverProps } from '$app/components/document/Mention/Mention.hooks';
|
||||
import RecentPages from '$app/components/document/Mention/RecentPages';
|
||||
import { formatMention, MentionType } from '$app_reducers/document/async-actions/mention';
|
||||
import { useSubscribePanelSearchText } from '$app/components/document/_shared/usePanelSearchText';
|
||||
|
||||
function MentionPopover() {
|
||||
const { docId, controller } = useSubscribeDocument();
|
||||
const { open, blockId } = useSubscribeMentionState();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(
|
||||
@ -20,7 +22,7 @@ function MentionPopover() {
|
||||
);
|
||||
}, [dispatch, docId]);
|
||||
|
||||
const { searchText } = useSubscribeMentionSearchText({
|
||||
const { searchText } = useSubscribePanelSearchText({
|
||||
blockId,
|
||||
open,
|
||||
});
|
||||
@ -29,12 +31,6 @@ function MentionPopover() {
|
||||
open,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (searchText === '' && popoverOpen) {
|
||||
onClose();
|
||||
}
|
||||
}, [searchText, popoverOpen, onClose]);
|
||||
|
||||
const onSelectPage = useCallback(
|
||||
async (pageId: string) => {
|
||||
await dispatch(
|
||||
@ -70,8 +66,7 @@ function MentionPopover() {
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
boxShadow:
|
||||
"var(--shadow-resize-popover)",
|
||||
boxShadow: 'var(--shadow-resize-popover)',
|
||||
}}
|
||||
className={'flex w-[420px] flex-col rounded-md bg-bg-body px-4 py-2'}
|
||||
>
|
||||
|
@ -52,8 +52,11 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
|
||||
isActive,
|
||||
})
|
||||
);
|
||||
const actived = await isFormatActive();
|
||||
|
||||
setIsActive(actived);
|
||||
},
|
||||
[controller, dispatch, isActive]
|
||||
[controller, dispatch, isActive, isFormatActive]
|
||||
);
|
||||
|
||||
const addTemporaryInput = useCallback(
|
||||
|
@ -83,18 +83,9 @@ export function useKeyDown(id: string) {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// handle @ key for mention panel
|
||||
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
return e.key === '@';
|
||||
},
|
||||
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
dispatch(openMention({ docId }));
|
||||
},
|
||||
},
|
||||
...turnIntoEvents,
|
||||
];
|
||||
}, [docId, commonKeyEvents, controller, dispatch, id, turnIntoEvents]);
|
||||
}, [commonKeyEvents, controller, dispatch, id, turnIntoEvents]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
|
@ -6,7 +6,7 @@ import { blockConfig } from '$app/constants/document/config';
|
||||
|
||||
import Delta, { Op } from 'quill-delta';
|
||||
import { useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks';
|
||||
import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||
import { getBlock, getBlockDelta } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import { slashCommandActions } from '$app_reducers/document/slice';
|
||||
import { getDeltaText } from '$app/utils/document/delta';
|
||||
@ -23,9 +23,10 @@ export function useTurnIntoBlockEvents(id: string) {
|
||||
const range = rangeRef.current?.caret;
|
||||
|
||||
if (!range || range.id !== id) return;
|
||||
const node = getBlock(docId, id);
|
||||
const delta = new Delta(node.data.delta || []);
|
||||
|
||||
const delta = getBlockDelta(docId, id);
|
||||
|
||||
if (!delta) return '';
|
||||
return getDeltaText(delta.slice(0, range.index));
|
||||
}, [docId, id, rangeRef]);
|
||||
|
||||
@ -33,8 +34,9 @@ export function useTurnIntoBlockEvents(id: string) {
|
||||
const range = rangeRef.current?.caret;
|
||||
|
||||
if (!range || range.id !== id) return;
|
||||
const node = getBlock(docId, id);
|
||||
const delta = new Delta(node.data.delta || []);
|
||||
const delta = getBlockDelta(docId, id);
|
||||
|
||||
if (!delta) return '';
|
||||
const content = delta.slice(range.index);
|
||||
|
||||
return new Delta(content);
|
||||
@ -174,9 +176,7 @@ export function useTurnIntoBlockEvents(id: string) {
|
||||
id,
|
||||
controller,
|
||||
type: BlockType.DividerBlock,
|
||||
data: {
|
||||
delta: delta?.ops as Op[],
|
||||
},
|
||||
data: {},
|
||||
})
|
||||
);
|
||||
},
|
||||
@ -187,12 +187,17 @@ export function useTurnIntoBlockEvents(id: string) {
|
||||
e.preventDefault();
|
||||
if (!controller) return;
|
||||
const defaultData = blockConfig[BlockType.CodeBlock].defaultData;
|
||||
const data = {
|
||||
...defaultData,
|
||||
delta: getDeltaContent()?.ops as Op[],
|
||||
};
|
||||
|
||||
dispatch(turnToBlockThunk({ id, data, type: BlockType.CodeBlock, controller }));
|
||||
dispatch(
|
||||
turnToBlockThunk({
|
||||
id,
|
||||
data: {
|
||||
...defaultData,
|
||||
},
|
||||
type: BlockType.CodeBlock,
|
||||
controller,
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { BlockType, NestedBlock } from '$app/interfaces/document';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Delta from 'quill-delta';
|
||||
import Delta, { Op } from 'quill-delta';
|
||||
import { useDelta } from '$app/components/document/_shared/EditorHooks/useDelta';
|
||||
|
||||
export function useChange(node: NestedBlock<BlockType.TextBlock | BlockType.CodeBlock>) {
|
||||
@ -15,13 +15,10 @@ export function useChange(node: NestedBlock<BlockType.TextBlock | BlockType.Code
|
||||
}, [delta]);
|
||||
|
||||
const onChange = useCallback(
|
||||
(newContents: Delta, oldContents: Delta, _source?: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const isSame = newContents.diff(oldContents).ops.length === 0;
|
||||
if (isSame) return;
|
||||
setValue(newContents);
|
||||
update(newContents);
|
||||
async (ops: Op[], newDelta: Delta) => {
|
||||
if (ops.length === 0) return;
|
||||
setValue(newDelta);
|
||||
await update(ops, newDelta);
|
||||
},
|
||||
[update]
|
||||
);
|
||||
|
@ -2,31 +2,34 @@ import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef } from 'react';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions';
|
||||
import Delta from 'quill-delta';
|
||||
import Delta, { Op } from 'quill-delta';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
|
||||
export function useDelta({ id, onDeltaChange }: { id: string; onDeltaChange?: (delta: Delta) => void }) {
|
||||
const { controller } = useSubscribeDocument();
|
||||
const dispatch = useAppDispatch();
|
||||
const penddingRef = useRef(false);
|
||||
const { node } = useSubscribeNode(id);
|
||||
const { delta: deltaStr } = useSubscribeNode(id);
|
||||
|
||||
const delta = useMemo(() => {
|
||||
if (!node || !node.data.delta) return new Delta();
|
||||
return new Delta(node.data.delta);
|
||||
}, [node]);
|
||||
if (!deltaStr) return new Delta();
|
||||
const deltaJson = JSON.parse(deltaStr);
|
||||
|
||||
return new Delta(deltaJson);
|
||||
}, [deltaStr]);
|
||||
|
||||
useEffect(() => {
|
||||
onDeltaChange?.(delta);
|
||||
}, [delta, onDeltaChange]);
|
||||
|
||||
const update = useCallback(
|
||||
async (delta: Delta) => {
|
||||
async (ops: Op[], newDelta: Delta) => {
|
||||
if (!controller) return;
|
||||
await dispatch(
|
||||
updateNodeDeltaThunk({
|
||||
id,
|
||||
delta: delta.ops,
|
||||
ops,
|
||||
newDelta,
|
||||
controller,
|
||||
})
|
||||
);
|
||||
|
@ -1,23 +0,0 @@
|
||||
.ql-container.ql-snow {
|
||||
border: none;
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
.ql-editor {
|
||||
outline: none;
|
||||
max-width: 100%;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
padding: 4px 2px;
|
||||
text-align: left;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.ql-editor.ql-blank::before {
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
|
@ -1,30 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useEditor } from '$app/components/document/_shared/QuillEditor/useEditor';
|
||||
import 'quill/dist/quill.snow.css';
|
||||
import './Editor.css';
|
||||
import { EditorProps } from '$app/interfaces/document';
|
||||
|
||||
function Editor({
|
||||
value,
|
||||
onChange,
|
||||
onSelectionChange,
|
||||
selection,
|
||||
placeholder = "Type '/' for commands",
|
||||
...props
|
||||
}: EditorProps) {
|
||||
const { ref, editor } = useEditor({
|
||||
value,
|
||||
onChange,
|
||||
onSelectionChange,
|
||||
selection,
|
||||
placeholder,
|
||||
});
|
||||
return (
|
||||
<div className={'min-h-[30px]'}>
|
||||
<div ref={ref} {...props} />
|
||||
{!editor && <div className={'px-0.5 py-1 text-text-caption'}>{placeholder}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Editor);
|
@ -1,100 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Quill, { Sources } from 'quill';
|
||||
import Delta from 'quill-delta';
|
||||
import { adaptDeltaForQuill } from '$app/utils/document/quill_editor';
|
||||
import { EditorProps } from '$app/interfaces/document';
|
||||
|
||||
/**
|
||||
* Here we can use ts-ignore because the quill-delta's version of quill is not uploaded to DefinitelyTyped
|
||||
*/
|
||||
export function useEditor({ placeholder, value, onChange, onSelectionChange, selection }: EditorProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [editor, setEditor] = useState<Quill>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const editor = new Quill(ref.current, {
|
||||
modules: {
|
||||
toolbar: false, // Snow includes toolbar by default
|
||||
},
|
||||
theme: 'snow',
|
||||
formats: ['bold', 'italic', 'underline', 'strike', 'code'],
|
||||
placeholder: placeholder || 'Please enter some text...',
|
||||
});
|
||||
const keyboard = editor.getModule('keyboard');
|
||||
// clear all keyboard bindings
|
||||
keyboard.bindings = {};
|
||||
const initialDelta = new Delta(adaptDeltaForQuill(value?.ops || []));
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
editor.setContents(initialDelta);
|
||||
setEditor(editor);
|
||||
}, []);
|
||||
|
||||
// listen to text-change event
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const onTextChange = (delta: Delta, oldContents: Delta, source: Sources) => {
|
||||
const newContents = oldContents.compose(delta);
|
||||
const newOps = adaptDeltaForQuill(newContents.ops, true);
|
||||
const newDelta = new Delta(newOps);
|
||||
onChange?.(newDelta, oldContents, source);
|
||||
if (source === 'user') {
|
||||
const selection = editor.getSelection(false);
|
||||
onSelectionChange?.(selection, null, source);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
editor.on('text-change', onTextChange);
|
||||
return () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
editor.off('text-change', onTextChange);
|
||||
};
|
||||
}, [editor, onChange, onSelectionChange]);
|
||||
|
||||
// listen to selection-change event
|
||||
useEffect(() => {
|
||||
const handleSelectionChange = () => {
|
||||
if (!editor) return;
|
||||
const selection = editor.getSelection(false);
|
||||
onSelectionChange?.(selection, null, 'user');
|
||||
};
|
||||
document.addEventListener('selectionchange', handleSelectionChange);
|
||||
return () => {
|
||||
document.removeEventListener('selectionchange', handleSelectionChange);
|
||||
};
|
||||
}, [editor, onSelectionChange]);
|
||||
|
||||
// set value
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const content = editor.getContents();
|
||||
|
||||
const newOps = adaptDeltaForQuill(value?.ops || []);
|
||||
const newDelta = new Delta(newOps);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const diffDelta = content.diff(newDelta);
|
||||
const isSame = diffDelta.ops.length === 0;
|
||||
if (isSame) return;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
editor.updateContents(diffDelta, 'api');
|
||||
}, [editor, value]);
|
||||
|
||||
// set Selection
|
||||
useEffect(() => {
|
||||
if (!editor || !selection) return;
|
||||
if (JSON.stringify(selection) === JSON.stringify(editor.getSelection())) return;
|
||||
|
||||
editor.setSelection(selection);
|
||||
}, [selection, editor]);
|
||||
|
||||
return {
|
||||
ref,
|
||||
editor,
|
||||
};
|
||||
}
|
@ -1,19 +1,13 @@
|
||||
import { EditorProps } from '$app/interfaces/document';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { BaseRange, Descendant, Editor, NodeEntry, Range, Selection, Transforms } from 'slate';
|
||||
import {
|
||||
converToIndexLength,
|
||||
convertToDelta,
|
||||
convertToSlateSelection,
|
||||
indent,
|
||||
outdent,
|
||||
} from '$app/utils/document/slate_editor';
|
||||
import { BaseRange, Editor, NodeEntry, Range, Selection, Transforms } from 'slate';
|
||||
import { converToIndexLength, convertToSlateSelection, indent, outdent } from '$app/utils/document/slate_editor';
|
||||
import { focusNodeByIndex } from '$app/utils/document/node';
|
||||
import { Keyboard } from '$app/constants/document/keyboard';
|
||||
import Delta from 'quill-delta';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import { useSlateYjs } from '$app/components/document/_shared/SlateEditor/useSlateYjs';
|
||||
import { openMention } from '$app_reducers/document/async-actions/mention';
|
||||
|
||||
const AFTER_RENDER_DELAY = 100;
|
||||
|
||||
@ -27,7 +21,7 @@ export function useEditor({
|
||||
isCodeBlock,
|
||||
temporarySelection,
|
||||
}: EditorProps) {
|
||||
const { editor } = useSlateYjs({ delta });
|
||||
const { editor } = useSlateYjs({ delta, onChange });
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const newValue = useMemo(() => [], []);
|
||||
const onSelectionChangeHandler = useCallback(
|
||||
@ -39,15 +33,9 @@ export function useEditor({
|
||||
[editor, onSelectionChange]
|
||||
);
|
||||
|
||||
const onChangeHandler = useCallback(
|
||||
(slateValue: Descendant[]) => {
|
||||
const oldContents = delta || new Delta();
|
||||
const newContents = convertToDelta(slateValue);
|
||||
onChange?.(newContents, oldContents);
|
||||
onSelectionChangeHandler(editor.selection);
|
||||
},
|
||||
[delta, editor, onChange, onSelectionChangeHandler]
|
||||
);
|
||||
const onChangeHandler = useCallback(() => {
|
||||
onSelectionChangeHandler(editor.selection);
|
||||
}, [editor, onSelectionChangeHandler]);
|
||||
|
||||
// Prevent attributes from being applied when entering text at the beginning or end of an inline block.
|
||||
// For example, when entering text before or after a mentioned page,
|
||||
@ -62,11 +50,13 @@ export function useEditor({
|
||||
const currentSelection = editor.selection || [];
|
||||
let removeMark = markKeys.length > 0;
|
||||
const [_, path] = editor.node(currentSelection);
|
||||
|
||||
if (removeMark) {
|
||||
const selectionStart = editor.start(currentSelection);
|
||||
const selectionEnd = editor.end(currentSelection);
|
||||
const isNodeEnd = editor.isEnd(selectionEnd, path);
|
||||
const isNodeStart = editor.isStart(selectionStart, path);
|
||||
|
||||
removeMark = isNodeStart || isNodeEnd;
|
||||
}
|
||||
|
||||
@ -85,6 +75,7 @@ export function useEditor({
|
||||
if (e.inputType === 'insertFromComposition') {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
preventInlineBlockAttributeOverride();
|
||||
},
|
||||
[preventInlineBlockAttributeOverride]
|
||||
@ -195,6 +186,7 @@ export function useEditor({
|
||||
if (!slateSelection) return;
|
||||
|
||||
const isEqual = JSON.stringify(slateSelection) === JSON.stringify(editor.selection);
|
||||
|
||||
if (isFocused && isEqual) return;
|
||||
|
||||
// why we didn't use slate api to change selection?
|
||||
|
@ -1,4 +1,4 @@
|
||||
import Delta from 'quill-delta';
|
||||
import Delta, { Op } from 'quill-delta';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import * as Y from 'yjs';
|
||||
import { convertToSlateValue } from '$app/utils/document/slate_editor';
|
||||
@ -7,7 +7,7 @@ import { withReact } from 'slate-react';
|
||||
import { createEditor } from 'slate';
|
||||
import { withMarkdown } from '$app/components/document/_shared/SlateEditor/markdown';
|
||||
|
||||
export function useSlateYjs({ delta }: { delta?: Delta }) {
|
||||
export function useSlateYjs({ delta, onChange }: { delta?: Delta; onChange: (ops: Op[], newDelta: Delta) => void }) {
|
||||
const [yText, setYText] = useState<Y.Text | undefined>(undefined);
|
||||
const sharedType = useMemo(() => {
|
||||
const yDoc = new Y.Doc();
|
||||
@ -26,15 +26,25 @@ export function useSlateYjs({ delta }: { delta?: Delta }) {
|
||||
// Connect editor in useEffect to comply with concurrent mode requirements.
|
||||
useEffect(() => {
|
||||
YjsEditor.connect(editor);
|
||||
const observer = (event: Y.YTextEvent) => {
|
||||
const ops = event.changes.delta as Op[];
|
||||
const newDelta = new Delta(yText?.toDelta());
|
||||
|
||||
onChange(ops, newDelta);
|
||||
};
|
||||
|
||||
yText?.observe(observer);
|
||||
return () => {
|
||||
YjsEditor.disconnect(editor);
|
||||
yText?.unobserve(observer);
|
||||
};
|
||||
}, [editor]);
|
||||
}, [editor, yText, onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!yText) return;
|
||||
const oldContents = new Delta(yText.toDelta());
|
||||
const diffDelta = oldContents.diff(delta || new Delta());
|
||||
|
||||
if (diffDelta.ops.length === 0) return;
|
||||
yText.applyDelta(diffDelta.ops);
|
||||
}, [delta, editor, yText]);
|
||||
|
@ -3,6 +3,7 @@ import { createContext, useMemo } from 'react';
|
||||
import { Node } from '$app/interfaces/document';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import { DOCUMENT_NAME, RECT_RANGE_NAME } from '$app/constants/document/name';
|
||||
import Delta from 'quill-delta';
|
||||
|
||||
/**
|
||||
* Subscribe node information
|
||||
@ -11,10 +12,18 @@ import { DOCUMENT_NAME, RECT_RANGE_NAME } from '$app/constants/document/name';
|
||||
export function useSubscribeNode(id: string) {
|
||||
const { docId } = useSubscribeDocument();
|
||||
|
||||
const node = useAppSelector<Node>((state) => {
|
||||
const { node, delta } = useAppSelector<{
|
||||
node: Node;
|
||||
delta: string;
|
||||
}>((state) => {
|
||||
const documentState = state[DOCUMENT_NAME][docId];
|
||||
const node = documentState?.nodes[id];
|
||||
const externalId = node?.externalId;
|
||||
|
||||
return documentState?.nodes[id];
|
||||
return {
|
||||
node,
|
||||
delta: externalId ? documentState?.deltaMap[externalId] : '',
|
||||
};
|
||||
});
|
||||
|
||||
const childIds = useAppSelector<string[] | undefined>((state) => {
|
||||
@ -40,6 +49,7 @@ export function useSubscribeNode(id: string) {
|
||||
return {
|
||||
node: memoizedNode,
|
||||
childIds: memoizedChildIds,
|
||||
delta,
|
||||
isSelected,
|
||||
};
|
||||
}
|
||||
@ -48,4 +58,15 @@ export function getBlock(docId: string, id: string) {
|
||||
return store.getState().document[docId]?.nodes[id];
|
||||
}
|
||||
|
||||
export function getBlockDelta(docId: string, id: string) {
|
||||
const node = getBlock(docId, id);
|
||||
|
||||
if (!node?.externalId) return;
|
||||
const deltaStr = store.getState().document[docId]?.deltaMap[node.externalId];
|
||||
const deltaJson = JSON.parse(deltaStr);
|
||||
const delta = new Delta(deltaJson);
|
||||
|
||||
return delta;
|
||||
}
|
||||
|
||||
export const NodeIdContext = createContext<string>('');
|
||||
|
@ -4,8 +4,6 @@ import { BlockData, BlockType, NestedBlock } from '$app/interfaces/document';
|
||||
import { blockConfig } from '$app/constants/document/config';
|
||||
import { turnToBlockThunk } from '$app_reducers/document/async-actions';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import Delta from 'quill-delta';
|
||||
import { getDeltaText } from '$app/utils/document/delta';
|
||||
import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
|
||||
|
||||
export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: () => void }) {
|
||||
@ -13,34 +11,6 @@ export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: ()
|
||||
|
||||
const { controller, docId } = useSubscribeDocument();
|
||||
|
||||
const getTurnIntoData = useCallback(
|
||||
(targetType: BlockType, sourceNode: NestedBlock) => {
|
||||
if (targetType === sourceNode.type) return;
|
||||
const config = blockConfig[targetType];
|
||||
const defaultData = config.defaultData;
|
||||
const data: BlockData<any> = {
|
||||
...defaultData,
|
||||
delta: sourceNode?.data?.delta || [],
|
||||
};
|
||||
|
||||
if (targetType === BlockType.EquationBlock) {
|
||||
data.formula = getDeltaText(new Delta(sourceNode.data.delta));
|
||||
delete data.delta;
|
||||
}
|
||||
|
||||
if (sourceNode.type === BlockType.EquationBlock) {
|
||||
data.delta = [
|
||||
{
|
||||
insert: node.data.formula,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
[node.data.formula]
|
||||
);
|
||||
|
||||
const turnIntoBlock = useCallback(
|
||||
async (type: BlockType, isSelected: boolean, data?: BlockData<any>) => {
|
||||
if (!controller || isSelected) {
|
||||
@ -48,8 +18,10 @@ export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: ()
|
||||
return;
|
||||
}
|
||||
|
||||
const config = blockConfig[type];
|
||||
const defaultData = config.defaultData;
|
||||
const updateData = {
|
||||
...getTurnIntoData(type, node),
|
||||
...defaultData,
|
||||
...data,
|
||||
};
|
||||
|
||||
@ -70,7 +42,7 @@ export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: ()
|
||||
})
|
||||
);
|
||||
},
|
||||
[controller, getTurnIntoData, node, dispatch, onClose, docId]
|
||||
[controller, node, dispatch, onClose, docId]
|
||||
);
|
||||
|
||||
const turnIntoHeading = useCallback(
|
||||
|
@ -0,0 +1,36 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Delta, { Op } from 'quill-delta';
|
||||
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||
import { getDeltaText } from '$app/utils/document/delta';
|
||||
|
||||
export function useSubscribePanelSearchText({ blockId, open }: { blockId: string; open: boolean }) {
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const beforeOpenDeltaRef = useRef<Op[]>([]);
|
||||
const { delta } = useSubscribeNode(blockId);
|
||||
const handleSearch = useCallback((newDelta: Delta) => {
|
||||
const diff = new Delta(beforeOpenDeltaRef.current).diff(newDelta);
|
||||
const text = getDeltaText(diff);
|
||||
|
||||
setSearchText(text);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !delta) return;
|
||||
handleSearch(new Delta(JSON.parse(delta)));
|
||||
}, [handleSearch, delta, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
beforeOpenDeltaRef.current = [];
|
||||
return;
|
||||
}
|
||||
|
||||
beforeOpenDeltaRef.current = new Delta(JSON.parse(delta)).ops;
|
||||
handleSearch(new Delta(JSON.parse(delta)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
return {
|
||||
searchText,
|
||||
};
|
||||
}
|
@ -7,9 +7,7 @@ import { randomEmoji } from '$app/utils/document/emoji';
|
||||
export const blockConfig: Record<string, BlockConfig> = {
|
||||
[BlockType.TextBlock]: {
|
||||
canAddChild: true,
|
||||
defaultData: {
|
||||
delta: [],
|
||||
},
|
||||
defaultData: {},
|
||||
splitProps: {
|
||||
nextLineRelationShip: SplitRelationship.NextSibling,
|
||||
nextLineBlockType: BlockType.TextBlock,
|
||||
@ -25,7 +23,6 @@ export const blockConfig: Record<string, BlockConfig> = {
|
||||
[BlockType.TodoListBlock]: {
|
||||
canAddChild: true,
|
||||
defaultData: {
|
||||
delta: [],
|
||||
checked: false,
|
||||
},
|
||||
splitProps: {
|
||||
@ -36,7 +33,6 @@ export const blockConfig: Record<string, BlockConfig> = {
|
||||
[BlockType.BulletedListBlock]: {
|
||||
canAddChild: true,
|
||||
defaultData: {
|
||||
delta: [],
|
||||
format: 'default',
|
||||
},
|
||||
splitProps: {
|
||||
@ -47,7 +43,6 @@ export const blockConfig: Record<string, BlockConfig> = {
|
||||
[BlockType.NumberedListBlock]: {
|
||||
canAddChild: true,
|
||||
defaultData: {
|
||||
delta: [],
|
||||
format: 'default',
|
||||
},
|
||||
splitProps: {
|
||||
@ -58,7 +53,6 @@ export const blockConfig: Record<string, BlockConfig> = {
|
||||
[BlockType.QuoteBlock]: {
|
||||
canAddChild: true,
|
||||
defaultData: {
|
||||
delta: [],
|
||||
size: 'default',
|
||||
},
|
||||
splitProps: {
|
||||
@ -69,7 +63,6 @@ export const blockConfig: Record<string, BlockConfig> = {
|
||||
[BlockType.CalloutBlock]: {
|
||||
canAddChild: true,
|
||||
defaultData: {
|
||||
delta: [],
|
||||
icon: randomEmoji(),
|
||||
},
|
||||
splitProps: {
|
||||
@ -80,7 +73,6 @@ export const blockConfig: Record<string, BlockConfig> = {
|
||||
[BlockType.ToggleListBlock]: {
|
||||
canAddChild: true,
|
||||
defaultData: {
|
||||
delta: [],
|
||||
collapsed: false,
|
||||
},
|
||||
splitProps: {
|
||||
@ -92,7 +84,6 @@ export const blockConfig: Record<string, BlockConfig> = {
|
||||
[BlockType.CodeBlock]: {
|
||||
canAddChild: false,
|
||||
defaultData: {
|
||||
delta: [],
|
||||
language: 'javascript',
|
||||
},
|
||||
},
|
||||
|
@ -12,4 +12,5 @@ export const BLOCK_MAP_NAME = 'blocks';
|
||||
export const META_NAME = 'meta';
|
||||
export const CHILDREN_MAP_NAME = 'children_map';
|
||||
|
||||
export const TEXT_MAP_NAME = 'text_map';
|
||||
export const EQUATION_PLACEHOLDER = '$';
|
||||
|
@ -62,9 +62,7 @@ export interface CalloutBlockData extends TextBlockData {
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface TextBlockData {
|
||||
delta: Op[];
|
||||
}
|
||||
export type TextBlockData = Record<string, any>;
|
||||
|
||||
export interface DividerBlockData {}
|
||||
|
||||
@ -120,9 +118,11 @@ export type BlockData<Type> = Type extends BlockType.HeadingBlock
|
||||
export interface NestedBlock<Type = any> {
|
||||
id: string;
|
||||
type: BlockType;
|
||||
data: BlockData<Type>;
|
||||
data: BlockData<Type> | any;
|
||||
parent: string | null;
|
||||
children: string;
|
||||
externalId?: string;
|
||||
externalType?: string;
|
||||
}
|
||||
|
||||
export type Node = NestedBlock;
|
||||
@ -133,12 +133,15 @@ export interface DocumentData {
|
||||
nodes: Record<string, Node>;
|
||||
// map of block id to children block ids
|
||||
children: Record<string, string[]>;
|
||||
|
||||
deltaMap: Record<string, string>;
|
||||
}
|
||||
export interface DocumentState {
|
||||
// map of block id to block
|
||||
nodes: Record<string, Node>;
|
||||
// map of block id to children block ids
|
||||
children: Record<string, string[]>;
|
||||
deltaMap: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SlashCommandState {
|
||||
@ -219,6 +222,9 @@ export enum ChangeType {
|
||||
ChildrenMapInsert,
|
||||
ChildrenMapUpdate,
|
||||
ChildrenMapDelete,
|
||||
DeltaMapInsert,
|
||||
DeltaMapUpdate,
|
||||
DeltaMapDelete,
|
||||
}
|
||||
|
||||
export interface BlockPBValue {
|
||||
@ -227,6 +233,8 @@ export interface BlockPBValue {
|
||||
parent: string;
|
||||
children: string;
|
||||
data: string;
|
||||
external_id?: string;
|
||||
external_type?: string;
|
||||
}
|
||||
|
||||
export enum SplitRelationship {
|
||||
@ -308,7 +316,7 @@ export interface EditorProps {
|
||||
decorateSelection?: RangeStaticNoId;
|
||||
temporarySelection?: RangeStaticNoId;
|
||||
onSelectionChange?: (range: RangeStaticNoId | null, oldRange: RangeStaticNoId | null, source?: Sources) => void;
|
||||
onChange?: (delta: Delta, oldDelta: Delta, source?: Sources) => void;
|
||||
onChange: (ops: Op[], newDelta: Delta) => void;
|
||||
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
|
@ -2,22 +2,23 @@ import {
|
||||
FlowyError,
|
||||
DocumentDataPB,
|
||||
OpenDocumentPayloadPB,
|
||||
CreateDocumentPayloadPB,
|
||||
ApplyActionPayloadPB,
|
||||
BlockActionPB,
|
||||
CloseDocumentPayloadPB,
|
||||
DocumentRedoUndoPayloadPB,
|
||||
DocumentRedoUndoResponsePB,
|
||||
TextDeltaPayloadPB,
|
||||
} from '@/services/backend';
|
||||
import { Result } from 'ts-results';
|
||||
import {
|
||||
DocumentEventApplyAction,
|
||||
DocumentEventCloseDocument,
|
||||
DocumentEventOpenDocument,
|
||||
DocumentEventCreateDocument,
|
||||
DocumentEventCanUndoRedo,
|
||||
DocumentEventRedo,
|
||||
DocumentEventUndo,
|
||||
DocumentEventCreateText,
|
||||
DocumentEventApplyTextDeltaEvent,
|
||||
} from '@/services/backend/events/flowy-document2';
|
||||
|
||||
export class DocumentBackendService {
|
||||
@ -27,6 +28,7 @@ export class DocumentBackendService {
|
||||
const payload = OpenDocumentPayloadPB.fromObject({
|
||||
document_id: this.viewId,
|
||||
});
|
||||
|
||||
return DocumentEventOpenDocument(payload);
|
||||
};
|
||||
|
||||
@ -35,13 +37,35 @@ export class DocumentBackendService {
|
||||
document_id: this.viewId,
|
||||
actions: actions,
|
||||
});
|
||||
|
||||
return DocumentEventApplyAction(payload);
|
||||
};
|
||||
|
||||
createText = (textId: string, defaultDelta?: string): Promise<Result<void, FlowyError>> => {
|
||||
const payload = TextDeltaPayloadPB.fromObject({
|
||||
document_id: this.viewId,
|
||||
text_id: textId,
|
||||
delta: defaultDelta,
|
||||
});
|
||||
|
||||
return DocumentEventCreateText(payload);
|
||||
};
|
||||
|
||||
applyTextDelta = (textId: string, delta: string): Promise<Result<void, FlowyError>> => {
|
||||
const payload = TextDeltaPayloadPB.fromObject({
|
||||
document_id: this.viewId,
|
||||
text_id: textId,
|
||||
delta: delta,
|
||||
});
|
||||
|
||||
return DocumentEventApplyTextDeltaEvent(payload);
|
||||
};
|
||||
|
||||
close = (): Promise<Result<void, FlowyError>> => {
|
||||
const payload = CloseDocumentPayloadPB.fromObject({
|
||||
document_id: this.viewId,
|
||||
});
|
||||
|
||||
return DocumentEventCloseDocument(payload);
|
||||
};
|
||||
|
||||
@ -49,6 +73,7 @@ export class DocumentBackendService {
|
||||
const payload = DocumentRedoUndoPayloadPB.fromObject({
|
||||
document_id: this.viewId,
|
||||
});
|
||||
|
||||
return DocumentEventCanUndoRedo(payload);
|
||||
};
|
||||
|
||||
@ -56,6 +81,7 @@ export class DocumentBackendService {
|
||||
const payload = DocumentRedoUndoPayloadPB.fromObject({
|
||||
document_id: this.viewId,
|
||||
});
|
||||
|
||||
return DocumentEventUndo(payload);
|
||||
};
|
||||
|
||||
@ -63,6 +89,7 @@ export class DocumentBackendService {
|
||||
const payload = DocumentRedoUndoPayloadPB.fromObject({
|
||||
document_id: this.viewId,
|
||||
});
|
||||
|
||||
return DocumentEventRedo(payload);
|
||||
};
|
||||
}
|
||||
|
@ -10,11 +10,10 @@ import {
|
||||
ChildrenPB,
|
||||
} from '@/services/backend';
|
||||
import { DocumentObserver } from './document_observer';
|
||||
import * as Y from 'yjs';
|
||||
import { get } from '@/appflowy_app/utils/tool';
|
||||
import { blockPB2Node } from '$app/utils/document/block';
|
||||
import { Log } from '$app/utils/log';
|
||||
import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '$app/constants/document/name';
|
||||
import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME, TEXT_MAP_NAME } from '$app/constants/document/name';
|
||||
|
||||
export class DocumentController {
|
||||
private readonly backendService: DocumentBackendService;
|
||||
@ -28,6 +27,10 @@ export class DocumentController {
|
||||
this.observer = new DocumentObserver(documentId);
|
||||
}
|
||||
|
||||
get backend() {
|
||||
return this.backendService;
|
||||
}
|
||||
|
||||
open = async (): Promise<DocumentData> => {
|
||||
await this.observer.subscribe({
|
||||
didReceiveUpdate: this.updated,
|
||||
@ -44,20 +47,36 @@ export class DocumentController {
|
||||
});
|
||||
});
|
||||
const children: Record<string, string[]> = {};
|
||||
const deltaMap: Record<string, string> = {};
|
||||
|
||||
get<Map<string, ChildrenPB>>(document.val, [META_NAME, CHILDREN_MAP_NAME]).forEach((child, key) => {
|
||||
children[key] = child.children;
|
||||
});
|
||||
|
||||
get<Map<string, string>>(document.val, [META_NAME, TEXT_MAP_NAME]).forEach((delta, key) => {
|
||||
deltaMap[key] = delta;
|
||||
});
|
||||
return {
|
||||
rootId: document.val.page_id,
|
||||
nodes,
|
||||
children,
|
||||
deltaMap,
|
||||
};
|
||||
}
|
||||
|
||||
return Promise.reject(document.val);
|
||||
};
|
||||
|
||||
applyTextDelta = async (textId: string, delta: string) => {
|
||||
const result = await this.backendService.applyTextDelta(textId, delta);
|
||||
|
||||
if (result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
return Promise.reject(result.err);
|
||||
};
|
||||
|
||||
applyActions = async (actions: ReturnType<typeof BlockActionPB.prototype.toObject>[]) => {
|
||||
Log.debug('applyActions', actions);
|
||||
if (actions.length === 0) return;
|
||||
@ -65,17 +84,40 @@ export class DocumentController {
|
||||
};
|
||||
|
||||
getInsertAction = (node: Node, prevId: string | null) => {
|
||||
// Here to make sure the delta is correct
|
||||
this.composeDelta(node);
|
||||
return {
|
||||
action: BlockActionTypePB.Insert,
|
||||
payload: this.getActionPayloadByNode(node, prevId),
|
||||
};
|
||||
};
|
||||
|
||||
getInsertTextActions = (node: Node, delta: string, prevId: string | null) => {
|
||||
const textId = node.externalId;
|
||||
|
||||
return [
|
||||
{
|
||||
action: BlockActionTypePB.InsertText,
|
||||
payload: {
|
||||
text_id: textId,
|
||||
delta,
|
||||
},
|
||||
},
|
||||
this.getInsertAction(node, prevId),
|
||||
];
|
||||
};
|
||||
|
||||
getApplyTextDeltaAction = (node: Node, delta: string) => {
|
||||
const textId = node.externalId;
|
||||
|
||||
return {
|
||||
action: BlockActionTypePB.ApplyTextDelta,
|
||||
payload: {
|
||||
text_id: textId,
|
||||
delta,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
getUpdateAction = (node: Node) => {
|
||||
// Here to make sure the delta is correct
|
||||
this.composeDelta(node);
|
||||
return {
|
||||
action: BlockActionTypePB.Update,
|
||||
payload: this.getActionPayloadByNode(node, ''),
|
||||
@ -152,31 +194,15 @@ export class DocumentController {
|
||||
children_id: node.children,
|
||||
data: JSON.stringify(node.data),
|
||||
ty: node.type,
|
||||
external_id: node.externalId,
|
||||
external_type: node.externalType,
|
||||
};
|
||||
};
|
||||
|
||||
private composeDelta = (node: Node) => {
|
||||
const delta = node.data.delta;
|
||||
|
||||
if (!delta) {
|
||||
return;
|
||||
}
|
||||
|
||||
// we use yjs to compose delta, it can make sure the delta is correct
|
||||
// for example, if we insert a text at the end of the line, the delta will be [{ insert: 'hello' }, { insert: " world" }]
|
||||
// but if we use yjs to compose the delta, the delta will be [{ insert: 'hello world' }]
|
||||
const ydoc = new Y.Doc();
|
||||
const ytext = ydoc.getText(node.id);
|
||||
|
||||
ytext.applyDelta(delta);
|
||||
Object.assign(node.data, { delta: ytext.toDelta() });
|
||||
};
|
||||
|
||||
private updated = (payload: Uint8Array) => {
|
||||
if (!this.onDocChange) return;
|
||||
const { events, is_remote } = DocEventPB.deserializeBinary(payload);
|
||||
|
||||
Log.debug('DocumentController', 'updated', { events, is_remote });
|
||||
events.forEach((blockEvent) => {
|
||||
blockEvent.event.forEach((_payload) => {
|
||||
this.onDocChange?.({
|
||||
|
@ -115,8 +115,9 @@ export class AuthBackendService {
|
||||
return UserEventSignIn(payload);
|
||||
};
|
||||
|
||||
signUp = (params: { name: string; email: string; password: string }) => {
|
||||
const payload = SignUpPayloadPB.fromObject({ name: params.name, email: params.email, password: params.password });
|
||||
signUp = (params: { name: string; email: string; password: string; }) => {
|
||||
const deviceId = nanoid(8);
|
||||
const payload = SignUpPayloadPB.fromObject({ name: params.name, email: params.email, password: params.password, device_id: deviceId });
|
||||
|
||||
return UserEventSignUp(payload);
|
||||
};
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { rectSelectionActions } from '$app_reducers/document/slice';
|
||||
import { getDuplicateActions } from '$app/utils/document/action';
|
||||
import { RootState } from '$app/stores/store';
|
||||
import { DOCUMENT_NAME } from '$app/constants/document/name';
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { DocumentState } from '$app/interfaces/document';
|
||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { blockConfig } from '$app/constants/document/config';
|
||||
|
@ -1,7 +1,6 @@
|
||||
export * from './delete';
|
||||
export * from './duplicate';
|
||||
export * from './insert';
|
||||
export * from './merge';
|
||||
export * from './update';
|
||||
export * from './indent';
|
||||
export * from './outdent';
|
||||
|
@ -1,47 +1,75 @@
|
||||
import { BlockData, BlockType, DocumentState } from '$app/interfaces/document';
|
||||
import { BlockData, BlockType } from '$app/interfaces/document';
|
||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { newBlock } from '$app/utils/document/block';
|
||||
import { generateId, newBlock } from '$app/utils/document/block';
|
||||
import { RootState } from '$app/stores/store';
|
||||
import { DOCUMENT_NAME } from '$app/constants/document/name';
|
||||
import { BlockDeltaOperator } from '$app/utils/document/block_delta';
|
||||
import Delta from 'quill-delta';
|
||||
|
||||
export const insertAfterNodeThunk = createAsyncThunk(
|
||||
'document/insertAfterNode',
|
||||
async (payload: { id: string; controller: DocumentController; data?: BlockData<any>; type?: BlockType }, thunkAPI) => {
|
||||
const {
|
||||
controller,
|
||||
type = BlockType.TextBlock,
|
||||
data = {
|
||||
delta: [],
|
||||
},
|
||||
id,
|
||||
} = payload;
|
||||
async (
|
||||
payload: {
|
||||
id: string;
|
||||
controller: DocumentController;
|
||||
type: BlockType;
|
||||
data?: BlockData<any>;
|
||||
defaultDelta?: Delta;
|
||||
},
|
||||
thunkAPI
|
||||
) => {
|
||||
const { controller, id, type, data, defaultDelta } = payload;
|
||||
const { getState } = thunkAPI;
|
||||
const state = getState() as RootState;
|
||||
const docId = controller.documentId;
|
||||
const docState = state[DOCUMENT_NAME][docId];
|
||||
const node = docState.nodes[id];
|
||||
const documentState = state[DOCUMENT_NAME][docId];
|
||||
const node = documentState.nodes[id];
|
||||
|
||||
if (!node) return;
|
||||
const parentId = node.parent;
|
||||
|
||||
if (!parentId) return;
|
||||
// create new node
|
||||
const newNode = newBlock<any>(type, parentId, data);
|
||||
let nodeId = newNode.id;
|
||||
const actions = [controller.getInsertAction(newNode, node.id)];
|
||||
const actions = [];
|
||||
let newNodeId;
|
||||
const deltaOperator = new BlockDeltaOperator(documentState, controller);
|
||||
|
||||
if (defaultDelta) {
|
||||
newNodeId = generateId();
|
||||
actions.push(
|
||||
...deltaOperator.getNewTextLineActions({
|
||||
blockId: newNodeId,
|
||||
parentId,
|
||||
prevId: node.id,
|
||||
delta: defaultDelta,
|
||||
type,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
const newNode = newBlock<any>(type, parentId, data);
|
||||
|
||||
actions.push(controller.getInsertAction(newNode, node.id));
|
||||
newNodeId = newNode.id;
|
||||
}
|
||||
|
||||
if (type === BlockType.DividerBlock) {
|
||||
const newTextNode = newBlock<any>(BlockType.TextBlock, parentId, {
|
||||
delta: [],
|
||||
});
|
||||
const nodeId = generateId();
|
||||
|
||||
nodeId = newTextNode.id;
|
||||
actions.push(controller.getInsertAction(newTextNode, newNode.id));
|
||||
actions.push(
|
||||
...deltaOperator.getNewTextLineActions({
|
||||
blockId: nodeId,
|
||||
parentId,
|
||||
prevId: newNodeId,
|
||||
delta: new Delta([{ insert: '' }]),
|
||||
type: BlockType.TextBlock,
|
||||
})
|
||||
);
|
||||
newNodeId = nodeId;
|
||||
}
|
||||
|
||||
await controller.applyActions(actions);
|
||||
|
||||
return nodeId;
|
||||
return newNodeId;
|
||||
}
|
||||
);
|
||||
|
@ -1,57 +0,0 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||
import { DocumentState } from '$app/interfaces/document';
|
||||
import Delta from 'quill-delta';
|
||||
import { blockConfig } from '$app/constants/document/config';
|
||||
import { getMoveChildrenActions } from '$app/utils/document/action';
|
||||
import { RootState } from '$app/stores/store';
|
||||
import { DOCUMENT_NAME } from '$app/constants/document/name';
|
||||
|
||||
/**
|
||||
* Merge two blocks
|
||||
* 1. merge delta
|
||||
* 2. move children
|
||||
* 3. delete current block
|
||||
*/
|
||||
export const mergeDeltaThunk = createAsyncThunk(
|
||||
'document/mergeDelta',
|
||||
async (payload: { sourceId: string; targetId: string; controller: DocumentController }, thunkAPI) => {
|
||||
const { sourceId, targetId, controller } = payload;
|
||||
const { getState } = thunkAPI;
|
||||
const state = getState() as RootState;
|
||||
const docId = controller.documentId;
|
||||
const docState = state[DOCUMENT_NAME][docId];
|
||||
const target = docState.nodes[targetId];
|
||||
const source = docState.nodes[sourceId];
|
||||
|
||||
if (!target || !source) return;
|
||||
const targetDelta = new Delta(target.data.delta);
|
||||
const sourceDelta = new Delta(source.data.delta);
|
||||
const mergeDelta = targetDelta.concat(sourceDelta);
|
||||
const ops = mergeDelta.ops;
|
||||
const updateAction = controller.getUpdateAction({
|
||||
...target,
|
||||
data: {
|
||||
...target.data,
|
||||
delta: ops,
|
||||
},
|
||||
});
|
||||
|
||||
const actions = [updateAction];
|
||||
// move children
|
||||
const children = docState.children[source.children].map((id) => docState.nodes[id]);
|
||||
const moveActions = getMoveChildrenActions({
|
||||
controller,
|
||||
children,
|
||||
target,
|
||||
});
|
||||
|
||||
actions.push(...moveActions);
|
||||
// delete current block
|
||||
const deleteAction = controller.getDeleteAction(source);
|
||||
|
||||
actions.push(deleteAction);
|
||||
|
||||
await controller.applyActions(actions);
|
||||
}
|
||||
);
|
@ -1,4 +1,4 @@
|
||||
import { BlockData, DocumentState } from '$app/interfaces/document';
|
||||
import { BlockData } from '$app/interfaces/document';
|
||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import Delta, { Op } from 'quill-delta';
|
||||
@ -6,19 +6,51 @@ import { RootState } from '$app/stores/store';
|
||||
import { DOCUMENT_NAME } from '$app/constants/document/name';
|
||||
import { updatePageName } from '$app_reducers/pages/async_actions';
|
||||
import { getDeltaText } from '$app/utils/document/delta';
|
||||
import { BlockDeltaOperator } from '$app/utils/document/block_delta';
|
||||
import { openMention, closeMention } from '$app_reducers/document/async-actions/mention';
|
||||
|
||||
const updateNodeDeltaAfterThunk = createAsyncThunk(
|
||||
'document/updateNodeDeltaAfter',
|
||||
async (
|
||||
payload: { docId: string; id: string; ops: Op[]; newDelta: Delta; oldDelta: Delta; controller: DocumentController },
|
||||
thunkAPI
|
||||
) => {
|
||||
const { dispatch } = thunkAPI;
|
||||
const { docId, ops, oldDelta, newDelta } = payload;
|
||||
const insertOps = ops.filter((op) => op.insert !== undefined);
|
||||
|
||||
const deleteOps = ops.filter((op) => op.delete !== undefined);
|
||||
const oldText = getDeltaText(oldDelta);
|
||||
const newText = getDeltaText(newDelta);
|
||||
const deleteText = oldText.slice(newText.length);
|
||||
|
||||
if (insertOps.length === 1 && insertOps[0].insert === '@') {
|
||||
dispatch(openMention({ docId }));
|
||||
}
|
||||
|
||||
if (deleteOps.length === 1 && deleteText === '@') {
|
||||
dispatch(closeMention({ docId }));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const updateNodeDeltaThunk = createAsyncThunk(
|
||||
'document/updateNodeDelta',
|
||||
async (payload: { id: string; delta: Op[]; controller: DocumentController }, thunkAPI) => {
|
||||
const { id, delta, controller } = payload;
|
||||
async (payload: { id: string; ops: Op[]; newDelta: Delta; controller: DocumentController }, thunkAPI) => {
|
||||
const { id, ops, newDelta, controller } = payload;
|
||||
const { getState, dispatch } = thunkAPI;
|
||||
const state = getState() as RootState;
|
||||
const docId = controller.documentId;
|
||||
const docState = state[DOCUMENT_NAME][docId];
|
||||
const node = docState.nodes[id];
|
||||
const oldDelta = new Delta(node.data.delta);
|
||||
const newDelta = new Delta(delta);
|
||||
|
||||
const deltaOperator = new BlockDeltaOperator(docState, controller);
|
||||
const oldDelta = deltaOperator.getDeltaWithBlockId(id);
|
||||
|
||||
if (!oldDelta) return;
|
||||
const diff = oldDelta?.diff(newDelta);
|
||||
|
||||
if (ops.length === 0 || diff?.ops.length === 0) return;
|
||||
// If the node is the root node, update the page name
|
||||
if (!node.parent) {
|
||||
await dispatch(
|
||||
@ -30,18 +62,10 @@ export const updateNodeDeltaThunk = createAsyncThunk(
|
||||
return;
|
||||
}
|
||||
|
||||
const diffDelta = newDelta.diff(oldDelta);
|
||||
if (!node.externalId) return;
|
||||
|
||||
if (diffDelta.ops.length === 0) return;
|
||||
|
||||
const newData = { ...node.data, delta };
|
||||
|
||||
await controller.applyActions([
|
||||
controller.getUpdateAction({
|
||||
...node,
|
||||
data: newData,
|
||||
}),
|
||||
]);
|
||||
await controller.applyTextDelta(node.externalId, JSON.stringify(ops));
|
||||
await dispatch(updateNodeDeltaAfterThunk({ docId, id, ops, newDelta, oldDelta, controller }));
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -1,19 +1,6 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { RootState } from '$app/stores/store';
|
||||
import { getMiddleIds, getMoveChildrenActions, getStartAndEndIdsByRange } from '$app/utils/document/action';
|
||||
import { BlockCopyData, BlockType, DocumentBlockJSON } from '$app/interfaces/document';
|
||||
import Delta from 'quill-delta';
|
||||
import { getDeltaByRange } from '$app/utils/document/delta';
|
||||
import { deleteRangeAndInsertThunk } from '$app_reducers/document/async-actions/range';
|
||||
import { BlockCopyData } from '$app/interfaces/document';
|
||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||
import {
|
||||
generateBlocks,
|
||||
getAppendBlockDeltaAction,
|
||||
getCopyBlock,
|
||||
getInsertBlockActions,
|
||||
} from '$app/utils/document/copy_paste';
|
||||
import { rangeActions } from '$app_reducers/document/slice';
|
||||
import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
|
||||
|
||||
export const copyThunk = createAsyncThunk<
|
||||
void,
|
||||
@ -23,70 +10,7 @@ export const copyThunk = createAsyncThunk<
|
||||
setClipboardData: (data: BlockCopyData) => void;
|
||||
}
|
||||
>('document/copy', async (payload, thunkAPI) => {
|
||||
const { getState, dispatch } = thunkAPI;
|
||||
const { setClipboardData, isCut = false, controller } = payload;
|
||||
const docId = controller.documentId;
|
||||
const state = getState() as RootState;
|
||||
const document = state[DOCUMENT_NAME][docId];
|
||||
const documentRange = state[RANGE_NAME][docId];
|
||||
const startAndEndIds = getStartAndEndIdsByRange(documentRange);
|
||||
|
||||
if (startAndEndIds.length === 0) return;
|
||||
const result: DocumentBlockJSON[] = [];
|
||||
|
||||
if (startAndEndIds.length === 1) {
|
||||
// copy single block
|
||||
const id = startAndEndIds[0];
|
||||
const node = document.nodes[id];
|
||||
const nodeDelta = new Delta(node.data.delta);
|
||||
const range = documentRange.ranges[id] || { index: 0, length: 0 };
|
||||
const isFull = range.index === 0 && range.length === nodeDelta.length();
|
||||
|
||||
if (isFull) {
|
||||
result.push(getCopyBlock(id, document, documentRange));
|
||||
} else {
|
||||
result.push({
|
||||
type: BlockType.TextBlock,
|
||||
children: [],
|
||||
data: {
|
||||
delta: getDeltaByRange(nodeDelta, range).ops,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// copy multiple blocks
|
||||
const copyIds: string[] = [];
|
||||
const [startId, endId] = startAndEndIds;
|
||||
const middleIds = getMiddleIds(document, startId, endId);
|
||||
|
||||
copyIds.push(startId, ...middleIds, endId);
|
||||
const map = new Map<string, DocumentBlockJSON>();
|
||||
|
||||
copyIds.forEach((id) => {
|
||||
const block = getCopyBlock(id, document, documentRange);
|
||||
|
||||
map.set(id, block);
|
||||
const node = document.nodes[id];
|
||||
const parent = node.parent;
|
||||
|
||||
if (parent && map.has(parent)) {
|
||||
map.get(parent)!.children.push(block);
|
||||
} else {
|
||||
result.push(block);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setClipboardData({
|
||||
json: JSON.stringify(result),
|
||||
// TODO: implement plain text and html
|
||||
text: '',
|
||||
html: '',
|
||||
});
|
||||
if (isCut) {
|
||||
// delete range blocks
|
||||
await dispatch(deleteRangeAndInsertThunk({ controller }));
|
||||
}
|
||||
// TODO: Migrate to Rust implementation.
|
||||
});
|
||||
|
||||
/**
|
||||
@ -106,139 +30,5 @@ export const pasteThunk = createAsyncThunk<
|
||||
controller: DocumentController;
|
||||
}
|
||||
>('document/paste', async (payload, thunkAPI) => {
|
||||
const { getState, dispatch } = thunkAPI;
|
||||
const { data, controller } = payload;
|
||||
|
||||
// delete range blocks
|
||||
await dispatch(deleteRangeAndInsertThunk({ controller }));
|
||||
|
||||
const state = getState() as RootState;
|
||||
const docId = controller.documentId;
|
||||
const document = state[DOCUMENT_NAME][docId];
|
||||
const documentRange = state[RANGE_NAME][docId];
|
||||
|
||||
let pasteData;
|
||||
|
||||
if (data.json) {
|
||||
pasteData = JSON.parse(data.json) as DocumentBlockJSON[];
|
||||
} else if (data.text) {
|
||||
// TODO: implement plain text
|
||||
} else if (data.html) {
|
||||
// TODO: implement html
|
||||
}
|
||||
|
||||
if (!pasteData) return;
|
||||
const { caret } = documentRange;
|
||||
|
||||
if (!caret) return;
|
||||
const currentBlock = document.nodes[caret.id];
|
||||
|
||||
if (!currentBlock.parent) return;
|
||||
const pasteBlocks = generateBlocks(pasteData, currentBlock.parent);
|
||||
const currentBlockDelta = new Delta(currentBlock.data.delta);
|
||||
const type = currentBlock.type;
|
||||
const actions = getInsertBlockActions(pasteBlocks, currentBlock.id, controller);
|
||||
const firstPasteBlock = pasteBlocks[0];
|
||||
const firstPasteBlockChildren = pasteBlocks.filter((block) => block.parent === firstPasteBlock.id);
|
||||
|
||||
const lastPasteBlock = pasteBlocks[pasteBlocks.length - 1];
|
||||
|
||||
if (type === BlockType.TextBlock && currentBlockDelta.length() === 0) {
|
||||
// move current block children to first paste block
|
||||
const children = document.children[currentBlock.children].map((id) => document.nodes[id]);
|
||||
const firstPasteBlockLastChild =
|
||||
firstPasteBlockChildren.length > 0 ? firstPasteBlockChildren[firstPasteBlockChildren.length - 1] : undefined;
|
||||
const prevId = firstPasteBlockLastChild ? firstPasteBlockLastChild.id : undefined;
|
||||
const moveChildrenActions = getMoveChildrenActions({
|
||||
target: firstPasteBlock,
|
||||
children,
|
||||
controller,
|
||||
prevId,
|
||||
});
|
||||
|
||||
actions.push(...moveChildrenActions);
|
||||
// delete current block
|
||||
actions.push(controller.getDeleteAction(currentBlock));
|
||||
await controller.applyActions(actions);
|
||||
// set caret to the end of the last paste block
|
||||
dispatch(
|
||||
rangeActions.setCaret({
|
||||
docId,
|
||||
caret: {
|
||||
id: lastPasteBlock.id,
|
||||
index: new Delta(lastPasteBlock.data.delta).length(),
|
||||
length: 0,
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// split current block
|
||||
const currentBeforeDelta = getDeltaByRange(currentBlockDelta, { index: 0, length: caret.index });
|
||||
const currentAfterDelta = getDeltaByRange(currentBlockDelta, {
|
||||
index: caret.index,
|
||||
length: currentBlockDelta.length() - caret.index,
|
||||
});
|
||||
|
||||
let newCaret: {
|
||||
id: string;
|
||||
index: number;
|
||||
length: number;
|
||||
};
|
||||
const firstPasteBlockDelta = new Delta(firstPasteBlock.data.delta);
|
||||
const lastPasteBlockDelta = new Delta(lastPasteBlock.data.delta);
|
||||
let mergeDelta = new Delta(currentBeforeDelta.ops).concat(firstPasteBlockDelta);
|
||||
|
||||
if (firstPasteBlock.id !== lastPasteBlock.id) {
|
||||
// update the last block of paste data
|
||||
actions.push(getAppendBlockDeltaAction(lastPasteBlock, currentAfterDelta, false, controller));
|
||||
newCaret = {
|
||||
id: lastPasteBlock.id,
|
||||
index: lastPasteBlockDelta.length(),
|
||||
length: 0,
|
||||
};
|
||||
} else {
|
||||
newCaret = {
|
||||
id: currentBlock.id,
|
||||
index: mergeDelta.length(),
|
||||
length: 0,
|
||||
};
|
||||
mergeDelta = mergeDelta.concat(currentAfterDelta);
|
||||
}
|
||||
|
||||
// update current block and merge the first block of paste data
|
||||
actions.push(
|
||||
controller.getUpdateAction({
|
||||
...currentBlock,
|
||||
data: {
|
||||
...currentBlock.data,
|
||||
delta: mergeDelta.ops,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// move the first block children of paste data to current block
|
||||
if (firstPasteBlockChildren.length > 0) {
|
||||
const moveChildrenActions = getMoveChildrenActions({
|
||||
target: currentBlock,
|
||||
children: firstPasteBlockChildren,
|
||||
controller,
|
||||
});
|
||||
|
||||
actions.push(...moveChildrenActions);
|
||||
}
|
||||
|
||||
// delete first block of paste data
|
||||
actions.push(controller.getDeleteAction(firstPasteBlock));
|
||||
await controller.applyActions(actions);
|
||||
// set caret to the end of the last paste block
|
||||
if (!newCaret) return;
|
||||
|
||||
dispatch(
|
||||
rangeActions.setCaret({
|
||||
docId,
|
||||
caret: newCaret,
|
||||
})
|
||||
);
|
||||
// TODO: Migrate to Rust implementation.
|
||||
});
|
||||
|
@ -0,0 +1,22 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { rangeActions } from '$app_reducers/document/slice';
|
||||
|
||||
export const setCursorRangeThunk = createAsyncThunk(
|
||||
'document/setCursorRange',
|
||||
async (payload: { docId: string; blockId: string; index: number; length?: number }, thunkAPI) => {
|
||||
const { blockId, index, docId, length = 0 } = payload;
|
||||
const { dispatch } = thunkAPI;
|
||||
|
||||
dispatch(rangeActions.initialState(docId));
|
||||
dispatch(
|
||||
rangeActions.setCaret({
|
||||
docId,
|
||||
caret: {
|
||||
id: blockId,
|
||||
index,
|
||||
length,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
@ -4,6 +4,8 @@ import { TextAction } from '$app/interfaces/document';
|
||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||
import Delta from 'quill-delta';
|
||||
import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
|
||||
import { BlockDeltaOperator } from '$app/utils/document/block_delta';
|
||||
import { BlockActionPB } from '@/services/backend';
|
||||
|
||||
type FormatValues = Record<string, (boolean | string | undefined)[]>;
|
||||
|
||||
@ -15,6 +17,7 @@ export const getFormatValuesThunk = createAsyncThunk(
|
||||
const document = state[DOCUMENT_NAME][docId];
|
||||
const documentRange = state[RANGE_NAME][docId];
|
||||
const { ranges } = documentRange;
|
||||
const deltaOperator = new BlockDeltaOperator(document);
|
||||
const mapAttrs = (delta: Delta, format: TextAction) => {
|
||||
return delta.ops.map((op) => op.attributes?.[format] as boolean | string | undefined);
|
||||
};
|
||||
@ -23,12 +26,13 @@ export const getFormatValuesThunk = createAsyncThunk(
|
||||
|
||||
Object.entries(ranges).forEach(([id, range]) => {
|
||||
const node = document.nodes[id];
|
||||
const delta = new Delta(node.data?.delta);
|
||||
const index = range?.index || 0;
|
||||
const length = range?.length || 0;
|
||||
const rangeDelta = delta.slice(index, index + length);
|
||||
const rangeDelta = deltaOperator.sliceDeltaWithBlockId(node.id, index, index + length);
|
||||
|
||||
formatValues[id] = mapAttrs(rangeDelta, format);
|
||||
if (rangeDelta) {
|
||||
formatValues[id] = mapAttrs(rangeDelta, format);
|
||||
}
|
||||
});
|
||||
return formatValues;
|
||||
}
|
||||
@ -73,6 +77,7 @@ export const toggleFormatThunk = createAsyncThunk(
|
||||
}
|
||||
|
||||
const formatValue = isActive ? null : true;
|
||||
|
||||
await dispatch(formatThunk({ format, value: formatValue, controller }));
|
||||
}
|
||||
);
|
||||
@ -87,23 +92,24 @@ export const formatThunk = createAsyncThunk(
|
||||
const document = state[DOCUMENT_NAME][docId];
|
||||
const documentRange = state[RANGE_NAME][docId];
|
||||
const { ranges } = documentRange;
|
||||
const deltaOperator = new BlockDeltaOperator(document, controller);
|
||||
const actions: ReturnType<typeof BlockActionPB.prototype.toObject>[] = [];
|
||||
|
||||
const actions = Object.entries(ranges).map(([id, range]) => {
|
||||
Object.entries(ranges).forEach(([id, range]) => {
|
||||
const node = document.nodes[id];
|
||||
const delta = new Delta(node.data?.delta);
|
||||
const delta = deltaOperator.getDeltaWithBlockId(node.id);
|
||||
|
||||
if (!delta) return;
|
||||
const index = range?.index || 0;
|
||||
const length = range?.length || 0;
|
||||
const diffDelta: Delta = new Delta();
|
||||
diffDelta.retain(index).retain(length, { [format]: value });
|
||||
const newDelta = delta.compose(diffDelta);
|
||||
|
||||
return controller.getUpdateAction({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
delta: newDelta.ops,
|
||||
},
|
||||
});
|
||||
diffDelta.retain(index).retain(length, { [format]: value });
|
||||
const action = deltaOperator.getApplyDeltaAction(node.id, diffDelta);
|
||||
|
||||
if (action) {
|
||||
actions.push(action);
|
||||
}
|
||||
});
|
||||
|
||||
await controller.applyActions(actions);
|
||||
|
@ -1,35 +1,29 @@
|
||||
import { createAsyncThunk } from "@reduxjs/toolkit";
|
||||
import { DocumentController } from "$app/stores/effects/document/document_controller";
|
||||
import { BlockType, RangeStatic, SplitRelationship } from "$app/interfaces/document";
|
||||
import { turnToTextBlockThunk } from "$app_reducers/document/async-actions/turn_to";
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||
import { BlockType, RangeStatic } from '$app/interfaces/document';
|
||||
import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/turn_to';
|
||||
import {
|
||||
findNextHasDeltaNode,
|
||||
findPrevHasDeltaNode,
|
||||
getInsertEnterNodeAction,
|
||||
getLeftCaretByRange,
|
||||
getRightCaretByRange,
|
||||
transformToNextLineCaret,
|
||||
transformToPrevLineCaret
|
||||
} from "$app/utils/document/action";
|
||||
import Delta from "quill-delta";
|
||||
import { indentNodeThunk, mergeDeltaThunk, outdentNodeThunk } from "$app_reducers/document/async-actions/blocks";
|
||||
import { rangeActions } from "$app_reducers/document/slice";
|
||||
import { RootState } from "$app/stores/store";
|
||||
import { blockConfig } from "$app/constants/document/config";
|
||||
import { Keyboard } from "$app/constants/document/keyboard";
|
||||
import { DOCUMENT_NAME, RANGE_NAME } from "$app/constants/document/name";
|
||||
import { getDeltaText, getPreviousWordIndex } from "$app/utils/document/delta";
|
||||
import { updatePageName } from "$app_reducers/pages/async_actions";
|
||||
import { newBlock } from "$app/utils/document/block";
|
||||
|
||||
transformToPrevLineCaret,
|
||||
} from '$app/utils/document/action';
|
||||
import { indentNodeThunk, outdentNodeThunk } from '$app_reducers/document/async-actions/blocks';
|
||||
import { rangeActions } from '$app_reducers/document/slice';
|
||||
import { RootState } from '$app/stores/store';
|
||||
import { Keyboard } from '$app/constants/document/keyboard';
|
||||
import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
|
||||
import { getPreviousWordIndex } from '$app/utils/document/delta';
|
||||
import { updatePageName } from '$app_reducers/pages/async_actions';
|
||||
import { BlockDeltaOperator } from '$app/utils/document/block_delta';
|
||||
import { setCursorRangeThunk } from '$app_reducers/document/async-actions/cursor';
|
||||
|
||||
/**
|
||||
- Deletes a block using the backspace or delete key.
|
||||
- If the block is not a text block, it is converted into a text block.
|
||||
- If the block is a text block:
|
||||
- - If the block is the first line, it is merged into the document title, and a new line is inserted.
|
||||
- - If the block is not the first line and it has a next sibling, it is merged into the previous line (including the previous sibling and its parent).
|
||||
- - If the block has no next sibling and is not a top-level block, it is outdented (moved to a higher level in the hierarchy).
|
||||
- - If the block has a next sibling, it is merged into the prev line (including its children).
|
||||
- - If the block has no next sibling, it is outdented (moved to a higher level in the hierarchy).
|
||||
*/
|
||||
export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
|
||||
'document/backspaceDeleteActionForBlock',
|
||||
@ -41,6 +35,14 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
|
||||
const node = state.nodes[id];
|
||||
|
||||
if (!node.parent) return;
|
||||
const deltaOperator = new BlockDeltaOperator(state, controller, async (name: string) => {
|
||||
await dispatch(
|
||||
updatePageName({
|
||||
id: docId,
|
||||
name,
|
||||
})
|
||||
);
|
||||
});
|
||||
const parent = state.nodes[node.parent];
|
||||
const children = state.children[parent.children];
|
||||
const index = children.indexOf(id);
|
||||
@ -53,65 +55,31 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
|
||||
}
|
||||
|
||||
const isTopLevel = parent.type === BlockType.PageBlock;
|
||||
const isFirstLine = isTopLevel && index === 0;
|
||||
|
||||
if (isTopLevel && isFirstLine) {
|
||||
// merge to document title and insert a new line
|
||||
const parentDelta = new Delta(parent.data.delta);
|
||||
const caretIndex = parentDelta.length();
|
||||
const caret = {
|
||||
id: parent.id,
|
||||
index: caretIndex,
|
||||
length: 0,
|
||||
};
|
||||
const titleDelta = parentDelta.concat(new Delta(node.data.delta));
|
||||
await dispatch(updatePageName({ id: docId, name: getDeltaText(titleDelta) }));
|
||||
const actions = [
|
||||
controller.getDeleteAction(node),
|
||||
]
|
||||
|
||||
if (!nextNodeId) {
|
||||
// insert a new line
|
||||
const block = newBlock<any>(BlockType.TextBlock, parent.id, {
|
||||
delta: [{ insert: "" }]
|
||||
});
|
||||
actions.push(controller.getInsertAction(block, null));
|
||||
}
|
||||
await controller.applyActions(actions);
|
||||
dispatch(rangeActions.initialState(docId));
|
||||
dispatch(
|
||||
rangeActions.setCaret({
|
||||
docId,
|
||||
caret,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (isTopLevel || nextNodeId) {
|
||||
// merge to previous line
|
||||
const prevLine = findPrevHasDeltaNode(state, id);
|
||||
if (!prevLine) return;
|
||||
const caretIndex = new Delta(prevLine.data.delta).length();
|
||||
const prevLineId = deltaOperator.findPrevTextLine(id);
|
||||
|
||||
if (!prevLineId) return;
|
||||
|
||||
const res = await deltaOperator.mergeText(prevLineId, id);
|
||||
|
||||
if (!res) return;
|
||||
const caret = {
|
||||
id: prevLine.id,
|
||||
index: caretIndex,
|
||||
id: res.id,
|
||||
index: res.index,
|
||||
length: 0,
|
||||
};
|
||||
|
||||
await dispatch(
|
||||
mergeDeltaThunk({
|
||||
sourceId: id,
|
||||
targetId: prevLine.id,
|
||||
controller,
|
||||
})
|
||||
);
|
||||
dispatch(rangeActions.initialState(docId));
|
||||
dispatch(
|
||||
rangeActions.setCaret({
|
||||
setCursorRangeThunk({
|
||||
docId,
|
||||
caret,
|
||||
blockId: caret.id,
|
||||
index: caret.index,
|
||||
length: caret.length,
|
||||
})
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -121,10 +89,9 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
|
||||
);
|
||||
|
||||
/**
|
||||
* Insert a new node after the current node by pressing enter.
|
||||
* 1. Split the current node into two nodes.
|
||||
* 2. Insert a new node after the current node.
|
||||
* 3. Move the children of the current node to the new node if needed.
|
||||
* enter key handler
|
||||
* 1. If node is empty, and it is not a text block, turn it into a text block.
|
||||
* 2. Otherwise, split the node into two nodes.
|
||||
*/
|
||||
export const enterActionForBlockThunk = createAsyncThunk(
|
||||
'document/insertNodeByEnter',
|
||||
@ -138,81 +105,45 @@ export const enterActionForBlockThunk = createAsyncThunk(
|
||||
const caret = state[RANGE_NAME][docId]?.caret;
|
||||
|
||||
if (!node || !caret || caret.id !== id) return;
|
||||
const delta = new Delta(node.data.delta);
|
||||
|
||||
const nodeDelta = delta.slice(0, caret.index);
|
||||
|
||||
const insertNodeDelta = new Delta(node.data.delta).slice(caret.index + caret.length);
|
||||
|
||||
const isDocumentTitle = !node.parent;
|
||||
// update page title and insert a new line
|
||||
if (isDocumentTitle) {
|
||||
// update page title
|
||||
await dispatch(updatePageName({
|
||||
id: docId,
|
||||
name: getDeltaText(nodeDelta),
|
||||
}));
|
||||
// insert a new line
|
||||
const block = newBlock<any>(BlockType.TextBlock, node.id, {
|
||||
delta: insertNodeDelta.ops,
|
||||
});
|
||||
const insertNodeAction = controller.getInsertAction(block, null);
|
||||
await controller.applyActions([insertNodeAction]);
|
||||
dispatch(rangeActions.initialState(docId));
|
||||
dispatch(
|
||||
rangeActions.setCaret({
|
||||
docId,
|
||||
caret: {
|
||||
id: block.id,
|
||||
index: 0,
|
||||
length: 0,
|
||||
},
|
||||
const deltaOperator = new BlockDeltaOperator(documentState, controller, async (name: string) => {
|
||||
await dispatch(
|
||||
updatePageName({
|
||||
id: docId,
|
||||
name,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
const isDocumentTitle = !node.parent;
|
||||
let newLineId;
|
||||
|
||||
if (delta.length() === 0 && node.type !== BlockType.TextBlock) {
|
||||
const delta = deltaOperator.getDeltaWithBlockId(node.id);
|
||||
|
||||
if (!delta) return;
|
||||
if (!isDocumentTitle && delta.length() === 0 && node.type !== BlockType.TextBlock) {
|
||||
// If the node is not a text block, turn it to a text block
|
||||
await dispatch(turnToTextBlockThunk({ id, controller }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const insertNodeAction = getInsertEnterNodeAction(node, insertNodeDelta, controller);
|
||||
|
||||
if (!insertNodeAction) return;
|
||||
|
||||
const updateNode = {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
delta: nodeDelta.ops,
|
||||
newLineId = await deltaOperator.splitText(
|
||||
{
|
||||
id: node.id,
|
||||
index: caret.index,
|
||||
},
|
||||
};
|
||||
{
|
||||
id: node.id,
|
||||
index: caret.index + caret.length,
|
||||
}
|
||||
);
|
||||
|
||||
const children = documentState.children[node.children];
|
||||
const needMoveChildren = blockConfig[node.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
|
||||
const moveChildrenAction = needMoveChildren
|
||||
? controller.getMoveChildrenAction(
|
||||
children.map((id) => documentState.nodes[id]),
|
||||
insertNodeAction.id,
|
||||
''
|
||||
)
|
||||
: [];
|
||||
const actions = [insertNodeAction.action, controller.getUpdateAction(updateNode), ...moveChildrenAction];
|
||||
|
||||
await controller.applyActions(actions);
|
||||
|
||||
dispatch(rangeActions.initialState(docId));
|
||||
if (!newLineId) return;
|
||||
dispatch(
|
||||
rangeActions.setCaret({
|
||||
setCursorRangeThunk({
|
||||
docId,
|
||||
caret: {
|
||||
id: insertNodeAction.id,
|
||||
index: 0,
|
||||
length: 0,
|
||||
},
|
||||
blockId: newLineId,
|
||||
index: 0,
|
||||
length: 0,
|
||||
})
|
||||
);
|
||||
}
|
||||
@ -275,7 +206,10 @@ export const leftActionForBlockThunk = createAsyncThunk(
|
||||
|
||||
if (!node || !caret || id !== caret.id) return;
|
||||
let newCaret: RangeStatic;
|
||||
const deltaOperator = new BlockDeltaOperator(documentState);
|
||||
const delta = deltaOperator.getDeltaWithBlockId(node.id);
|
||||
|
||||
if (!delta) return;
|
||||
if (caret.length > 0) {
|
||||
newCaret = {
|
||||
id,
|
||||
@ -284,7 +218,6 @@ export const leftActionForBlockThunk = createAsyncThunk(
|
||||
};
|
||||
} else {
|
||||
if (caret.index > 0) {
|
||||
const delta = new Delta(node.data.delta);
|
||||
const newIndex = getPreviousWordIndex(delta, caret.index);
|
||||
|
||||
newCaret = {
|
||||
@ -293,13 +226,14 @@ export const leftActionForBlockThunk = createAsyncThunk(
|
||||
length: 0,
|
||||
};
|
||||
} else {
|
||||
const prevNode = findPrevHasDeltaNode(documentState, id);
|
||||
const prevNodeId = deltaOperator.findPrevTextLine(id);
|
||||
|
||||
if (!prevNode) return;
|
||||
const prevDelta = new Delta(prevNode.data.delta);
|
||||
if (!prevNodeId) return;
|
||||
const prevDelta = deltaOperator.getDeltaWithBlockId(prevNodeId);
|
||||
|
||||
if (!prevDelta) return;
|
||||
newCaret = {
|
||||
id: prevNode.id,
|
||||
id: prevNodeId,
|
||||
index: prevDelta.length(),
|
||||
length: 0,
|
||||
};
|
||||
@ -333,7 +267,10 @@ export const rightActionForBlockThunk = createAsyncThunk(
|
||||
|
||||
if (!node || !caret || id !== caret.id) return;
|
||||
let newCaret: RangeStatic;
|
||||
const delta = new Delta(node.data.delta);
|
||||
const deltaOperator = new BlockDeltaOperator(documentState);
|
||||
const delta = deltaOperator.getDeltaWithBlockId(node.id);
|
||||
|
||||
if (!delta) return;
|
||||
const deltaLength = delta.length();
|
||||
|
||||
if (caret.length > 0) {
|
||||
@ -352,11 +289,11 @@ export const rightActionForBlockThunk = createAsyncThunk(
|
||||
length: 0,
|
||||
};
|
||||
} else {
|
||||
const nextNode = findNextHasDeltaNode(documentState, id);
|
||||
const nextNodeId = deltaOperator.findNextTextLine(id);
|
||||
|
||||
if (!nextNode) return;
|
||||
if (!nextNodeId) return;
|
||||
newCaret = {
|
||||
id: nextNode.id,
|
||||
id: nextNodeId,
|
||||
index: 0,
|
||||
length: 0,
|
||||
};
|
||||
|
@ -2,10 +2,10 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { RootState } from '$app/stores/store';
|
||||
import { DOCUMENT_NAME, MENTION_NAME, RANGE_NAME } from '$app/constants/document/name';
|
||||
import Delta from 'quill-delta';
|
||||
import { getDeltaText } from '$app/utils/document/delta';
|
||||
import { mentionActions } from '$app_reducers/document/mention_slice';
|
||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||
import { rangeActions } from '$app_reducers/document/slice';
|
||||
import { BlockDeltaOperator } from '$app/utils/document/block_delta';
|
||||
import { setCursorRangeThunk } from '$app_reducers/document/async-actions/cursor';
|
||||
|
||||
export enum MentionType {
|
||||
PAGE = 'page',
|
||||
@ -15,27 +15,16 @@ export const openMention = createAsyncThunk('document/mention/open', async (payl
|
||||
const { dispatch, getState } = thunkAPI;
|
||||
const state = getState() as RootState;
|
||||
const rangeState = state[RANGE_NAME][docId];
|
||||
const documentState = state[DOCUMENT_NAME][docId];
|
||||
const { caret } = rangeState;
|
||||
|
||||
if (!caret) return;
|
||||
const { id, index } = caret;
|
||||
const node = state[DOCUMENT_NAME][docId].nodes[id];
|
||||
const { id } = caret;
|
||||
const node = documentState.nodes[id];
|
||||
|
||||
if (!node.parent) {
|
||||
return;
|
||||
}
|
||||
const nodeDelta = new Delta(node.data?.delta);
|
||||
|
||||
const beforeDelta = nodeDelta.slice(0, index);
|
||||
const beforeText = getDeltaText(beforeDelta);
|
||||
let canOpenMention = !beforeText;
|
||||
if (!canOpenMention) {
|
||||
if (index === 1) {
|
||||
canOpenMention = beforeText.endsWith('@');
|
||||
} else {
|
||||
canOpenMention = beforeText.endsWith(' ');
|
||||
}
|
||||
}
|
||||
|
||||
if (!canOpenMention) return;
|
||||
|
||||
dispatch(
|
||||
mentionActions.open({
|
||||
@ -45,6 +34,17 @@ export const openMention = createAsyncThunk('document/mention/open', async (payl
|
||||
);
|
||||
});
|
||||
|
||||
export const closeMention = createAsyncThunk('document/mention/close', async (payload: { docId: string }, thunkAPI) => {
|
||||
const { docId } = payload;
|
||||
const { dispatch } = thunkAPI;
|
||||
|
||||
dispatch(
|
||||
mentionActions.close({
|
||||
docId,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
export const formatMention = createAsyncThunk(
|
||||
'document/mention/format',
|
||||
async (
|
||||
@ -58,12 +58,17 @@ export const formatMention = createAsyncThunk(
|
||||
const mentionState = state[MENTION_NAME][docId];
|
||||
const { blockId } = mentionState;
|
||||
const rangeState = state[RANGE_NAME][docId];
|
||||
const documentState = state[DOCUMENT_NAME][docId];
|
||||
const caret = rangeState.caret;
|
||||
|
||||
if (!caret) return;
|
||||
const index = caret.index - searchTextLength;
|
||||
|
||||
const node = state[DOCUMENT_NAME][docId].nodes[blockId];
|
||||
const nodeDelta = new Delta(node.data?.delta);
|
||||
const deltaOperator = new BlockDeltaOperator(documentState);
|
||||
|
||||
const nodeDelta = deltaOperator.getDeltaWithBlockId(blockId);
|
||||
|
||||
if (!nodeDelta) return;
|
||||
const diffDelta = new Delta()
|
||||
.retain(index)
|
||||
.delete(searchTextLength)
|
||||
@ -73,18 +78,17 @@ export const formatMention = createAsyncThunk(
|
||||
[type]: value,
|
||||
},
|
||||
});
|
||||
const newDelta = nodeDelta.compose(diffDelta);
|
||||
const updateAction = controller.getUpdateAction({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
delta: newDelta.ops,
|
||||
},
|
||||
});
|
||||
const applyTextDeltaAction = deltaOperator.getApplyDeltaAction(blockId, diffDelta);
|
||||
|
||||
await controller.applyActions([updateAction]);
|
||||
|
||||
dispatch(rangeActions.initialState(docId));
|
||||
dispatch(rangeActions.setCaret({ docId, caret: { id: blockId, index, length: 0 } }));
|
||||
if (!applyTextDeltaAction) return;
|
||||
await controller.applyActions([applyTextDeltaAction]);
|
||||
dispatch(
|
||||
setCursorRangeThunk({
|
||||
docId,
|
||||
blockId,
|
||||
index,
|
||||
length: 0,
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
@ -8,8 +8,9 @@ import { blockConfig } from '$app/constants/document/config';
|
||||
import Delta, { Op } from 'quill-delta';
|
||||
import { getDeltaText } from '$app/utils/document/delta';
|
||||
import { RootState } from '$app/stores/store';
|
||||
import { DOCUMENT_NAME } from '$app/constants/document/name';
|
||||
import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
|
||||
import { blockEditActions } from '$app_reducers/document/block_edit_slice';
|
||||
import { BlockDeltaOperator } from '$app/utils/document/block_delta';
|
||||
|
||||
/**
|
||||
* add block below click
|
||||
@ -26,13 +27,19 @@ export const addBlockBelowClickThunk = createAsyncThunk(
|
||||
const node = state.nodes[id];
|
||||
|
||||
if (!node) return;
|
||||
const delta = (node.data.delta as Op[]) || [];
|
||||
const text = delta.map((d) => d.insert).join('');
|
||||
const deltaOperator = new BlockDeltaOperator(state, controller);
|
||||
const delta = deltaOperator.getDeltaWithBlockId(id);
|
||||
|
||||
// if current block is not empty, insert a new block after current block
|
||||
if (node.type !== BlockType.TextBlock || text !== '') {
|
||||
if (!delta || delta.length() > 1) {
|
||||
const { payload: newBlockId } = await dispatch(
|
||||
insertAfterNodeThunk({ id: id, type: BlockType.TextBlock, controller, data: { delta: [] } })
|
||||
insertAfterNodeThunk({
|
||||
id: id,
|
||||
type: BlockType.TextBlock,
|
||||
controller,
|
||||
data: {},
|
||||
defaultDelta: new Delta([{ insert: '' }]),
|
||||
})
|
||||
);
|
||||
|
||||
if (newBlockId) {
|
||||
@ -59,99 +66,3 @@ export const addBlockBelowClickThunk = createAsyncThunk(
|
||||
dispatch(slashCommandActions.openSlashCommand({ docId, blockId: id }));
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* slash command action be triggered
|
||||
* 1. if current block is empty, operate on current block
|
||||
* 2. if current block is not empty, insert a new block after current block and operate on new block
|
||||
*/
|
||||
export const triggerSlashCommandActionThunk = createAsyncThunk(
|
||||
'document/slashCommandAction',
|
||||
async (
|
||||
payload: {
|
||||
id: string;
|
||||
controller: DocumentController;
|
||||
props: {
|
||||
data?: BlockData<any>;
|
||||
type: BlockType;
|
||||
};
|
||||
},
|
||||
thunkAPI
|
||||
) => {
|
||||
const { id, controller, props } = payload;
|
||||
const { dispatch, getState } = thunkAPI;
|
||||
const docId = controller.documentId;
|
||||
const state = getState() as RootState;
|
||||
const document = state[DOCUMENT_NAME][docId];
|
||||
const node = document.nodes[id];
|
||||
|
||||
if (!node) return;
|
||||
const delta = new Delta(node.data.delta);
|
||||
const text = getDeltaText(delta);
|
||||
const defaultData = blockConfig[props.type].defaultData;
|
||||
|
||||
if (node.type === BlockType.TextBlock && (text === '' || text === '/')) {
|
||||
const { payload: newId } = await dispatch(
|
||||
turnToBlockThunk({
|
||||
id,
|
||||
controller,
|
||||
type: props.type,
|
||||
data: {
|
||||
...defaultData,
|
||||
...props.data,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
dispatch(
|
||||
blockEditActions.setBlockEditState({
|
||||
id: docId,
|
||||
state: {
|
||||
id: newId as string,
|
||||
editing: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// if current block has slash command, remove slash command
|
||||
if (text.slice(0, 1) === '/') {
|
||||
const updateNode = {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
delta: delta.slice(1, delta.length()).ops,
|
||||
},
|
||||
};
|
||||
|
||||
await controller.applyActions([controller.getUpdateAction(updateNode)]);
|
||||
}
|
||||
|
||||
const insertNodePayload = await dispatch(
|
||||
insertAfterNodeThunk({
|
||||
id,
|
||||
controller,
|
||||
type: props.type,
|
||||
data: defaultData,
|
||||
})
|
||||
);
|
||||
const newBlockId = insertNodePayload.payload as string;
|
||||
|
||||
dispatch(
|
||||
rangeActions.setCaret({
|
||||
docId,
|
||||
caret: { id: newBlockId, index: 0, length: 0 },
|
||||
})
|
||||
);
|
||||
dispatch(
|
||||
blockEditActions.setBlockEditState({
|
||||
id: docId,
|
||||
state: {
|
||||
id: newBlockId,
|
||||
editing: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
@ -1,19 +1,13 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { RootState } from '$app/stores/store';
|
||||
import { rangeActions } from '$app_reducers/document/slice';
|
||||
import Delta from 'quill-delta';
|
||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||
import {
|
||||
getAfterMergeCaretByRange,
|
||||
getInsertEnterNodeAction,
|
||||
getMergeEndDeltaToStartActionsByRange,
|
||||
getMiddleIds,
|
||||
getMiddleIdsByRange,
|
||||
getStartAndEndExtentDelta,
|
||||
} from '$app/utils/document/action';
|
||||
import { RangeState, SplitRelationship } from '$app/interfaces/document';
|
||||
import { blockConfig } from '$app/constants/document/config';
|
||||
import { getMiddleIds, getStartAndEndIdsByRange } from '$app/utils/document/action';
|
||||
import { RangeState } from '$app/interfaces/document';
|
||||
import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
|
||||
import { BlockDeltaOperator } from '$app/utils/document/block_delta';
|
||||
import { setCursorRangeThunk } from '$app_reducers/document/async-actions/cursor';
|
||||
import { updatePageName } from '$app_reducers/pages/async_actions';
|
||||
|
||||
interface storeRangeThunkPayload {
|
||||
docId: string;
|
||||
@ -71,18 +65,20 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
|
||||
|
||||
// amend anchor range because slatejs will stop update selection when dragging quickly
|
||||
const isForward = anchor.point.y < focus.point.y;
|
||||
const anchorDelta = new Delta(documentState.nodes[anchor.id].data.delta);
|
||||
const deltaOperator = new BlockDeltaOperator(documentState);
|
||||
|
||||
if (isForward) {
|
||||
const selectedDelta = anchorDelta.slice(anchorIndex);
|
||||
const selectedDelta = deltaOperator.sliceDeltaWithBlockId(anchor.id, anchorIndex);
|
||||
|
||||
if (!selectedDelta) return;
|
||||
ranges[anchor.id] = {
|
||||
index: anchorIndex,
|
||||
length: selectedDelta.length(),
|
||||
};
|
||||
} else {
|
||||
const selectedDelta = anchorDelta.slice(0, anchorIndex + anchorLength);
|
||||
const selectedDelta = deltaOperator.sliceDeltaWithBlockId(anchor.id, 0, anchorIndex + anchorLength);
|
||||
|
||||
if (!selectedDelta) return;
|
||||
ranges[anchor.id] = {
|
||||
index: 0,
|
||||
length: selectedDelta.length(),
|
||||
@ -98,8 +94,10 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
|
||||
middleIds.forEach((id) => {
|
||||
const node = documentState.nodes[id];
|
||||
|
||||
if (!node || !node.data.delta) return;
|
||||
const delta = new Delta(node.data.delta);
|
||||
if (!node) return;
|
||||
const delta = deltaOperator.getDeltaWithBlockId(node.id);
|
||||
|
||||
if (!delta) return;
|
||||
const rangeStatic = {
|
||||
index: 0,
|
||||
length: delta.length(),
|
||||
@ -120,48 +118,52 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
|
||||
* delete range and insert delta
|
||||
* 1. merge start and end delta to start node and delete end node
|
||||
* 2. delete middle nodes
|
||||
* 3. move end node's children to start node
|
||||
* 3. clear range
|
||||
*/
|
||||
export const deleteRangeAndInsertThunk = createAsyncThunk(
|
||||
'document/deleteRange',
|
||||
async (payload: { controller: DocumentController; insertDelta?: Delta }, thunkAPI) => {
|
||||
const { controller, insertDelta } = payload;
|
||||
async (payload: { controller: DocumentController; insertChar?: string }, thunkAPI) => {
|
||||
const { controller, insertChar } = payload;
|
||||
const docId = controller.documentId;
|
||||
const { getState, dispatch } = thunkAPI;
|
||||
const state = getState() as RootState;
|
||||
const rangeState = state[RANGE_NAME][docId];
|
||||
const documentState = state[DOCUMENT_NAME][docId];
|
||||
|
||||
const actions = [];
|
||||
// get merge actions
|
||||
const mergeActions = getMergeEndDeltaToStartActionsByRange(state, controller, insertDelta);
|
||||
|
||||
if (mergeActions) {
|
||||
actions.push(...mergeActions);
|
||||
}
|
||||
|
||||
// get middle nodes
|
||||
const middleIds = getMiddleIdsByRange(rangeState, documentState);
|
||||
// delete middle nodes
|
||||
const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(documentState.nodes[id])) || [];
|
||||
|
||||
actions.push(...deleteMiddleNodesActions);
|
||||
|
||||
const caret = getAfterMergeCaretByRange(rangeState, insertDelta);
|
||||
|
||||
if (actions.length === 0) return;
|
||||
// apply actions
|
||||
await controller.applyActions(actions);
|
||||
// clear range
|
||||
dispatch(rangeActions.initialState(docId));
|
||||
if (caret) {
|
||||
dispatch(
|
||||
rangeActions.setCaret({
|
||||
docId,
|
||||
caret,
|
||||
const deltaOperator = new BlockDeltaOperator(documentState, controller, async (name: string) => {
|
||||
await dispatch(
|
||||
updatePageName({
|
||||
id: docId,
|
||||
name,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
const [startId, endId] = getStartAndEndIdsByRange(rangeState);
|
||||
const startSelection = rangeState.ranges[startId];
|
||||
const endSelection = rangeState.ranges[endId];
|
||||
|
||||
if (!startSelection || !endSelection) return;
|
||||
const id = await deltaOperator.deleteText(
|
||||
{
|
||||
id: startId,
|
||||
index: startSelection.index,
|
||||
},
|
||||
{
|
||||
id: endId,
|
||||
index: endSelection.length,
|
||||
},
|
||||
insertChar
|
||||
);
|
||||
|
||||
if (!id) return;
|
||||
dispatch(
|
||||
setCursorRangeThunk({
|
||||
docId,
|
||||
blockId: id,
|
||||
index: insertChar ? startSelection.index + insertChar.length : startSelection.index,
|
||||
length: 0,
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@ -169,7 +171,7 @@ export const deleteRangeAndInsertThunk = createAsyncThunk(
|
||||
* delete range and insert enter
|
||||
* 1. if shift key, insert '\n' to start node and concat end node delta
|
||||
* 2. if not shift key
|
||||
* 2.1 insert node under start node, and concat end node delta to insert node
|
||||
* 2.1 insert node under start node
|
||||
* 2.2 filter rest children and move to insert node, if need
|
||||
* 3. delete middle nodes
|
||||
* 4. clear range
|
||||
@ -183,84 +185,39 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
|
||||
const state = getState() as RootState;
|
||||
const rangeState = state[RANGE_NAME][docId];
|
||||
const documentState = state[DOCUMENT_NAME][docId];
|
||||
const actions = [];
|
||||
|
||||
const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(documentState, rangeState) || {};
|
||||
|
||||
if (!startDelta || !endDelta || !endNode || !startNode) return;
|
||||
|
||||
// get middle nodes
|
||||
const middleIds = getMiddleIds(documentState, startNode.id, endNode.id);
|
||||
|
||||
let newStartDelta = new Delta(startDelta);
|
||||
let caret = null;
|
||||
|
||||
if (shiftKey) {
|
||||
newStartDelta = newStartDelta.insert('\n').concat(endDelta);
|
||||
caret = getAfterMergeCaretByRange(rangeState, new Delta().insert('\n'));
|
||||
} else {
|
||||
const insertNodeDelta = new Delta(endDelta);
|
||||
const insertNodeAction = getInsertEnterNodeAction(startNode, insertNodeDelta, controller);
|
||||
|
||||
if (!insertNodeAction) return;
|
||||
actions.push(insertNodeAction.action);
|
||||
caret = {
|
||||
id: insertNodeAction.id,
|
||||
index: 0,
|
||||
length: 0,
|
||||
};
|
||||
// move start node children to insert node
|
||||
const needMoveChildren =
|
||||
blockConfig[startNode.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
|
||||
|
||||
if (needMoveChildren) {
|
||||
// filter children by delete middle ids
|
||||
const children = documentState.children[startNode.children].filter((id) => middleIds?.includes(id));
|
||||
const moveChildrenAction = needMoveChildren
|
||||
? controller.getMoveChildrenAction(
|
||||
children.map((id) => documentState.nodes[id]),
|
||||
insertNodeAction.id,
|
||||
''
|
||||
)
|
||||
: [];
|
||||
|
||||
actions.push(...moveChildrenAction);
|
||||
}
|
||||
}
|
||||
|
||||
// udpate start node
|
||||
const updateAction = controller.getUpdateAction({
|
||||
...startNode,
|
||||
data: {
|
||||
...startNode.data,
|
||||
delta: newStartDelta.ops,
|
||||
},
|
||||
});
|
||||
|
||||
if (endNode.id !== startNode.id) {
|
||||
// delete end node
|
||||
const deleteAction = controller.getDeleteAction(endNode);
|
||||
|
||||
actions.push(updateAction, deleteAction);
|
||||
}
|
||||
|
||||
// delete middle nodes
|
||||
const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(documentState.nodes[id])) || [];
|
||||
|
||||
actions.push(...deleteMiddleNodesActions);
|
||||
|
||||
// apply actions
|
||||
await controller.applyActions(actions);
|
||||
|
||||
// clear range
|
||||
dispatch(rangeActions.initialState(docId));
|
||||
if (caret) {
|
||||
dispatch(
|
||||
rangeActions.setCaret({
|
||||
docId,
|
||||
caret,
|
||||
const deltaOperator = new BlockDeltaOperator(documentState, controller, async (name: string) => {
|
||||
await dispatch(
|
||||
updatePageName({
|
||||
id: docId,
|
||||
name,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
const [startId, endId] = getStartAndEndIdsByRange(rangeState);
|
||||
const startSelection = rangeState.ranges[startId];
|
||||
const endSelection = rangeState.ranges[endId];
|
||||
|
||||
if (!startSelection || !endSelection) return;
|
||||
const newLineId = await deltaOperator.splitText(
|
||||
{
|
||||
id: startId,
|
||||
index: startSelection.index,
|
||||
},
|
||||
{
|
||||
id: endId,
|
||||
index: endSelection.length,
|
||||
},
|
||||
shiftKey
|
||||
);
|
||||
|
||||
if (!newLineId) return;
|
||||
dispatch(
|
||||
setCursorRangeThunk({
|
||||
docId,
|
||||
blockId: newLineId,
|
||||
index: shiftKey ? startSelection.index + 1 : 0,
|
||||
length: 0,
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
@ -7,6 +7,7 @@ import { TemporaryState, TemporaryType } from '$app/interfaces/document';
|
||||
import { temporaryActions } from '$app_reducers/document/temporary_slice';
|
||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||
import { rangeActions } from '$app_reducers/document/slice';
|
||||
import { BlockDeltaOperator } from '$app/utils/document/block_delta';
|
||||
|
||||
export const createTemporary = createAsyncThunk(
|
||||
'document/temporary/create',
|
||||
@ -15,6 +16,8 @@ export const createTemporary = createAsyncThunk(
|
||||
const { dispatch, getState } = thunkAPI;
|
||||
const state = getState() as RootState;
|
||||
let temporaryState = payload.state;
|
||||
const documentState = state[DOCUMENT_NAME][docId];
|
||||
const deltaOperator = new BlockDeltaOperator(documentState);
|
||||
|
||||
if (!temporaryState && type) {
|
||||
const caret = state[RANGE_NAME][docId].caret;
|
||||
@ -28,12 +31,22 @@ export const createTemporary = createAsyncThunk(
|
||||
index,
|
||||
length,
|
||||
};
|
||||
|
||||
const node = state[DOCUMENT_NAME][docId].nodes[id];
|
||||
const nodeDelta = new Delta(node.data?.delta);
|
||||
const rangeDelta = getDeltaByRange(nodeDelta, selection);
|
||||
const text = getDeltaText(rangeDelta);
|
||||
const nodeDelta = deltaOperator.getDeltaWithBlockId(node.id);
|
||||
|
||||
if (!nodeDelta) return;
|
||||
const rangeDelta = deltaOperator.sliceDeltaWithBlockId(
|
||||
node.id,
|
||||
selection.index,
|
||||
selection.index + selection.length
|
||||
);
|
||||
|
||||
if (!rangeDelta) return;
|
||||
const text = deltaOperator.getDeltaText(rangeDelta);
|
||||
|
||||
const data = newDataWithTemporaryType(type, text);
|
||||
|
||||
temporaryState = {
|
||||
id,
|
||||
selection,
|
||||
@ -71,17 +84,17 @@ export const formatTemporary = createAsyncThunk(
|
||||
async (payload: { controller: DocumentController }, thunkAPI) => {
|
||||
const { controller } = payload;
|
||||
const docId = controller.documentId;
|
||||
const { dispatch, getState } = thunkAPI;
|
||||
const { getState } = thunkAPI;
|
||||
const state = getState() as RootState;
|
||||
const temporaryState = state[TEMPORARY_NAME][docId];
|
||||
const documentState = state[DOCUMENT_NAME][docId];
|
||||
const deltaOperator = new BlockDeltaOperator(documentState, controller);
|
||||
|
||||
if (!temporaryState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, selection, type, data } = temporaryState;
|
||||
const node = state[DOCUMENT_NAME][docId].nodes[id];
|
||||
const nodeDelta = new Delta(node.data?.delta);
|
||||
const { index, length } = selection;
|
||||
const diffDelta: Delta = new Delta();
|
||||
let newSelection = selection;
|
||||
@ -106,6 +119,7 @@ export const formatTemporary = createAsyncThunk(
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case TemporaryType.Link: {
|
||||
if (!data.text) return;
|
||||
if (!data.href) {
|
||||
@ -115,6 +129,7 @@ export const formatTemporary = createAsyncThunk(
|
||||
href: data.href,
|
||||
});
|
||||
}
|
||||
|
||||
newSelection = {
|
||||
index: selection.index,
|
||||
length: data.text.length,
|
||||
@ -126,17 +141,10 @@ export const formatTemporary = createAsyncThunk(
|
||||
break;
|
||||
}
|
||||
|
||||
const newDelta = nodeDelta.compose(diffDelta);
|
||||
const applyTextDeltaAction = deltaOperator.getApplyDeltaAction(id, diffDelta);
|
||||
|
||||
const updateAction = controller.getUpdateAction({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
delta: newDelta.ops,
|
||||
},
|
||||
});
|
||||
|
||||
await controller.applyActions([updateAction]);
|
||||
if (!applyTextDeltaAction) return;
|
||||
await controller.applyActions([applyTextDeltaAction]);
|
||||
return {
|
||||
...temporaryState,
|
||||
selection: newSelection,
|
||||
|
@ -2,9 +2,13 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||
import { BlockData, BlockType } from '$app/interfaces/document';
|
||||
import { blockConfig } from '$app/constants/document/config';
|
||||
import { newBlock } from '$app/utils/document/block';
|
||||
import { rangeActions } from '$app_reducers/document/slice';
|
||||
import { generateId, newBlock } from '$app/utils/document/block';
|
||||
import { RootState } from '$app/stores/store';
|
||||
import { BlockDeltaOperator } from '$app/utils/document/block_delta';
|
||||
import Delta from 'quill-delta';
|
||||
import { setCursorRangeThunk } from '$app_reducers/document/async-actions/cursor';
|
||||
import { blockEditActions } from '$app_reducers/document/block_edit_slice';
|
||||
import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
|
||||
|
||||
/**
|
||||
* transform to block
|
||||
@ -20,45 +24,94 @@ export const turnToBlockThunk = createAsyncThunk(
|
||||
const { id, controller, type, data } = payload;
|
||||
const docId = controller.documentId;
|
||||
const { dispatch, getState } = thunkAPI;
|
||||
const state = (getState() as RootState).document[docId];
|
||||
|
||||
const node = state.nodes[id];
|
||||
const state = getState() as RootState;
|
||||
const documentState = state[DOCUMENT_NAME][docId];
|
||||
const caret = state[RANGE_NAME][docId].caret;
|
||||
const node = documentState.nodes[id];
|
||||
|
||||
if (!node.parent) return;
|
||||
|
||||
const parent = state.nodes[node.parent];
|
||||
const children = state.children[node.children].map((id) => state.nodes[id]);
|
||||
|
||||
const block = newBlock<any>(type, parent.id, type === BlockType.DividerBlock ? {} : data);
|
||||
let caretId = block.id;
|
||||
const parent = documentState.nodes[node.parent];
|
||||
const children = documentState.children[node.children].map((id) => documentState.nodes[id]);
|
||||
let caretId,
|
||||
caretIndex = caret?.index || 0;
|
||||
const deltaOperator = new BlockDeltaOperator(documentState, controller);
|
||||
let delta = deltaOperator.getDeltaWithBlockId(node.id);
|
||||
// insert new block after current block
|
||||
const insertActions = [controller.getInsertAction(block, node.id)];
|
||||
const insertActions = [];
|
||||
|
||||
if (type === BlockType.DividerBlock) {
|
||||
const newTextNode = newBlock<any>(BlockType.TextBlock, parent.id, data);
|
||||
|
||||
insertActions.push(controller.getInsertAction(newTextNode, block.id));
|
||||
caretId = newTextNode.id;
|
||||
if (node.type === BlockType.EquationBlock) {
|
||||
delta = new Delta([{ insert: node.data.formula }]);
|
||||
}
|
||||
|
||||
if (delta && type === BlockType.EquationBlock) {
|
||||
data.formula = deltaOperator.getDeltaText(delta);
|
||||
const block = newBlock<any>(type, parent.id, data);
|
||||
|
||||
insertActions.push(controller.getInsertAction(block, node.id));
|
||||
caretId = block.id;
|
||||
caretIndex = 0;
|
||||
} else if (delta && type === BlockType.DividerBlock) {
|
||||
const block = newBlock<any>(type, parent.id, data);
|
||||
|
||||
insertActions.push(controller.getInsertAction(block, node.id));
|
||||
const nodeId = generateId();
|
||||
const actions = deltaOperator.getNewTextLineActions({
|
||||
blockId: nodeId,
|
||||
parentId: parent.id,
|
||||
prevId: block.id || null,
|
||||
delta: delta ? delta : new Delta([{ insert: '' }]),
|
||||
type,
|
||||
data,
|
||||
});
|
||||
|
||||
caretId = nodeId;
|
||||
caretIndex = 0;
|
||||
insertActions.push(...actions);
|
||||
} else if (delta) {
|
||||
caretId = generateId();
|
||||
|
||||
const actions = deltaOperator.getNewTextLineActions({
|
||||
blockId: caretId,
|
||||
parentId: parent.id,
|
||||
prevId: node.id,
|
||||
delta: delta,
|
||||
type,
|
||||
data,
|
||||
});
|
||||
|
||||
insertActions.push(...actions);
|
||||
}
|
||||
|
||||
if (!caretId) return;
|
||||
// check if prev node is allowed to have children
|
||||
const config = blockConfig[block.type];
|
||||
const config = blockConfig[type];
|
||||
// if new block is not allowed to have children, move children to parent
|
||||
const newParent = config.canAddChild ? block : parent;
|
||||
const newParentId = config.canAddChild ? caretId : parent.id;
|
||||
// if move children to parent, set prev to current block, otherwise the prev is empty
|
||||
const newPrev = newParent.id === parent.id ? block.id : '';
|
||||
const moveChildrenActions = controller.getMoveChildrenAction(children, newParent.id, newPrev);
|
||||
const newPrev = config.canAddChild ? null : caretId;
|
||||
const moveChildrenActions = controller.getMoveChildrenAction(children, newParentId, newPrev);
|
||||
|
||||
// delete current block
|
||||
const deleteAction = controller.getDeleteAction(node);
|
||||
|
||||
// submit actions
|
||||
await controller.applyActions([...insertActions, ...moveChildrenActions, deleteAction]);
|
||||
// set cursor in new block
|
||||
dispatch(
|
||||
rangeActions.setCaret({
|
||||
setCursorRangeThunk({
|
||||
docId,
|
||||
caret: { id: caretId, index: 0, length: 0 },
|
||||
blockId: caretId,
|
||||
index: caretIndex,
|
||||
length: 0,
|
||||
})
|
||||
);
|
||||
dispatch(
|
||||
blockEditActions.setBlockEditState({
|
||||
id: docId,
|
||||
state: {
|
||||
id: caretId,
|
||||
editing: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
return caretId;
|
||||
@ -75,20 +128,14 @@ export const turnToTextBlockThunk = createAsyncThunk(
|
||||
'document/turnToTextBlock',
|
||||
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
|
||||
const { id, controller } = payload;
|
||||
const { dispatch, getState } = thunkAPI;
|
||||
const docId = controller.documentId;
|
||||
const state = (getState() as RootState).document[docId];
|
||||
const node = state.nodes[id];
|
||||
const data = {
|
||||
delta: node.data.delta,
|
||||
};
|
||||
const { dispatch } = thunkAPI;
|
||||
|
||||
await dispatch(
|
||||
turnToBlockThunk({
|
||||
id,
|
||||
controller,
|
||||
type: BlockType.TextBlock,
|
||||
data,
|
||||
data: {},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
@ -21,6 +21,7 @@ export const mentionSlice = createSlice({
|
||||
}
|
||||
) => {
|
||||
const { docId, blockId } = action.payload;
|
||||
|
||||
state[docId] = {
|
||||
open: true,
|
||||
blockId,
|
||||
@ -28,6 +29,7 @@ export const mentionSlice = createSlice({
|
||||
},
|
||||
close: (state, action: { payload: { docId: string } }) => {
|
||||
const { docId } = action.payload;
|
||||
|
||||
delete state[docId];
|
||||
},
|
||||
},
|
||||
|
@ -15,6 +15,7 @@ import { DOCUMENT_NAME, RANGE_NAME, RECT_RANGE_NAME, SLASH_COMMAND_NAME } from '
|
||||
import { blockEditSlice } from '$app_reducers/document/block_edit_slice';
|
||||
import { Op } from 'quill-delta';
|
||||
import { mentionSlice } from '$app_reducers/document/mention_slice';
|
||||
import { generateId } from '$app/utils/document/block';
|
||||
|
||||
const initialState: Record<string, DocumentState> = {};
|
||||
|
||||
@ -37,6 +38,7 @@ export const documentSlice = createSlice({
|
||||
state[docId] = {
|
||||
nodes: {},
|
||||
children: {},
|
||||
deltaMap: {},
|
||||
};
|
||||
},
|
||||
clear: (state, action: PayloadAction<string>) => {
|
||||
@ -52,13 +54,15 @@ export const documentSlice = createSlice({
|
||||
docId: string;
|
||||
nodes: Record<string, Node>;
|
||||
children: Record<string, string[]>;
|
||||
deltaMap: Record<string, string>;
|
||||
}>
|
||||
) => {
|
||||
const { docId, nodes, children } = action.payload;
|
||||
const { docId, nodes, children, deltaMap } = action.payload;
|
||||
|
||||
state[docId] = {
|
||||
nodes,
|
||||
children,
|
||||
deltaMap,
|
||||
};
|
||||
},
|
||||
|
||||
@ -72,10 +76,16 @@ export const documentSlice = createSlice({
|
||||
) => {
|
||||
const { docId, delta, rootId } = action.payload;
|
||||
const documentState = state[docId];
|
||||
|
||||
if (!documentState) return;
|
||||
const rootNode = documentState.nodes[rootId];
|
||||
|
||||
if (!rootNode) return;
|
||||
rootNode.data.delta = delta;
|
||||
let externalId = rootNode.externalId;
|
||||
|
||||
if (!externalId) externalId = generateId();
|
||||
rootNode.externalId = externalId;
|
||||
documentState.deltaMap[externalId] = JSON.stringify(delta);
|
||||
},
|
||||
/**
|
||||
This function listens for changes in the data layer triggered by the data API,
|
||||
|
@ -0,0 +1,277 @@
|
||||
import { BlockDeltaOperator } from '$app/utils/document/block_delta';
|
||||
import { mockDocument } from './document_state';
|
||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||
import { generateId } from '$app/utils/document/block';
|
||||
|
||||
jest.mock('nanoid', () => ({ nanoid: jest.fn().mockReturnValue(String(Math.random())) }));
|
||||
|
||||
jest.mock('$app/utils/document/emoji', () => ({
|
||||
randomEmoji: jest.fn().mockReturnValue('👍'),
|
||||
}));
|
||||
|
||||
jest.mock('$app/stores/effects/document/document_observer', () => ({
|
||||
DocumentObserver: jest.fn().mockImplementation(() => ({
|
||||
subscribe: jest.fn().mockReturnValue(Promise.resolve()),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('$app/stores/effects/document/document_bd_svc', () => ({
|
||||
DocumentBackendService: jest.fn().mockImplementation(() => ({
|
||||
open: jest.fn().mockReturnValue(Promise.resolve({ ok: true, val: mockDocument })),
|
||||
applyActions: jest.fn().mockReturnValue(Promise.resolve({ ok: true })),
|
||||
createText: jest.fn().mockReturnValue(Promise.resolve({ ok: true })),
|
||||
applyTextDelta: jest.fn().mockReturnValue(Promise.resolve({ ok: true })),
|
||||
close: jest.fn().mockReturnValue(Promise.resolve({ ok: true })),
|
||||
canUndoRedo: jest.fn().mockReturnValue(Promise.resolve({ ok: true })),
|
||||
undo: jest.fn().mockReturnValue(Promise.resolve({ ok: true })),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('Test BlockDeltaOperator', () => {
|
||||
let operator: BlockDeltaOperator;
|
||||
let controller: DocumentController;
|
||||
beforeEach(() => {
|
||||
controller = new DocumentController(generateId());
|
||||
operator = new BlockDeltaOperator(mockDocument, controller);
|
||||
});
|
||||
test('get block', () => {
|
||||
const block = operator.getBlock('1');
|
||||
expect(block).toEqual(undefined);
|
||||
|
||||
const blockId = Object.keys(mockDocument.nodes)[0];
|
||||
const block2 = operator.getBlock(blockId);
|
||||
expect(block2).toEqual(mockDocument.nodes[blockId]);
|
||||
});
|
||||
|
||||
test('get delta with block id', () => {
|
||||
const blockId = 'gtYcSzwLYw';
|
||||
const delta = operator.getDeltaWithBlockId(blockId);
|
||||
expect(delta).toBeTruthy();
|
||||
const deltaStr = JSON.stringify(delta!.ops);
|
||||
const externalId = mockDocument.nodes[blockId].externalId;
|
||||
expect(externalId).toBeTruthy();
|
||||
expect(deltaStr).toEqual(mockDocument.deltaMap[externalId!]);
|
||||
});
|
||||
|
||||
test('get delta text', () => {
|
||||
const blockId = 'gtYcSzwLYw';
|
||||
const delta = operator.getDeltaWithBlockId(blockId);
|
||||
expect(delta).toBeTruthy();
|
||||
const text = operator.getDeltaText(delta!);
|
||||
expect(text).toEqual('Welcome to AppFlowy!');
|
||||
});
|
||||
|
||||
test('get split delta', () => {
|
||||
const blockId = 'gtYcSzwLYw';
|
||||
const splitDeltaResult = operator.getSplitDelta(blockId, 7, 4);
|
||||
expect(splitDeltaResult).toBeTruthy();
|
||||
const { updateDelta, diff, insertDelta } = splitDeltaResult!;
|
||||
expect(updateDelta).toBeTruthy();
|
||||
expect(diff).toBeTruthy();
|
||||
expect(insertDelta).toBeTruthy();
|
||||
expect(updateDelta.ops).toEqual([{ insert: 'Welcome' }]);
|
||||
expect(diff.ops).toEqual([{ retain: 7 }, { delete: 13 }]);
|
||||
expect(insertDelta.ops).toEqual([{ insert: 'AppFlowy!' }]);
|
||||
|
||||
const blockId1 = 'wh475aelU_';
|
||||
const splitDeltaResult1 = operator.getSplitDelta(blockId1, 14, 0);
|
||||
expect(splitDeltaResult1).toBeTruthy();
|
||||
const { updateDelta: updateDelta1, diff: diff1, insertDelta: insertDelta1 } = splitDeltaResult1!;
|
||||
expect(updateDelta1).toBeTruthy();
|
||||
expect(diff1).toBeTruthy();
|
||||
expect(insertDelta1).toBeTruthy();
|
||||
expect(updateDelta1.ops).toEqual([
|
||||
{ insert: 'Markdown ' },
|
||||
{ insert: 'refer', attributes: { href: 'https://appflowy.gitbook.io/docs/essential-documentation/markdown' } },
|
||||
]);
|
||||
expect(diff1.ops).toEqual([{ retain: 14 }, { delete: 4 }]);
|
||||
expect(insertDelta1.ops).toEqual([
|
||||
{ insert: 'ence', attributes: { href: 'https://appflowy.gitbook.io/docs/essential-documentation/markdown' } },
|
||||
]);
|
||||
});
|
||||
|
||||
test('split a line text', async () => {
|
||||
const startId = 'gtYcSzwLYw';
|
||||
const endId = 'gtYcSzwLYw';
|
||||
const index = 7;
|
||||
await operator.splitText(
|
||||
{
|
||||
id: startId,
|
||||
index,
|
||||
},
|
||||
{
|
||||
id: endId,
|
||||
index,
|
||||
}
|
||||
);
|
||||
const backendService = controller.backend;
|
||||
expect(backendService.applyActions).toBeCalledTimes(1);
|
||||
// @ts-ignore
|
||||
const actions = backendService.applyActions.mock.calls[0][0];
|
||||
expect(actions).toBeTruthy();
|
||||
expect(actions.length).toEqual(3);
|
||||
expect(actions[0].action).toEqual(5);
|
||||
expect(actions[0].payload).toEqual({
|
||||
delta: '[{"retain":7},{"delete":13}]',
|
||||
text_id: 'KbkL-wXQrN',
|
||||
});
|
||||
expect(actions[1].action).toEqual(4);
|
||||
expect(actions[1].payload).toHaveProperty('text_id');
|
||||
expect(actions[1].payload).toHaveProperty('delta');
|
||||
expect(actions[1].payload.delta).toEqual('[{"insert":" to AppFlowy!"}]');
|
||||
expect(actions[1].payload.text_id).toEqual(actions[2].payload.block.external_id);
|
||||
expect(actions[2].action).toEqual(0);
|
||||
expect(actions[2].payload).toHaveProperty('block');
|
||||
expect(actions[2].payload.block.parent_id).toEqual('ifF_PvQeOu');
|
||||
expect(actions[2].payload.block.ty).toEqual('paragraph');
|
||||
expect(actions[2].payload.block).toHaveProperty('external_id');
|
||||
expect(actions[2].payload.block.external_id).toBeTruthy();
|
||||
expect(actions[2].payload.parent_id).toEqual('ifF_PvQeOu');
|
||||
expect(actions[2].payload.prev_id).toEqual('gtYcSzwLYw');
|
||||
});
|
||||
|
||||
test('split multi line text', async () => {
|
||||
const startId = 'pYV_AGVqEE';
|
||||
const endId = 'eqf0luv-Fy';
|
||||
const startIndex = 8;
|
||||
const endIndex = 5;
|
||||
await operator.splitText(
|
||||
{
|
||||
id: startId,
|
||||
index: startIndex,
|
||||
},
|
||||
{
|
||||
id: endId,
|
||||
index: endIndex,
|
||||
}
|
||||
);
|
||||
const backendService = controller.backend;
|
||||
expect(backendService.applyActions).toBeCalledTimes(1);
|
||||
// @ts-ignore
|
||||
const actions = backendService.applyActions.mock.calls[0][0];
|
||||
expect(actions).toBeTruthy();
|
||||
expect(actions.length).toEqual(6);
|
||||
expect(actions[0].action).toEqual(5);
|
||||
expect(actions[0].payload.text_id).toEqual('F3zvDsXHha');
|
||||
expect(actions[0].payload.delta).toEqual('[{"retain":8},{"delete":87}]');
|
||||
expect(actions[1].action).toEqual(2);
|
||||
expect(actions[1].payload.parent_id).toEqual('ifF_PvQeOu');
|
||||
expect(actions[1].payload.prev_id).toEqual('');
|
||||
expect(actions[2].action).toEqual(2);
|
||||
expect(actions[2].payload.parent_id).toEqual('ifF_PvQeOu');
|
||||
expect(actions[2].payload.prev_id).toEqual('');
|
||||
expect(actions[3].action).toEqual(4);
|
||||
expect(actions[3].payload.text_id).toEqual(actions[4].payload.block.external_id);
|
||||
expect(actions[3].payload.delta).toEqual(
|
||||
'[{"insert":" "},{"attributes":{"code":true},"insert":"+"},{"insert":" next to any page title in the sidebar to "},{"attributes":{"font_color":"0xff8427e0"},"insert":"quickly"},{"insert":" add a new subpage, "},{"attributes":{"code":true},"insert":"Document"},{"attributes":{"code":false},"insert":", "},{"attributes":{"code":true},"insert":"Grid"},{"attributes":{"code":false},"insert":", or "},{"attributes":{"code":true},"insert":"Kanban Board"},{"attributes":{"code":false},"insert":"."}]'
|
||||
);
|
||||
expect(actions[4].action).toEqual(0);
|
||||
expect(actions[4].payload.parent_id).toEqual('ifF_PvQeOu');
|
||||
expect(actions[4].payload.prev_id).toEqual('pYV_AGVqEE');
|
||||
expect(actions[5].action).toEqual(2);
|
||||
expect(actions[5].payload.parent_id).toEqual('ifF_PvQeOu');
|
||||
expect(actions[5].payload.prev_id).toEqual('');
|
||||
});
|
||||
|
||||
test('delete a line text', async () => {
|
||||
const startId = 'gtYcSzwLYw';
|
||||
const endId = 'gtYcSzwLYw';
|
||||
await operator.deleteText(
|
||||
{
|
||||
id: startId,
|
||||
index: 7,
|
||||
},
|
||||
{
|
||||
id: endId,
|
||||
index: 8,
|
||||
}
|
||||
);
|
||||
const backendService = controller.backend;
|
||||
expect(backendService.applyActions).toBeCalledTimes(1);
|
||||
// @ts-ignore
|
||||
const actions = backendService.applyActions.mock.calls[0][0];
|
||||
expect(actions).toBeTruthy();
|
||||
expect(actions.length).toEqual(1);
|
||||
expect(actions[0].action).toEqual(5);
|
||||
expect(actions[0].payload).toEqual({
|
||||
delta: '[{"retain":7},{"delete":1}]',
|
||||
text_id: 'KbkL-wXQrN',
|
||||
});
|
||||
});
|
||||
|
||||
test('delete multi line text', async () => {
|
||||
const startId = 'pYV_AGVqEE';
|
||||
const endId = 'eqf0luv-Fy';
|
||||
const startIndex = 8;
|
||||
const endIndex = 5;
|
||||
await operator.splitText(
|
||||
{
|
||||
id: startId,
|
||||
index: startIndex,
|
||||
},
|
||||
{
|
||||
id: endId,
|
||||
index: endIndex,
|
||||
}
|
||||
);
|
||||
const backendService = controller.backend;
|
||||
expect(backendService.applyActions).toBeCalledTimes(1);
|
||||
// @ts-ignore
|
||||
const actions = backendService.applyActions.mock.calls[0][0];
|
||||
expect(actions).toBeTruthy();
|
||||
expect(actions.length).toEqual(6);
|
||||
expect(actions[0].action).toEqual(5);
|
||||
expect(actions[0].payload.text_id).toEqual('F3zvDsXHha');
|
||||
expect(actions[0].payload.delta).toEqual('[{"retain":8},{"delete":87}]');
|
||||
expect(actions[1].action).toEqual(2);
|
||||
expect(actions[1].payload.parent_id).toEqual('ifF_PvQeOu');
|
||||
expect(actions[1].payload.prev_id).toEqual('');
|
||||
expect(actions[2].action).toEqual(2);
|
||||
expect(actions[2].payload.parent_id).toEqual('ifF_PvQeOu');
|
||||
expect(actions[2].payload.prev_id).toEqual('');
|
||||
expect(actions[3].action).toEqual(4);
|
||||
expect(actions[3].payload.delta).toEqual(
|
||||
'[{"insert":" "},{"attributes":{"code":true},"insert":"+"},{"insert":" next to any page title in the sidebar to "},{"attributes":{"font_color":"0xff8427e0"},"insert":"quickly"},{"insert":" add a new subpage, "},{"attributes":{"code":true},"insert":"Document"},{"attributes":{"code":false},"insert":", "},{"attributes":{"code":true},"insert":"Grid"},{"attributes":{"code":false},"insert":", or "},{"attributes":{"code":true},"insert":"Kanban Board"},{"attributes":{"code":false},"insert":"."}]'
|
||||
);
|
||||
expect(actions[3].payload.text_id).toEqual(actions[4].payload.block.external_id);
|
||||
expect(actions[4].action).toEqual(0);
|
||||
expect(actions[4].payload.parent_id).toEqual('ifF_PvQeOu');
|
||||
expect(actions[4].payload.prev_id).toEqual('pYV_AGVqEE');
|
||||
expect(actions[5].action).toEqual(2);
|
||||
expect(actions[5].payload.parent_id).toEqual('ifF_PvQeOu');
|
||||
expect(actions[5].payload.prev_id).toEqual('');
|
||||
});
|
||||
|
||||
test('merge two line text', async () => {
|
||||
const startId = 'gtYcSzwLYw';
|
||||
const endId = 'YsJ-DVO-sC';
|
||||
await operator.mergeText(startId, endId);
|
||||
const backendService = controller.backend;
|
||||
expect(backendService.applyActions).toBeCalledTimes(1);
|
||||
// @ts-ignore
|
||||
const actions = backendService.applyActions.mock.calls[0][0];
|
||||
expect(actions).toBeTruthy();
|
||||
expect(actions.length).toEqual(2);
|
||||
expect(actions[0].action).toEqual(5);
|
||||
expect(actions[0].payload).toEqual({
|
||||
delta: '[{"retain":20},{"insert":"Here are the basics"}]',
|
||||
text_id: 'KbkL-wXQrN',
|
||||
});
|
||||
expect(actions[1].action).toEqual(2);
|
||||
expect(actions[1].payload).toEqual({
|
||||
block: {
|
||||
id: 'YsJ-DVO-sC',
|
||||
ty: 'heading',
|
||||
parent_id: 'ifF_PvQeOu',
|
||||
children_id: 'PM5MctaruD',
|
||||
data: '{"level":2}',
|
||||
external_id: 'QHPzz4O1mV',
|
||||
external_type: 'text',
|
||||
},
|
||||
parent_id: 'ifF_PvQeOu',
|
||||
prev_id: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
@ -0,0 +1,322 @@
|
||||
import { DocumentState } from '$app/interfaces/document';
|
||||
|
||||
export const mockDocument = {
|
||||
nodes: {
|
||||
wh475aelU_: {
|
||||
id: 'wh475aelU_',
|
||||
type: 'numbered_list',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: 'VcfuvGuodm',
|
||||
data: {},
|
||||
externalId: 'sUF-3L5JHd',
|
||||
externalType: 'text',
|
||||
},
|
||||
pYV_AGVqEE: {
|
||||
id: 'pYV_AGVqEE',
|
||||
type: 'todo_list',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: 'e6ByZ0nZk9',
|
||||
data: { checked: false },
|
||||
externalId: 'F3zvDsXHha',
|
||||
externalType: 'text',
|
||||
},
|
||||
'0whp025621': {
|
||||
id: '0whp025621',
|
||||
type: 'callout',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: 'b5ypKcGf5_',
|
||||
data: { bgColor: '#F0F0F0', icon: '🥰' },
|
||||
externalId: 'P_ODpxtY-S',
|
||||
externalType: 'text',
|
||||
},
|
||||
d4Qo2OFOpX: {
|
||||
id: 'd4Qo2OFOpX',
|
||||
type: 'paragraph',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: '2lNOUVOJJ5',
|
||||
data: {},
|
||||
externalId: 'QT_VkSHge-',
|
||||
externalType: 'text',
|
||||
},
|
||||
tLi0Tg4dBc: {
|
||||
id: 'tLi0Tg4dBc',
|
||||
type: 'paragraph',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: 'rgDc-GrgOa',
|
||||
data: {},
|
||||
externalId: '7FQuBVPxeZ',
|
||||
externalType: 'text',
|
||||
},
|
||||
'-sili1kmaR': {
|
||||
id: '-sili1kmaR',
|
||||
type: 'todo_list',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: 'mAxPJngROh',
|
||||
data: { checked: false },
|
||||
externalId: 'VGLCGgx_rk',
|
||||
externalType: 'text',
|
||||
},
|
||||
'5I64JF3Hzw': {
|
||||
id: '5I64JF3Hzw',
|
||||
type: 'numbered_list',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: 'TzJU1gv2PE',
|
||||
data: {},
|
||||
externalId: 'zYOHSlXpWE',
|
||||
externalType: 'text',
|
||||
},
|
||||
'eqf0luv-Fy': {
|
||||
id: 'eqf0luv-Fy',
|
||||
type: 'todo_list',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: 'oxKR2cHeeH',
|
||||
data: {
|
||||
checked: false,
|
||||
},
|
||||
externalId: '6BnmM6ZkJV',
|
||||
externalType: 'text',
|
||||
},
|
||||
ZMPoVs7lC4: {
|
||||
id: 'ZMPoVs7lC4',
|
||||
type: 'numbered_list',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: 'jwB_QmOn21',
|
||||
data: {},
|
||||
externalId: 'qIDnwwdSQF',
|
||||
externalType: 'text',
|
||||
},
|
||||
'PM-4wkNVlu': {
|
||||
id: 'PM-4wkNVlu',
|
||||
type: 'paragraph',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: 'HdHqxm7-e-',
|
||||
data: {},
|
||||
externalId: 'lPI1KU7usc',
|
||||
externalType: 'text',
|
||||
},
|
||||
'5qS3sKv9C2': {
|
||||
id: '5qS3sKv9C2',
|
||||
type: 'heading',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: 'LaCFrFbNeA',
|
||||
data: { level: 2 },
|
||||
externalId: 'fy82xqO08a',
|
||||
externalType: 'text',
|
||||
},
|
||||
tEGSjQM2LP: {
|
||||
id: 'tEGSjQM2LP',
|
||||
type: 'todo_list',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: 'G_zBND8YZl',
|
||||
data: { checked: true },
|
||||
externalId: 'xWJGGIB-fp',
|
||||
externalType: 'text',
|
||||
},
|
||||
IteP77UNrr: {
|
||||
id: 'IteP77UNrr',
|
||||
type: 'divider',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: '8ZAdHr4H4J',
|
||||
data: {},
|
||||
externalId: '',
|
||||
externalType: '',
|
||||
},
|
||||
vMc1WwxjJu: {
|
||||
id: 'vMc1WwxjJu',
|
||||
type: 'quote',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: 'zWkL_b8_Mi',
|
||||
data: {},
|
||||
externalId: 'oOxRotTYg2',
|
||||
externalType: 'text',
|
||||
},
|
||||
gtYcSzwLYw: {
|
||||
id: 'gtYcSzwLYw',
|
||||
type: 'heading',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: 'WhIA288H8O',
|
||||
data: { level: 1 },
|
||||
externalId: 'KbkL-wXQrN',
|
||||
externalType: 'text',
|
||||
},
|
||||
jk7YrtfAgz: {
|
||||
id: 'jk7YrtfAgz',
|
||||
type: 'paragraph',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: 'KIO68twg3J',
|
||||
data: {},
|
||||
externalId: 'b3BIaLzS_o',
|
||||
externalType: 'text',
|
||||
},
|
||||
jAl6GnPNB_: {
|
||||
id: 'jAl6GnPNB_',
|
||||
type: 'todo_list',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: 'HR3s1f_gpD',
|
||||
data: { checked: false },
|
||||
externalId: 'qiW6xN-o5Q',
|
||||
externalType: 'text',
|
||||
},
|
||||
NFtEOGjXEm: {
|
||||
id: 'NFtEOGjXEm',
|
||||
type: 'paragraph',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: 'kDx1WbW6ni',
|
||||
data: {},
|
||||
externalId: 'r19i_oNV3O',
|
||||
externalType: 'text',
|
||||
},
|
||||
'4f6_TWg8x5': {
|
||||
id: '4f6_TWg8x5',
|
||||
type: 'paragraph',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: 'RGXTAjco5O',
|
||||
data: {},
|
||||
externalId: 'pf1dV9EJer',
|
||||
externalType: 'text',
|
||||
},
|
||||
xFhJgOxACc: {
|
||||
id: 'xFhJgOxACc',
|
||||
type: 'heading',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: 'CMqq7y9JTX',
|
||||
data: { level: 2 },
|
||||
externalId: 'b3mbfhloLa',
|
||||
externalType: 'text',
|
||||
},
|
||||
'kih-t9tRZr': {
|
||||
id: 'kih-t9tRZr',
|
||||
type: 'code',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: 'fnWMHsa5if',
|
||||
data: { language: 'rust' },
|
||||
externalId: 'HBZkdYM6Ka',
|
||||
externalType: 'text',
|
||||
},
|
||||
ifF_PvQeOu: {
|
||||
id: 'ifF_PvQeOu',
|
||||
type: 'page',
|
||||
parent: '',
|
||||
children: '5_bawmri6x',
|
||||
data: {},
|
||||
externalId: 'm_SX-ck0GL',
|
||||
externalType: 'text',
|
||||
},
|
||||
'YsJ-DVO-sC': {
|
||||
id: 'YsJ-DVO-sC',
|
||||
type: 'heading',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: 'PM5MctaruD',
|
||||
data: { level: 2 },
|
||||
externalId: 'QHPzz4O1mV',
|
||||
externalType: 'text',
|
||||
},
|
||||
JcIU0PjpyD: {
|
||||
id: 'JcIU0PjpyD',
|
||||
type: 'todo_list',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: 'xcYFnxMXai',
|
||||
data: { checked: false },
|
||||
externalId: 'g4WQvF8doI',
|
||||
externalType: 'text',
|
||||
},
|
||||
Oi2cxSuUls: {
|
||||
id: 'Oi2cxSuUls',
|
||||
type: 'paragraph',
|
||||
parent: 'ifF_PvQeOu',
|
||||
children: 'NI4TCeq2Lv',
|
||||
data: {},
|
||||
externalId: 'D27H4Hf9re',
|
||||
externalType: 'text',
|
||||
},
|
||||
},
|
||||
children: {
|
||||
xcYFnxMXai: [],
|
||||
'5_bawmri6x': [
|
||||
'gtYcSzwLYw',
|
||||
'YsJ-DVO-sC',
|
||||
'jAl6GnPNB_',
|
||||
'-sili1kmaR',
|
||||
'pYV_AGVqEE',
|
||||
'JcIU0PjpyD',
|
||||
'tEGSjQM2LP',
|
||||
'eqf0luv-Fy',
|
||||
'4f6_TWg8x5',
|
||||
'IteP77UNrr',
|
||||
'PM-4wkNVlu',
|
||||
'5qS3sKv9C2',
|
||||
'5I64JF3Hzw',
|
||||
'wh475aelU_',
|
||||
'ZMPoVs7lC4',
|
||||
'kih-t9tRZr',
|
||||
'Oi2cxSuUls',
|
||||
'xFhJgOxACc',
|
||||
'vMc1WwxjJu',
|
||||
'd4Qo2OFOpX',
|
||||
'0whp025621',
|
||||
'tLi0Tg4dBc',
|
||||
'jk7YrtfAgz',
|
||||
'NFtEOGjXEm',
|
||||
],
|
||||
'rgDc-GrgOa': [],
|
||||
jwB_QmOn21: [],
|
||||
b5ypKcGf5_: [],
|
||||
LaCFrFbNeA: [],
|
||||
'8ZAdHr4H4J': [],
|
||||
'HdHqxm7-e-': [],
|
||||
G_zBND8YZl: [],
|
||||
CMqq7y9JTX: [],
|
||||
WhIA288H8O: [],
|
||||
HR3s1f_gpD: [],
|
||||
zWkL_b8_Mi: [],
|
||||
KIO68twg3J: [],
|
||||
oxKR2cHeeH: [],
|
||||
fnWMHsa5if: [],
|
||||
kDx1WbW6ni: [],
|
||||
'2lNOUVOJJ5': [],
|
||||
PM5MctaruD: [],
|
||||
TzJU1gv2PE: [],
|
||||
RGXTAjco5O: [],
|
||||
e6ByZ0nZk9: [],
|
||||
VcfuvGuodm: [],
|
||||
mAxPJngROh: [],
|
||||
NI4TCeq2Lv: [],
|
||||
},
|
||||
deltaMap: {
|
||||
VGLCGgx_rk:
|
||||
'[{"insert":"Highlight ","attributes":{"bg_color":"0x4dffeb3b"}},{"insert":"any text, and use the editing menu to "},{"insert":"style","attributes":{"italic":true}},{"insert":" "},{"insert":"your","attributes":{"bold":true}},{"insert":" "},{"insert":"writing","attributes":{"underline":true}},{"insert":" "},{"insert":"however","attributes":{"code":true}},{"insert":" you "},{"insert":"like.","attributes":{"strikethrough":true}}]',
|
||||
'6BnmM6ZkJV':
|
||||
'[{"insert":"Click "},{"insert":"+","attributes":{"code":true}},{"insert":" next to any page title in the sidebar to "},{"insert":"quickly","attributes":{"font_color":"0xff8427e0"}},{"insert":" add a new subpage, "},{"insert":"Document","attributes":{"code":true}},{"insert":", ","attributes":{"code":false}},{"insert":"Grid","attributes":{"code":true}},{"insert":", or ","attributes":{"code":false}},{"insert":"Kanban Board","attributes":{"code":true}},{"insert":".","attributes":{"code":false}}]',
|
||||
g4WQvF8doI:
|
||||
'[{"insert":"Type "},{"insert":"/","attributes":{"code":true}},{"insert":" followed by "},{"insert":"/bullet","attributes":{"code":true}},{"insert":" or "},{"insert":"/num","attributes":{"code":true}},{"insert":" to create a list.","attributes":{"code":false}}]',
|
||||
HBZkdYM6Ka:
|
||||
'[{"insert":"// This is the main function.\\nfn main() {\\n // Print text to the console.\\n println!(\\"Hello World!\\");\\n}"}]',
|
||||
'qiW6xN-o5Q': '[{"insert":"Click anywhere and just start typing."}]',
|
||||
'KbkL-wXQrN': '[{"insert":"Welcome to AppFlowy!"}]',
|
||||
lPI1KU7usc: '[]',
|
||||
D27H4Hf9re: '[]',
|
||||
oOxRotTYg2:
|
||||
'[{"insert":"Click "},{"insert":"?","attributes":{"code":true}},{"insert":" at the bottom right for help and support."}]',
|
||||
'P_ODpxtY-S':
|
||||
'[{"insert":"\\nLike AppFlowy? Follow us:\\n"},{"insert":"GitHub","attributes":{"href":"https://github.com/AppFlowy-IO/AppFlowy"}},{"insert":"\\n"},{"insert":"Twitter","attributes":{"href":"https://twitter.com/appflowy"}},{"insert":": @appflowy\\n"},{"insert":"Newsletter","attributes":{"href":"https://blog-appflowy.ghost.io/"}},{"insert":"\\n"}]',
|
||||
F3zvDsXHha:
|
||||
'[{"insert":"As soon as you type "},{"insert":"/","attributes":{"code":true,"font_color":"0xff00b5ff"}},{"insert":" a menu will pop up. Select "},{"insert":"different types","attributes":{"bg_color":"0x4d9c27b0"}},{"insert":" of content blocks you can add."}]',
|
||||
fy82xqO08a: '[{"insert":"Keyboard shortcuts, markdown, and code block"}]',
|
||||
'sUF-3L5JHd':
|
||||
'[{"insert":"Markdown "},{"insert":"reference","attributes":{"href":"https://appflowy.gitbook.io/docs/essential-documentation/markdown"}}]',
|
||||
r19i_oNV3O: '[]',
|
||||
'm_SX-ck0GL': '[]',
|
||||
b3mbfhloLa: '[{"insert":"Have a question❓"}]',
|
||||
'xWJGGIB-fp':
|
||||
'[{"insert":"Click "},{"insert":"+ New Page ","attributes":{"code":true}},{"insert":"button at the bottom of your sidebar to add a new page."}]',
|
||||
'QT_VkSHge-': '[]',
|
||||
zYOHSlXpWE:
|
||||
'[{"insert":"Keyboard shortcuts "},{"insert":"guide","attributes":{"href":"https://appflowy.gitbook.io/docs/essential-documentation/shortcuts"}}]',
|
||||
b3BIaLzS_o: '[]',
|
||||
'7FQuBVPxeZ': '[]',
|
||||
pf1dV9EJer: '[]',
|
||||
QHPzz4O1mV: '[{"insert":"Here are the basics"}]',
|
||||
qIDnwwdSQF:
|
||||
'[{"insert":"Type "},{"insert":"/code","attributes":{"code":true}},{"insert":" to insert a code block","attributes":{"code":false}}]',
|
||||
},
|
||||
} as DocumentState;
|
@ -24,6 +24,7 @@ import {
|
||||
transformIndexToPrevLine,
|
||||
} from '$app/utils/document/delta';
|
||||
import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
|
||||
import { BlockDeltaOperator } from '$app/utils/document/block_delta';
|
||||
|
||||
export function getMiddleIds(document: DocumentState, startId: string, endId: string) {
|
||||
const middleIds = [];
|
||||
@ -54,207 +55,6 @@ export function getStartAndEndIdsByRange(rangeState: RangeState) {
|
||||
return [startId, endId];
|
||||
}
|
||||
|
||||
export function getMiddleIdsByRange(rangeState: RangeState, document: DocumentState) {
|
||||
const ids = getStartAndEndIdsByRange(rangeState);
|
||||
|
||||
if (ids.length < 2) return;
|
||||
const [startId, endId] = ids;
|
||||
|
||||
return getMiddleIds(document, startId, endId);
|
||||
}
|
||||
|
||||
export function getAfterMergeCaretByRange(rangeState: RangeState, insertDelta?: Delta) {
|
||||
const { anchor, focus, ranges } = rangeState;
|
||||
|
||||
if (!anchor || !focus) return;
|
||||
|
||||
const isForward = anchor.point.y < focus.point.y;
|
||||
const startId = isForward ? anchor.id : focus.id;
|
||||
const startRange = ranges[startId];
|
||||
|
||||
if (!startRange) return;
|
||||
const offset = insertDelta ? insertDelta.length() : 0;
|
||||
|
||||
return {
|
||||
id: startId,
|
||||
index: startRange.index + offset,
|
||||
length: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function getStartAndEndExtentDelta(documentState: DocumentState, rangeState: RangeState) {
|
||||
const ids = getStartAndEndIdsByRange(rangeState);
|
||||
|
||||
if (ids.length === 0) return;
|
||||
const startId = ids[0];
|
||||
const endId = ids[ids.length - 1];
|
||||
const { ranges } = rangeState;
|
||||
// get start and end delta
|
||||
const startRange = ranges[startId];
|
||||
const endRange = ranges[endId];
|
||||
|
||||
if (!startRange || !endRange) return;
|
||||
const startNode = documentState.nodes[startId];
|
||||
const startNodeDelta = new Delta(startNode.data.delta);
|
||||
const startBeforeExtentDelta = getBeofreExtentDeltaByRange(startNodeDelta, startRange);
|
||||
|
||||
const endNode = documentState.nodes[endId];
|
||||
const endNodeDelta = new Delta(endNode.data.delta);
|
||||
const endAfterExtentDelta = getAfterExtentDeltaByRange(endNodeDelta, endRange);
|
||||
|
||||
return {
|
||||
startNode,
|
||||
endNode,
|
||||
startDelta: startBeforeExtentDelta,
|
||||
endDelta: endAfterExtentDelta,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMergeEndDeltaToStartActionsByRange(
|
||||
state: RootState,
|
||||
controller: DocumentController,
|
||||
insertDelta?: Delta
|
||||
) {
|
||||
const actions = [];
|
||||
const docId = controller.documentId;
|
||||
const documentState = state[DOCUMENT_NAME][docId];
|
||||
const rangeState = state[RANGE_NAME][docId];
|
||||
const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(documentState, rangeState) || {};
|
||||
|
||||
if (!startDelta || !endDelta || !endNode || !startNode) return;
|
||||
// merge start and end nodes
|
||||
const mergeDelta = startDelta.concat(insertDelta || new Delta()).concat(endDelta);
|
||||
|
||||
actions.push(
|
||||
controller.getUpdateAction({
|
||||
...startNode,
|
||||
data: {
|
||||
delta: mergeDelta.ops,
|
||||
},
|
||||
})
|
||||
);
|
||||
if (endNode.id !== startNode.id) {
|
||||
const children = documentState.children[endNode.children].map((id) => documentState.nodes[id]);
|
||||
|
||||
const moveChildrenActions = getMoveChildrenActions({
|
||||
target: startNode,
|
||||
children,
|
||||
controller,
|
||||
});
|
||||
|
||||
actions.push(...moveChildrenActions);
|
||||
// delete end node
|
||||
actions.push(controller.getDeleteAction(endNode));
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export function getMoveChildrenActions({
|
||||
target,
|
||||
children,
|
||||
controller,
|
||||
prevId = '',
|
||||
}: {
|
||||
target: NestedBlock;
|
||||
children: NestedBlock[];
|
||||
controller: DocumentController;
|
||||
prevId?: string;
|
||||
}) {
|
||||
// move children
|
||||
const config = blockConfig[target.type];
|
||||
const targetParentId = config.canAddChild ? target.id : target.parent;
|
||||
|
||||
if (!targetParentId) return [];
|
||||
const targetPrevId = targetParentId === target.id ? prevId : target.id;
|
||||
const moveActions = controller.getMoveChildrenAction(children, targetParentId, targetPrevId);
|
||||
|
||||
return moveActions;
|
||||
}
|
||||
|
||||
export function getInsertEnterNodeFields(sourceNode: NestedBlock) {
|
||||
if (!sourceNode.parent) return;
|
||||
const parentId = sourceNode.parent;
|
||||
|
||||
const config = blockConfig[sourceNode.type].splitProps || {
|
||||
nextLineRelationShip: SplitRelationship.NextSibling,
|
||||
nextLineBlockType: BlockType.TextBlock,
|
||||
};
|
||||
|
||||
const newNodeType = config.nextLineBlockType;
|
||||
const relationShip = config.nextLineRelationShip;
|
||||
const defaultData = blockConfig[newNodeType].defaultData;
|
||||
|
||||
// if the defaultData property is not defined for the new block type, we throw an error.
|
||||
if (!defaultData) {
|
||||
throw new Error(`Cannot split node of type ${sourceNode.type} to ${newNodeType}`);
|
||||
}
|
||||
|
||||
const newParentId = relationShip === SplitRelationship.NextSibling ? parentId : sourceNode.id;
|
||||
const newPrevId = relationShip === SplitRelationship.NextSibling ? sourceNode.id : '';
|
||||
|
||||
return {
|
||||
parentId: newParentId,
|
||||
prevId: newPrevId,
|
||||
type: newNodeType,
|
||||
data: defaultData,
|
||||
};
|
||||
}
|
||||
|
||||
export function getInsertEnterNodeAction(
|
||||
sourceNode: NestedBlock,
|
||||
insertNodeDelta: Delta,
|
||||
controller: DocumentController
|
||||
) {
|
||||
const insertNodeFields = getInsertEnterNodeFields(sourceNode);
|
||||
|
||||
if (!insertNodeFields) return;
|
||||
const { type, data, parentId, prevId } = insertNodeFields;
|
||||
const insertNode = newBlock<any>(type, parentId, {
|
||||
...data,
|
||||
delta: insertNodeDelta.ops,
|
||||
});
|
||||
|
||||
return {
|
||||
id: insertNode.id,
|
||||
action: controller.getInsertAction(insertNode, prevId),
|
||||
};
|
||||
}
|
||||
|
||||
export function findPrevHasDeltaNode(state: DocumentState, id: string) {
|
||||
const prevLineId = getPrevLineId(state, id);
|
||||
|
||||
if (!prevLineId) return;
|
||||
let prevLine = state.nodes[prevLineId];
|
||||
|
||||
// Find the prev line that has delta
|
||||
while (prevLine && !prevLine.data.delta) {
|
||||
const id = getPrevLineId(state, prevLine.id);
|
||||
|
||||
if (!id) return;
|
||||
prevLine = state.nodes[id];
|
||||
}
|
||||
|
||||
return prevLine;
|
||||
}
|
||||
|
||||
export function findNextHasDeltaNode(state: DocumentState, id: string) {
|
||||
const nextLineId = getNextLineId(state, id);
|
||||
|
||||
if (!nextLineId) return;
|
||||
let nextLine = state.nodes[nextLineId];
|
||||
|
||||
// Find the next line that has delta
|
||||
while (nextLine && !nextLine.data.delta) {
|
||||
const id = getNextLineId(state, nextLine.id);
|
||||
|
||||
if (!id) return;
|
||||
nextLine = state.nodes[id];
|
||||
}
|
||||
|
||||
return nextLine;
|
||||
}
|
||||
|
||||
export function isPrintableKeyEvent(event: KeyboardEvent) {
|
||||
const key = event.key;
|
||||
const isPrintable = key.length === 1;
|
||||
@ -298,7 +98,10 @@ export function getRightCaretByRange(rangeState: RangeState) {
|
||||
}
|
||||
|
||||
export function transformToPrevLineCaret(document: DocumentState, caret: RangeStatic) {
|
||||
const delta = new Delta(document.nodes[caret.id].data.delta);
|
||||
const deltaOperator = new BlockDeltaOperator(document);
|
||||
const delta = deltaOperator.getDeltaWithBlockId(caret.id);
|
||||
|
||||
if (!delta) return;
|
||||
const inTopEdge = caretInTopEdgeByDelta(delta, caret.index);
|
||||
|
||||
if (!inTopEdge) {
|
||||
@ -311,25 +114,31 @@ export function transformToPrevLineCaret(document: DocumentState, caret: RangeSt
|
||||
};
|
||||
}
|
||||
|
||||
const prevLine = findPrevHasDeltaNode(document, caret.id);
|
||||
const prevLineId = deltaOperator.findPrevTextLine(caret.id);
|
||||
|
||||
if (!prevLine) return;
|
||||
if (!prevLineId) return;
|
||||
const relativeIndex = getIndexRelativeEnter(delta, caret.index);
|
||||
const prevLineIndex = getLastLineIndex(new Delta(prevLine.data.delta));
|
||||
const prevLineText = getDeltaText(new Delta(prevLine.data.delta));
|
||||
const prevLineDelta = deltaOperator.getDeltaWithBlockId(prevLineId);
|
||||
|
||||
if (!prevLineDelta) return;
|
||||
const prevLineIndex = getLastLineIndex(prevLineDelta);
|
||||
const prevLineText = deltaOperator.getDeltaText(prevLineDelta);
|
||||
const newPrevLineIndex = prevLineIndex + relativeIndex;
|
||||
const prevLineLength = prevLineText.length;
|
||||
const index = newPrevLineIndex > prevLineLength ? prevLineLength : newPrevLineIndex;
|
||||
|
||||
return {
|
||||
id: prevLine.id,
|
||||
id: prevLineId,
|
||||
index,
|
||||
length: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformToNextLineCaret(document: DocumentState, caret: RangeStatic) {
|
||||
const delta = new Delta(document.nodes[caret.id].data.delta);
|
||||
const deltaOperator = new BlockDeltaOperator(document);
|
||||
const delta = deltaOperator.getDeltaWithBlockId(caret.id);
|
||||
|
||||
if (!delta) return;
|
||||
const inBottomEdge = caretInBottomEdgeByDelta(delta, caret.index);
|
||||
|
||||
if (!inBottomEdge) {
|
||||
@ -343,15 +152,18 @@ export function transformToNextLineCaret(document: DocumentState, caret: RangeSt
|
||||
return;
|
||||
}
|
||||
|
||||
const nextLine = findNextHasDeltaNode(document, caret.id);
|
||||
const nextLineId = deltaOperator.findNextTextLine(caret.id);
|
||||
|
||||
if (!nextLine) return;
|
||||
const nextLineText = getDeltaText(new Delta(nextLine.data.delta));
|
||||
if (!nextLineId) return;
|
||||
const nextLineDelta = deltaOperator.getDeltaWithBlockId(nextLineId);
|
||||
|
||||
if (!nextLineDelta) return;
|
||||
const nextLineText = deltaOperator.getDeltaText(nextLineDelta);
|
||||
const relativeIndex = getIndexRelativeEnter(delta, caret.index);
|
||||
const index = relativeIndex >= nextLineText.length ? nextLineText.length : relativeIndex;
|
||||
|
||||
return {
|
||||
id: nextLine.id,
|
||||
id: nextLineId,
|
||||
index,
|
||||
length: 0,
|
||||
};
|
||||
|
@ -18,6 +18,8 @@ export function blockPB2Node(block: BlockPB) {
|
||||
parent: block.parent_id,
|
||||
children: block.children_id,
|
||||
data,
|
||||
externalId: block.external_id,
|
||||
externalType: block.external_type,
|
||||
};
|
||||
|
||||
return node;
|
||||
@ -97,12 +99,12 @@ export function getPrevNodeId(state: DocumentState, id: string) {
|
||||
return prevNodeId;
|
||||
}
|
||||
|
||||
export function newBlock<Type>(type: BlockType, parentId: string, data: BlockData<Type>): NestedBlock<Type> {
|
||||
export function newBlock<Type>(type: BlockType, parentId: string, data?: BlockData<Type>): NestedBlock<Type> {
|
||||
return {
|
||||
id: generateId(),
|
||||
type,
|
||||
parent: parentId,
|
||||
children: generateId(),
|
||||
data,
|
||||
data: data ? data : {},
|
||||
};
|
||||
}
|
||||
|
@ -0,0 +1,453 @@
|
||||
import { BlockData, BlockType, DocumentState, NestedBlock, SplitRelationship } from '$app/interfaces/document';
|
||||
import { generateId, getNextLineId, getPrevLineId } from '$app/utils/document/block';
|
||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||
import Delta, { Op } from 'quill-delta';
|
||||
import { blockConfig } from '$app/constants/document/config';
|
||||
|
||||
export class BlockDeltaOperator {
|
||||
constructor(
|
||||
private state: DocumentState,
|
||||
private controller?: DocumentController,
|
||||
private updatePageName?: (name: string) => Promise<void>
|
||||
) {}
|
||||
|
||||
getBlock = (blockId: string) => {
|
||||
return this.state.nodes[blockId];
|
||||
};
|
||||
|
||||
getExternalId = (blockId: string) => {
|
||||
return this.getBlock(blockId)?.externalId;
|
||||
};
|
||||
|
||||
getDeltaStrWithExternalId = (externalId: string) => {
|
||||
return this.state.deltaMap[externalId];
|
||||
};
|
||||
|
||||
getDeltaWithExternalId = (externalId: string) => {
|
||||
const deltaStr = this.getDeltaStrWithExternalId(externalId);
|
||||
|
||||
if (!deltaStr) return;
|
||||
return new Delta(JSON.parse(deltaStr));
|
||||
};
|
||||
|
||||
getDeltaWithBlockId = (blockId: string) => {
|
||||
const externalId = this.getExternalId(blockId);
|
||||
|
||||
if (!externalId) return;
|
||||
return this.getDeltaWithExternalId(externalId);
|
||||
};
|
||||
|
||||
hasDelta = (blockId: string) => {
|
||||
const externalId = this.getExternalId(blockId);
|
||||
|
||||
if (!externalId) return false;
|
||||
return !!this.getDeltaStrWithExternalId(externalId);
|
||||
};
|
||||
|
||||
getDeltaText = (delta: Delta) => {
|
||||
return delta.ops.map((op) => op.insert).join('');
|
||||
};
|
||||
|
||||
sliceDeltaWithBlockId = (blockId: string, startIndex: number, endIndex?: number) => {
|
||||
const delta = this.getDeltaWithBlockId(blockId);
|
||||
|
||||
return delta?.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
getSplitDelta = (blockId: string, index: number, length: number) => {
|
||||
const externalId = this.getExternalId(blockId);
|
||||
|
||||
if (!externalId) return;
|
||||
const delta = this.getDeltaWithExternalId(externalId);
|
||||
|
||||
if (!delta) return;
|
||||
const diff = new Delta().retain(index).delete(delta.length() - index);
|
||||
const updateDelta = delta.slice(0, index);
|
||||
const insertDelta = delta.slice(index + length);
|
||||
|
||||
return {
|
||||
diff,
|
||||
updateDelta,
|
||||
insertDelta,
|
||||
};
|
||||
};
|
||||
|
||||
getApplyDeltaAction = (blockId: string, delta: Delta) => {
|
||||
const block = this.getBlock(blockId);
|
||||
const deltaStr = JSON.stringify(delta.ops);
|
||||
|
||||
return this.controller?.getApplyTextDeltaAction(block, deltaStr);
|
||||
};
|
||||
|
||||
getNewTextLineActions = ({
|
||||
blockId,
|
||||
delta,
|
||||
parentId,
|
||||
type = BlockType.TextBlock,
|
||||
prevId,
|
||||
data = {},
|
||||
}: {
|
||||
blockId: string;
|
||||
delta: Delta;
|
||||
parentId: string;
|
||||
type: BlockType;
|
||||
prevId: string | null;
|
||||
data?: BlockData<any>;
|
||||
}) => {
|
||||
const externalId = generateId();
|
||||
const block = {
|
||||
id: blockId,
|
||||
type,
|
||||
externalId,
|
||||
externalType: 'text',
|
||||
parent: parentId,
|
||||
children: generateId(),
|
||||
data,
|
||||
};
|
||||
const deltaStr = JSON.stringify(delta.ops);
|
||||
|
||||
if (!this.controller) return [];
|
||||
return this.controller?.getInsertTextActions(block, deltaStr, prevId);
|
||||
};
|
||||
|
||||
splitText = async (
|
||||
startBlock: {
|
||||
id: string;
|
||||
index: number;
|
||||
},
|
||||
endBlock: {
|
||||
id: string;
|
||||
index: number;
|
||||
},
|
||||
shiftKey?: boolean
|
||||
) => {
|
||||
if (!this.controller) return;
|
||||
|
||||
const startNode = this.getBlock(startBlock.id);
|
||||
const endNode = this.getBlock(endBlock.id);
|
||||
const startNodeIsRoot = !startNode.parent;
|
||||
|
||||
if (!startNode || !endNode) return;
|
||||
const startNodeDelta = this.getDeltaWithBlockId(startNode.id);
|
||||
const endNodeDelta = this.getDeltaWithBlockId(endNode.id);
|
||||
|
||||
if (!startNodeDelta || !endNodeDelta) return;
|
||||
let diff: Delta, insertDelta;
|
||||
|
||||
if (startNode.id === endNode.id) {
|
||||
const splitResult = this.getSplitDelta(startNode.id, startBlock.index, endBlock.index - startBlock.index);
|
||||
|
||||
if (!splitResult) return;
|
||||
diff = splitResult.diff;
|
||||
insertDelta = splitResult.insertDelta;
|
||||
} else {
|
||||
const startSplitResult = this.getSplitDelta(
|
||||
startNode.id,
|
||||
startBlock.index,
|
||||
startNodeDelta.length() - startBlock.index
|
||||
);
|
||||
|
||||
const endSplitResult = this.getSplitDelta(endNode.id, 0, endBlock.index);
|
||||
|
||||
if (!startSplitResult || !endSplitResult) return;
|
||||
diff = startSplitResult.diff;
|
||||
insertDelta = endSplitResult.insertDelta;
|
||||
}
|
||||
|
||||
if (!diff || !insertDelta) return;
|
||||
|
||||
const actions = [];
|
||||
|
||||
const { nextLineBlockType, nextLineRelationShip } = blockConfig[startNode.type]?.splitProps || {
|
||||
nextLineBlockType: BlockType.TextBlock,
|
||||
nextLineRelationShip: SplitRelationship.NextSibling,
|
||||
};
|
||||
const parentId =
|
||||
nextLineRelationShip === SplitRelationship.NextSibling && startNode.parent ? startNode.parent : startNode.id;
|
||||
const prevId = nextLineRelationShip === SplitRelationship.NextSibling && startNode.parent ? startNode.id : null;
|
||||
|
||||
let newLineId = startNode.id;
|
||||
|
||||
// delete middle nodes
|
||||
if (startNode.id !== endNode.id) {
|
||||
actions.push(...this.getDeleteMiddleNodesActions(startNode.id, endNode.id));
|
||||
}
|
||||
|
||||
if (shiftKey) {
|
||||
const enter = new Delta().insert('\n');
|
||||
const newOps = diff.ops.concat(enter.ops.concat(insertDelta.ops));
|
||||
|
||||
diff = new Delta(newOps);
|
||||
if (startNode.id !== endNode.id) {
|
||||
// move the children of endNode to startNode
|
||||
actions.push(...this.getMoveChildrenActions(endNode.id, startNode));
|
||||
}
|
||||
} else {
|
||||
newLineId = generateId();
|
||||
actions.push(
|
||||
...this.getNewTextLineActions({
|
||||
blockId: newLineId,
|
||||
delta: insertDelta,
|
||||
parentId,
|
||||
type: nextLineBlockType,
|
||||
prevId,
|
||||
})
|
||||
);
|
||||
if (!startNodeIsRoot) {
|
||||
// move the children of startNode to newLine
|
||||
actions.push(
|
||||
...this.getMoveChildrenActions(
|
||||
startNode.id,
|
||||
{
|
||||
id: newLineId,
|
||||
type: nextLineBlockType,
|
||||
},
|
||||
[endNode.id]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (startNode.id !== endNode.id) {
|
||||
// move the children of endNode to newLine
|
||||
actions.push(
|
||||
...this.getMoveChildrenActions(endNode.id, {
|
||||
id: newLineId,
|
||||
type: nextLineBlockType,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (startNode.id !== endNode.id) {
|
||||
// delete end node
|
||||
const deleteEndNodeAction = this.controller.getDeleteAction(endNode);
|
||||
|
||||
actions.push(deleteEndNodeAction);
|
||||
}
|
||||
|
||||
if (startNode.parent) {
|
||||
// apply delta
|
||||
const applyDeltaAction = this.getApplyDeltaAction(startNode.id, diff);
|
||||
|
||||
if (applyDeltaAction) actions.unshift(applyDeltaAction);
|
||||
} else {
|
||||
await this.updateRootNodeDelta(startNode.id, diff);
|
||||
}
|
||||
|
||||
await this.controller.applyActions(actions);
|
||||
|
||||
return newLineId;
|
||||
};
|
||||
|
||||
deleteText = async (
|
||||
startBlock: {
|
||||
id: string;
|
||||
index: number;
|
||||
},
|
||||
endBlock: {
|
||||
id: string;
|
||||
index: number;
|
||||
},
|
||||
insertChar?: string
|
||||
) => {
|
||||
if (!this.controller) return;
|
||||
const startNode = this.getBlock(startBlock.id);
|
||||
const endNode = this.getBlock(endBlock.id);
|
||||
|
||||
if (!startNode || !endNode) return;
|
||||
const startNodeDelta = this.getDeltaWithBlockId(startNode.id);
|
||||
const endNodeDelta = this.getDeltaWithBlockId(endNode.id);
|
||||
|
||||
if (!startNodeDelta || !endNodeDelta) return;
|
||||
|
||||
let startDiff: Delta | undefined;
|
||||
const actions = [];
|
||||
|
||||
if (startNode.id === endNode.id) {
|
||||
const length = endBlock.index - startBlock.index;
|
||||
|
||||
const newOps: Op[] = [
|
||||
{
|
||||
retain: startBlock.index,
|
||||
},
|
||||
{
|
||||
delete: length,
|
||||
},
|
||||
];
|
||||
|
||||
if (insertChar) {
|
||||
newOps.push({
|
||||
insert: insertChar,
|
||||
});
|
||||
}
|
||||
|
||||
startDiff = new Delta(newOps);
|
||||
} else {
|
||||
const startSplitResult = this.getSplitDelta(
|
||||
startNode.id,
|
||||
startBlock.index,
|
||||
startNodeDelta.length() - startBlock.index
|
||||
);
|
||||
const endSplitResult = this.getSplitDelta(endNode.id, 0, endBlock.index);
|
||||
|
||||
if (!startSplitResult || !endSplitResult) return;
|
||||
const insertDelta = endSplitResult.insertDelta;
|
||||
const newOps = [...startSplitResult.diff.ops];
|
||||
|
||||
if (insertChar) {
|
||||
newOps.push({
|
||||
insert: insertChar,
|
||||
});
|
||||
}
|
||||
|
||||
newOps.push(...insertDelta.ops);
|
||||
startDiff = new Delta(newOps);
|
||||
// delete middle nodes
|
||||
actions.push(...this.getDeleteMiddleNodesActions(startNode.id, endNode.id));
|
||||
// move the children of endNode to startNode
|
||||
actions.push(...this.getMoveChildrenActions(endNode.id, startNode));
|
||||
// delete end node
|
||||
const deleteEndNodeAction = this.controller.getDeleteAction(endNode);
|
||||
|
||||
actions.push(deleteEndNodeAction);
|
||||
}
|
||||
|
||||
if (!startDiff) return;
|
||||
if (startNode.parent) {
|
||||
const applyDeltaAction = this.getApplyDeltaAction(startNode.id, startDiff);
|
||||
|
||||
if (applyDeltaAction) actions.unshift(applyDeltaAction);
|
||||
} else {
|
||||
await this.updateRootNodeDelta(startNode.id, startDiff);
|
||||
}
|
||||
|
||||
await this.controller.applyActions(actions);
|
||||
|
||||
return startNode.id;
|
||||
};
|
||||
|
||||
mergeText = async (targetId: string, sourceId: string) => {
|
||||
if (!this.controller || targetId === sourceId) return;
|
||||
const startNode = this.getBlock(targetId);
|
||||
const endNode = this.getBlock(sourceId);
|
||||
|
||||
if (!startNode || !endNode) return;
|
||||
const startNodeDelta = this.getDeltaWithBlockId(startNode.id);
|
||||
const endNodeDelta = this.getDeltaWithBlockId(endNode.id);
|
||||
|
||||
if (!startNodeDelta || !endNodeDelta) return;
|
||||
|
||||
const startNodeIsRoot = !startNode.parent;
|
||||
const actions = [];
|
||||
const index = startNodeDelta.length();
|
||||
const retain = new Delta().retain(startNodeDelta.length());
|
||||
const newOps = [...retain.ops, ...endNodeDelta.ops];
|
||||
const diff = new Delta(newOps);
|
||||
|
||||
if (!startNodeIsRoot) {
|
||||
const applyDeltaAction = this.getApplyDeltaAction(startNode.id, diff);
|
||||
|
||||
if (applyDeltaAction) actions.push(applyDeltaAction);
|
||||
} else {
|
||||
await this.updateRootNodeDelta(startNode.id, diff);
|
||||
}
|
||||
|
||||
const moveChildrenActions = this.getMoveChildrenActions(endNode.id, startNode);
|
||||
|
||||
// move the children of endNode to startNode
|
||||
actions.push(...moveChildrenActions);
|
||||
// delete end node
|
||||
const deleteEndNodeAction = this.controller.getDeleteAction(endNode);
|
||||
|
||||
actions.push(deleteEndNodeAction);
|
||||
|
||||
await this.controller.applyActions(actions);
|
||||
return {
|
||||
id: targetId,
|
||||
index,
|
||||
};
|
||||
};
|
||||
updateRootNodeDelta = async (id: string, diff: Delta) => {
|
||||
const nodeDelta = this.getDeltaWithBlockId(id);
|
||||
const delta = nodeDelta?.compose(diff);
|
||||
|
||||
const name = delta ? this.getDeltaText(delta) : '';
|
||||
|
||||
await this.updatePageName?.(name);
|
||||
};
|
||||
|
||||
getMoveChildrenActions = (
|
||||
blockId: string,
|
||||
newParent: {
|
||||
id: string;
|
||||
type: BlockType;
|
||||
},
|
||||
excludeIds?: string[]
|
||||
) => {
|
||||
if (!this.controller) return [];
|
||||
const block = this.getBlock(blockId);
|
||||
const config = blockConfig[newParent.type];
|
||||
|
||||
if (!config.canAddChild) return [];
|
||||
const childrenId = block.children;
|
||||
const children = this.state.children[childrenId]
|
||||
.filter((id) => !excludeIds || (excludeIds && !excludeIds.includes(id)))
|
||||
.map((id) => this.getBlock(id));
|
||||
|
||||
return this.controller.getMoveChildrenAction(children, newParent.id, null);
|
||||
};
|
||||
|
||||
getDeleteMiddleNodesActions = (startId: string, endId: string) => {
|
||||
const controller = this.controller;
|
||||
|
||||
if (!controller) return [];
|
||||
const middleIds = this.getMiddleIds(startId, endId);
|
||||
|
||||
return middleIds.map((id) => controller.getDeleteAction(this.getBlock(id)));
|
||||
};
|
||||
|
||||
getMiddleIds = (startId: string, endId: string) => {
|
||||
const middleIds = [];
|
||||
let currentId: string | undefined = startId;
|
||||
|
||||
while (currentId && currentId !== endId) {
|
||||
const nextId = getNextLineId(this.state, currentId);
|
||||
|
||||
if (nextId && nextId !== endId) {
|
||||
middleIds.push(nextId);
|
||||
}
|
||||
|
||||
currentId = nextId;
|
||||
}
|
||||
|
||||
return middleIds;
|
||||
};
|
||||
|
||||
findPrevTextLine = (blockId: string) => {
|
||||
let currentId: string | undefined = blockId;
|
||||
|
||||
while (currentId) {
|
||||
const prevId = getPrevLineId(this.state, currentId);
|
||||
|
||||
if (prevId && this.hasDelta(prevId)) {
|
||||
return prevId;
|
||||
}
|
||||
|
||||
currentId = prevId;
|
||||
}
|
||||
};
|
||||
|
||||
findNextTextLine = (blockId: string) => {
|
||||
let currentId: string | undefined = blockId;
|
||||
|
||||
while (currentId) {
|
||||
const nextId = getNextLineId(this.state, currentId);
|
||||
|
||||
if (nextId && this.hasDelta(nextId)) {
|
||||
return nextId;
|
||||
}
|
||||
|
||||
currentId = nextId;
|
||||
}
|
||||
};
|
||||
}
|
@ -1,85 +1,3 @@
|
||||
import { BlockData, DocumentBlockJSON, DocumentState, NestedBlock, RangeState } from '$app/interfaces/document';
|
||||
import { getDeltaByRange } from '$app/utils/document/delta';
|
||||
import Delta from 'quill-delta';
|
||||
import { generateId } from '$app/utils/document/block';
|
||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||
|
||||
export function getCopyData(
|
||||
node: NestedBlock,
|
||||
range: {
|
||||
index: number;
|
||||
length: number;
|
||||
}
|
||||
): BlockData<any> {
|
||||
const nodeDeltaOps = node.data.delta;
|
||||
if (!nodeDeltaOps) {
|
||||
return {
|
||||
...node.data,
|
||||
};
|
||||
}
|
||||
const delta = getDeltaByRange(new Delta(node.data.delta), range);
|
||||
return {
|
||||
...node.data,
|
||||
delta: delta.ops,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCopyBlock(id: string, document: DocumentState, documentRange: RangeState): DocumentBlockJSON {
|
||||
const node = document.nodes[id];
|
||||
const range = documentRange.ranges[id] || { index: 0, length: 0 };
|
||||
const copyData = getCopyData(node, range);
|
||||
return {
|
||||
type: node.type,
|
||||
data: copyData,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function generateBlocks(data: DocumentBlockJSON[], parentId: string) {
|
||||
const blocks: NestedBlock[] = [];
|
||||
function dfs(data: DocumentBlockJSON[], parentId: string) {
|
||||
data.forEach((item) => {
|
||||
const block = {
|
||||
id: generateId(),
|
||||
type: item.type,
|
||||
data: item.data,
|
||||
parent: parentId,
|
||||
children: generateId(),
|
||||
};
|
||||
blocks.push(block);
|
||||
if (item.children) {
|
||||
dfs(item.children, block.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
dfs(data, parentId);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export function getInsertBlockActions(blocks: NestedBlock[], prevId: string, controller: DocumentController) {
|
||||
return blocks.map((block, index) => {
|
||||
const prevBlockId = index === 0 ? prevId : blocks[index - 1].id;
|
||||
return controller.getInsertAction(block, prevBlockId);
|
||||
});
|
||||
}
|
||||
|
||||
export function getAppendBlockDeltaAction(
|
||||
block: NestedBlock,
|
||||
appendDelta: Delta,
|
||||
isForward: boolean,
|
||||
controller: DocumentController
|
||||
) {
|
||||
const nodeDelta = new Delta(block.data.delta);
|
||||
const mergeDelta = isForward ? appendDelta.concat(nodeDelta) : nodeDelta.concat(appendDelta);
|
||||
return controller.getUpdateAction({
|
||||
...block,
|
||||
data: {
|
||||
...block.data,
|
||||
delta: mergeDelta.ops,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function copyText(text: string) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
@ -2,7 +2,8 @@ import { DeltaTypePB } from '@/services/backend/models/flowy-document2';
|
||||
import { BlockPBValue, BlockType, ChangeType, DocumentState, NestedBlock } from '$app/interfaces/document';
|
||||
import { Log } from '../log';
|
||||
import { isEqual } from '$app/utils/tool';
|
||||
import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '$app/constants/document/name';
|
||||
import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME, TEXT_MAP_NAME } from '$app/constants/document/name';
|
||||
import Delta, { Op } from 'quill-delta';
|
||||
|
||||
// This is a list of all the possible changes that can happen to document data
|
||||
const matchCases = [
|
||||
@ -12,6 +13,9 @@ const matchCases = [
|
||||
{ match: matchChildrenMapInsert, type: ChangeType.ChildrenMapInsert, onMatch: onMatchChildrenInsert },
|
||||
{ match: matchChildrenMapUpdate, type: ChangeType.ChildrenMapUpdate, onMatch: onMatchChildrenUpdate },
|
||||
{ match: matchChildrenMapDelete, type: ChangeType.ChildrenMapDelete, onMatch: onMatchChildrenDelete },
|
||||
{ match: matchDeltaMapInsert, type: ChangeType.DeltaMapInsert, onMatch: onMatchDeltaInsert },
|
||||
{ match: matchDeltaMapUpdate, type: ChangeType.DeltaMapUpdate, onMatch: onMatchDeltaUpdate },
|
||||
{ match: matchDeltaMapDelete, type: ChangeType.DeltaMapDelete, onMatch: onMatchDeltaDelete },
|
||||
];
|
||||
|
||||
export function matchChange(
|
||||
@ -25,7 +29,7 @@ export function matchChange(
|
||||
command: DeltaTypePB;
|
||||
path: string[];
|
||||
id: string;
|
||||
value: BlockPBValue & string[];
|
||||
value: BlockPBValue & string[] & Op[];
|
||||
}
|
||||
) {
|
||||
const matchCase = matchCases.find((item) => item.match(command, path));
|
||||
@ -99,6 +103,39 @@ function matchChildrenMapDelete(command: DeltaTypePB, path: string[]) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param command DeltaTypePB.Inserted
|
||||
* @param command
|
||||
* @param path [META_NAME, TEXT_MAP_NAME]
|
||||
*/
|
||||
function matchDeltaMapInsert(command: DeltaTypePB, path: string[]) {
|
||||
if (path.length !== 2) return false;
|
||||
return command === DeltaTypePB.Inserted && path[0] === META_NAME && path[1] === TEXT_MAP_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param command DeltaTypePB.Updated
|
||||
* @param command
|
||||
* @param path [META_NAME, TEXT_MAP_NAME, id]
|
||||
*/
|
||||
function matchDeltaMapUpdate(command: DeltaTypePB, path: string[]) {
|
||||
if (path.length !== 3) return false;
|
||||
return (
|
||||
command === DeltaTypePB.Updated && path[0] === META_NAME && path[1] === TEXT_MAP_NAME && typeof path[2] === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param command DeltaTypePB.Removed
|
||||
* @param path [META_NAME, TEXT_MAP_NAME, id]
|
||||
*/
|
||||
function matchDeltaMapDelete(command: DeltaTypePB, path: string[]) {
|
||||
if (path.length !== 3) return false;
|
||||
return (
|
||||
command === DeltaTypePB.Removed && path[0] === META_NAME && path[1] === TEXT_MAP_NAME && typeof path[2] === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function onMatchBlockInsert(state: DocumentState, blockId: string, blockValue: BlockPBValue) {
|
||||
state.nodes[blockId] = blockChangeValue2Node(blockValue);
|
||||
}
|
||||
@ -133,6 +170,22 @@ function onMatchChildrenDelete(state: DocumentState, id: string, _children: stri
|
||||
delete state.children[id];
|
||||
}
|
||||
|
||||
function onMatchDeltaInsert(state: DocumentState, id: string, ops: Op[]) {
|
||||
state.deltaMap[id] = JSON.stringify(ops);
|
||||
}
|
||||
|
||||
function onMatchDeltaUpdate(state: DocumentState, id: string, ops: Op[]) {
|
||||
const delta = new Delta(ops);
|
||||
const oldDelta = new Delta(JSON.parse(state.deltaMap[id]));
|
||||
const newDelta = oldDelta.compose(delta);
|
||||
|
||||
state.deltaMap[id] = JSON.stringify(newDelta.ops);
|
||||
}
|
||||
|
||||
function onMatchDeltaDelete(state: DocumentState, id: string, _ops: Op[]) {
|
||||
delete state.deltaMap[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* convert block change value to node
|
||||
* @param value
|
||||
@ -143,9 +196,9 @@ export function blockChangeValue2Node(value: BlockPBValue): NestedBlock {
|
||||
type: value.ty as BlockType,
|
||||
parent: value.parent,
|
||||
children: value.children,
|
||||
data: {
|
||||
delta: [],
|
||||
},
|
||||
data: {},
|
||||
externalId: value.external_id,
|
||||
externalType: value.external_type,
|
||||
};
|
||||
|
||||
if ('data' in value && typeof value.data === 'string') {
|
||||
@ -168,7 +221,7 @@ export function parseValue(value: string) {
|
||||
valueJson = JSON.parse(value);
|
||||
} catch {
|
||||
Log.error('[onDataChange] json parse error', value);
|
||||
return;
|
||||
return value;
|
||||
}
|
||||
|
||||
return valueJson;
|
||||
|
@ -1,20 +1,20 @@
|
||||
export class Log {
|
||||
static error(...msg: unknown[]) {
|
||||
console.log(...msg);
|
||||
console.error(...msg);
|
||||
}
|
||||
static info(...msg: unknown[]) {
|
||||
console.log(...msg);
|
||||
console.info(...msg);
|
||||
}
|
||||
|
||||
static debug(...msg: unknown[]) {
|
||||
console.log(...msg);
|
||||
console.debug(...msg);
|
||||
}
|
||||
|
||||
static trace(...msg: unknown[]) {
|
||||
console.log(...msg);
|
||||
console.trace(...msg);
|
||||
}
|
||||
|
||||
static warn(...msg: unknown[]) {
|
||||
console.log(...msg);
|
||||
console.warn(...msg);
|
||||
}
|
||||
}
|
||||
|
@ -1,43 +0,0 @@
|
||||
export {}
|
||||
// import { AuthBackendService, UserBackendService } from '../appflowy_app/stores/effects/user/user_bd_svc';
|
||||
// import { randomFillSync } from 'crypto';
|
||||
// import { nanoid } from '@reduxjs/toolkit';
|
||||
|
||||
// beforeAll(() => {
|
||||
// //@ts-ignore
|
||||
// window.crypto = {
|
||||
// // @ts-ignore
|
||||
// getRandomValues: function (buffer) {
|
||||
// // @ts-ignore
|
||||
// return randomFillSync(buffer);
|
||||
// },
|
||||
// };
|
||||
// });
|
||||
|
||||
// describe('User backend service', () => {
|
||||
// it('sign up', async () => {
|
||||
// const service = new AuthBackendService();
|
||||
// const result = await service.autoSignUp();
|
||||
// expect(result.ok).toBeTruthy;
|
||||
// });
|
||||
|
||||
// it('sign in', async () => {
|
||||
// const authService = new AuthBackendService();
|
||||
// const email = nanoid(4) + '@appflowy.io';
|
||||
// const password = nanoid(10);
|
||||
// const signUpResult = await authService.signUp({ name: 'nathan', email: email, password: password });
|
||||
// expect(signUpResult.ok).toBeTruthy;
|
||||
|
||||
// const signInResult = await authService.signIn({ email: email, password: password });
|
||||
// expect(signInResult.ok).toBeTruthy;
|
||||
// });
|
||||
|
||||
// it('get user profile', async () => {
|
||||
// const service = new AuthBackendService();
|
||||
// const result = await service.autoSignUp();
|
||||
// const userProfile = result.unwrap();
|
||||
|
||||
// const userService = new UserBackendService(userProfile.id);
|
||||
// expect((await userService.getUserProfile()).unwrap()).toBe(userProfile);
|
||||
// });
|
||||
// });
|
@ -20,8 +20,8 @@
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"$app/*": ["src/appflowy_app/*"],
|
||||
"$app_reducers/*": ["src/appflowy_app/stores/reducers/*"],
|
||||
},
|
||||
"$app_reducers/*": ["src/appflowy_app/stores/reducers/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts", "../app_flowy/assets/translations"],
|
||||
"exclude": ["node_modules"],
|
||||
|
40
frontend/rust-lib/Cargo.lock
generated
40
frontend/rust-lib/Cargo.lock
generated
@ -120,7 +120,7 @@ checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6"
|
||||
[[package]]
|
||||
name = "appflowy-integrate"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"collab",
|
||||
@ -612,7 +612,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@ -631,7 +631,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-database"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@ -660,7 +660,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-define"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"collab",
|
||||
@ -672,7 +672,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-derive"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -684,12 +684,13 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-document"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"collab",
|
||||
"collab-derive",
|
||||
"collab-persistence",
|
||||
"lib0",
|
||||
"nanoid",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
@ -703,7 +704,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-folder"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@ -723,7 +724,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-persistence"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bincode",
|
||||
@ -744,16 +745,17 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-plugins"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"collab",
|
||||
"collab-define",
|
||||
"collab-persistence",
|
||||
"collab-sync",
|
||||
"collab-sync-protocol",
|
||||
"collab-ws",
|
||||
"futures-util",
|
||||
"lib0",
|
||||
"parking_lot",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
@ -770,23 +772,15 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "collab-sync"
|
||||
name = "collab-sync-protocol"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"collab",
|
||||
"futures-util",
|
||||
"lib0",
|
||||
"md5",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"y-sync",
|
||||
"yrs",
|
||||
]
|
||||
@ -794,7 +788,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-user"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"collab",
|
||||
@ -810,10 +804,10 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-ws"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"collab-sync",
|
||||
"collab-sync-protocol",
|
||||
"futures-util",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
@ -49,14 +49,14 @@ lto = false
|
||||
incremental = false
|
||||
|
||||
[patch.crates-io]
|
||||
collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
|
||||
collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
|
||||
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
|
||||
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
|
||||
appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
|
||||
collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
|
||||
collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
|
||||
collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
|
||||
collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
|
||||
collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
|
||||
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
|
||||
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
|
||||
appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
|
||||
collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
|
||||
collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
|
||||
collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
|
||||
|
||||
#collab = { path = "../AppFlowy-Collab/collab" }
|
||||
#collab-folder = { path = "../AppFlowy-Collab/collab-folder" }
|
||||
|
@ -52,11 +52,6 @@ fn subscribe_document_changed(doc_id: &str, document: &MutexDocument) {
|
||||
document
|
||||
.lock()
|
||||
.subscribe_block_changed(move |events, is_remote| {
|
||||
tracing::trace!(
|
||||
"document changed: {:?}, from remote: {}",
|
||||
&events,
|
||||
is_remote
|
||||
);
|
||||
// send notification to the client.
|
||||
send_notification(&doc_id, DocumentNotification::DidReceiveUpdate)
|
||||
.payload::<DocEventPB>((events, is_remote).into())
|
||||
|
@ -17,12 +17,17 @@ impl From<DocumentData> for DocumentDataPB {
|
||||
.map(|(id, children)| (id, children.into()))
|
||||
.collect();
|
||||
|
||||
let text_map = data.meta.text_map.unwrap_or_default();
|
||||
|
||||
let page_id = data.page_id;
|
||||
|
||||
Self {
|
||||
page_id,
|
||||
blocks,
|
||||
meta: MetaPB { children_map },
|
||||
meta: MetaPB {
|
||||
children_map,
|
||||
text_map,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -42,12 +47,16 @@ impl From<DocumentDataPB> for DocumentData {
|
||||
.map(|(id, children)| (id, children.children))
|
||||
.collect();
|
||||
|
||||
let text_map = data.meta.text_map;
|
||||
let page_id = data.page_id;
|
||||
|
||||
DocumentData {
|
||||
page_id,
|
||||
blocks,
|
||||
meta: DocumentMeta { children_map },
|
||||
meta: DocumentMeta {
|
||||
children_map,
|
||||
text_map: Some(text_map),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -60,6 +69,8 @@ impl From<Block> for BlockPB {
|
||||
data: serde_json::to_string(&block.data).unwrap_or_default(),
|
||||
parent_id: block.parent,
|
||||
children_id: block.children,
|
||||
external_id: block.external_id,
|
||||
external_type: block.external_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -166,12 +166,20 @@ pub struct BlockPB {
|
||||
|
||||
#[pb(index = 5)]
|
||||
pub children_id: String,
|
||||
|
||||
#[pb(index = 6, one_of)]
|
||||
pub external_id: Option<String>,
|
||||
|
||||
#[pb(index = 7, one_of)]
|
||||
pub external_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, ProtoBuf, Debug)]
|
||||
pub struct MetaPB {
|
||||
#[pb(index = 1)]
|
||||
pub children_map: HashMap<String, ChildrenPB>,
|
||||
#[pb(index = 2)]
|
||||
pub text_map: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Default, ProtoBuf, Debug)]
|
||||
@ -191,14 +199,26 @@ pub struct BlockActionPB {
|
||||
|
||||
#[derive(Default, ProtoBuf, Debug)]
|
||||
pub struct BlockActionPayloadPB {
|
||||
#[pb(index = 1)]
|
||||
pub block: BlockPB,
|
||||
// When action = Insert, Update, Delete or Move, block needs to be passed.
|
||||
#[pb(index = 1, one_of)]
|
||||
pub block: Option<BlockPB>,
|
||||
|
||||
// When action = Insert or Move, prev_id needs to be passed.
|
||||
#[pb(index = 2, one_of)]
|
||||
pub prev_id: Option<String>,
|
||||
|
||||
// When action = Insert or Move, parent_id needs to be passed.
|
||||
#[pb(index = 3, one_of)]
|
||||
pub parent_id: Option<String>,
|
||||
|
||||
// When action = InsertText or ApplyTextDelta, text_id needs to be passed.
|
||||
#[pb(index = 4, one_of)]
|
||||
pub text_id: Option<String>,
|
||||
|
||||
// When action = InsertText or ApplyTextDelta, delta needs to be passed.
|
||||
// The format of delta is a JSON string, similar to the serialization result of [{ "insert": "Hello World" }].
|
||||
#[pb(index = 5, one_of)]
|
||||
pub delta: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(ProtoBuf_Enum, Debug)]
|
||||
@ -207,6 +227,8 @@ pub enum BlockActionTypePB {
|
||||
Update = 1,
|
||||
Delete = 2,
|
||||
Move = 3,
|
||||
InsertText = 4,
|
||||
ApplyTextDelta = 5,
|
||||
}
|
||||
|
||||
impl Default for BlockActionTypePB {
|
||||
@ -384,3 +406,36 @@ impl From<SyncState> for DocumentSyncStatePB {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, ProtoBuf, Debug)]
|
||||
pub struct TextDeltaPayloadPB {
|
||||
#[pb(index = 1)]
|
||||
pub document_id: String,
|
||||
|
||||
#[pb(index = 2)]
|
||||
pub text_id: String,
|
||||
|
||||
#[pb(index = 3, one_of)]
|
||||
pub delta: Option<String>,
|
||||
}
|
||||
|
||||
pub struct TextDeltaParams {
|
||||
pub document_id: String,
|
||||
pub text_id: String,
|
||||
pub delta: String,
|
||||
}
|
||||
|
||||
impl TryInto<TextDeltaParams> for TextDeltaPayloadPB {
|
||||
type Error = ErrorCode;
|
||||
fn try_into(self) -> Result<TextDeltaParams, Self::Error> {
|
||||
let document_id =
|
||||
NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?;
|
||||
let text_id = NotEmptyStr::parse(self.text_id).map_err(|_| ErrorCode::TextIdIsEmpty)?;
|
||||
let delta = self.delta.map_or_else(|| "".to_string(), |delta| delta);
|
||||
Ok(TextDeltaParams {
|
||||
document_id: document_id.0,
|
||||
text_id: text_id.0,
|
||||
delta,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -91,6 +91,36 @@ pub(crate) async fn apply_action_handler(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handler for creating a text
|
||||
pub(crate) async fn create_text_handler(
|
||||
data: AFPluginData<TextDeltaPayloadPB>,
|
||||
manager: AFPluginState<Weak<DocumentManager>>,
|
||||
) -> FlowyResult<()> {
|
||||
let manager = upgrade_document(manager)?;
|
||||
let params: TextDeltaParams = data.into_inner().try_into()?;
|
||||
let doc_id = params.document_id;
|
||||
let document = manager.get_document(&doc_id).await?;
|
||||
let document = document.lock();
|
||||
document.create_text(¶ms.text_id, params.delta);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handler for applying delta to a text
|
||||
pub(crate) async fn apply_text_delta_handler(
|
||||
data: AFPluginData<TextDeltaPayloadPB>,
|
||||
manager: AFPluginState<Weak<DocumentManager>>,
|
||||
) -> FlowyResult<()> {
|
||||
let manager = upgrade_document(manager)?;
|
||||
let params: TextDeltaParams = data.into_inner().try_into()?;
|
||||
let doc_id = params.document_id;
|
||||
let document = manager.get_document(&doc_id).await?;
|
||||
let text_id = params.text_id;
|
||||
let delta = params.delta;
|
||||
let document = document.lock();
|
||||
document.apply_text_delta(&text_id, delta);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn convert_data_to_document(
|
||||
data: AFPluginData<ConvertDataPayloadPB>,
|
||||
) -> DataResult<DocumentDataPB, FlowyError> {
|
||||
@ -198,6 +228,8 @@ impl From<BlockActionTypePB> for BlockActionType {
|
||||
BlockActionTypePB::Update => Self::Update,
|
||||
BlockActionTypePB::Delete => Self::Delete,
|
||||
BlockActionTypePB::Move => Self::Move,
|
||||
BlockActionTypePB::InsertText => Self::InsertText,
|
||||
BlockActionTypePB::ApplyTextDelta => Self::ApplyTextDelta,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -205,9 +237,11 @@ impl From<BlockActionTypePB> for BlockActionType {
|
||||
impl From<BlockActionPayloadPB> for BlockActionPayload {
|
||||
fn from(pb: BlockActionPayloadPB) -> Self {
|
||||
Self {
|
||||
block: pb.block.into(),
|
||||
block: pb.block.map(|b| b.into()),
|
||||
parent_id: pb.parent_id,
|
||||
prev_id: pb.prev_id,
|
||||
text_id: pb.text_id,
|
||||
delta: pb.delta,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -224,8 +258,8 @@ impl From<BlockPB> for Block {
|
||||
children: pb.children_id,
|
||||
parent: pb.parent_id,
|
||||
data,
|
||||
external_id: None,
|
||||
external_type: None,
|
||||
external_id: pb.external_id,
|
||||
external_type: pb.external_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -25,6 +25,8 @@ pub fn init(document_manager: Weak<DocumentManager>) -> AFPlugin {
|
||||
.event(DocumentEvent::Undo, undo_handler)
|
||||
.event(DocumentEvent::CanUndoRedo, can_undo_redo_handler)
|
||||
.event(DocumentEvent::GetDocumentSnapshots, get_snapshot_handler)
|
||||
.event(DocumentEvent::CreateText, create_text_handler)
|
||||
.event(DocumentEvent::ApplyTextDeltaEvent, apply_text_delta_handler)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Display, ProtoBuf_Enum, Flowy_Event)]
|
||||
@ -68,4 +70,10 @@ pub enum DocumentEvent {
|
||||
|
||||
#[event(input = "OpenDocumentPayloadPB", output = "RepeatedDocumentSnapshotPB")]
|
||||
GetDocumentSnapshots = 9,
|
||||
|
||||
#[event(input = "TextDeltaPayloadPB")]
|
||||
CreateText = 10,
|
||||
|
||||
#[event(input = "TextDeltaPayloadPB")]
|
||||
ApplyTextDeltaEvent = 11,
|
||||
}
|
||||
|
@ -11,6 +11,8 @@ use super::block::Block;
|
||||
|
||||
pub struct JsonToDocumentParser;
|
||||
|
||||
const DELTA: &str = "delta";
|
||||
const TEXT_EXTERNAL_TYPE: &str = "text";
|
||||
impl JsonToDocumentParser {
|
||||
pub fn json_str_to_document(json_str: &str) -> FlowyResult<DocumentDataPB> {
|
||||
let root = serde_json::from_str::<Block>(json_str)?;
|
||||
@ -19,15 +21,20 @@ impl JsonToDocumentParser {
|
||||
|
||||
// generate the blocks
|
||||
// the root's parent id is empty
|
||||
let blocks = Self::generate_blocks(&root, Some(page_id.clone()), "".to_string());
|
||||
let (blocks, text_map) = Self::generate_blocks(&root, Some(page_id.clone()), "".to_string());
|
||||
|
||||
// generate the children map
|
||||
let children_map = Self::generate_children_map(&blocks);
|
||||
|
||||
// generate the text map
|
||||
let text_map = Self::generate_text_map(&text_map);
|
||||
Ok(DocumentDataPB {
|
||||
page_id,
|
||||
blocks: blocks.into_iter().collect(),
|
||||
meta: MetaPB { children_map },
|
||||
meta: MetaPB {
|
||||
children_map,
|
||||
text_map,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -35,15 +42,31 @@ impl JsonToDocumentParser {
|
||||
block: &Block,
|
||||
id: Option<String>,
|
||||
parent_id: String,
|
||||
) -> IndexMap<String, BlockPB> {
|
||||
let block_pb = Self::block_to_block_pb(block, id, parent_id);
|
||||
) -> (IndexMap<String, BlockPB>, IndexMap<String, String>) {
|
||||
let (block_pb, delta) = Self::block_to_block_pb(block, id, parent_id);
|
||||
let mut blocks = IndexMap::new();
|
||||
let mut text_map = IndexMap::new();
|
||||
for child in &block.children {
|
||||
let child_blocks = Self::generate_blocks(child, None, block_pb.id.clone());
|
||||
let (child_blocks, child_blocks_text_map) =
|
||||
Self::generate_blocks(child, None, block_pb.id.clone());
|
||||
blocks.extend(child_blocks);
|
||||
text_map.extend(child_blocks_text_map);
|
||||
}
|
||||
let external_id = block_pb.external_id.clone();
|
||||
blocks.insert(block_pb.id.clone(), block_pb);
|
||||
blocks
|
||||
if let Some(delta) = delta {
|
||||
if let Some(external_id) = external_id {
|
||||
text_map.insert(external_id, delta);
|
||||
}
|
||||
}
|
||||
(blocks, text_map)
|
||||
}
|
||||
|
||||
fn generate_text_map(text_map: &IndexMap<String, String>) -> HashMap<String, String> {
|
||||
text_map
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn generate_children_map(blocks: &IndexMap<String, BlockPB>) -> HashMap<String, ChildrenPB> {
|
||||
@ -69,14 +92,32 @@ impl JsonToDocumentParser {
|
||||
children_map
|
||||
}
|
||||
|
||||
fn block_to_block_pb(block: &Block, id: Option<String>, parent_id: String) -> BlockPB {
|
||||
fn block_to_block_pb(
|
||||
block: &Block,
|
||||
id: Option<String>,
|
||||
parent_id: String,
|
||||
) -> (BlockPB, Option<String>) {
|
||||
let id = id.unwrap_or_else(|| nanoid!(10));
|
||||
BlockPB {
|
||||
id,
|
||||
ty: block.ty.clone(),
|
||||
data: serde_json::to_string(&block.data).unwrap(),
|
||||
parent_id,
|
||||
children_id: nanoid!(10),
|
||||
}
|
||||
let mut data = block.data.clone();
|
||||
|
||||
let delta = data.remove(DELTA).map(|d| d.to_string());
|
||||
|
||||
let (external_id, external_type) = match delta {
|
||||
None => (None, None),
|
||||
Some(_) => (Some(nanoid!(10)), Some(TEXT_EXTERNAL_TYPE.to_string())),
|
||||
};
|
||||
|
||||
(
|
||||
BlockPB {
|
||||
id,
|
||||
ty: block.ty.clone(),
|
||||
data: serde_json::to_string(&data).unwrap(),
|
||||
parent_id,
|
||||
children_id: nanoid!(10),
|
||||
external_id,
|
||||
external_type,
|
||||
},
|
||||
delta,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -24,9 +24,11 @@ async fn document_apply_insert_block_with_empty_parent_id() {
|
||||
let insert_text_action = BlockAction {
|
||||
action: BlockActionType::Insert,
|
||||
payload: BlockActionPayload {
|
||||
block: text_block,
|
||||
block: Some(text_block),
|
||||
parent_id: Some(page_id.clone()),
|
||||
prev_id: None,
|
||||
delta: None,
|
||||
text_id: None,
|
||||
},
|
||||
};
|
||||
document.lock().apply_action(vec![insert_text_action]);
|
||||
|
@ -37,9 +37,11 @@ async fn undo_redo_test() {
|
||||
let insert_text_action = BlockAction {
|
||||
action: BlockActionType::Insert,
|
||||
payload: BlockActionPayload {
|
||||
block: text_block,
|
||||
block: Some(text_block),
|
||||
parent_id: Some(page_id),
|
||||
prev_id: None,
|
||||
delta: None,
|
||||
text_id: None,
|
||||
},
|
||||
};
|
||||
document.apply_action(vec![insert_text_action]);
|
||||
|
@ -21,7 +21,6 @@ async fn restore_document() {
|
||||
let data_a = document_a.lock().get_document_data().unwrap();
|
||||
assert_eq!(data_a, data);
|
||||
|
||||
// open a document
|
||||
let data_b = test
|
||||
.get_document(&doc_id)
|
||||
.await
|
||||
@ -76,9 +75,11 @@ async fn document_apply_insert_action() {
|
||||
let insert_text_action = BlockAction {
|
||||
action: BlockActionType::Insert,
|
||||
payload: BlockActionPayload {
|
||||
block: text_block,
|
||||
parent_id: None,
|
||||
prev_id: None,
|
||||
block: Some(text_block),
|
||||
delta: None,
|
||||
text_id: None,
|
||||
},
|
||||
};
|
||||
document.lock().apply_action(vec![insert_text_action]);
|
||||
@ -123,9 +124,11 @@ async fn document_apply_update_page_action() {
|
||||
let action = BlockAction {
|
||||
action: BlockActionType::Update,
|
||||
payload: BlockActionPayload {
|
||||
block: page_block_clone,
|
||||
parent_id: None,
|
||||
prev_id: None,
|
||||
block: Some(page_block_clone),
|
||||
delta: None,
|
||||
text_id: None,
|
||||
},
|
||||
};
|
||||
let actions = vec![action];
|
||||
@ -169,9 +172,11 @@ async fn document_apply_update_action() {
|
||||
let insert_text_action = BlockAction {
|
||||
action: BlockActionType::Insert,
|
||||
payload: BlockActionPayload {
|
||||
block: text_block,
|
||||
block: Some(text_block),
|
||||
parent_id: None,
|
||||
prev_id: None,
|
||||
delta: None,
|
||||
text_id: None,
|
||||
},
|
||||
};
|
||||
document.lock().apply_action(vec![insert_text_action]);
|
||||
@ -192,9 +197,11 @@ async fn document_apply_update_action() {
|
||||
let update_text_action = BlockAction {
|
||||
action: BlockActionType::Update,
|
||||
payload: BlockActionPayload {
|
||||
block: updated_text_block,
|
||||
block: Some(updated_text_block),
|
||||
parent_id: None,
|
||||
prev_id: None,
|
||||
delta: None,
|
||||
text_id: None,
|
||||
},
|
||||
};
|
||||
document.lock().apply_action(vec![update_text_action]);
|
||||
|
@ -1,3 +1,4 @@
|
||||
use collab_document::blocks::json_str_to_hashmap;
|
||||
use flowy_document2::parser::json::parser::JsonToDocumentParser;
|
||||
use serde_json::json;
|
||||
|
||||
@ -101,3 +102,22 @@ fn test_parser_nested_children() {
|
||||
assert_eq!(page_first_child.ty, "paragraph");
|
||||
assert_eq!(page_first_child.parent_id, page_id.to_owned());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parse_readme_test() {
|
||||
let json = include_str!("../../../../flowy-core/assets/read_me.json");
|
||||
let document = JsonToDocumentParser::json_str_to_document(json).unwrap();
|
||||
|
||||
document.blocks.iter().for_each(|(_, block)| {
|
||||
let data = json_str_to_hashmap(&block.data).ok();
|
||||
assert!(data.is_some());
|
||||
if let Some(data) = data {
|
||||
assert!(data.get("delta").is_none());
|
||||
}
|
||||
|
||||
if let Some(external_id) = &block.external_id {
|
||||
let text = document.meta.text_map.get(external_id);
|
||||
assert!(text.is_some());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -238,6 +238,8 @@ pub enum ErrorCode {
|
||||
|
||||
#[error("Parse url failed")]
|
||||
InvalidURL = 78,
|
||||
#[error("Text id is empty")]
|
||||
TextIdIsEmpty = 79,
|
||||
}
|
||||
|
||||
impl ErrorCode {
|
||||
|
@ -2,8 +2,10 @@ use flowy_document2::entities::*;
|
||||
use flowy_document2::event_map::DocumentEvent;
|
||||
use flowy_folder2::entities::{CreateViewPayloadPB, ViewLayoutPB, ViewPB};
|
||||
use flowy_folder2::event_map::FolderEvent;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::document::utils::{gen_id, gen_text_block_data};
|
||||
use crate::document::utils::{gen_delta_str, gen_id, gen_text_block_data};
|
||||
use crate::event_builder::EventBuilder;
|
||||
use crate::FlowyCoreTest;
|
||||
|
||||
@ -90,6 +92,11 @@ impl DocumentEventTest {
|
||||
children_map.get(&children_id).map(|c| c.children.clone())
|
||||
}
|
||||
|
||||
pub async fn get_block_text_delta(&self, doc_id: &str, text_id: &str) -> Option<String> {
|
||||
let document_data = self.get_document_data(doc_id).await;
|
||||
document_data.meta.text_map.get(text_id).cloned()
|
||||
}
|
||||
|
||||
pub async fn apply_actions(&self, payload: ApplyActionPayloadPB) {
|
||||
let core = &self.inner;
|
||||
EventBuilder::new(core.clone())
|
||||
@ -99,6 +106,24 @@ impl DocumentEventTest {
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn create_text(&self, payload: TextDeltaPayloadPB) {
|
||||
let core = &self.inner;
|
||||
EventBuilder::new(core.clone())
|
||||
.event(DocumentEvent::CreateText)
|
||||
.payload(payload)
|
||||
.async_send()
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn apply_text_delta(&self, payload: TextDeltaPayloadPB) {
|
||||
let core = &self.inner;
|
||||
EventBuilder::new(core.clone())
|
||||
.event(DocumentEvent::ApplyTextDeltaEvent)
|
||||
.payload(payload)
|
||||
.async_send()
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn undo(&self, doc_id: String) -> DocumentRedoUndoResponsePB {
|
||||
let core = &self.inner;
|
||||
let payload = DocumentRedoUndoPayloadPB {
|
||||
@ -138,6 +163,19 @@ impl DocumentEventTest {
|
||||
.parse::<DocumentRedoUndoResponsePB>()
|
||||
}
|
||||
|
||||
pub async fn apply_delta_for_block(&self, document_id: &str, block_id: &str, delta: String) {
|
||||
let block = self.get_block(document_id, block_id).await;
|
||||
// Here is unsafe, but it should be fine for testing.
|
||||
let text_id = block.unwrap().external_id.unwrap();
|
||||
self
|
||||
.apply_text_delta(TextDeltaPayloadPB {
|
||||
document_id: document_id.to_string(),
|
||||
text_id,
|
||||
delta: Some(delta),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Insert a new text block at the index of parent's children.
|
||||
/// return the new block id.
|
||||
pub async fn insert_index(
|
||||
@ -171,7 +209,18 @@ impl DocumentEventTest {
|
||||
};
|
||||
|
||||
let new_block_id = gen_id();
|
||||
let data = gen_text_block_data(&text);
|
||||
let data = gen_text_block_data();
|
||||
|
||||
let external_id = gen_id();
|
||||
let external_type = "text".to_string();
|
||||
|
||||
self
|
||||
.create_text(TextDeltaPayloadPB {
|
||||
document_id: document_id.to_string(),
|
||||
text_id: external_id.clone(),
|
||||
delta: Some(gen_delta_str(&text)),
|
||||
})
|
||||
.await;
|
||||
|
||||
let new_block = BlockPB {
|
||||
id: new_block_id.clone(),
|
||||
@ -179,13 +228,17 @@ impl DocumentEventTest {
|
||||
data,
|
||||
parent_id: parent_id.clone(),
|
||||
children_id: gen_id(),
|
||||
external_id: Some(external_id),
|
||||
external_type: Some(external_type),
|
||||
};
|
||||
let action = BlockActionPB {
|
||||
action: BlockActionTypePB::Insert,
|
||||
payload: BlockActionPayloadPB {
|
||||
block: new_block,
|
||||
block: Some(new_block),
|
||||
prev_id,
|
||||
parent_id: Some(parent_id),
|
||||
text_id: None,
|
||||
delta: None,
|
||||
},
|
||||
};
|
||||
let payload = ApplyActionPayloadPB {
|
||||
@ -196,20 +249,22 @@ impl DocumentEventTest {
|
||||
new_block_id
|
||||
}
|
||||
|
||||
pub async fn update(&self, document_id: &str, block_id: &str, text: &str) {
|
||||
pub async fn update_data(&self, document_id: &str, block_id: &str, data: HashMap<String, Value>) {
|
||||
let block = self.get_block(document_id, block_id).await.unwrap();
|
||||
let data = gen_text_block_data(text);
|
||||
|
||||
let new_block = {
|
||||
let mut new_block = block.clone();
|
||||
new_block.data = data;
|
||||
new_block.data = serde_json::to_string(&data).unwrap();
|
||||
new_block
|
||||
};
|
||||
let action = BlockActionPB {
|
||||
action: BlockActionTypePB::Update,
|
||||
payload: BlockActionPayloadPB {
|
||||
block: new_block,
|
||||
block: Some(new_block),
|
||||
prev_id: None,
|
||||
parent_id: Some(block.parent_id.clone()),
|
||||
text_id: None,
|
||||
delta: None,
|
||||
},
|
||||
};
|
||||
let payload = ApplyActionPayloadPB {
|
||||
@ -225,9 +280,11 @@ impl DocumentEventTest {
|
||||
let action = BlockActionPB {
|
||||
action: BlockActionTypePB::Delete,
|
||||
payload: BlockActionPayloadPB {
|
||||
block,
|
||||
block: Some(block),
|
||||
prev_id: None,
|
||||
parent_id: Some(parent_id),
|
||||
text_id: None,
|
||||
delta: None,
|
||||
},
|
||||
};
|
||||
let payload = ApplyActionPayloadPB {
|
||||
|
@ -8,13 +8,12 @@ pub fn gen_id() -> String {
|
||||
nanoid!(10)
|
||||
}
|
||||
|
||||
pub fn gen_text_block_data(text: &str) -> String {
|
||||
json!({
|
||||
"delta": [{
|
||||
"insert": text
|
||||
}]
|
||||
})
|
||||
.to_string()
|
||||
pub fn gen_text_block_data() -> String {
|
||||
json!({}).to_string()
|
||||
}
|
||||
|
||||
pub fn gen_delta_str(text: &str) -> String {
|
||||
json!([{ "insert": text }]).to_string()
|
||||
}
|
||||
|
||||
pub struct ParseDocumentData {
|
||||
@ -56,13 +55,17 @@ pub fn gen_insert_block_action(document: OpenDocumentData) -> BlockActionPB {
|
||||
data,
|
||||
parent_id: page_id.clone(),
|
||||
children_id: gen_id(),
|
||||
external_id: None,
|
||||
external_type: None,
|
||||
};
|
||||
BlockActionPB {
|
||||
action: BlockActionTypePB::Insert,
|
||||
payload: BlockActionPayloadPB {
|
||||
block: new_block,
|
||||
block: Some(new_block),
|
||||
prev_id: Some(first_block_id),
|
||||
parent_id: Some(page_id),
|
||||
text_id: None,
|
||||
delta: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,9 @@
|
||||
use collab_document::blocks::json_str_to_hashmap;
|
||||
use flowy_document2::entities::*;
|
||||
use flowy_test::document::document_event::DocumentEventTest;
|
||||
use flowy_test::document::utils::*;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_document_event_test() {
|
||||
@ -70,20 +73,50 @@ async fn insert_text_block_test() {
|
||||
let block = test.get_block(&view.id, &block_id).await;
|
||||
assert!(block.is_some());
|
||||
let block = block.unwrap();
|
||||
let data = gen_text_block_data(text);
|
||||
assert_eq!(block.data, data);
|
||||
assert!(block.external_id.is_some());
|
||||
let external_id = block.external_id.unwrap();
|
||||
let delta = test.get_block_text_delta(&view.id, &external_id).await;
|
||||
assert_eq!(delta.unwrap(), json!([{ "insert": text }]).to_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_text_block_test() {
|
||||
async fn update_block_test() {
|
||||
let test = DocumentEventTest::new().await;
|
||||
let view = test.create_document().await;
|
||||
let block_id = test.insert_index(&view.id, "Hello World", 1, None).await;
|
||||
let update_text = "Hello World 2";
|
||||
test.update(&view.id, &block_id, update_text).await;
|
||||
let data: HashMap<String, Value> = HashMap::from([
|
||||
(
|
||||
"bg_color".to_string(),
|
||||
serde_json::to_value("#000000").unwrap(),
|
||||
),
|
||||
(
|
||||
"text_color".to_string(),
|
||||
serde_json::to_value("#ffffff").unwrap(),
|
||||
),
|
||||
]);
|
||||
test.update_data(&view.id, &block_id, data.clone()).await;
|
||||
let block = test.get_block(&view.id, &block_id).await;
|
||||
assert!(block.is_some());
|
||||
let block = block.unwrap();
|
||||
let update_data = gen_text_block_data(update_text);
|
||||
assert_eq!(block.data, update_data);
|
||||
let block_data = json_str_to_hashmap(&block.data).ok().unwrap();
|
||||
assert_eq!(block_data, data);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_text_delta_test() {
|
||||
let test = DocumentEventTest::new().await;
|
||||
let view = test.create_document().await;
|
||||
let text = "Hello World";
|
||||
let block_id = test.insert_index(&view.id, text, 1, None).await;
|
||||
let update_delta = json!([{ "retain": 5 }, { "insert": "!" }]).to_string();
|
||||
test
|
||||
.apply_delta_for_block(&view.id, &block_id, update_delta)
|
||||
.await;
|
||||
let block = test.get_block(&view.id, &block_id).await;
|
||||
let text_id = block.unwrap().external_id.unwrap();
|
||||
let block_delta = test.get_block_text_delta(&view.id, &text_id).await;
|
||||
assert_eq!(
|
||||
block_delta.unwrap(),
|
||||
json!([{ "insert": "Hello! World" }]).to_string()
|
||||
);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user