feat: support converting documents to JSON, HTML, or TEXT. (#3811)

* feat: support converting documents to JSON, HTML, or TEXT

* fix: modify the comment

* fix: modify the comment
This commit is contained in:
Kilu.He 2023-10-30 12:50:31 +08:00 committed by GitHub
parent e08a1a6974
commit dd9b1fb78f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
56 changed files with 2136 additions and 6 deletions

View File

@ -36,7 +36,7 @@ export const turnToBlockThunk = createAsyncThunk(
let caretId,
caretIndex = caret?.index || 0;
const deltaOperator = new BlockDeltaOperator(documentState, controller);
let delta = deltaOperator.getDeltaWithBlockId(node.id);
let delta = deltaOperator.getDeltaWithBlockId(node.id) || new Delta([{ insert: '' }]);
// insert new block after current block
const insertActions = [];
@ -44,14 +44,14 @@ export const turnToBlockThunk = createAsyncThunk(
delta = new Delta([{ insert: node.data.formula }]);
}
if (delta && type === BlockType.EquationBlock) {
if (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) {
} else if (type === BlockType.DividerBlock) {
const block = newBlock<any>(type, parent.id, data);
insertActions.push(controller.getInsertAction(block, node.id));
@ -68,7 +68,7 @@ export const turnToBlockThunk = createAsyncThunk(
caretId = nodeId;
caretIndex = 0;
insertActions.push(...actions);
} else if (delta) {
} else {
caretId = generateId();
const actions = deltaOperator.getNewTextLineActions({

View File

@ -4,6 +4,9 @@ use serde_json::Value;
use flowy_document2::entities::*;
use flowy_document2::event_map::DocumentEvent;
use flowy_document2::parser::parser_entities::{
ConvertDocumentPayloadPB, ConvertDocumentResponsePB,
};
use flowy_folder2::entities::{CreateViewPayloadPB, ViewLayoutPB, ViewPB};
use flowy_folder2::event_map::FolderEvent;
@ -108,6 +111,19 @@ impl DocumentEventTest {
.await;
}
pub async fn convert_document(
&self,
payload: ConvertDocumentPayloadPB,
) -> ConvertDocumentResponsePB {
let core = &self.inner;
EventBuilder::new(core.clone())
.event(DocumentEvent::ConvertDocument)
.payload(payload)
.async_send()
.await
.parse::<ConvertDocumentResponsePB>()
}
pub async fn create_text(&self, payload: TextDeltaPayloadPB) {
let core = &self.inner;
EventBuilder::new(core.clone())

View File

@ -2,6 +2,7 @@ use collab_document::blocks::json_str_to_hashmap;
use event_integration::document::document_event::DocumentEventTest;
use event_integration::document::utils::*;
use flowy_document2::entities::*;
use flowy_document2::parser::parser_entities::{ConvertDocumentPayloadPB, ExportTypePB};
use serde_json::{json, Value};
use std::collections::HashMap;
@ -120,3 +121,35 @@ async fn apply_text_delta_test() {
json!([{ "insert": "Hello! World" }]).to_string()
);
}
macro_rules! generate_convert_document_test_cases {
($($json:ident, $text:ident, $html:ident),*) => {
[
$((ExportTypePB { json: $json, text: $text, html: $html }, ($json, $text, $html))),*
]
};
}
#[tokio::test]
async fn convert_document_test() {
let test = DocumentEventTest::new().await;
let view = test.create_document().await;
let test_cases = generate_convert_document_test_cases! {
true, true, true,
false, true, true,
false, false, false
};
for (export_types, (json_assert, text_assert, html_assert)) in test_cases.iter() {
let copy_payload = ConvertDocumentPayloadPB {
document_id: view.id.to_string(),
range: None,
export_types: export_types.clone(),
};
let result = test.convert_document(copy_payload).await;
assert_eq!(result.json.is_some(), *json_assert);
assert_eq!(result.text.is_some(), *text_assert);
assert_eq!(result.html.is_some(), *html_assert);
}
}

View File

@ -1,3 +1,3 @@
# Check out the FlowyConfig (located in flowy_toml.rs) for more details.
proto_input = ["src/event_map.rs", "src/entities.rs", "src/notification.rs"]
proto_input = ["src/event_map.rs", "src/entities.rs", "src/notification.rs", "src/parser/parser_entities.rs"]
event_files = ["src/event_map.rs"]

View File

@ -15,6 +15,11 @@ use flowy_error::{FlowyError, FlowyResult};
use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult};
use crate::entities::*;
use crate::parser::document_data_parser::DocumentDataParser;
use crate::parser::parser_entities::{
ConvertDocumentParams, ConvertDocumentPayloadPB, ConvertDocumentResponsePB,
};
use crate::{manager::DocumentManager, parser::json::parser::JsonToDocumentParser};
fn upgrade_document(
@ -303,3 +308,45 @@ impl From<(&Vec<BlockEvent>, bool)> for DocEventPB {
}
}
}
/**
* Handler for converting a document to a JSON string, HTML string, or plain text string.
* @param data: AFPluginData<[ConvertDocumentPayloadPB]>
* @param manager: AFPluginState<Weak<DocumentManager>>
* @return DataResult<[ConvertDocumentResponsePB], FlowyError>
*/
pub async fn convert_document(
data: AFPluginData<ConvertDocumentPayloadPB>,
manager: AFPluginState<Weak<DocumentManager>>,
) -> DataResult<ConvertDocumentResponsePB, FlowyError> {
let manager = upgrade_document(manager)?;
let params: ConvertDocumentParams = data.into_inner().try_into()?;
let document = manager.get_document(&params.document_id).await?;
let document_data = document.lock().get_document_data()?;
let parser = DocumentDataParser::new(Arc::new(document_data), params.range);
if !params.export_types.any_enabled() {
return data_result_ok(ConvertDocumentResponsePB::default());
}
let root = &parser.to_json();
data_result_ok(ConvertDocumentResponsePB {
json: params
.export_types
.json
.then(|| serde_json::to_string(root).unwrap_or_default()),
html: params
.export_types
.html
.then(|| parser.to_html_with_json(root)),
text: params
.export_types
.text
.then(|| parser.to_text_with_json(root)),
})
}

View File

@ -5,6 +5,7 @@ use strum_macros::Display;
use flowy_derive::{Flowy_Event, ProtoBuf_Enum};
use lib_dispatch::prelude::AFPlugin;
use crate::event_handler::convert_document;
use crate::event_handler::get_snapshot_handler;
use crate::{event_handler::*, manager::DocumentManager};
@ -27,6 +28,7 @@ pub fn init(document_manager: Weak<DocumentManager>) -> AFPlugin {
.event(DocumentEvent::GetDocumentSnapshots, get_snapshot_handler)
.event(DocumentEvent::CreateText, create_text_handler)
.event(DocumentEvent::ApplyTextDeltaEvent, apply_text_delta_handler)
.event(DocumentEvent::ConvertDocument, convert_document)
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Display, ProtoBuf_Enum, Flowy_Event)]
@ -76,4 +78,49 @@ pub enum DocumentEvent {
#[event(input = "TextDeltaPayloadPB")]
ApplyTextDeltaEvent = 11,
/// Handler for converting a document to a JSON string, HTML string, or plain text string.
///
/// ConvertDocumentPayloadPB is the input of this event.
/// ConvertDocumentResponsePB is the output of this event.
///
/// # Examples
///
/// Basic usage:
///
/// ```txt
/// // document: [{ "block_id": "1", "type": "paragraph", "data": {"delta": [{ "insert": "Hello World!" }] } }, { "block_id": "2", "type": "paragraph", "data": {"delta": [{ "insert": "Hello World!" }] }
/// let test = DocumentEventTest::new().await;
/// let view = test.create_document().await;
/// let payload = ConvertDocumentPayloadPB {
/// document_id: view.id,
/// range: Some(RangePB {
/// start: SelectionPB {
/// block_id: "1".to_string(),
/// index: 0,
/// length: 5,
/// },
/// end: SelectionPB {
/// block_id: "2".to_string(),
/// index: 5,
/// length: 7,
/// }
/// }),
/// export_types: ConvertTypePB {
/// json: true,
/// text: true,
/// html: true,
/// },
/// };
/// let result = test.convert_document(payload).await;
/// assert_eq!(result.json, Some("[{ \"block_id\": \"1\", \"type\": \"paragraph\", \"data\": {\"delta\": [{ \"insert\": \"Hello\" }] } }, { \"block_id\": \"2\", \"type\": \"paragraph\", \"data\": {\"delta\": [{ \"insert\": \" World!\" }] } }".to_string()));
/// assert_eq!(result.text, Some("Hello\n World!".to_string()));
/// assert_eq!(result.html, Some("<p>Hello</p><p> World!</p>".to_string()));
/// ```
/// #
#[event(
input = "ConvertDocumentPayloadPB",
output = "ConvertDocumentResponsePB"
)]
ConvertDocument = 12,
}

View File

@ -0,0 +1,37 @@
pub const DELTA: &str = "delta";
pub const LEVEL: &str = "level";
pub const NUMBER: &str = "number";
pub const CHECKED: &str = "checked";
pub const COLLAPSED: &str = "collapsed";
pub const LANGUAGE: &str = "language";
pub const ICON: &str = "icon";
pub const WIDTH: &str = "width";
pub const HEIGHT: &str = "height";
pub const URL: &str = "url";
pub const CAPTION: &str = "caption";
pub const ALIGN: &str = "align";
pub const PAGE: &str = "page";
pub const HEADING: &str = "heading";
pub const PARAGRAPH: &str = "paragraph";
pub const NUMBERED_LIST: &str = "numbered_list";
pub const BULLETED_LIST: &str = "bulleted_list";
pub const TODO_LIST: &str = "todo_list";
pub const TOGGLE_LIST: &str = "toggle_list";
pub const QUOTE: &str = "quote";
pub const CALLOUT: &str = "callout";
pub const IMAGE: &str = "image";
pub const DIVIDER: &str = "divider";
pub const MATH_EQUATION: &str = "math_equation";
pub const BOLD: &str = "bold";
pub const ITALIC: &str = "italic";
pub const STRIKETHROUGH: &str = "strikethrough";
pub const CODE: &str = "code";
pub const UNDERLINE: &str = "underline";
pub const FONT_COLOR: &str = "font_color";
pub const BG_COLOR: &str = "bg_color";
pub const HREF: &str = "href";
pub const FORMULA: &str = "formula";
pub const MENTION: &str = "mention";

View File

@ -0,0 +1,180 @@
use crate::parser::parser_entities::{ConvertBlockToHtmlParams, NestedBlock, Range};
use crate::parser::utils::{
block_to_nested_json, get_delta_for_block, get_delta_for_selection, get_flat_block_ids,
ConvertBlockToJsonParams,
};
use collab_document::blocks::DocumentData;
use std::collections::HashMap;
use std::sync::Arc;
/// DocumentDataParser is a struct for parsing a document's data and converting it to JSON, HTML, or text.
pub struct DocumentDataParser {
/// The document data to parse.
pub document_data: Arc<DocumentData>,
/// The range of the document data to parse. If the range is None, the entire document data will be parsed.
pub range: Option<Range>,
}
impl DocumentDataParser {
pub fn new(document_data: Arc<DocumentData>, range: Option<Range>) -> Self {
Self {
document_data,
range,
}
}
/// Converts the JSON to an HTML representation.
pub fn to_html_with_json(&self, json: &Option<NestedBlock>) -> String {
let mut html = String::new();
html.push_str("<meta charset=\"UTF-8\">");
if let Some(json) = json {
let params = ConvertBlockToHtmlParams {
prev_block_ty: None,
next_block_ty: None,
};
html.push_str(json.convert_to_html(params).as_str());
}
html
}
/// Converts the JSON to plain text.
pub fn to_text_with_json(&self, json: &Option<NestedBlock>) -> String {
if let Some(json) = json {
json.convert_to_text()
} else {
String::new()
}
}
/// Converts the document data to HTML.
pub fn to_html(&self) -> String {
let json = self.to_json();
self.to_html_with_json(&json)
}
/// Converts the document data to plain text.
pub fn to_text(&self) -> String {
let json = self.to_json();
self.to_text_with_json(&json)
}
/// Converts the document data to a nested JSON structure, considering the optional range.
pub fn to_json(&self) -> Option<NestedBlock> {
let root_id = &self.document_data.page_id;
// flatten the block id list.
let block_id_list = get_flat_block_ids(root_id, &self.document_data);
// collect the block ids in the range.
let mut in_range_block_ids = self.collect_in_range_block_ids(&block_id_list);
// insert the root block id if it is not in the in-range block ids.
if !in_range_block_ids.contains(root_id) {
in_range_block_ids.push(root_id.to_string());
}
// build the parameters for converting the block to JSON with the in-range block ids.
let convert_params = self.build_convert_json_params(&in_range_block_ids);
// convert the root block to JSON.
let mut root = block_to_nested_json(root_id, &convert_params)?;
// If the start block's parent is outside the in-range selection, we need to insert the start block.
if self.should_insert_start_block() {
self.insert_start_block_json(&mut root, &convert_params);
}
Some(root)
}
/// Collects the block ids in the range.
fn collect_in_range_block_ids(&self, block_id_list: &Vec<String>) -> Vec<String> {
if let Some(range) = &self.range {
// Find the positions of start and end block IDs in the list
let mut start_index = block_id_list
.iter()
.position(|id| id == &range.start.block_id)
.unwrap_or(0);
let mut end_index = block_id_list
.iter()
.position(|id| id == &range.end.block_id)
.unwrap_or(0);
if start_index > end_index {
// Swap start and end if they are in reverse order
std::mem::swap(&mut start_index, &mut end_index);
}
// Slice the block IDs based on the positions of start and end
block_id_list[start_index..=end_index].to_vec()
} else {
// If no range is specified, return the entire list
block_id_list.to_owned()
}
}
/// Builds the parameters for converting the block to JSON.
/// ConvertBlockToJsonParams format:
/// {
/// blocks: HashMap<String, Arc<Block>>, // in-range blocks
/// relation_map: HashMap<String, Arc<Vec<String>>>, // in-range blocks' children
/// delta_map: HashMap<String, String>, // in-range blocks' delta
/// }
fn build_convert_json_params(&self, block_id_list: &[String]) -> ConvertBlockToJsonParams {
let mut delta_map = HashMap::new();
let mut in_range_blocks = HashMap::new();
let mut relation_map = HashMap::new();
for block_id in block_id_list {
if let Some(block) = self.document_data.blocks.get(block_id) {
// Insert the block into the in-range block map.
in_range_blocks.insert(block_id.to_string(), Arc::new(block.to_owned()));
// If the block has children, insert the children into the relation map.
if let Some(children) = self.document_data.meta.children_map.get(&block.children) {
relation_map.insert(block_id.to_string(), Arc::new(children.to_owned()));
}
let delta = match &self.range {
Some(range) if block_id == &range.start.block_id => {
get_delta_for_selection(&range.start, &self.document_data)
},
Some(range) if block_id == &range.end.block_id => {
get_delta_for_selection(&range.end, &self.document_data)
},
_ => get_delta_for_block(block_id, &self.document_data),
};
// If the delta exists, insert it into the delta map.
if let Some(delta) = delta {
delta_map.insert(block_id.to_string(), delta);
}
}
}
ConvertBlockToJsonParams {
blocks: in_range_blocks,
relation_map,
delta_map,
}
}
// Checks if the start block should be inserted whether the start block's parent is outside the in-range selection.
fn should_insert_start_block(&self) -> bool {
if let Some(range) = &self.range {
if let Some(start_block) = self.document_data.blocks.get(&range.start.block_id) {
return start_block.parent != self.document_data.page_id;
}
}
false
}
// Inserts the start block JSON to the root JSON.
fn insert_start_block_json(
&self,
root: &mut NestedBlock,
convert_params: &ConvertBlockToJsonParams,
) {
let start = &self.range.as_ref().unwrap().start;
if let Some(start_block_json) = block_to_nested_json(&start.block_id, convert_params) {
root.children.insert(0, start_block_json);
}
}
}

View File

@ -1 +1,5 @@
pub mod constant;
pub mod document_data_parser;
pub mod json;
pub mod parser_entities;
pub mod utils;

View File

@ -0,0 +1,481 @@
use crate::parse::NotEmptyStr;
use crate::parser::constant::{
BG_COLOR, BOLD, BULLETED_LIST, CALLOUT, CHECKED, CODE, DELTA, DIVIDER, FONT_COLOR, FORMULA,
HEADING, HREF, ICON, IMAGE, ITALIC, LANGUAGE, LEVEL, MATH_EQUATION, NUMBERED_LIST, PAGE,
PARAGRAPH, QUOTE, STRIKETHROUGH, TODO_LIST, TOGGLE_LIST, UNDERLINE, URL,
};
use crate::parser::utils::{
convert_insert_delta_from_json, convert_nested_block_children_to_html, delta_to_html,
delta_to_text,
};
use flowy_derive::ProtoBuf;
use flowy_error::ErrorCode;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
#[derive(Default, ProtoBuf)]
pub struct SelectionPB {
#[pb(index = 1)]
pub block_id: String,
#[pb(index = 2)]
pub index: u32,
#[pb(index = 3)]
pub length: u32,
}
#[derive(Default, ProtoBuf)]
pub struct RangePB {
#[pb(index = 1)]
pub start: SelectionPB,
#[pb(index = 2)]
pub end: SelectionPB,
}
/**
* ExportTypePB
* @field json: bool // export json data
* @field html: bool // export html data
* @field text: bool // export text data
*/
#[derive(Default, ProtoBuf, Debug, Clone)]
pub struct ExportTypePB {
#[pb(index = 1)]
pub json: bool,
#[pb(index = 2)]
pub html: bool,
#[pb(index = 3)]
pub text: bool,
}
/**
* ConvertDocumentPayloadPB
* @field document_id: String
* @file range: Option<RangePB> - optional // if range is None, copy the whole document
* @field export_types: [ExportTypePB]
*/
#[derive(Default, ProtoBuf)]
pub struct ConvertDocumentPayloadPB {
#[pb(index = 1)]
pub document_id: String,
#[pb(index = 2, one_of)]
pub range: Option<RangePB>,
#[pb(index = 3)]
pub export_types: ExportTypePB,
}
#[derive(Default, ProtoBuf, Debug)]
pub struct ConvertDocumentResponsePB {
#[pb(index = 1, one_of)]
pub json: Option<String>,
#[pb(index = 2, one_of)]
pub html: Option<String>,
#[pb(index = 3, one_of)]
pub text: Option<String>,
}
pub struct Selection {
pub block_id: String,
pub index: u32,
pub length: u32,
}
pub struct Range {
pub start: Selection,
pub end: Selection,
}
pub struct ExportType {
pub json: bool,
pub html: bool,
pub text: bool,
}
pub struct ConvertDocumentParams {
pub document_id: String,
pub range: Option<Range>,
pub export_types: ExportType,
}
impl ExportType {
pub fn any_enabled(&self) -> bool {
self.json || self.html || self.text
}
}
impl From<SelectionPB> for Selection {
fn from(data: SelectionPB) -> Self {
Selection {
block_id: data.block_id,
index: data.index,
length: data.length,
}
}
}
impl From<RangePB> for Range {
fn from(data: RangePB) -> Self {
Range {
start: data.start.into(),
end: data.end.into(),
}
}
}
impl From<ExportTypePB> for ExportType {
fn from(data: ExportTypePB) -> Self {
ExportType {
json: data.json,
html: data.html,
text: data.text,
}
}
}
impl TryInto<ConvertDocumentParams> for ConvertDocumentPayloadPB {
type Error = ErrorCode;
fn try_into(self) -> Result<ConvertDocumentParams, Self::Error> {
let document_id =
NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?;
let range = self.range.map(|data| data.into());
Ok(ConvertDocumentParams {
document_id: document_id.0,
range,
export_types: self.export_types.into(),
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InsertDelta {
#[serde(default)]
pub insert: String,
#[serde(default)]
pub attributes: Option<HashMap<String, Value>>,
}
impl InsertDelta {
pub fn to_text(&self) -> String {
self.insert.clone()
}
pub fn to_html(&self) -> String {
let mut html = String::new();
let mut style = String::new();
// If there are attributes, serialize them as a HashMap.
if let Some(attrs) = &self.attributes {
// Serialize the font color attributes.
if let Some(color) = attrs.get(FONT_COLOR) {
style.push_str(&format!(
"color: {};",
color.to_string().replace("0x", "#").trim_matches('\"')
));
}
// Serialize the background color attributes.
if let Some(color) = attrs.get(BG_COLOR) {
style.push_str(&format!(
"background-color: {};",
color.to_string().replace("0x", "#").trim_matches('\"')
));
}
// Serialize the href attributes.
if let Some(href) = attrs.get(HREF) {
html.push_str(&format!("<a href={}>", href));
}
// Serialize the code attributes.
if let Some(code) = attrs.get(CODE) {
if code.as_bool().unwrap_or(false) {
html.push_str("<code>");
}
}
// Serialize the italic, underline, strikethrough, bold, formula attributes.
if let Some(italic) = attrs.get(ITALIC) {
if italic.as_bool().unwrap_or(false) {
style.push_str("font-style: italic;");
}
}
if let Some(underline) = attrs.get(UNDERLINE) {
if underline.as_bool().unwrap_or(false) {
style.push_str("text-decoration: underline;");
}
}
if let Some(strikethrough) = attrs.get(STRIKETHROUGH) {
if strikethrough.as_bool().unwrap_or(false) {
style.push_str("text-decoration: line-through;");
}
}
if let Some(bold) = attrs.get(BOLD) {
if bold.as_bool().unwrap_or(false) {
style.push_str("font-weight: bold;");
}
}
if let Some(formula) = attrs.get(FORMULA) {
if formula.as_bool().unwrap_or(false) {
style.push_str("font-family: fantasy;");
}
}
}
// Serialize the attributes to style.
if !style.is_empty() {
html.push_str(&format!("<span style=\"{}\">", style));
}
// Serialize the insert field.
html.push_str(&self.insert);
// Close the style tag.
if !style.is_empty() {
html.push_str("</span>");
}
// Close the tags: <a>, <code>.
if let Some(attrs) = &self.attributes {
if attrs.contains_key(HREF) {
html.push_str("</a>");
}
if attrs.contains_key(CODE) {
html.push_str("</code>");
}
}
html
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NestedBlock {
#[serde(default)]
pub id: String,
#[serde(rename = "type")]
pub ty: String,
#[serde(default)]
pub data: HashMap<String, Value>,
#[serde(default)]
pub children: Vec<NestedBlock>,
}
impl Eq for NestedBlock {}
impl PartialEq for NestedBlock {
// ignore the id field
fn eq(&self, other: &Self) -> bool {
self.ty == other.ty
&& self.data.iter().all(|(k, v)| {
let other_v = other.data.get(k).unwrap_or(&Value::Null);
if k == DELTA {
let v = convert_insert_delta_from_json(v);
let other_v = convert_insert_delta_from_json(other_v);
return v == other_v;
}
v == other_v
})
&& self.children == other.children
}
}
pub struct ConvertBlockToHtmlParams {
pub prev_block_ty: Option<String>,
pub next_block_ty: Option<String>,
}
impl NestedBlock {
pub fn new(
id: String,
ty: String,
data: HashMap<String, Value>,
children: Vec<NestedBlock>,
) -> Self {
Self {
id,
ty,
data,
children,
}
}
pub fn add_child(&mut self, child: NestedBlock) {
self.children.push(child);
}
pub fn convert_to_html(&self, params: ConvertBlockToHtmlParams) -> String {
let mut html = String::new();
let text_html = self
.data
.get("delta")
.and_then(convert_insert_delta_from_json)
.map(|delta| delta_to_html(&delta))
.unwrap_or_default();
let prev_block_ty = params.prev_block_ty.unwrap_or_default();
let next_block_ty = params.next_block_ty.unwrap_or_default();
match self.ty.as_str() {
HEADING => {
let level = self.data.get(LEVEL).unwrap_or(&Value::Null);
if level.as_u64().unwrap_or(0) > 6 {
html.push_str(&format!("<h6>{}</h6>", text_html));
} else {
html.push_str(&format!("<h{}>{}</h{}>", level, text_html, level));
}
},
PARAGRAPH => {
html.push_str(&format!("<p>{}</p>", text_html));
html.push_str(&convert_nested_block_children_to_html(Arc::new(
self.to_owned(),
)));
},
CALLOUT => {
html.push_str(&format!(
"<p>{}{}</p>",
self
.data
.get(ICON)
.unwrap_or(&Value::Null)
.to_string()
.trim_matches('\"'),
text_html
));
},
IMAGE => {
html.push_str(&format!(
"<img src={} alt={} />",
self.data.get(URL).unwrap(),
"AppFlowy-Image"
));
},
DIVIDER => {
html.push_str("<hr />");
},
MATH_EQUATION => {
let formula = self.data.get(FORMULA).unwrap_or(&Value::Null);
html.push_str(&format!(
"<p>{}</p>",
formula.to_string().trim_matches('\"')
));
},
CODE => {
let language = self.data.get(LANGUAGE).unwrap_or(&Value::Null);
html.push_str(&format!(
"<pre><code class=\"language-{}\">{}</code></pre>",
language.to_string().trim_matches('\"'),
text_html
));
},
BULLETED_LIST | NUMBERED_LIST | TODO_LIST | TOGGLE_LIST => {
let list_type = match self.ty.as_str() {
BULLETED_LIST => "ul",
NUMBERED_LIST => "ol",
TODO_LIST => "ul",
TOGGLE_LIST => "ul",
_ => "ul", // Default to "ul" for unknown types
};
if prev_block_ty != self.ty {
html.push_str(&format!("<{}>", list_type));
}
if self.ty == TODO_LIST {
let checked_str = if self
.data
.get(CHECKED)
.and_then(|checked| checked.as_bool())
.unwrap_or(false)
{
"x"
} else {
" "
};
html.push_str(&format!("<li>[{}] {}</li>", checked_str, text_html));
} else {
html.push_str(&format!("<li>{}</li>", text_html));
}
html.push_str(&convert_nested_block_children_to_html(Arc::new(
self.to_owned(),
)));
if next_block_ty != self.ty {
html.push_str(&format!("</{}>", list_type));
}
},
QUOTE => {
if prev_block_ty != self.ty {
html.push_str("<blockquote>");
}
html.push_str(&format!("<p>{}</p>", text_html));
html.push_str(&convert_nested_block_children_to_html(Arc::new(
self.to_owned(),
)));
if next_block_ty != self.ty {
html.push_str("</blockquote>");
}
},
PAGE => {
if !text_html.is_empty() {
html.push_str(&format!("<p>{}</p>", text_html));
}
html.push_str(&convert_nested_block_children_to_html(Arc::new(
self.to_owned(),
)));
},
_ => {
html.push_str(&format!("<p>{}</p>", text_html));
html.push_str(&convert_nested_block_children_to_html(Arc::new(
self.to_owned(),
)));
},
};
html
}
pub fn convert_to_text(&self) -> String {
let mut text = String::new();
let delta_text = self
.data
.get("delta")
.and_then(convert_insert_delta_from_json)
.map(|delta| delta_to_text(&delta))
.unwrap_or_default();
match self.ty.as_str() {
CALLOUT => {
text.push_str(&format!(
"{}{}\n",
self
.data
.get(ICON)
.unwrap_or(&Value::Null)
.to_string()
.trim_matches('\"'),
delta_text
));
},
MATH_EQUATION => {
let formula = self.data.get(FORMULA).unwrap_or(&Value::Null);
text.push_str(&format!("{}\n", formula.to_string().trim_matches('\"')));
},
PAGE => {
if !delta_text.is_empty() {
text.push_str(&format!("{}\n", delta_text));
}
for child in &self.children {
text.push_str(&child.convert_to_text());
}
},
_ => {
text.push_str(&format!("{}\n", delta_text));
for child in &self.children {
text.push_str(&child.convert_to_text());
}
},
};
text
}
}

View File

@ -0,0 +1,167 @@
use crate::parser::constant::DELTA;
use crate::parser::parser_entities::{
ConvertBlockToHtmlParams, InsertDelta, NestedBlock, Selection,
};
use collab_document::blocks::{Block, DocumentData};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
pub struct ConvertBlockToJsonParams {
pub(crate) blocks: HashMap<String, Arc<Block>>,
pub(crate) relation_map: HashMap<String, Arc<Vec<String>>>,
pub(crate) delta_map: HashMap<String, Vec<InsertDelta>>,
}
pub fn block_to_nested_json(
block_id: &str,
convert_params: &ConvertBlockToJsonParams,
) -> Option<NestedBlock> {
let blocks = &convert_params.blocks;
let relation_map = &convert_params.relation_map;
let delta_map = &convert_params.delta_map;
// Attempt to retrieve the block using the block_id
let block = blocks.get(block_id)?;
// Retrieve the children for this block from the relation map
let children = relation_map.get(&block.id)?;
// Recursively convert children blocks to JSON
let children: Vec<_> = children
.iter()
.filter_map(|child_id| block_to_nested_json(child_id, convert_params))
.collect();
// Clone block data
let mut data = block.data.clone();
// Insert delta into data if available
if let Some(delta) = delta_map.get(&block.id) {
if let Ok(delta_value) = serde_json::to_value(delta) {
data.insert(DELTA.to_string(), delta_value);
}
}
// Create and return the NestedBlock
Some(NestedBlock {
id: block.id.to_string(),
ty: block.ty.to_string(),
children,
data,
})
}
pub fn get_flat_block_ids(block_id: &str, data: &DocumentData) -> Vec<String> {
let blocks = &data.blocks;
let children_map = &data.meta.children_map;
if let Some(block) = blocks.get(block_id) {
let mut result = vec![block.id.clone()];
if let Some(child_ids) = children_map.get(&block.children) {
for child_id in child_ids {
let child_blocks = get_flat_block_ids(child_id, data);
result.extend(child_blocks);
}
return result;
}
}
vec![]
}
pub fn get_delta_for_block(block_id: &str, data: &DocumentData) -> Option<Vec<InsertDelta>> {
let text_map = data.meta.text_map.as_ref()?; // Retrieve the text_map reference
data.blocks.get(block_id).and_then(|block| {
let text_id = block.external_id.as_ref()?;
let delta_str = text_map.get(text_id)?;
serde_json::from_str::<Vec<InsertDelta>>(delta_str).ok()
})
}
pub fn get_delta_for_selection(
selection: &Selection,
data: &DocumentData,
) -> Option<Vec<InsertDelta>> {
let delta = get_delta_for_block(&selection.block_id, data)?;
let start = selection.index as usize;
let end = (selection.index + selection.length) as usize;
Some(slice_delta(&delta, start, end))
}
pub fn slice_delta(delta: &Vec<InsertDelta>, start: usize, end: usize) -> Vec<InsertDelta> {
let mut result = vec![];
let mut index = 0;
for d in delta {
let content = &d.insert;
let text_len = content.len();
// skip if index is not reached
if index + text_len <= start {
index += text_len;
continue;
}
// break if index is over end
if index >= end {
break;
}
// slice content, and push to result
let start_offset = std::cmp::max(0, start as isize - index as isize) as usize;
let end_offset = std::cmp::min(end - index, text_len);
let content = content[start_offset..end_offset].to_string();
result.push(InsertDelta {
insert: content,
attributes: d.attributes.clone(),
});
index += text_len;
}
result
}
pub fn delta_to_text(delta: &Vec<InsertDelta>) -> String {
let mut result = String::new();
for d in delta {
result.push_str(d.to_text().as_str());
}
result
}
pub fn delta_to_html(delta: &Vec<InsertDelta>) -> String {
let mut result = String::new();
for d in delta {
result.push_str(d.to_html().as_str());
}
result
}
pub fn convert_nested_block_children_to_html(block: Arc<NestedBlock>) -> String {
let children = &block.children;
let mut html = String::new();
let num_children = children.len();
for (i, child) in children.iter().enumerate() {
let prev_block_ty = if i > 0 {
Some(children[i - 1].ty.to_string())
} else {
None
};
let next_block_ty = if i + 1 < num_children {
Some(children[i + 1].ty.to_string())
} else {
None
};
let child_html = child.convert_to_html(ConvertBlockToHtmlParams {
prev_block_ty,
next_block_ty,
});
html.push_str(&child_html);
}
html
}
pub fn convert_insert_delta_from_json(delta_value: &Value) -> Option<Vec<InsertDelta>> {
serde_json::from_value::<Vec<InsertDelta>>(delta_value.to_owned()).ok()
}

View File

@ -0,0 +1 @@
<meta charset="UTF-8"><ul><li>Highlight</li><p>You can also</p><ul><li>nest</li></ul></ul>

View File

@ -0,0 +1,6 @@
<meta charset="UTF-8"><p>🥰
Like AppFlowy? Follow us:
<a href="https://github.com/AppFlowy-IO/AppFlowy">GitHub</a>
<a href="https://twitter.com/appflowy">Twitter</a>: @appflowy
<a href="https://blog-appflowy.ghost.io/">Newsletter</a>
</p>

View File

@ -0,0 +1,5 @@
<meta charset="UTF-8"><pre><code class="language-rust">// This is the main function.
fn main() {
// Print text to the console.
println!("Hello World!");
}</code></pre>

View File

@ -0,0 +1 @@
<meta charset="UTF-8"><hr />

View File

@ -0,0 +1 @@
<meta charset="UTF-8"><h1>Heading1</h1><h2>Heading2</h2><h3>Heading3</h3>

View File

@ -0,0 +1 @@
<meta charset="UTF-8"><img src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png" alt=AppFlowy-Image />

View File

@ -0,0 +1 @@
<meta charset="UTF-8"><p>E = MC^2</p>

View File

@ -0,0 +1 @@
<meta charset="UTF-8"><ol><li>Highlight</li><p>You can also</p><ol><li>nest</li></ol></ol>

View File

@ -0,0 +1,6 @@
<meta charset="UTF-8"><p>
Like AppFlowy? Follow us:
<a href="https://github.com/AppFlowy-IO/AppFlowy">GitHub</a>
<a href="https://twitter.com/appflowy">Twitter</a>: @appflowy
<a href="https://blog-appflowy.ghost.io/">Newsletter</a>
</p><p>Click <code>?</code> at the bottom right for help and support.</p><p><span style="background-color: #4dffeb3b;">Highlight </span>any text, and use the editing menu to <span style="font-style: italic;">style</span> <span style="font-weight: bold;">your</span> <span style="text-decoration: underline;">writing</span> <code>however</code><span style="color: #4dffeb3b;"> you </span><span style="text-decoration: line-through;">like.</span><span style="font-family: fantasy;">1+1=2</span></p>

View File

@ -0,0 +1 @@
<meta charset="UTF-8"><blockquote><p>This is a quote</p><p>This is a paragraph</p></blockquote>

View File

@ -0,0 +1 @@
<meta charset="UTF-8"><ul><li>[x] Highlight</li><p>You can also</p><ul><li>[ ] nest</li></ul></ul>

View File

@ -0,0 +1 @@
<meta charset="UTF-8"><ul><li>Click <code>?</code> at the bottom right for help and support.</li><p>This is a paragraph</p><ul><li>This is a toggle list</li></ul></ul>

View File

@ -0,0 +1,37 @@
{
"type": "page",
"children": [
{
"type": "bulleted_list",
"data": {
"delta": [
{
"insert": "Highlight"
}
]
},
"children": [
{
"type": "paragraph",
"data": {
"delta": [
{
"insert": "You can also"
}
]
}
},
{
"type": "bulleted_list",
"data": {
"delta": [
{
"insert": "nest"
}
]
}
}
]
}
]
}

View File

@ -0,0 +1,32 @@
{
"type": "page",
"data": {},
"children": [
{
"type": "callout",
"data": {
"delta": [
{ "insert": "\nLike AppFlowy? Follow us:\n" },
{
"attributes": {
"href": "https://github.com/AppFlowy-IO/AppFlowy"
},
"insert": "GitHub"
},
{ "insert": "\n" },
{
"attributes": { "href": "https://twitter.com/appflowy" },
"insert": "Twitter"
},
{ "insert": ": @appflowy\n" },
{
"attributes": { "href": "https://blog-appflowy.ghost.io/" },
"insert": "Newsletter"
},
{ "insert": "\n" }
],
"icon": "🥰"
}
}
]
}

View File

@ -0,0 +1,14 @@
{
"type": "page",
"children": [{
"type": "code",
"data": {
"language": "rust",
"delta": [
{
"insert": "// This is the main function.\nfn main() {\n // Print text to the console.\n println!(\"Hello World!\");\n}"
}
]
}
}]
}

View File

@ -0,0 +1,10 @@
{
"type": "page",
"data": {},
"children": [
{
"type": "divider",
"data": {}
}
]
}

View File

@ -0,0 +1,39 @@
{
"type": "page",
"data": {},
"children": [
{
"type": "heading",
"data": {
"level": 1,
"delta": [
{
"insert": "Heading1"
}
]
}
},
{
"type": "heading",
"data": {
"level": 2,
"delta": [
{
"insert": "Heading2"
}
]
}
},
{
"type": "heading",
"data": {
"level": 3,
"delta": [
{
"insert": "Heading3"
}
]
}
}
]
}

View File

@ -0,0 +1,15 @@
{
"type": "page",
"data": {},
"children": [
{
"type": "image",
"data": {
"url": "https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png",
"width": 272,
"height": 92,
"align": "center"
}
}
]
}

View File

@ -0,0 +1,267 @@
{
"type": "page",
"data": {},
"children": [
{
"type": "heading",
"data": { "delta": [{ "insert": "Welcome to AppFlowy!" }], "level": 1 }
},
{
"type": "heading",
"data": { "delta": [{ "insert": "Here are the basics" }], "level": 2 }
},
{
"type": "heading",
"data": { "delta": [{ "insert": "Here is H3" }], "level": 3 }
},
{
"type": "todo_list",
"data": {
"delta": [{ "insert": "Click anywhere and just start typing." }],
"checked": false
},
"children": [
{
"type": "todo_list",
"data": {
"delta": [
{ "insert": "Click " },
{ "attributes": { "code": true }, "insert": "Enter" },
{ "insert": " to create a new line." }
],
"checked": false
}
}
]
},
{
"type": "todo_list",
"data": {
"checked": false,
"delta": [
{
"attributes": { "bg_color": "0x4dffeb3b" },
"insert": "Highlight "
},
{ "insert": "any text, and use the editing menu to " },
{ "attributes": { "italic": true }, "insert": "style" },
{ "insert": " " },
{ "attributes": { "bold": true }, "insert": "your" },
{ "insert": " " },
{ "attributes": { "underline": true }, "insert": "writing" },
{ "insert": " " },
{ "attributes": { "code": true }, "insert": "however" },
{ "insert": " you " },
{ "attributes": { "strikethrough": true }, "insert": "like." }
]
}
},
{
"type": "todo_list",
"data": {
"checked": false,
"delta": [
{ "insert": "As soon as you type " },
{
"attributes": { "code": true, "font_color": "0xff00b5ff" },
"insert": "/"
},
{ "insert": " a menu will pop up. Select " },
{
"attributes": { "bg_color": "0x4d9c27b0" },
"insert": "different types"
},
{ "insert": " of content blocks you can add." }
]
}
},
{
"type": "todo_list",
"data": {
"delta": [
{ "insert": "Type " },
{ "attributes": { "code": true }, "insert": "/" },
{ "insert": " followed by " },
{ "attributes": { "code": true }, "insert": "/bullet" },
{ "insert": " or " },
{ "attributes": { "code": true }, "insert": "/num" },
{ "attributes": { "code": false }, "insert": " to create a list." }
],
"checked": false
}
},
{
"type": "todo_list",
"data": {
"delta": [
{ "insert": "Click " },
{ "attributes": { "code": true }, "insert": "+ New Page " },
{
"insert": "button at the bottom of your sidebar to add a new page."
}
],
"checked": true
}
},
{
"type": "todo_list",
"data": {
"checked": false,
"delta": [
{ "insert": "Click " },
{ "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": "." }
]
}
},
{ "type": "paragraph", "data": { "delta": [] } },
{ "type": "divider" },
{ "type": "paragraph", "data": { "delta": [] } },
{
"type": "heading",
"data": {
"delta": [{ "insert": "Keyboard shortcuts, markdown, and code block" }],
"level": 2
}
},
{
"type": "numbered_list",
"data": {
"delta": [
{ "insert": "Keyboard shortcuts " },
{
"attributes": {
"href": "https://appflowy.gitbook.io/docs/essential-documentation/shortcuts"
},
"insert": "guide"
}
]
}
},
{
"type": "numbered_list",
"data": {
"delta": [
{ "insert": "Markdown " },
{
"attributes": {
"href": "https://appflowy.gitbook.io/docs/essential-documentation/markdown"
},
"insert": "reference"
}
]
}
},
{
"type": "numbered_list",
"data": {
"delta": [
{ "insert": "Type " },
{ "attributes": { "code": true }, "insert": "/code" },
{
"attributes": { "code": false },
"insert": " to insert a code block"
}
]
}
},
{
"type": "code",
"data": {
"language": "rust",
"delta": [
{
"insert": "// This is the main function.\nfn main() {\n // Print text to the console.\n println!(\"Hello World!\");\n}"
}
]
}
},
{
"type": "paragraph",
"data": { "delta": [] },
"children": [{
"type": "paragraph",
"data": { "delta": [{ "insert": "This is a paragraph" }] },
"children": [{
"type": "paragraph",
"data": { "delta": [{ "insert": "This is a paragraph" }] }
}]
}]
},
{
"type": "heading",
"data": { "level": 2, "delta": [{ "insert": "Have a question❓" }] }
},
{
"type": "toggle_list",
"data": {
"delta": [
{ "insert": "Click " },
{ "attributes": { "code": true }, "insert": "?" },
{ "insert": " at the bottom right for help and support." }
]
},
"children": [
{
"type": "paragraph",
"data": { "delta": [{ "insert": "This is a paragraph" }] }
},
{
"type": "paragraph",
"data": { "delta": [{ "insert": "This is a paragraph" }] }
}
]
},
{
"type": "quote",
"data": {
"delta": [
{ "insert": "Click " },
{ "attributes": { "code": true }, "insert": "?" },
{ "insert": " at the bottom right for help and support." }
]
}
},
{ "type": "paragraph", "data": { "delta": [] } },
{
"type": "callout",
"data": {
"delta": [
{ "insert": "\nLike AppFlowy? Follow us:\n" },
{
"attributes": {
"href": "https://github.com/AppFlowy-IO/AppFlowy"
},
"insert": "GitHub"
},
{ "insert": "\n" },
{
"attributes": { "href": "https://twitter.com/appflowy" },
"insert": "Twitter"
},
{ "insert": ": @appflowy\n" },
{
"attributes": { "href": "https://blog-appflowy.ghost.io/" },
"insert": "Newsletter"
},
{ "insert": "\n" }
],
"icon": "🥰"
}
},
{ "type": "paragraph", "data": { "delta": [] } },
{ "type": "paragraph", "data": { "delta": [] } },
{ "type": "paragraph", "data": { "delta": [] } }
]
}

View File

@ -0,0 +1,9 @@
{
"type": "page",
"children": [{
"type": "math_equation",
"data": {
"formula": "E = MC^2"
}
}]
}

View File

@ -0,0 +1,37 @@
{
"type": "page",
"children": [
{
"type": "numbered_list",
"data": {
"delta": [
{
"insert": "Highlight"
}
]
},
"children": [
{
"type": "paragraph",
"data": {
"delta": [
{
"insert": "You can also"
}
]
}
},
{
"type": "numbered_list",
"data": {
"delta": [
{
"insert": "nest"
}
]
}
}
]
}
]
}

View File

@ -0,0 +1,59 @@
{
"type": "page",
"data": {},
"children": [
{
"type": "paragraph",
"data": { "delta": [
{ "insert": "\nLike AppFlowy? Follow us:\n" },
{
"attributes": {
"href": "https://github.com/AppFlowy-IO/AppFlowy"
},
"insert": "GitHub"
},
{ "insert": "\n" },
{
"attributes": { "href": "https://twitter.com/appflowy" },
"insert": "Twitter"
},
{ "insert": ": @appflowy\n" },
{
"attributes": { "href": "https://blog-appflowy.ghost.io/" },
"insert": "Newsletter"
},
{ "insert": "\n" }
]},
"children": [{
"type": "paragraph",
"data": {
"delta": [
{ "insert": "Click " },
{ "attributes": { "code": true }, "insert": "?" },
{ "insert": " at the bottom right for help and support." }
]
}
}]
},
{
"type": "paragraph",
"data": { "delta": [
{
"attributes": { "bg_color": "0x4dffeb3b" },
"insert": "Highlight "
},
{ "insert": "any text, and use the editing menu to " },
{ "attributes": { "italic": true }, "insert": "style" },
{ "insert": " " },
{ "attributes": { "bold": true }, "insert": "your" },
{ "insert": " " },
{ "attributes": { "underline": true }, "insert": "writing" },
{ "insert": " " },
{ "attributes": { "code": true }, "insert": "however" },
{ "insert": " you ", "attributes": { "font_color": "0x4dffeb3b" } },
{ "attributes": { "strikethrough": true }, "insert": "like." },
{ "attributes": { "formula": true }, "insert": "1+1=2" }
] }
}
]
}

View File

@ -0,0 +1,25 @@
{
"type": "page",
"children": [
{
"type": "quote",
"data": {
"delta": [
{
"insert": "This is a quote"
}
]
},
"children": [{
"type": "paragraph",
"data": {
"delta": [
{
"insert": "This is a paragraph"
}
]
}
}]
}
]
}

View File

@ -0,0 +1,100 @@
{
"type": "page",
"data": {},
"children": [
{
"type": "heading",
"data": { "delta": [{ "insert": " are the basics" }], "level": 2 }
},
{
"type": "heading",
"data": { "delta": [{ "insert": "Here is H3" }], "level": 3 }
},
{
"type": "todo_list",
"data": {
"delta": [{ "insert": "Click anywhere and just start typing." }],
"checked": false
},
"children": [
{
"type": "todo_list",
"data": {
"delta": [
{ "insert": "Click " },
{ "attributes": { "code": true }, "insert": "Enter" },
{ "insert": " to create a new line." }
],
"checked": false
}
}
]
},
{
"type": "todo_list",
"data": {
"checked": false,
"delta": [
{
"attributes": { "bg_color": "0x4dffeb3b" },
"insert": "Highlight "
},
{ "insert": "any text, and use the editing menu to " },
{ "attributes": { "italic": true }, "insert": "style" },
{ "insert": " " },
{ "attributes": { "bold": true }, "insert": "your" },
{ "insert": " " },
{ "attributes": { "underline": true }, "insert": "writing" },
{ "insert": " " },
{ "attributes": { "code": true }, "insert": "however" },
{ "insert": " you " },
{ "attributes": { "strikethrough": true }, "insert": "like." }
]
}
},
{
"type": "todo_list",
"data": {
"checked": false,
"delta": [
{ "insert": "As soon as you type " },
{
"attributes": { "code": true, "font_color": "0xff00b5ff" },
"insert": "/"
},
{ "insert": " a menu will pop up. Select " },
{
"attributes": { "bg_color": "0x4d9c27b0" },
"insert": "different types"
},
{ "insert": " of content blocks you can add." }
]
}
},
{
"type": "todo_list",
"data": {
"delta": [
{ "insert": "Type " },
{ "attributes": { "code": true }, "insert": "/" },
{ "insert": " followed by " },
{ "attributes": { "code": true }, "insert": "/bullet" },
{ "insert": " or " },
{ "attributes": { "code": true }, "insert": "/num" },
{ "attributes": { "code": false }, "insert": " to create a list." }
],
"checked": false
}
},
{
"type": "todo_list",
"data": {
"delta": [
{ "insert": "Click " },
{ "attributes": { "code": true }, "insert": "+ New" }
],
"checked": true
}
}
]
}

View File

@ -0,0 +1,178 @@
{
"type": "page",
"data": {},
"children": [
{
"type": "todo_list",
"data": {
"delta": [
{ "attributes": { "code": true }, "insert": "Enter" },
{ "insert": " to create a new line." }
],
"checked": false
}
},
{
"type": "todo_list",
"data": {
"checked": false,
"delta": [
{
"attributes": { "bg_color": "0x4dffeb3b" },
"insert": "Highlight "
},
{ "insert": "any text, and use the editing menu to " },
{ "attributes": { "italic": true }, "insert": "style" },
{ "insert": " " },
{ "attributes": { "bold": true }, "insert": "your" },
{ "insert": " " },
{ "attributes": { "underline": true }, "insert": "writing" },
{ "insert": " " },
{ "attributes": { "code": true }, "insert": "however" },
{ "insert": " you " },
{ "attributes": { "strikethrough": true }, "insert": "like." }
]
}
},
{
"type": "todo_list",
"data": {
"checked": false,
"delta": [
{ "insert": "As soon as you type " },
{
"attributes": { "code": true, "font_color": "0xff00b5ff" },
"insert": "/"
},
{ "insert": " a menu will pop up. Select " },
{
"attributes": { "bg_color": "0x4d9c27b0" },
"insert": "different types"
},
{ "insert": " of content blocks you can add." }
]
}
},
{
"type": "todo_list",
"data": {
"delta": [
{ "insert": "Type " },
{ "attributes": { "code": true }, "insert": "/" },
{ "insert": " followed by " },
{ "attributes": { "code": true }, "insert": "/bullet" },
{ "insert": " or " },
{ "attributes": { "code": true }, "insert": "/num" },
{ "attributes": { "code": false }, "insert": " to create a list." }
],
"checked": false
}
},
{
"type": "todo_list",
"data": {
"delta": [
{ "insert": "Click " },
{ "attributes": { "code": true }, "insert": "+ New Page " },
{
"insert": "button at the bottom of your sidebar to add a new page."
}
],
"checked": true
}
},
{
"type": "todo_list",
"data": {
"checked": false,
"delta": [
{ "insert": "Click " },
{ "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": "." }
]
}
},
{ "type": "paragraph", "data": { "delta": [] } },
{ "type": "divider" },
{ "type": "paragraph", "data": { "delta": [] } },
{
"type": "heading",
"data": {
"delta": [{ "insert": "Keyboard shortcuts, markdown, and code block" }],
"level": 2
}
},
{
"type": "numbered_list",
"data": {
"delta": [
{ "insert": "Keyboard shortcuts " },
{
"attributes": {
"href": "https://appflowy.gitbook.io/docs/essential-documentation/shortcuts"
},
"insert": "guide"
}
]
}
},
{
"type": "numbered_list",
"data": {
"delta": [
{ "insert": "Markdown " },
{
"attributes": {
"href": "https://appflowy.gitbook.io/docs/essential-documentation/markdown"
},
"insert": "reference"
}
]
}
},
{
"type": "numbered_list",
"data": {
"delta": [
{ "insert": "Type " },
{ "attributes": { "code": true }, "insert": "/code" },
{
"attributes": { "code": false },
"insert": " to insert a code block"
}
]
}
},
{
"type": "code",
"data": {
"language": "rust",
"delta": [
{
"insert": "// This is the main function.\nfn main() {\n // Print text to the console.\n println!(\"Hello World!\");\n}"
}
]
}
},
{
"type": "paragraph",
"data": { "delta": [] },
"children": [{
"type": "paragraph",
"data": { "delta": [{ "insert": "This is a p" }] },
"children": []
}]
}
]
}

View File

@ -0,0 +1,39 @@
{
"type": "page",
"children": [
{
"type": "todo_list",
"data": {
"checked": true,
"delta": [
{
"insert": "Highlight"
}
]
},
"children": [
{
"type": "paragraph",
"data": {
"delta": [
{
"insert": "You can also"
}
]
}
},
{
"type": "todo_list",
"checked": false,
"data": {
"delta": [
{
"insert": "nest"
}
]
}
}
]
}
]
}

View File

@ -0,0 +1,25 @@
{
"type": "page",
"children": [
{
"type": "toggle_list",
"data": {
"delta": [
{ "insert": "Click " },
{ "attributes": { "code": true }, "insert": "?" },
{ "insert": " at the bottom right for help and support." }
]
},
"children": [
{
"type": "paragraph",
"data": { "delta": [{ "insert": "This is a paragraph" }] }
},
{
"type": "toggle_list",
"data": { "delta": [{ "insert": "This is a toggle list" }] }
}
]
}
]
}

View File

@ -0,0 +1,3 @@
Highlight
You can also
nest

View File

@ -0,0 +1,6 @@
🥰
Like AppFlowy? Follow us:
GitHub
Twitter: @appflowy
Newsletter

View File

@ -0,0 +1,5 @@
// This is the main function.
fn main() {
// Print text to the console.
println!("Hello World!");
}

View File

@ -0,0 +1,3 @@
Heading1
Heading2
Heading3

View File

@ -0,0 +1 @@
E = MC^2

View File

@ -0,0 +1,3 @@
Highlight
You can also
nest

View File

@ -0,0 +1,8 @@
Like AppFlowy? Follow us:
GitHub
Twitter: @appflowy
Newsletter
Click ? at the bottom right for help and support.
Highlight any text, and use the editing menu to style your writing however you like.1+1=2

View File

@ -0,0 +1,2 @@
This is a quote
This is a paragraph

View File

@ -0,0 +1,3 @@
Highlight
You can also
nest

View File

@ -0,0 +1,3 @@
Click ? at the bottom right for help and support.
This is a paragraph
This is a toggle list

View File

@ -2,4 +2,4 @@ mod document_insert_test;
mod document_redo_undo_test;
mod document_test;
mod event_handler_test;
mod util;
pub mod util;

View File

@ -0,0 +1,105 @@
use collab_document::blocks::DocumentData;
use flowy_document2::parser::document_data_parser::DocumentDataParser;
use flowy_document2::parser::json::parser::JsonToDocumentParser;
use flowy_document2::parser::parser_entities::{NestedBlock, Range, Selection};
use std::sync::Arc;
#[tokio::test]
async fn document_data_parse_json_test() {
let initial_json_str = include_str!("../assets/json/initial_document.json");
let document_data = JsonToDocumentParser::json_str_to_document(initial_json_str)
.unwrap()
.into();
let parser = DocumentDataParser::new(Arc::new(document_data), None);
let read_me_json = serde_json::from_str::<NestedBlock>(initial_json_str).unwrap();
let json = parser.to_json().unwrap();
assert_eq!(read_me_json, json);
}
// range_1 is a range from the 2nd block to the 8th block
#[tokio::test]
async fn document_data_to_json_with_range_1_test() {
let initial_json_str = include_str!("../assets/json/initial_document.json");
let document_data: DocumentData = JsonToDocumentParser::json_str_to_document(initial_json_str)
.unwrap()
.into();
let children_map = &document_data.meta.children_map;
let page_block_id = &document_data.page_id;
let blocks = &document_data.blocks;
let page_block = blocks.get(page_block_id).unwrap();
let children = children_map.get(page_block.children.as_str()).unwrap();
let range = Range {
start: Selection {
block_id: children.get(1).unwrap().to_string(),
index: 4,
length: 15,
},
end: Selection {
block_id: children.get(7).unwrap().to_string(),
index: 0,
length: 11,
},
};
let parser = DocumentDataParser::new(Arc::new(document_data), Some(range));
let json = parser.to_json().unwrap();
let part_1 = include_str!("../assets/json/range_1.json");
let part_1_json = serde_json::from_str::<NestedBlock>(part_1).unwrap();
assert_eq!(part_1_json, json);
}
// range_2 is a range from the 4th block's first child to the 18th block's first child
#[tokio::test]
async fn document_data_to_json_with_range_2_test() {
let initial_json_str = include_str!("../assets/json/initial_document.json");
let document_data: DocumentData = JsonToDocumentParser::json_str_to_document(initial_json_str)
.unwrap()
.into();
let children_map = &document_data.meta.children_map;
let page_block_id = &document_data.page_id;
let blocks = &document_data.blocks;
let page_block = blocks.get(page_block_id).unwrap();
let start_block_parent_id = children_map
.get(page_block.children.as_str())
.unwrap()
.get(3)
.unwrap();
let start_block_parent = blocks.get(start_block_parent_id).unwrap();
let start_block_id = children_map
.get(start_block_parent.children.as_str())
.unwrap()
.get(0)
.unwrap();
let start = Selection {
block_id: start_block_id.to_string(),
index: 6,
length: 27,
};
let end_block_parent_id = children_map
.get(page_block.children.as_str())
.unwrap()
.get(17)
.unwrap();
let end_block_parent = blocks.get(end_block_parent_id).unwrap();
let end_block_children = children_map
.get(end_block_parent.children.as_str())
.unwrap();
let end_block_id = end_block_children.get(0).unwrap();
let end = Selection {
block_id: end_block_id.to_string(),
index: 0,
length: 11,
};
let range = Range { start, end };
let parser = DocumentDataParser::new(Arc::new(document_data), Some(range));
let json = parser.to_json().unwrap();
let part_2 = include_str!("../assets/json/range_2.json");
let part_2_json = serde_json::from_str::<NestedBlock>(part_2).unwrap();
assert_eq!(part_2_json, json);
}

View File

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

View File

@ -0,0 +1,37 @@
use crate::parser::html_text::utils::{assert_document_html_eq, assert_document_text_eq};
macro_rules! generate_test_cases {
($($block_ty:ident),*) => {
[
$(
(
include_str!(concat!("../../assets/json/", stringify!($block_ty), ".json")),
include_str!(concat!("../../assets/html/", stringify!($block_ty), ".html")),
include_str!(concat!("../../assets/text/", stringify!($block_ty), ".txt")),
)
),*
]
};
}
#[tokio::test]
async fn block_tests() {
let test_cases = generate_test_cases!(
heading,
callout,
paragraph,
divider,
image,
math_equation,
code,
bulleted_list,
numbered_list,
todo_list,
toggle_list,
quote
);
for (json_data, expect_html, expect_text) in test_cases.iter() {
assert_document_html_eq(json_data, expect_html);
assert_document_text_eq(json_data, expect_text);
}
}

View File

@ -0,0 +1,21 @@
use flowy_document2::parser::document_data_parser::DocumentDataParser;
use flowy_document2::parser::json::parser::JsonToDocumentParser;
use std::sync::Arc;
pub fn assert_document_html_eq(source: &str, expect: &str) {
let document_data = JsonToDocumentParser::json_str_to_document(source)
.unwrap()
.into();
let parser = DocumentDataParser::new(Arc::new(document_data), None);
let html = parser.to_html();
assert_eq!(expect, html);
}
pub fn assert_document_text_eq(source: &str, expect: &str) {
let document_data = JsonToDocumentParser::json_str_to_document(source)
.unwrap()
.into();
let parser = DocumentDataParser::new(Arc::new(document_data), None);
let text = parser.to_text();
assert_eq!(expect, text);
}

View File

@ -1 +1,3 @@
mod document_data_parser_test;
mod html_text;
mod json;