chore: create redo/undo bridge (#2760)

* chore: create redo/undo bridge

* chore: update test

* chore: review update

* chore: review update

* chore: react redo/undo

* chore: review update

* chore: add test

* chore: review update

* chore: generate document id

* chore: update undo/redo

* chore: update cargo lock
This commit is contained in:
Kilu.He 2023-06-15 10:37:51 +08:00 committed by GitHub
parent 27dd719aa8
commit 95f8b2e9a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 981 additions and 407 deletions

File diff suppressed because it is too large Load Diff

View File

@ -6,10 +6,12 @@ import BlockSlash from '$app/components/document/BlockSlash';
import { useCopy } from '$app/components/document/_shared/CopyPasteHooks/useCopy';
import { usePaste } from '$app/components/document/_shared/CopyPasteHooks/usePaste';
import LinkEditPopover from '$app/components/document/_shared/TextLink/LinkEditPopover';
import { useUndoRedo } from '$app/components/document/_shared/UndoHooks/useUndoRedo';
export default function Overlay({ container }: { container: HTMLDivElement }) {
useCopy(container);
usePaste(container);
useUndoRedo(container);
return (
<>
<BlockSideToolbar container={container} />

View File

@ -88,9 +88,11 @@ export function useKeyDown(id: string) {
const onKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
e.stopPropagation();
const filteredEvents = interceptEvents.filter((event) => event.canHandle(e));
filteredEvents.forEach((event) => event.handler(e));
filteredEvents.forEach((event) => {
e.stopPropagation();
event.handler(e);
});
},
[interceptEvents]
);

View File

@ -19,7 +19,7 @@ export function useSlateYjs({ delta }: { delta?: Delta }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const editor = useMemo(() => withYjs(withReact(createEditor()), sharedType), []);
const editor = useMemo(() => withReact(withYjs(createEditor(), sharedType)), []);
// Connect editor in useEffect to comply with concurrent mode requirements.
useEffect(() => {

View File

@ -0,0 +1,39 @@
import { useCallback, useContext, useEffect } from 'react';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import isHotkey from 'is-hotkey';
import { Keyboard } from '@/appflowy_app/constants/document/keyboard';
export function useUndoRedo(container: HTMLDivElement) {
const controller = useContext(DocumentControllerContext);
const onUndo = useCallback(() => {
if (!controller) return;
controller.undo();
}, [controller]);
const onRedo = useCallback(() => {
if (!controller) return;
controller.redo();
}, [controller]);
const handleKeyDownCapture = useCallback(
(e: KeyboardEvent) => {
if (isHotkey(Keyboard.keys.UNDO, e)) {
e.stopPropagation();
onUndo();
}
if (isHotkey(Keyboard.keys.REDO, e)) {
e.stopPropagation();
onRedo();
}
},
[onRedo, onUndo]
);
useEffect(() => {
container.addEventListener('keydown', handleKeyDownCapture, true);
return () => {
container.removeEventListener('keydown', handleKeyDownCapture, true);
};
}, [container, handleKeyDownCapture]);
}

View File

@ -10,8 +10,6 @@ import { AppObserver } from '$app/stores/effects/folder/app/app_observer';
import { useNavigate } from 'react-router-dom';
import { INITIAL_FOLDER_HEIGHT, PAGE_ITEM_HEIGHT } from '../../_shared/constants';
import { DocumentController } from '$app/stores/effects/document/document_controller';
export const useFolderEvents = (folder: IFolder, pages: IPage[]) => {
const appDispatch = useAppDispatch();
const workspace = useAppSelector((state) => state.workspace);
@ -118,9 +116,6 @@ export const useFolderEvents = (folder: IFolder, pages: IPage[]) => {
layoutType: ViewLayoutPB.Document,
});
try {
const c = new DocumentController(newView.id);
await c.create();
await c.dispose();
appDispatch(
pagesActions.addPage({
folderId: folder.id,

View File

@ -38,5 +38,7 @@ export const Keyboard = {
COPY: 'Mod+c',
CUT: 'Mod+x',
PASTE: 'Mod+v',
REDO: 'Mod+Shift+z',
UNDO: 'Mod+z',
},
};

View File

@ -6,6 +6,8 @@ import {
ApplyActionPayloadPB,
BlockActionPB,
CloseDocumentPayloadPB,
DocumentRedoUndoPayloadPB,
DocumentRedoUndoResponsePB,
} from '@/services/backend';
import { Result } from 'ts-results';
import {
@ -13,18 +15,14 @@ import {
DocumentEventCloseDocument,
DocumentEventOpenDocument,
DocumentEventCreateDocument,
DocumentEventCanUndoRedo,
DocumentEventRedo,
DocumentEventUndo,
} from '@/services/backend/events/flowy-document2';
export class DocumentBackendService {
constructor(public readonly viewId: string) {}
create = (): Promise<Result<void, FlowyError>> => {
const payload = CreateDocumentPayloadPB.fromObject({
document_id: this.viewId,
});
return DocumentEventCreateDocument(payload);
};
open = (): Promise<Result<DocumentDataPB, FlowyError>> => {
const payload = OpenDocumentPayloadPB.fromObject({
document_id: this.viewId,
@ -46,4 +44,25 @@ export class DocumentBackendService {
});
return DocumentEventCloseDocument(payload);
};
canUndoRedo = (): Promise<Result<DocumentRedoUndoResponsePB, FlowyError>> => {
const payload = DocumentRedoUndoPayloadPB.fromObject({
document_id: this.viewId,
});
return DocumentEventCanUndoRedo(payload);
};
undo = (): Promise<Result<DocumentRedoUndoResponsePB, FlowyError>> => {
const payload = DocumentRedoUndoPayloadPB.fromObject({
document_id: this.viewId,
});
return DocumentEventUndo(payload);
};
redo = (): Promise<Result<DocumentRedoUndoResponsePB, FlowyError>> => {
const payload = DocumentRedoUndoPayloadPB.fromObject({
document_id: this.viewId,
});
return DocumentEventRedo(payload);
};
}

View File

@ -31,13 +31,6 @@ export class DocumentController {
this.observer = new DocumentObserver(documentId);
}
create = async (): Promise<FlowyError | void> => {
const result = await this.backendService.create();
if (result.ok) {
return;
}
return result.val;
};
open = async (): Promise<DocumentData> => {
await this.observer.subscribe({
didReceiveUpdate: this.updated,
@ -114,6 +107,26 @@ export class DocumentController {
};
};
canUndo = async () => {
const result = await this.backendService.canUndoRedo();
return result.ok && result.val.can_undo;
};
canRedo = async () => {
const result = await this.backendService.canUndoRedo();
return result.ok && result.val.can_redo;
};
undo = async () => {
const result = await this.backendService.undo();
return result.ok && result.val.is_success;
};
redo = async () => {
const result = await this.backendService.redo();
return result.ok && result.val.is_success;
};
dispose = async () => {
this.onDocChange = undefined;
await this.backendService.close();

View File

@ -1710,6 +1710,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber 0.3.16",
"uuid",
]
[[package]]
@ -1867,6 +1868,7 @@ dependencies = [
"dotenv",
"flowy-core",
"flowy-database2",
"flowy-document2",
"flowy-folder2",
"flowy-net",
"flowy-notification",

View File

@ -143,7 +143,7 @@ impl FolderOperationHandler for DocumentFolderOperation {
let manager = self.0.clone();
let view_id = view_id.to_string();
FutureResult::new(async move {
let document = manager.get_document(view_id)?;
let document = manager.get_document_from_disk(view_id)?;
let data: DocumentDataPB = document.lock().get_document()?.into();
let data_bytes = data.into_bytes().map_err(|_| FlowyError::invalid_data())?;
Ok(data_bytes)

View File

@ -27,6 +27,7 @@ tracing = { version = "0.1", features = ["log"] }
tokio = { version = "1.26", features = ["full"] }
anyhow = "1.0"
indexmap = {version = "1.9.2", features = ["serde"]}
uuid = { version = "1.3.3", features = ["v4"] }
[dev-dependencies]
tempfile = "3.4.0"

View File

@ -1,6 +1,9 @@
use collab_document::blocks::{BlockAction, DocumentData};
use std::collections::HashMap;
use crate::parse::{NotEmptyStr, NotEmptyVec};
use flowy_derive::{ProtoBuf, ProtoBuf_Enum};
use flowy_error::ErrorCode;
#[derive(Default, ProtoBuf)]
pub struct OpenDocumentPayloadPB {
@ -8,6 +11,54 @@ pub struct OpenDocumentPayloadPB {
pub document_id: String,
}
pub struct OpenDocumentParams {
pub document_id: String,
}
impl TryInto<OpenDocumentParams> for OpenDocumentPayloadPB {
type Error = ErrorCode;
fn try_into(self) -> Result<OpenDocumentParams, Self::Error> {
let document_id =
NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?;
Ok(OpenDocumentParams {
document_id: document_id.0,
})
}
}
#[derive(Default, ProtoBuf)]
pub struct DocumentRedoUndoPayloadPB {
#[pb(index = 1)]
pub document_id: String,
}
pub struct DocumentRedoUndoParams {
pub document_id: String,
}
impl TryInto<DocumentRedoUndoParams> for DocumentRedoUndoPayloadPB {
type Error = ErrorCode;
fn try_into(self) -> Result<DocumentRedoUndoParams, Self::Error> {
let document_id =
NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?;
Ok(DocumentRedoUndoParams {
document_id: document_id.0,
})
}
}
#[derive(Default, Debug, ProtoBuf)]
pub struct DocumentRedoUndoResponsePB {
#[pb(index = 1)]
pub can_undo: bool,
#[pb(index = 2)]
pub can_redo: bool,
#[pb(index = 3)]
pub is_success: bool,
}
#[derive(Default, ProtoBuf)]
pub struct CreateDocumentPayloadPB {
#[pb(index = 1)]
@ -17,12 +68,45 @@ pub struct CreateDocumentPayloadPB {
pub initial_data: Option<DocumentDataPB>,
}
pub struct CreateDocumentParams {
pub document_id: String,
pub initial_data: Option<DocumentData>,
}
impl TryInto<CreateDocumentParams> for CreateDocumentPayloadPB {
type Error = ErrorCode;
fn try_into(self) -> Result<CreateDocumentParams, Self::Error> {
let document_id =
NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?;
let initial_data = self.initial_data.map(|data| data.into());
Ok(CreateDocumentParams {
document_id: document_id.0,
initial_data,
})
}
}
#[derive(Default, ProtoBuf)]
pub struct CloseDocumentPayloadPB {
#[pb(index = 1)]
pub document_id: String,
}
pub struct CloseDocumentParams {
pub document_id: String,
}
impl TryInto<CloseDocumentParams> for CloseDocumentPayloadPB {
type Error = ErrorCode;
fn try_into(self) -> Result<CloseDocumentParams, Self::Error> {
let document_id =
NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?;
Ok(CloseDocumentParams {
document_id: document_id.0,
})
}
}
#[derive(Default, ProtoBuf, Debug)]
pub struct ApplyActionPayloadPB {
#[pb(index = 1)]
@ -32,11 +116,23 @@ pub struct ApplyActionPayloadPB {
pub actions: Vec<BlockActionPB>,
}
#[derive(Default, ProtoBuf)]
pub struct GetDocumentDataPayloadPB {
#[pb(index = 1)]
pub struct ApplyActionParams {
pub document_id: String,
// Support customize initial data
pub actions: Vec<BlockAction>,
}
impl TryInto<ApplyActionParams> for ApplyActionPayloadPB {
type Error = ErrorCode;
fn try_into(self) -> Result<ApplyActionParams, Self::Error> {
let document_id =
NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?;
let actions = NotEmptyVec::parse(self.actions).map_err(|_| ErrorCode::ApplyActionsIsEmpty)?;
let actions = actions.0.into_iter().map(BlockAction::from).collect();
Ok(ApplyActionParams {
document_id: document_id.0,
actions,
})
}
}
#[derive(Default, Debug, ProtoBuf)]
@ -226,3 +322,17 @@ pub struct ConvertDataPayloadPB {
#[pb(index = 2)]
pub data: Vec<u8>,
}
pub struct ConvertDataParams {
pub convert_type: ConvertType,
pub data: Vec<u8>,
}
impl TryInto<ConvertDataParams> for ConvertDataPayloadPB {
type Error = ErrorCode;
fn try_into(self) -> Result<ConvertDataParams, Self::Error> {
let convert_type = self.convert_type;
let data = self.data;
Ok(ConvertDataParams { convert_type, data })
}
}

View File

@ -14,11 +14,16 @@ use collab_document::blocks::{
use flowy_error::{FlowyError, FlowyResult};
use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult};
use crate::entities::{
ApplyActionParams, CloseDocumentParams, ConvertDataParams, CreateDocumentParams,
DocumentRedoUndoParams, OpenDocumentParams,
};
use crate::{
entities::{
ApplyActionPayloadPB, BlockActionPB, BlockActionPayloadPB, BlockActionTypePB, BlockEventPB,
BlockEventPayloadPB, BlockPB, CloseDocumentPayloadPB, ConvertDataPayloadPB, ConvertType,
CreateDocumentPayloadPB, DeltaTypePB, DocEventPB, DocumentDataPB, OpenDocumentPayloadPB,
CreateDocumentPayloadPB, DeltaTypePB, DocEventPB, DocumentDataPB, DocumentRedoUndoPayloadPB,
DocumentRedoUndoResponsePB, OpenDocumentPayloadPB,
},
manager::DocumentManager,
parser::json::parser::JsonToDocumentParser,
@ -29,9 +34,8 @@ pub(crate) async fn create_document_handler(
data: AFPluginData<CreateDocumentPayloadPB>,
manager: AFPluginState<Arc<DocumentManager>>,
) -> FlowyResult<()> {
let data = data.into_inner();
let initial_data = data.initial_data.map(|data| data.into());
manager.create_document(data.document_id, initial_data)?;
let params: CreateDocumentParams = data.into_inner().try_into()?;
manager.create_document(params.document_id, params.initial_data)?;
Ok(())
}
@ -40,8 +44,9 @@ pub(crate) async fn open_document_handler(
data: AFPluginData<OpenDocumentPayloadPB>,
manager: AFPluginState<Arc<DocumentManager>>,
) -> DataResult<DocumentDataPB, FlowyError> {
let context = data.into_inner();
let document = manager.open_document(context.document_id)?;
let params: OpenDocumentParams = data.into_inner().try_into()?;
let doc_id = params.document_id;
let document = manager.get_or_open_document(doc_id)?;
let document_data = document.lock().get_document()?;
data_result_ok(DocumentDataPB::from(document_data))
}
@ -50,8 +55,9 @@ pub(crate) async fn close_document_handler(
data: AFPluginData<CloseDocumentPayloadPB>,
manager: AFPluginState<Arc<DocumentManager>>,
) -> FlowyResult<()> {
let context = data.into_inner();
manager.close_document(&context.document_id)?;
let params: CloseDocumentParams = data.into_inner().try_into()?;
let doc_id = params.document_id;
manager.close_document(&doc_id)?;
Ok(())
}
@ -61,8 +67,9 @@ pub(crate) async fn get_document_data_handler(
data: AFPluginData<OpenDocumentPayloadPB>,
manager: AFPluginState<Arc<DocumentManager>>,
) -> DataResult<DocumentDataPB, FlowyError> {
let context = data.into_inner();
let document = manager.get_document(context.document_id)?;
let params: OpenDocumentParams = data.into_inner().try_into()?;
let doc_id = params.document_id;
let document = manager.get_document_from_disk(doc_id)?;
let document_data = document.lock().get_document()?;
data_result_ok(DocumentDataPB::from(document_data))
}
@ -72,10 +79,10 @@ pub(crate) async fn apply_action_handler(
data: AFPluginData<ApplyActionPayloadPB>,
manager: AFPluginState<Arc<DocumentManager>>,
) -> FlowyResult<()> {
let context = data.into_inner();
let doc_id = context.document_id;
let document = manager.open_document(doc_id)?;
let actions = context.actions.into_iter().map(BlockAction::from).collect();
let params: ApplyActionParams = data.into_inner().try_into()?;
let doc_id = params.document_id;
let document = manager.get_or_open_document(doc_id)?;
let actions = params.actions;
document.lock().apply_action(actions);
Ok(())
}
@ -92,8 +99,9 @@ pub(crate) async fn convert_data_to_document(
pub fn convert_data_to_document_internal(
payload: ConvertDataPayloadPB,
) -> Result<DocumentDataPB, FlowyError> {
let convert_type = payload.convert_type;
let data = payload.data;
let params: ConvertDataParams = payload.try_into()?;
let convert_type = params.convert_type;
let data = params.data;
match convert_type {
ConvertType::Json => {
let json_str = String::from_utf8(data).map_err(|_| FlowyError::invalid_data())?;
@ -103,6 +111,60 @@ pub fn convert_data_to_document_internal(
}
}
pub(crate) async fn redo_handler(
data: AFPluginData<DocumentRedoUndoPayloadPB>,
manager: AFPluginState<Arc<DocumentManager>>,
) -> DataResult<DocumentRedoUndoResponsePB, FlowyError> {
let params: DocumentRedoUndoParams = data.into_inner().try_into()?;
let doc_id = params.document_id;
let document = manager.get_or_open_document(doc_id)?;
let document = document.lock();
let redo = document.redo();
let can_redo = document.can_redo();
let can_undo = document.can_undo();
data_result_ok(DocumentRedoUndoResponsePB {
can_redo,
can_undo,
is_success: redo,
})
}
pub(crate) async fn undo_handler(
data: AFPluginData<DocumentRedoUndoPayloadPB>,
manager: AFPluginState<Arc<DocumentManager>>,
) -> DataResult<DocumentRedoUndoResponsePB, FlowyError> {
let params: DocumentRedoUndoParams = data.into_inner().try_into()?;
let doc_id = params.document_id;
let document = manager.get_or_open_document(doc_id)?;
let document = document.lock();
let undo = document.undo();
let can_redo = document.can_redo();
let can_undo = document.can_undo();
data_result_ok(DocumentRedoUndoResponsePB {
can_redo,
can_undo,
is_success: undo,
})
}
pub(crate) async fn can_undo_redo_handler(
data: AFPluginData<DocumentRedoUndoPayloadPB>,
manager: AFPluginState<Arc<DocumentManager>>,
) -> DataResult<DocumentRedoUndoResponsePB, FlowyError> {
let params: DocumentRedoUndoParams = data.into_inner().try_into()?;
let doc_id = params.document_id;
let document = manager.get_or_open_document(doc_id)?;
let document = document.lock();
let can_redo = document.can_redo();
let can_undo = document.can_undo();
drop(document);
data_result_ok(DocumentRedoUndoResponsePB {
can_redo,
can_undo,
is_success: true,
})
}
impl From<BlockActionPB> for BlockAction {
fn from(pb: BlockActionPB) -> Self {
Self {

View File

@ -6,8 +6,9 @@ use lib_dispatch::prelude::AFPlugin;
use crate::{
event_handler::{
apply_action_handler, close_document_handler, convert_data_to_document,
create_document_handler, get_document_data_handler, open_document_handler,
apply_action_handler, can_undo_redo_handler, close_document_handler, convert_data_to_document,
create_document_handler, get_document_data_handler, open_document_handler, redo_handler,
undo_handler,
},
manager::DocumentManager,
};
@ -26,6 +27,9 @@ pub fn init(document_manager: Arc<DocumentManager>) -> AFPlugin {
DocumentEvent::ConvertDataToDocument,
convert_data_to_document,
);
plugin = plugin.event(DocumentEvent::Redo, redo_handler);
plugin = plugin.event(DocumentEvent::Undo, undo_handler);
plugin = plugin.event(DocumentEvent::CanUndoRedo, can_undo_redo_handler);
plugin
}
@ -45,9 +49,27 @@ pub enum DocumentEvent {
#[event(input = "ApplyActionPayloadPB")]
ApplyAction = 3,
#[event(input = "GetDocumentDataPayloadPB")]
#[event(input = "OpenDocumentPayloadPB")]
GetDocumentData = 4,
#[event(input = "ConvertDataPayloadPB", output = "DocumentDataPB")]
ConvertDataToDocument = 5,
#[event(
input = "DocumentRedoUndoPayloadPB",
output = "DocumentRedoUndoResponsePB"
)]
Redo = 6,
#[event(
input = "DocumentRedoUndoPayloadPB",
output = "DocumentRedoUndoResponsePB"
)]
Undo = 7,
#[event(
input = "DocumentRedoUndoPayloadPB",
output = "DocumentRedoUndoResponsePB"
)]
CanUndoRedo = 8,
}

View File

@ -9,3 +9,4 @@ pub mod parser;
pub mod protobuf;
mod notification;
mod parse;

View File

@ -54,16 +54,15 @@ impl DocumentManager {
Ok(document)
}
pub fn open_document(&self, doc_id: String) -> FlowyResult<Arc<Document>> {
/// get document
/// read the existing document from the map if it exists, otherwise read it from the disk and write it to the map.
pub fn get_or_open_document(&self, doc_id: String) -> FlowyResult<Arc<Document>> {
if let Some(doc) = self.documents.read().get(&doc_id) {
return Ok(doc.clone());
}
tracing::debug!("open_document: {:?}", &doc_id);
let uid = self.user.user_id()?;
let db = self.user.collab_db()?;
let collab = self.collab_builder.build(uid, &doc_id, "document", db);
// read the existing document from the disk.
let document = Arc::new(Document::new(collab)?);
let document = self.get_document_from_disk(doc_id.clone())?;
// save the document to the memory and read it from the memory if we open the same document again.
// and we don't want to subscribe to the document changes if we open the same document again.
self
@ -87,7 +86,9 @@ impl DocumentManager {
Ok(document)
}
pub fn get_document(&self, doc_id: String) -> FlowyResult<Arc<Document>> {
/// get document
/// read the existing document from the disk.
pub fn get_document_from_disk(&self, doc_id: String) -> FlowyResult<Arc<Document>> {
let uid = self.user.user_id()?;
let db = self.user.collab_db()?;
let collab = self.collab_builder.build(uid, &doc_id, "document", db);

View File

@ -0,0 +1,23 @@
#[derive(Debug)]
pub struct NotEmptyStr(pub String);
impl NotEmptyStr {
pub fn parse(s: String) -> Result<Self, String> {
if s.trim().is_empty() {
return Err("Input string is empty".to_owned());
}
Ok(Self(s))
}
}
#[derive(Debug)]
pub struct NotEmptyVec<T>(pub Vec<T>);
impl<T> NotEmptyVec<T> {
pub fn parse(v: Vec<T>) -> Result<Self, String> {
if v.is_empty() {
return Err("Input vector is empty".to_owned());
}
Ok(Self(v))
}
}

View File

@ -1,25 +1,21 @@
use std::{collections::HashMap, sync::Arc, vec};
use std::{collections::HashMap, vec};
use crate::document::util::default_collab_builder;
use crate::document::util;
use crate::document::util::gen_id;
use collab_document::blocks::{Block, BlockAction, BlockActionPayload, BlockActionType};
use flowy_document2::document_block_keys::PARAGRAPH_BLOCK_TYPE;
use flowy_document2::document_data::default_document_data;
use flowy_document2::{document::Document, manager::DocumentManager};
use nanoid::nanoid;
use super::util::FakeUser;
#[test]
fn document_apply_insert_block_with_empty_parent_id() {
let (_, document, page_id) = create_and_open_empty_document();
let (_, document, page_id) = util::create_and_open_empty_document();
// create a text block with no parent
let text_block_id = nanoid!(10);
let text_block_id = gen_id();
let text_block = Block {
id: text_block_id.clone(),
ty: PARAGRAPH_BLOCK_TYPE.to_string(),
parent: "".to_string(),
children: nanoid!(10),
children: gen_id(),
external_id: None,
external_type: None,
data: HashMap::new(),
@ -38,20 +34,3 @@ fn document_apply_insert_block_with_empty_parent_id() {
let block = document.lock().get_block(&text_block_id).unwrap();
assert_eq!(block.parent, page_id);
}
fn create_and_open_empty_document() -> (DocumentManager, Arc<Document>, String) {
let user = FakeUser::new();
let manager = DocumentManager::new(Arc::new(user), default_collab_builder());
let doc_id: String = nanoid!(10);
let data = default_document_data();
// create a document
_ = manager
.create_document(doc_id.clone(), Some(data.clone()))
.unwrap();
let document = manager.open_document(doc_id).unwrap();
(manager, document, data.page_id)
}

View File

@ -0,0 +1,59 @@
use crate::document::util::{default_collab_builder, gen_document_id, gen_id, FakeUser};
use collab_document::blocks::{Block, BlockAction, BlockActionPayload, BlockActionType};
use flowy_document2::document_block_keys::PARAGRAPH_BLOCK_TYPE;
use flowy_document2::document_data::default_document_data;
use flowy_document2::manager::DocumentManager;
use std::collections::HashMap;
use std::sync::Arc;
#[tokio::test]
async fn undo_redo_test() {
let user = FakeUser::new();
let manager = DocumentManager::new(Arc::new(user), default_collab_builder());
let doc_id: String = gen_document_id();
let data = default_document_data();
// create a document
_ = manager.create_document(doc_id.clone(), Some(data.clone()));
// open a document
let document = manager.get_or_open_document(doc_id.clone()).unwrap();
let document = document.lock();
let page_block = document.get_block(&data.page_id).unwrap();
let page_id = page_block.id;
let text_block_id = gen_id();
// insert a text block
let text_block = Block {
id: text_block_id.clone(),
ty: PARAGRAPH_BLOCK_TYPE.to_string(),
parent: page_id.clone(),
children: gen_id(),
external_id: None,
external_type: None,
data: HashMap::new(),
};
let insert_text_action = BlockAction {
action: BlockActionType::Insert,
payload: BlockActionPayload {
block: text_block,
parent_id: Some(page_id.clone()),
prev_id: None,
},
};
document.apply_action(vec![insert_text_action]);
let can_undo = document.can_undo();
assert_eq!(can_undo, true);
// undo the insert
let undo = document.undo();
assert_eq!(undo, true);
assert_eq!(document.get_block(&text_block_id), None);
let can_redo = document.can_redo();
assert!(can_redo);
// redo the insert
let redo = document.redo();
assert_eq!(redo, true);
}

View File

@ -1,14 +1,13 @@
use std::{collections::HashMap, sync::Arc, vec};
use collab_document::blocks::{Block, BlockAction, BlockActionPayload, BlockActionType};
use nanoid::nanoid;
use serde_json::{json, to_value, Value};
use flowy_document2::document_block_keys::PARAGRAPH_BLOCK_TYPE;
use flowy_document2::document_data::default_document_data;
use flowy_document2::manager::DocumentManager;
use crate::document::util::default_collab_builder;
use crate::document::util::{default_collab_builder, gen_document_id, gen_id};
use super::util::FakeUser;
@ -18,7 +17,7 @@ fn restore_document() {
let manager = DocumentManager::new(Arc::new(user), default_collab_builder());
// create a document
let doc_id: String = nanoid!(10);
let doc_id: String = gen_document_id();
let data = default_document_data();
let document_a = manager
.create_document(doc_id.clone(), Some(data.clone()))
@ -28,7 +27,7 @@ fn restore_document() {
// open a document
let data_b = manager
.open_document(doc_id.clone())
.get_or_open_document(doc_id.clone())
.unwrap()
.lock()
.get_document()
@ -41,7 +40,7 @@ fn restore_document() {
_ = manager.create_document(doc_id.clone(), Some(data.clone()));
// open a document
let data_b = manager
.open_document(doc_id.clone())
.get_or_open_document(doc_id.clone())
.unwrap()
.lock()
.get_document()
@ -57,22 +56,22 @@ fn document_apply_insert_action() {
let user = FakeUser::new();
let manager = DocumentManager::new(Arc::new(user), default_collab_builder());
let doc_id: String = nanoid!(10);
let doc_id: String = gen_document_id();
let data = default_document_data();
// create a document
_ = manager.create_document(doc_id.clone(), Some(data.clone()));
// open a document
let document = manager.open_document(doc_id.clone()).unwrap();
let document = manager.get_or_open_document(doc_id.clone()).unwrap();
let page_block = document.lock().get_block(&data.page_id).unwrap();
// insert a text block
let text_block = Block {
id: nanoid!(10),
id: gen_id(),
ty: PARAGRAPH_BLOCK_TYPE.to_string(),
parent: page_block.id,
children: nanoid!(10),
children: gen_id(),
external_id: None,
external_type: None,
data: HashMap::new(),
@ -92,7 +91,7 @@ fn document_apply_insert_action() {
// re-open the document
let data_b = manager
.open_document(doc_id.clone())
.get_or_open_document(doc_id.clone())
.unwrap()
.lock()
.get_document()
@ -108,14 +107,14 @@ fn document_apply_update_page_action() {
let user = FakeUser::new();
let manager = DocumentManager::new(Arc::new(user), default_collab_builder());
let doc_id: String = nanoid!(10);
let doc_id: String = gen_document_id();
let data = default_document_data();
// create a document
_ = manager.create_document(doc_id.clone(), Some(data.clone()));
// open a document
let document = manager.open_document(doc_id.clone()).unwrap();
let document = manager.get_or_open_document(doc_id.clone()).unwrap();
let page_block = document.lock().get_block(&data.page_id).unwrap();
let mut page_block_clone = page_block;
@ -139,7 +138,7 @@ fn document_apply_update_page_action() {
_ = manager.close_document(&doc_id);
// re-open the document
let document = manager.open_document(doc_id).unwrap();
let document = manager.get_or_open_document(doc_id).unwrap();
let page_block_new = document.lock().get_block(&data.page_id).unwrap();
assert_eq!(page_block_old, page_block_new);
assert!(page_block_new.data.contains_key("delta"));
@ -150,23 +149,23 @@ fn document_apply_update_action() {
let user = FakeUser::new();
let manager = DocumentManager::new(Arc::new(user), default_collab_builder());
let doc_id: String = nanoid!(10);
let doc_id: String = gen_document_id();
let data = default_document_data();
// create a document
_ = manager.create_document(doc_id.clone(), Some(data.clone()));
// open a document
let document = manager.open_document(doc_id.clone()).unwrap();
let document = manager.get_or_open_document(doc_id.clone()).unwrap();
let page_block = document.lock().get_block(&data.page_id).unwrap();
// insert a text block
let text_block_id = nanoid!(10);
let text_block_id = gen_id();
let text_block = Block {
id: text_block_id.clone(),
ty: PARAGRAPH_BLOCK_TYPE.to_string(),
parent: page_block.id,
children: nanoid!(10),
children: gen_id(),
external_id: None,
external_type: None,
data: HashMap::new(),
@ -207,7 +206,7 @@ fn document_apply_update_action() {
_ = manager.close_document(&doc_id);
// re-open the document
let document = manager.open_document(doc_id.clone()).unwrap();
let document = manager.get_or_open_document(doc_id.clone()).unwrap();
let block = document.lock().get_block(&text_block_id).unwrap();
assert_eq!(block.data, updated_text_block_data);
// close a document

View File

@ -1,4 +1,5 @@
mod document_insert_test;
mod document_redo_undo_test;
mod document_test;
mod event_handler_test;
mod util;

View File

@ -3,11 +3,14 @@ use appflowy_integrate::collab_builder::{AppFlowyCollabBuilder, CloudStorageType
use std::sync::Arc;
use appflowy_integrate::RocksCollabDB;
use flowy_document2::document::Document;
use parking_lot::Once;
use tempfile::TempDir;
use tracing_subscriber::{fmt::Subscriber, util::SubscriberInitExt, EnvFilter};
use flowy_document2::manager::DocumentUser;
use flowy_document2::document_data::default_document_data;
use flowy_document2::manager::{DocumentManager, DocumentUser};
use nanoid::nanoid;
pub struct FakeUser {
kv: Arc<RocksCollabDB>,
@ -53,3 +56,29 @@ pub fn default_collab_builder() -> Arc<AppFlowyCollabBuilder> {
let builder = AppFlowyCollabBuilder::new(CloudStorageType::Local, None);
Arc::new(builder)
}
pub fn create_and_open_empty_document() -> (DocumentManager, Arc<Document>, String) {
let user = FakeUser::new();
let manager = DocumentManager::new(Arc::new(user), default_collab_builder());
let doc_id: String = gen_document_id();
let data = default_document_data();
// create a document
_ = manager
.create_document(doc_id.clone(), Some(data.clone()))
.unwrap();
let document = manager.get_or_open_document(doc_id).unwrap();
(manager, document, data.page_id)
}
pub fn gen_document_id() -> String {
let uuid = uuid::Uuid::new_v4();
uuid.to_string()
}
pub fn gen_id() -> String {
nanoid!(10)
}

View File

@ -202,6 +202,12 @@ pub enum ErrorCode {
#[error("Only one application can access the database")]
MultipleDBInstance = 66,
#[error("Document id is empty")]
DocumentIdIsEmpty = 67,
#[error("Apply actions is empty")]
ApplyActionsIsEmpty = 68,
}
impl ErrorCode {

View File

@ -11,6 +11,7 @@ flowy-user = { path = "../flowy-user"}
flowy-net = { path = "../flowy-net"}
flowy-folder2 = { path = "../flowy-folder2", features = ["test_helper"] }
flowy-database2 = { path = "../flowy-database2" }
flowy-document2 = { path = "../flowy-document2" }
lib-dispatch = { path = "../lib-dispatch" }
lib-ot = { path = "../../../shared-lib/lib-ot" }
lib-infra = { path = "../../../shared-lib/lib-infra" }

View File

@ -0,0 +1,107 @@
use crate::event_builder::EventBuilder;
use crate::FlowyCoreTest;
use flowy_document2::entities::*;
use flowy_document2::event_map::DocumentEvent;
use flowy_folder2::entities::{CreateViewPayloadPB, ViewLayoutPB, ViewPB};
use flowy_folder2::event_map::FolderEvent;
pub struct DocumentEventTest {
inner: FlowyCoreTest,
}
pub struct OpenDocumentData {
pub id: String,
pub data: DocumentDataPB,
}
impl DocumentEventTest {
pub async fn new() -> Self {
let sdk = FlowyCoreTest::new_with_user().await;
Self { inner: sdk }
}
pub async fn create_document(&self) -> ViewPB {
let core = &self.inner;
let current_workspace = core.get_current_workspace().await.workspace;
let parent_id = current_workspace.id.clone();
let payload = CreateViewPayloadPB {
parent_view_id: parent_id.to_string(),
name: "document".to_string(),
desc: "".to_string(),
thumbnail: None,
layout: ViewLayoutPB::Document,
initial_data: vec![],
meta: Default::default(),
set_as_current: true,
};
EventBuilder::new(core.clone())
.event(FolderEvent::CreateView)
.payload(payload)
.async_send()
.await
.parse::<ViewPB>()
}
pub async fn open_document(&self, doc_id: String) -> OpenDocumentData {
let core = &self.inner;
let payload = OpenDocumentPayloadPB {
document_id: doc_id.clone(),
};
let data = EventBuilder::new(core.clone())
.event(DocumentEvent::OpenDocument)
.payload(payload)
.async_send()
.await
.parse::<DocumentDataPB>();
OpenDocumentData { id: doc_id, data }
}
pub async fn apply_actions(&self, payload: ApplyActionPayloadPB) {
let core = &self.inner;
EventBuilder::new(core.clone())
.event(DocumentEvent::ApplyAction)
.payload(payload)
.async_send()
.await;
}
pub async fn undo(&self, doc_id: String) -> DocumentRedoUndoResponsePB {
let core = &self.inner;
let payload = DocumentRedoUndoPayloadPB {
document_id: doc_id.clone(),
};
EventBuilder::new(core.clone())
.event(DocumentEvent::Undo)
.payload(payload)
.async_send()
.await
.parse::<DocumentRedoUndoResponsePB>()
}
pub async fn redo(&self, doc_id: String) -> DocumentRedoUndoResponsePB {
let core = &self.inner;
let payload = DocumentRedoUndoPayloadPB {
document_id: doc_id.clone(),
};
EventBuilder::new(core.clone())
.event(DocumentEvent::Redo)
.payload(payload)
.async_send()
.await
.parse::<DocumentRedoUndoResponsePB>()
}
pub async fn can_undo_redo(&self, doc_id: String) -> DocumentRedoUndoResponsePB {
let core = &self.inner;
let payload = DocumentRedoUndoPayloadPB {
document_id: doc_id.clone(),
};
EventBuilder::new(core.clone())
.event(DocumentEvent::CanUndoRedo)
.payload(payload)
.async_send()
.await
.parse::<DocumentRedoUndoResponsePB>()
}
}

View File

@ -15,6 +15,7 @@ use flowy_user::errors::FlowyError;
use crate::event_builder::EventBuilder;
use crate::user_event::{async_sign_up, init_user_setting, SignUpContext};
pub mod document_event;
pub mod event_builder;
pub mod folder_event;
pub mod user_event;

View File

@ -0,0 +1,2 @@
mod test;
mod utils;

View File

@ -0,0 +1,62 @@
use crate::document::utils::*;
use flowy_document2::entities::*;
use flowy_test::document_event::DocumentEventTest;
#[tokio::test]
async fn get_document_event_test() {
let test = DocumentEventTest::new().await;
let view = test.create_document().await;
let document = test.open_document(view.id).await;
let document_data = document.data;
assert!(!document_data.page_id.is_empty());
assert!(document_data.blocks.len() > 1);
}
#[tokio::test]
async fn apply_document_event_test() {
let test = DocumentEventTest::new().await;
let view = test.create_document().await;
let doc_id = view.id.clone();
let document = test.open_document(doc_id.clone()).await;
let block_count = document.data.blocks.len();
let insert_action = gen_insert_block_action(document);
let payload = ApplyActionPayloadPB {
document_id: doc_id.clone(),
actions: vec![insert_action],
};
test.apply_actions(payload).await;
let document = test.open_document(doc_id).await;
let document_data = document.data;
let block_count_after = document_data.blocks.len();
assert_eq!(block_count_after, block_count + 1);
}
#[tokio::test]
async fn undo_redo_event_test() {
let test = DocumentEventTest::new().await;
let view = test.create_document().await;
let doc_id = view.id.clone();
let document = test.open_document(doc_id.clone()).await;
let insert_action = gen_insert_block_action(document);
let payload = ApplyActionPayloadPB {
document_id: doc_id.clone(),
actions: vec![insert_action],
};
test.apply_actions(payload).await;
let block_count_after_insert = test.open_document(doc_id.clone()).await.data.blocks.len();
// undo insert action
let can_undo = test.can_undo_redo(doc_id.clone()).await.can_undo;
assert!(can_undo);
test.undo(doc_id.clone()).await;
let block_count_after_undo = test.open_document(doc_id.clone()).await.data.blocks.len();
assert_eq!(block_count_after_undo, block_count_after_insert - 1);
// redo insert action
let can_redo = test.can_undo_redo(doc_id.clone()).await.can_redo;
assert!(can_redo);
test.redo(doc_id.clone()).await;
let block_count_after_redo = test.open_document(doc_id.clone()).await.data.blocks.len();
assert_eq!(block_count_after_redo, block_count_after_insert);
}

View File

@ -0,0 +1,58 @@
use flowy_document2::entities::*;
use flowy_test::document_event::OpenDocumentData;
use nanoid::nanoid;
use std::collections::HashMap;
pub fn gen_id() -> String {
nanoid!(10)
}
pub struct ParseDocumentData {
pub doc_id: String,
pub page_id: String,
pub blocks: HashMap<String, BlockPB>,
pub children_map: HashMap<String, ChildrenPB>,
pub first_block_id: String,
}
pub fn parse_document_data(document: OpenDocumentData) -> ParseDocumentData {
let doc_id = document.id.clone();
let data = document.data;
let page_id = data.page_id;
let blocks = data.blocks;
let children_map = data.meta.children_map;
let page_block = blocks.get(&page_id).unwrap();
let children_id = page_block.children_id.clone();
let children = children_map.get(&children_id).unwrap();
let block_id = children.children.get(0).unwrap().to_string();
ParseDocumentData {
doc_id,
page_id,
blocks,
children_map,
first_block_id: block_id,
}
}
pub fn gen_insert_block_action(document: OpenDocumentData) -> BlockActionPB {
let parse_data = parse_document_data(document);
let first_block_id = parse_data.first_block_id;
let block = parse_data.blocks.get(&first_block_id).unwrap();
let page_id = parse_data.page_id;
let data = block.data.clone();
let new_block_id = gen_id();
let new_block = BlockPB {
id: new_block_id.clone(),
ty: block.ty.clone(),
data,
parent_id: page_id.clone(),
children_id: gen_id(),
};
BlockActionPB {
action: BlockActionTypePB::Insert,
payload: BlockActionPayloadPB {
block: new_block,
prev_id: Some(first_block_id),
parent_id: Some(page_id),
},
}
}

View File

@ -1,3 +1,4 @@
mod database;
mod document;
mod folder;
mod user;