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:
Kilu.He 2023-09-12 20:49:03 +08:00 committed by GitHub
parent 9565173baf
commit c7af04b317
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
81 changed files with 3343 additions and 1875 deletions

View File

@ -94,6 +94,7 @@ jobs:
mkdir dist
pnpm install
cargo make --cwd .. tauri_build
pnpm test
pnpm test:errors
- name: Check for uncommitted changes

View File

@ -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

View File

@ -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();
}
}

View File

@ -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(

View File

@ -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;
}

View File

@ -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));
}

View File

@ -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

View File

@ -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',
);
});

View File

@ -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

View 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?)$",
};

View File

@ -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

View File

@ -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",

View File

@ -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" }

View File

@ -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);

View File

@ -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?.();

View File

@ -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,
};
}

View File

@ -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]);

View File

@ -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'}
>

View File

@ -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(

View File

@ -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>) => {

View File

@ -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,
})
);
},
},
{

View File

@ -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]
);

View File

@ -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,
})
);

View File

@ -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;
}

View File

@ -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);

View File

@ -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,
};
}

View File

@ -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?

View File

@ -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]);

View File

@ -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>('');

View File

@ -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(

View File

@ -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,
};
}

View File

@ -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',
},
},

View File

@ -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 = '$';

View File

@ -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;
}

View File

@ -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);
};
}

View File

@ -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?.({

View File

@ -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);
};

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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;
}
);

View File

@ -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);
}
);

View File

@ -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 }));
}
);

View File

@ -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.
});

View File

@ -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,
},
})
);
}
);

View File

@ -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);

View File

@ -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,
};

View File

@ -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,
})
);
}
);

View File

@ -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,
},
})
);
}
);

View File

@ -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,
})
);
}
);

View File

@ -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,

View File

@ -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: {},
})
);
}

View File

@ -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];
},
},

View File

@ -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,

View File

@ -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 {};

View File

@ -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;

View File

@ -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,
};

View File

@ -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 : {},
};
}

View File

@ -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;
}
};
}

View File

@ -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);
}

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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);
// });
// });

View File

@ -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"],

View File

@ -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",

View File

@ -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" }

View File

@ -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())

View File

@ -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,
}
}
}

View File

@ -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,
})
}
}

View File

@ -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(&params.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,
}
}
}

View File

@ -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,
}

View File

@ -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,
)
}
}

View File

@ -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]);

View File

@ -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]);

View File

@ -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]);

View File

@ -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());
}
});
}

View File

@ -238,6 +238,8 @@ pub enum ErrorCode {
#[error("Parse url failed")]
InvalidURL = 78,
#[error("Text id is empty")]
TextIdIsEmpty = 79,
}
impl ErrorCode {

View File

@ -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 {

View File

@ -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,
},
}
}

View File

@ -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()
);
}