From 851296fa0ea848abdbceae035dd0ea094897f23c Mon Sep 17 00:00:00 2001 From: "Kilu.He" <108015703+qinluhe@users.noreply.github.com> Date: Sat, 23 Dec 2023 21:14:32 +0800 Subject: [PATCH] fix: document title operation & copy & pasted * fix: pasted html * fix: document title operation * fix: code review * fix: jest test * fix: copy & pasted * fix: remove default style when pasted html * fix: link selection * fix: rust test --- frontend/appflowy_tauri/src-tauri/Cargo.toml | 2 +- .../appflowy_tauri/src-tauri/tauri.conf.json | 5 + .../application/document/document.service.ts | 294 ++++++++++++++++++ .../application/document/document.types.ts | 12 +- .../application/document/document_service.ts | 127 -------- .../_shared/ViewTitle/ViewTitleInput.tsx | 42 +-- .../components/_shared/ViewTitle/index.tsx | 18 +- .../components/edit_record/RecordDocument.tsx | 2 +- .../components/edit_record/RecordHeader.tsx | 2 +- .../record_properties/PropertyList.tsx | 1 - .../record_properties/RecordProperties.tsx | 6 +- .../components/document/Document.tsx | 37 +-- .../document_header/DocumentHeader.tsx | 26 +- .../components/editor/command/index.ts | 59 ++-- .../components/editor/command/tab.ts | 40 ++- .../components/blocks/_shared/Placeholder.tsx | 68 +--- .../blocks/_shared/PlaceholderContent.tsx | 88 ++++++ .../components/blocks/callout/Callout.tsx | 2 +- .../components/blocks/callout/CalloutIcon.tsx | 2 +- .../components/blocks/heading/Heading.tsx | 3 +- .../editor/components/blocks/page/Page.tsx | 22 ++ .../editor/components/blocks/page/index.ts | 1 + .../components/editor/CollaborativeEditor.tsx | 34 +- .../components/editor/CustomEditable.tsx | 25 +- .../editor/components/editor/Editor.hooks.ts | 16 +- .../editor/components/editor/Editor.tsx | 21 +- .../editor/components/editor/Element.tsx | 3 + .../editor/shortcuts/shortcuts.hooks.ts | 20 +- .../editor/shortcuts/withCommandShortcuts.ts | 2 +- .../editor/shortcuts/withMarkdownShortcuts.ts | 2 +- .../editor/components/marks/link/Link.tsx | 57 ++-- .../BlockActionsToolbar.hooks.ts | 3 +- .../SlashCommandPanel.hooks.ts | 21 +- .../SelectionToolbar.hooks.ts | 27 +- .../components/editor/plugins/constants.ts | 2 +- .../components/editor/plugins/utils.ts | 6 +- .../editor/plugins/withBlockDeleteBackward.ts | 2 +- .../editor/plugins/withBlockInsertBreak.ts | 6 +- .../editor/plugins/withBlockPlugins.ts | 4 +- .../editor/plugins/withMergeNodes.ts | 101 ++++-- .../components/editor/plugins/withPasted.ts | 171 ++++++---- .../editor/plugins/withSplitNodes.ts | 7 +- .../editor/provider/__tests__/action.test.ts | 14 +- .../editor/provider/__tests__/observe.test.ts | 6 +- .../__tests__/utils/mockBackendService.ts | 3 +- .../components/editor/provider/data_client.ts | 8 +- .../components/editor/provider/provider.ts | 8 +- .../editor/provider/utils/action.ts | 15 +- .../editor/provider/utils/convert.ts | 8 +- .../editor/provider/utils/relation.ts | 2 + .../layout/Breadcrumb/Breadcrumb.hooks.ts | 3 + .../components/layout/Breadcrumb/index.tsx | 8 +- .../stores/reducers/pages/async_actions.ts | 2 + frontend/appflowy_tauri/vite.config.ts | 2 +- .../flowy-document2/src/parser/constant.rs | 3 + .../src/parser/document_data_parser.rs | 8 +- .../src/parser/external/utils.rs | 7 + .../flowy-document2/src/parser/utils.rs | 1 + .../tests/assets/json/google_docs.json | 19 -- 59 files changed, 961 insertions(+), 545 deletions(-) create mode 100644 frontend/appflowy_tauri/src/appflowy_app/application/document/document.service.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/application/document/document_service.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/PlaceholderContent.tsx create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/page/Page.tsx create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/page/index.ts diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.toml b/frontend/appflowy_tauri/src-tauri/Cargo.toml index a1b3bcd97d..a42be9b43e 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.toml +++ b/frontend/appflowy_tauri/src-tauri/Cargo.toml @@ -34,7 +34,7 @@ lru = "0.12.0" [dependencies] serde_json.workspace = true serde.workspace = true -tauri = { version = "1.5", features = ["fs-all", "shell-open"] } +tauri = { version = "1.5", features = ["clipboard-all", "fs-all", "shell-open"] } tauri-utils = "1.5" bytes.workspace = true tracing.workspace = true diff --git a/frontend/appflowy_tauri/src-tauri/tauri.conf.json b/frontend/appflowy_tauri/src-tauri/tauri.conf.json index 9908928657..7e9b0692a3 100644 --- a/frontend/appflowy_tauri/src-tauri/tauri.conf.json +++ b/frontend/appflowy_tauri/src-tauri/tauri.conf.json @@ -29,6 +29,11 @@ "removeFile": true, "renameFile": true, "exists": true + }, + "clipboard": { + "all": true, + "writeText": true, + "readText": true } }, "bundle": { diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/document/document.service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/document/document.service.ts new file mode 100644 index 0000000000..2053a44644 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/document/document.service.ts @@ -0,0 +1,294 @@ +import { + ApplyActionPayloadPB, + BlockActionPB, + BlockPB, + CloseDocumentPayloadPB, + ConvertDataToJsonPayloadPB, + ConvertDocumentPayloadPB, + InputType, + OpenDocumentPayloadPB, + TextDeltaPayloadPB, +} from '@/services/backend'; +import { + DocumentEventApplyAction, + DocumentEventApplyTextDeltaEvent, + DocumentEventCloseDocument, + DocumentEventConvertDataToJSON, + DocumentEventConvertDocument, + DocumentEventOpenDocument, +} from '@/services/backend/events/flowy-document2'; +import get from 'lodash-es/get'; +import { EditorData, EditorNodeType } from '$app/application/document/document.types'; +import { Log } from '$app/utils/log'; +import { Op } from 'quill-delta'; +import { Element } from 'slate'; +import { generateId, transformToInlineElement } from '$app/components/editor/provider/utils/convert'; + +export function blockPB2Node(block: BlockPB) { + let data = {}; + + try { + data = JSON.parse(block.data); + } catch { + Log.error('[Document Open] json parse error', block.data); + } + + const node = { + id: block.id, + type: block.ty as EditorNodeType, + parent: block.parent_id, + children: block.children_id, + data, + externalId: block.external_id, + externalType: block.external_type, + }; + + return node; +} + +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 = '$'; +export async function openDocument(docId: string): Promise { + const payload = OpenDocumentPayloadPB.fromObject({ + document_id: docId, + }); + + const result = await DocumentEventOpenDocument(payload); + + if (!result.ok) { + return Promise.reject(result.val); + } + + const documentDataPB = result.val; + + if (!documentDataPB) { + return Promise.reject('documentDataPB is null'); + } + + const data: EditorData = { + viewId: docId, + rootId: documentDataPB.page_id, + nodeMap: {}, + childrenMap: {}, + relativeMap: {}, + deltaMap: {}, + externalIdMap: {}, + }; + + get(documentDataPB, BLOCK_MAP_NAME).forEach((block) => { + Object.assign(data.nodeMap, { + [block.id]: blockPB2Node(block), + }); + data.relativeMap[block.children_id] = block.id; + if (block.external_id) { + data.externalIdMap[block.external_id] = block.id; + } + }); + + get(documentDataPB, [META_NAME, CHILDREN_MAP_NAME]).forEach((child, key) => { + const blockId = data.relativeMap[key]; + + data.childrenMap[blockId] = child.children; + }); + + get(documentDataPB, [META_NAME, TEXT_MAP_NAME]).forEach((delta, key) => { + const blockId = data.externalIdMap[key]; + + data.deltaMap[blockId] = delta ? JSON.parse(delta) : []; + }); + + return data; +} + +export async function closeDocument(docId: string) { + const payload = CloseDocumentPayloadPB.fromObject({ + document_id: docId, + }); + + const result = await DocumentEventCloseDocument(payload); + + if (!result.ok) { + return Promise.reject(result.val); + } + + return result.val; +} + +export async function applyActions(docId: string, actions: ReturnType[]) { + if (actions.length === 0) return; + const payload = ApplyActionPayloadPB.fromObject({ + document_id: docId, + actions: actions, + }); + + const result = await DocumentEventApplyAction(payload); + + if (!result.ok) { + return Promise.reject(result.val); + } + + return result.val; +} + +export async function applyText(docId: string, textId: string, delta: string) { + const payload = TextDeltaPayloadPB.fromObject({ + document_id: docId, + text_id: textId, + delta: delta, + }); + + const res = await DocumentEventApplyTextDeltaEvent(payload); + + if (!res.ok) { + return Promise.reject(res.val); + } + + return res.val; +} + +export async function getClipboardData( + docId: string, + range: { + start: { + blockId: string; + index: number; + length: number; + }; + end?: { + blockId: string; + index: number; + length: number; + }; + } +) { + const payload = ConvertDocumentPayloadPB.fromObject({ + range: { + start: { + block_id: range.start.blockId, + index: range.start.index, + length: range.start.length, + }, + end: range.end + ? { + block_id: range.end.blockId, + index: range.end.index, + length: range.end.length, + } + : undefined, + }, + document_id: docId, + parse_types: { + json: true, + html: true, + text: true, + }, + }); + + const result = await DocumentEventConvertDocument(payload); + + if (!result.ok) { + return Promise.reject(result.val); + } + + return { + html: result.val.html, + text: result.val.text, + json: result.val.json, + }; +} + +export async function convertBlockToJson(data: string, type: InputType) { + const payload = ConvertDataToJsonPayloadPB.fromObject({ + data, + input_type: type, + }); + + const result = await DocumentEventConvertDataToJSON(payload); + + if (!result.ok) { + return Promise.reject(result.val); + } + + try { + const block = JSON.parse(result.val.json); + + return flattenBlockJson(block); + } catch (e) { + return Promise.reject(e); + } +} + +interface BlockJSON { + type: string; + children: BlockJSON[]; + data: { + [key: string]: boolean | string | number | undefined; + } & { + delta?: Op[]; + }; +} + +function flattenBlockJson(block: BlockJSON) { + const nodes: Element[] = []; + + const traverse = (block: BlockJSON, parentId: string, level: number, isHidden: boolean) => { + const { delta, ...data } = block.data; + const blockId = generateId(); + const node: Element = { + blockId, + type: block.type, + data, + children: [], + parentId, + level, + textId: generateId(), + isHidden, + }; + + node.children = delta + ? delta.map((op) => { + const matchInline = transformToInlineElement(op); + + if (matchInline) { + return matchInline; + } + + return { + text: op.insert as string, + ...op.attributes, + }; + }) + : [ + { + text: '', + }, + ]; + nodes.push(node); + const children = block.children; + + for (const child of children) { + let isHidden = false; + + if (node.type === EditorNodeType.ToggleListBlock) { + const collapsed = (node.data as { collapsed: boolean })?.collapsed; + + if (collapsed) { + isHidden = true; + } + } + + traverse(child, blockId, level + 1, isHidden); + } + + return node; + }; + + traverse(block, '', 0, false); + + nodes.shift(); + return nodes; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts b/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts index 33d56d4acb..a71edb8ce5 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts @@ -1,5 +1,5 @@ import { Op } from 'quill-delta'; -import { HTMLAttributes, MutableRefObject } from 'react'; +import { HTMLAttributes } from 'react'; import { Element } from 'slate'; import { ViewIconTypePB, ViewLayoutPB } from '@/services/backend'; import { YXmlText } from 'yjs/dist/src/types/YXmlText'; @@ -14,6 +14,9 @@ export interface EditorNode { externalType?: string; } +export interface PageNode extends Element { + type: EditorNodeType.Page; +} export interface ParagraphNode extends Element { type: EditorNodeType.Paragraph; } @@ -120,11 +123,14 @@ export interface MentionPage { export interface EditorProps { id: string; sharedType?: YXmlText; - appendTextRef?: MutableRefObject<((text: string) => void) | null>; + title?: string; + onTitleChange?: (title: string) => void; + showTitle?: boolean; } export enum EditorNodeType { Paragraph = 'paragraph', + Page = 'page', HeadingBlock = 'heading', TodoListBlock = 'todo_list', BulletedListBlock = 'bulleted_list', @@ -139,6 +145,8 @@ export enum EditorNodeType { GridBlock = 'grid', } +export const blockTypes: string[] = Object.values(EditorNodeType); + export enum EditorInlineNodeType { Mention = 'mention', Formula = 'formula', diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/document/document_service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/document/document_service.ts deleted file mode 100644 index ff53a2651b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/document/document_service.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { - ApplyActionPayloadPB, - BlockActionPB, - BlockPB, - OpenDocumentPayloadPB, - TextDeltaPayloadPB, -} from '@/services/backend'; -import { - DocumentEventApplyAction, - DocumentEventApplyTextDeltaEvent, - DocumentEventOpenDocument, -} from '@/services/backend/events/flowy-document2'; -import get from 'lodash-es/get'; -import { EditorData, EditorNodeType } from '$app/application/document/document.types'; -import { Log } from '$app/utils/log'; - -export function blockPB2Node(block: BlockPB) { - let data = {}; - - try { - data = JSON.parse(block.data); - } catch { - Log.error('[Document Open] json parse error', block.data); - } - - const node = { - id: block.id, - type: block.ty as EditorNodeType, - parent: block.parent_id, - children: block.children_id, - data, - externalId: block.external_id, - externalType: block.external_type, - }; - - return node; -} - -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 = '$'; -export async function openDocument(docId: string): Promise { - const payload = OpenDocumentPayloadPB.fromObject({ - document_id: docId, - }); - - const result = await DocumentEventOpenDocument(payload); - - if (!result.ok) { - return Promise.reject(result.val); - } - - const documentDataPB = result.val; - - if (!documentDataPB) { - return Promise.reject('documentDataPB is null'); - } - - const data: EditorData = { - viewId: docId, - rootId: documentDataPB.page_id, - nodeMap: {}, - childrenMap: {}, - relativeMap: {}, - deltaMap: {}, - externalIdMap: {}, - }; - - get(documentDataPB, BLOCK_MAP_NAME).forEach((block) => { - Object.assign(data.nodeMap, { - [block.id]: blockPB2Node(block), - }); - data.relativeMap[block.children_id] = block.id; - if (block.external_id) { - data.externalIdMap[block.external_id] = block.id; - } - }); - - get(documentDataPB, [META_NAME, CHILDREN_MAP_NAME]).forEach((child, key) => { - const blockId = data.relativeMap[key]; - - data.childrenMap[blockId] = child.children; - }); - - get(documentDataPB, [META_NAME, TEXT_MAP_NAME]).forEach((delta, key) => { - const blockId = data.externalIdMap[key]; - - data.deltaMap[blockId] = delta ? JSON.parse(delta) : []; - }); - - return data; -} - -export async function applyActions(docId: string, actions: ReturnType[]) { - if (actions.length === 0) return; - const payload = ApplyActionPayloadPB.fromObject({ - document_id: docId, - actions: actions, - }); - - const result = await DocumentEventApplyAction(payload); - - if (!result.ok) { - return Promise.reject(result.val); - } - - return result.val; -} - -export async function applyText(docId: string, textId: string, delta: string) { - const payload = TextDeltaPayloadPB.fromObject({ - document_id: docId, - text_id: textId, - delta: delta, - }); - - const res = await DocumentEventApplyTextDeltaEvent(payload); - - if (!res.ok) { - return Promise.reject(res.val); - } - - return res.val; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/ViewTitle/ViewTitleInput.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/ViewTitle/ViewTitleInput.tsx index 5e5c4d06d2..ff3923109e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/ViewTitle/ViewTitleInput.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/ViewTitle/ViewTitleInput.tsx @@ -2,52 +2,28 @@ import React, { FormEventHandler, memo, useCallback, useRef } from 'react'; import { TextareaAutosize } from '@mui/material'; import { useTranslation } from 'react-i18next'; -function ViewTitleInput({ - value, - onChange, - onSplitTitle, -}: { - value: string; - onChange: (value: string) => void; - onSplitTitle?: (splitText: string) => void; -}) { +function ViewTitleInput({ value, onChange }: { value: string; onChange?: (value: string) => void }) { const { t } = useTranslation(); const textareaRef = useRef(null); - const onTitleChange: FormEventHandler = (e) => { - const value = e.currentTarget.value; + const onTitleChange: FormEventHandler = useCallback( + (e) => { + const value = e.currentTarget.value; - onChange(value); - }; - - const handleBreakLine = useCallback(() => { - if (!onSplitTitle) return; - const selectionStart = textareaRef.current?.selectionStart; - - if (value) { - const newValue = value.slice(0, selectionStart); - - onChange(newValue); - onSplitTitle(value.slice(selectionStart)); - } - }, [onSplitTitle, onChange, value]); + onChange?.(value); + }, + [onChange] + ); return ( { - if (e.key === 'Enter') { - e.preventDefault(); - e.stopPropagation(); - handleBreakLine(); - } - }} + className={`min-h-[40px] resize-none text-4xl font-bold leading-[50px] caret-text-title`} /> ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/ViewTitle/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/ViewTitle/index.tsx index 71216f129f..3df18b1a87 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/ViewTitle/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/ViewTitle/index.tsx @@ -6,12 +6,12 @@ import ViewTitleInput from '$app/components/_shared/ViewTitle/ViewTitleInput'; interface Props { view: Page; - onTitleChange: (title: string) => void; - onUpdateIcon: (icon: PageIcon) => void; - onSplitTitle?: (splitText: string) => void; + showTitle?: boolean; + onTitleChange?: (title: string) => void; + onUpdateIcon?: (icon: PageIcon) => void; } -function ViewTitle({ view, onTitleChange, onUpdateIcon: onUpdateIconProp, onSplitTitle }: Props) { +function ViewTitle({ view, onTitleChange, showTitle = true, onUpdateIcon: onUpdateIconProp }: Props) { const [hover, setHover] = useState(false); const [icon, setIcon] = useState(view.icon); @@ -27,7 +27,7 @@ function ViewTitle({ view, onTitleChange, onUpdateIcon: onUpdateIconProp, onSpli }; setIcon(newIcon); - onUpdateIconProp(newIcon); + onUpdateIconProp?.(newIcon); }, [onUpdateIconProp] ); @@ -39,9 +39,11 @@ function ViewTitle({ view, onTitleChange, onUpdateIcon: onUpdateIconProp, onSpli onMouseLeave={() => setHover(false)} > -
- -
+ {showTitle && ( +
+ +
+ )} ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordDocument.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordDocument.tsx index 1365ba95b4..ad01a335d4 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordDocument.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordDocument.tsx @@ -6,7 +6,7 @@ interface Props { } function RecordDocument({ documentId }: Props) { - return ; + return ; } export default React.memo(RecordDocument); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordHeader.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordHeader.tsx index e6fda86e38..6210ed5f70 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordHeader.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordHeader.tsx @@ -30,7 +30,7 @@ function RecordHeader({ page, row }: Props) { return (
- +
); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/PropertyList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/PropertyList.tsx index 395f772f9e..0443545d49 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/PropertyList.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/PropertyList.tsx @@ -4,7 +4,6 @@ import Property from '$app/components/database/components/edit_record/record_pro import { Draggable } from 'react-beautiful-dnd'; interface Props extends HTMLAttributes { - documentId?: string; properties: Field[]; rowId: string; placeholderNode?: React.ReactNode; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/RecordProperties.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/RecordProperties.tsx index 54129ec700..3f9de43e44 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/RecordProperties.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/RecordProperties.tsx @@ -10,11 +10,10 @@ import { DragDropContext, Droppable, DropResult, OnDragEndResponder } from 'reac import SwitchPropertiesVisible from '$app/components/database/components/edit_record/record_properties/SwitchPropertiesVisible'; interface Props { - documentId?: string; row: RowMeta; } -function RecordProperties({ documentId, row }: Props) { +function RecordProperties({ row }: Props) { const viewId = useViewId(); const { fields } = useDatabase(); const fieldId = useMemo(() => { @@ -73,10 +72,9 @@ function RecordProperties({ documentId, row }: Props) { {(dropProvided) => ( void) | null>(null); + const page = useAppSelector((state) => state.pages.pageMap[id]); - const onSplitTitle = useCallback((splitText: string) => { - if (appendTextRef.current === null) { - return; - } + const dispatch = useAppDispatch(); - const windowSelection = window.getSelection(); + const onTitleChange = useCallback( + (newTitle: string) => { + void dispatch( + updatePageName({ + id, + name: newTitle, + }) + ); + }, + [dispatch, id] + ); - windowSelection?.removeAllRanges(); - appendTextRef.current(splitText); - }, []); - - useEffect(() => { - return () => { - appendTextRef.current = null; - }; - }, []); + if (!page) return null; return (
- - + +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/document_header/DocumentHeader.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/document_header/DocumentHeader.tsx index 92744d0e95..6630202dc0 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/document_header/DocumentHeader.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/document_header/DocumentHeader.tsx @@ -1,29 +1,17 @@ import React, { useCallback } from 'react'; -import { PageIcon } from '$app_reducers/pages/slice'; -import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { Page, PageIcon } from '$app_reducers/pages/slice'; +import { useAppDispatch } from '$app/stores/store'; import ViewTitle from '$app/components/_shared/ViewTitle'; -import { updatePageIcon, updatePageName } from '$app_reducers/pages/async_actions'; +import { updatePageIcon } from '$app_reducers/pages/async_actions'; interface DocumentHeaderProps { - pageId: string; - onSplitTitle: (splitText: string) => void; + page: Page; } -export function DocumentHeader({ pageId, onSplitTitle }: DocumentHeaderProps) { - const page = useAppSelector((state) => state.pages.pageMap[pageId]); +export function DocumentHeader({ page }: DocumentHeaderProps) { const dispatch = useAppDispatch(); - const onTitleChange = useCallback( - (newTitle: string) => { - void dispatch( - updatePageName({ - id: pageId, - name: newTitle, - }) - ); - }, - [dispatch, pageId] - ); + const pageId = page.id; const onUpdateIcon = useCallback( (icon: PageIcon) => { void dispatch( @@ -39,7 +27,7 @@ export function DocumentHeader({ pageId, onSplitTitle }: DocumentHeaderProps) { if (!page) return null; return (
- +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/index.ts index d3ab6499dd..119649c779 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/index.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/index.ts @@ -1,5 +1,5 @@ import { ReactEditor } from 'slate-react'; -import { Editor, Element, Node, NodeEntry, Transforms } from 'slate'; +import { BasePoint, Editor, Element, Node, NodeEntry, Transforms } from 'slate'; import { LIST_TYPES, tabBackward, tabForward } from '$app/components/editor/command/tab'; import { isMarkActive, toggleMark } from '$app/components/editor/command/mark'; import { insertFormula, isFormulaActive, unwrapFormula, updateFormula } from '$app/components/editor/command/formula'; @@ -123,7 +123,7 @@ export const CustomEditor = { const index = editor.children.findIndex((child) => (child as Element).blockId === node.blockId); const level = node.level ?? 1; - const subordinateNodes: Element[] = []; + const subordinateNodes: (Element & { level: number })[] = []; if (index === editor.children.length - 1) return subordinateNodes; @@ -308,6 +308,14 @@ export const CustomEditor = { return CustomEditor.getBlockType(editor) === EditorNodeType.GridBlock; }, + selectionIncludeRoot: (editor: ReactEditor) => { + const [match] = Editor.nodes(editor, { + match: (n) => Element.isElement(n) && n.blockId !== undefined && n.type === EditorNodeType.Page, + }); + + return Boolean(match); + }, + isCodeBlock: (editor: ReactEditor) => { return CustomEditor.getBlockType(editor) === EditorNodeType.CodeBlock; }, @@ -332,33 +340,40 @@ export const CustomEditor = { Transforms.move(editor); }, - insertLineAtStart: (editor: ReactEditor & YjsEditor, node: Element) => { - const blockId = generateId(); - const parentId = editor.sharedRoot.getAttribute('blockId'); + basePointToIndexLength(editor: ReactEditor, point: BasePoint, toStart = false) { + const { path, offset } = point; - ReactEditor.focus(editor); - editor.insertNode( - { - ...node, - blockId, - parentId, - textId: generateId(), - level: 1, - }, - { - at: [0], - } - ); + const node = editor.children[path[0]] as Element; + const blockId = node.blockId; - editor.select({ + if (!blockId) return; + const beforeText = Editor.string(editor, { anchor: { - path: [0, 0], + path: [path[0], 0], offset: 0, }, focus: { - path: [0, 0], - offset: 0, + path, + offset, }, }); + + const index = beforeText.length; + const fullText = Editor.string(editor, [path[0]]); + const length = fullText.length - index; + + if (toStart) { + return { + index: 0, + length: index, + blockId, + }; + } else { + return { + index, + length, + blockId, + }; + } }, }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/tab.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/tab.ts index 7727a7e6a9..4c5303de71 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/tab.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/tab.ts @@ -43,21 +43,29 @@ export function tabForward(editor: ReactEditor) { const [node, path] = match as NodeEntry; + if (!node.level) return; // the node is not a list item if (!LIST_ITEM_TYPES.includes(node.type as EditorNodeType)) { return; } - const previous = Editor.previous(editor, { - match: (n) => !Editor.isEditor(n) && Element.isElement(n) && Editor.isBlock(editor, n) && n.level === node.level, - at: path, - }); + let previousNode; - if (!previous) return; + for (let i = path[0] - 1; i >= 0; i--) { + const ancestor = editor.children[i] as Element & { level: number }; - const [previousNode] = previous as NodeEntry; + if (ancestor.level === node.level) { + previousNode = ancestor; + break; + } + + if (ancestor.level < node.level) { + break; + } + } if (!previousNode) return; + const type = previousNode.type as EditorNodeType; // the previous node is not a list @@ -111,7 +119,7 @@ export function tabBackward(editor: ReactEditor) { const level = node.level; - if (level === 1) return; + if (level <= 1) return; const parent = CustomEditor.findParentNode(editor, node); if (!parent) return; @@ -122,6 +130,24 @@ export function tabBackward(editor: ReactEditor) { const newProperties = { level: level - 1, parentId: newParentId }; + const subordinates = CustomEditor.findNodeSubordinate(editor, node); + + subordinates.forEach((subordinate) => { + const subordinatePath = ReactEditor.findPath(editor, subordinate); + + const subordinateLevel = subordinate.level; + + Transforms.setNodes( + editor, + { + level: subordinateLevel - 1, + }, + { + at: subordinatePath, + } + ); + }); + const parentChildren = CustomEditor.findNodeChildren(editor, parent); const nodeIndex = parentChildren.findIndex((child) => child.blockId === node.blockId); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/Placeholder.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/Placeholder.tsx index a1f9f56613..e684347162 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/Placeholder.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/Placeholder.tsx @@ -1,65 +1,17 @@ -import React, { CSSProperties, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Editor, Element, Range } from 'slate'; -import { useSelected, useSlate } from 'slate-react'; -import { EditorNodeType, HeadingNode } from '$app/application/document/document.types'; +import React, { CSSProperties } from 'react'; +import { Editor, Element } from 'slate'; +import { useSlateStatic } from 'slate-react'; +import PlaceholderContent from '$app/components/editor/components/blocks/_shared/PlaceholderContent'; -function Placeholder({ node, className, style }: { node: Element; className?: string; style?: CSSProperties }) { - const editor = useSlate(); - const { t } = useTranslation(); +function Placeholder({ node, ...props }: { node: Element; className?: string; style?: CSSProperties }) { + const editor = useSlateStatic(); const isEmpty = Editor.isEmpty(editor, node); - const selected = useSelected() && editor.selection && Range.isCollapsed(editor.selection); - const unSelectedPlaceholder = useMemo(() => { - switch (node.type) { - case EditorNodeType.ToggleListBlock: - return t('document.plugins.toggleList'); - case EditorNodeType.QuoteBlock: - return t('editor.quote'); - case EditorNodeType.TodoListBlock: - return t('document.plugins.todoList'); - case EditorNodeType.NumberedListBlock: - return t('document.plugins.numberedList'); - case EditorNodeType.BulletedListBlock: - return t('document.plugins.bulletedList'); - case EditorNodeType.HeadingBlock: { - const level = (node as HeadingNode).data.level; + if (!isEmpty) { + return null; + } - switch (level) { - case 1: - return t('editor.mobileHeading1'); - case 2: - return t('editor.mobileHeading2'); - case 3: - return t('editor.mobileHeading3'); - default: - return ''; - } - } - - default: - return ''; - } - }, [node, t]); - - const selectedPlaceholder = useMemo(() => { - switch (node.type) { - case EditorNodeType.HeadingBlock: - return unSelectedPlaceholder; - default: - return t('editor.slashPlaceHolder'); - } - }, [node.type, t, unSelectedPlaceholder]); - - return isEmpty ? ( - - {selected ? selectedPlaceholder : unSelectedPlaceholder} - - ) : null; + return ; } export default React.memo(Placeholder); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/PlaceholderContent.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/PlaceholderContent.tsx new file mode 100644 index 0000000000..fb4be36d6f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/PlaceholderContent.tsx @@ -0,0 +1,88 @@ +import React, { CSSProperties, useMemo } from 'react'; +import { useSelected, useSlateStatic } from 'slate-react'; +import { Element } from 'slate'; +import { EditorNodeType, HeadingNode } from '$app/application/document/document.types'; +import { useTranslation } from 'react-i18next'; + +function PlaceholderContent({ node, ...attributes }: { node: Element; className?: string; style?: CSSProperties }) { + const { t } = useTranslation(); + const selected = useSelected(); + const editor = useSlateStatic(); + + const justOneParagraph = useMemo(() => { + const root = editor.children[0] as Element; + + if (node.type !== EditorNodeType.Paragraph) return false; + + if (editor.children.length === 1) return true; + + return root.type === EditorNodeType.Page && editor.children.length === 2; + }, [editor, node.type]); + + const unSelectedPlaceholder = useMemo(() => { + switch (node.type) { + case EditorNodeType.Paragraph: { + if (justOneParagraph) { + return t('editor.slashPlaceHolder'); + } + + return ''; + } + + case EditorNodeType.ToggleListBlock: + return t('document.plugins.toggleList'); + case EditorNodeType.QuoteBlock: + return t('editor.quote'); + case EditorNodeType.TodoListBlock: + return t('document.plugins.todoList'); + case EditorNodeType.NumberedListBlock: + return t('document.plugins.numberedList'); + case EditorNodeType.BulletedListBlock: + return t('document.plugins.bulletedList'); + case EditorNodeType.HeadingBlock: { + const level = (node as HeadingNode).data.level; + + switch (level) { + case 1: + return t('editor.mobileHeading1'); + case 2: + return t('editor.mobileHeading2'); + case 3: + return t('editor.mobileHeading3'); + default: + return ''; + } + } + + case EditorNodeType.Page: + return t('document.title.placeholder'); + default: + return ''; + } + }, [justOneParagraph, node, t]); + + const selectedPlaceholder = useMemo(() => { + switch (node.type) { + case EditorNodeType.HeadingBlock: + return unSelectedPlaceholder; + case EditorNodeType.Page: + return t('document.title.placeholder'); + default: + return t('editor.slashPlaceHolder'); + } + }, [node.type, t, unSelectedPlaceholder]); + + const className = useMemo(() => { + return `pointer-events-none absolute left-0.5 top-0 whitespace-nowrap text-text-placeholder ${ + attributes.className ?? '' + }`; + }, [attributes.className]); + + return ( + + {selected ? selectedPlaceholder : unSelectedPlaceholder} + + ); +} + +export default PlaceholderContent; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/Callout.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/Callout.tsx index 42e9f1983a..d81396c2f5 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/Callout.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/Callout.tsx @@ -13,7 +13,7 @@ export const Callout = memo( ref={ref} > -
{children}
+
{children}
); }) diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/CalloutIcon.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/CalloutIcon.tsx index d092d39147..3c32e988a1 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/CalloutIcon.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/CalloutIcon.tsx @@ -27,7 +27,7 @@ function CalloutIcon({ node }: { node: CalloutNode }) { onClick={() => { setOpen(true); }} - className={`p-1`} + className={`h-8 w-8 p-1`} > {node.data.icon} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/heading/Heading.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/heading/Heading.tsx index a49dbc4b25..aa814f90cd 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/heading/Heading.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/heading/Heading.tsx @@ -5,8 +5,7 @@ import { getHeadingCssProperty } from '$app/components/editor/plugins/utils'; export const Heading = memo( forwardRef>(({ node, children, ...attributes }, ref) => { - const { data } = node; - const { level } = data; + const level = node.data.level; const fontSizeCssProperty = getHeadingCssProperty(level); return ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/page/Page.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/page/Page.tsx new file mode 100644 index 0000000000..d0b7565b94 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/page/Page.tsx @@ -0,0 +1,22 @@ +import React, { forwardRef, memo, useMemo } from 'react'; +import { EditorElementProps, PageNode } from '$app/application/document/document.types'; +import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder'; + +export const Page = memo( + forwardRef>(({ node, children, ...attributes }, ref) => { + const className = useMemo(() => { + return `${attributes.className ?? ''} mb-2 text-4xl font-bold`; + }, [attributes.className]); + + return ( +
+ + + {children} + +
+ ); + }) +); + +export default Page; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/page/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/page/index.ts new file mode 100644 index 0000000000..d9925d7520 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/page/index.ts @@ -0,0 +1 @@ +export * from './Page'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CollaborativeEditor.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CollaborativeEditor.tsx index 1ef7a664b8..108b286d5d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CollaborativeEditor.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CollaborativeEditor.tsx @@ -5,12 +5,36 @@ import { EditorProps } from '$app/application/document/document.types'; import { Provider } from '$app/components/editor/provider'; import { YXmlText } from 'yjs/dist/src/types/YXmlText'; -export const CollaborativeEditor = (props: EditorProps) => { +export const CollaborativeEditor = ({ id, title, showTitle = true, onTitleChange }: EditorProps) => { const [sharedType, setSharedType] = useState(null); const provider = useMemo(() => { setSharedType(null); - return new Provider(props.id); - }, [props.id]); + return new Provider(id, showTitle); + }, [id, showTitle]); + + const root = useMemo(() => { + return showTitle ? (sharedType?.toDelta()[0].insert as YXmlText | null) : null; + }, [sharedType, showTitle]); + + useEffect(() => { + if (!root || root.toString() === title) return; + + if (root.length > 0) { + root.delete(0, root.length); + } + + root.insert(0, title || ''); + }, [title, root]); + + useEffect(() => { + if (!root) return; + const onChange = () => { + onTitleChange?.(root.toString()); + }; + + root.observe(onChange); + return () => root.unobserve(onChange); + }, [onTitleChange, root]); useEffect(() => { provider.connect(); @@ -26,9 +50,9 @@ export const CollaborativeEditor = (props: EditorProps) => { }; }, [provider]); - if (!sharedType || props.id !== provider.id) { + if (!sharedType || id !== provider.id) { return null; } - return ; + return ; }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CustomEditable.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CustomEditable.tsx index 56e847a810..90d7c508a3 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CustomEditable.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CustomEditable.tsx @@ -1,32 +1,11 @@ import React, { ComponentProps } from 'react'; -import { Editable, ReactEditor, useSlate } from 'slate-react'; +import { Editable } from 'slate-react'; import Element from './Element'; import { Leaf } from './Leaf'; -import { useTranslation } from 'react-i18next'; type CustomEditableProps = Omit, 'renderElement' | 'renderLeaf'> & Partial, 'renderElement' | 'renderLeaf'>>; export function CustomEditable({ renderElement = Element, renderLeaf = Leaf, ...props }: CustomEditableProps) { - const editor = useSlate(); - const { t } = useTranslation(); - - return ( - { - const focused = ReactEditor.isFocused(editor); - - if (focused) return <>; - return ( -
-
{children}
-
- ); - }} - renderElement={renderElement} - renderLeaf={renderLeaf} - /> - ); + return ; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.hooks.ts index 4156fd4f12..dff0225a5c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.hooks.ts @@ -1,7 +1,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { EditorNodeType, CodeNode } from '$app/application/document/document.types'; -import { createEditor, NodeEntry, BaseRange, Editor, Transforms, Element } from 'slate'; +import { createEditor, NodeEntry, BaseRange, Editor, Element } from 'slate'; import { ReactEditor, withReact } from 'slate-react'; import { withBlockPlugins } from '$app/components/editor/plugins/withBlockPlugins'; import { decorateCode } from '$app/components/editor/components/blocks/code/utils'; @@ -11,7 +11,7 @@ import { withYjs, YjsEditor, withYHistory } from '@slate-yjs/core'; import * as Y from 'yjs'; import { CustomEditor } from '$app/components/editor/command'; -export function useEditor(sharedType?: Y.XmlText) { +export function useEditor(sharedType: Y.XmlText) { const editor = useMemo(() => { if (!sharedType) return null; const e = withShortcuts(withBlockPlugins(withInlines(withReact(withYHistory(withYjs(createEditor(), sharedType)))))); @@ -26,16 +26,8 @@ export function useEditor(sharedType?: Y.XmlText) { return normalizeNode(entry); } - Transforms.insertNodes( - e, - [ - { - type: EditorNodeType.Paragraph, - children: [{ text: '' }], - }, - ], - { at: [0] } - ); + // Ensure editor always has at least 1 valid child + CustomEditor.insertEmptyLineAtEnd(e as ReactEditor & YjsEditor); }; return e; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.tsx index 90d6e49067..6c04a5a29c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { EditorSelectedBlockProvider, useDecorate, @@ -7,34 +7,19 @@ import { } from '$app/components/editor/components/editor/Editor.hooks'; import { Slate } from 'slate-react'; import { CustomEditable } from '$app/components/editor/components/editor/CustomEditable'; -import { EditorNodeType, EditorProps } from '$app/application/document/document.types'; import { SelectionToolbar } from '$app/components/editor/components/tools/selection_toolbar'; import { useShortcuts } from '$app/components/editor/components/editor/shortcuts'; import { BlockActionsToolbar } from '$app/components/editor/components/tools/block_actions'; import { SlashCommandPanel } from '$app/components/editor/components/tools/command_panel/slash_command_panel'; import { MentionPanel } from '$app/components/editor/components/tools/command_panel/mention_panel'; import { CircularProgress } from '@mui/material'; -import { CustomEditor } from '$app/components/editor/command'; +import * as Y from 'yjs'; -function Editor({ sharedType, appendTextRef }: EditorProps) { +function Editor({ sharedType }: { sharedType: Y.XmlText; id: string }) { const { editor, initialValue, handleOnClickEnd, ...props } = useEditor(sharedType); const decorate = useDecorate(editor); const { onDOMBeforeInput, onKeyDown: onShortcutsKeyDown } = useShortcuts(editor); - useEffect(() => { - if (!appendTextRef) return; - appendTextRef.current = (text: string) => { - CustomEditor.insertLineAtStart(editor, { - type: EditorNodeType.Paragraph, - children: [{ text }], - }); - }; - - return () => { - appendTextRef.current = null; - }; - }, [appendTextRef, editor]); - const { onSelectedBlock, selectedBlockId } = useEditorSelectedBlock(editor); if (editor.sharedRoot.length === 0) { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.tsx index 8217210d58..074ebf3bc8 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.tsx @@ -16,6 +16,7 @@ import { Mention } from '$app/components/editor/components/inline_nodes/mention' import { GridBlock } from '$app/components/editor/components/blocks/database'; import { MathEquation } from '$app/components/editor/components/blocks/math_equation'; import { useSelectedBlock } from '$app/components/editor/components/editor/Editor.hooks'; +import Page from '../blocks/page/Page'; function Element({ element, attributes, children }: RenderElementProps) { const node = element; @@ -33,6 +34,8 @@ function Element({ element, attributes, children }: RenderElementProps) { const Component = useMemo(() => { switch (node.type) { + case EditorNodeType.Page: + return Page; case EditorNodeType.HeadingBlock: return Heading; case EditorNodeType.TodoListBlock: diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/shortcuts.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/shortcuts.hooks.ts index af90e768dc..7bc7b52048 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/shortcuts.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/shortcuts.hooks.ts @@ -58,8 +58,15 @@ export function useShortcuts(editor: ReactEditor) { }); } + const node = getBlock(editor); + if (isHotkey('Tab', e)) { e.preventDefault(); + if (SOFT_BREAK_TYPES.includes(node?.type as EditorNodeType)) { + editor.insertText('\t'); + return; + } + return CustomEditor.tabForward(editor); } @@ -68,14 +75,19 @@ export function useShortcuts(editor: ReactEditor) { return CustomEditor.tabBackward(editor); } - const node = getBlock(editor); + if (isHotkey('Enter', e)) { + if (SOFT_BREAK_TYPES.includes(node?.type as EditorNodeType)) { + e.preventDefault(); + editor.insertText('\n'); + return; + } + } if (isHotkey('shift+Enter', e) && node) { + e.preventDefault(); if (SOFT_BREAK_TYPES.includes(node.type as EditorNodeType)) { - e.preventDefault(); CustomEditor.splitToParagraph(editor); - } else if (node.type === EditorNodeType.Paragraph) { - e.preventDefault(); + } else { editor.insertText('\n'); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/withCommandShortcuts.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/withCommandShortcuts.ts index 6bd7afdbbb..ba8942a4e1 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/withCommandShortcuts.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/withCommandShortcuts.ts @@ -24,7 +24,7 @@ export function withCommandShortcuts(editor: ReactEditor) { const { insertText, deleteBackward } = editor; editor.insertText = (text) => { - if (CustomEditor.isCodeBlock(editor)) { + if (CustomEditor.isCodeBlock(editor) || CustomEditor.selectionIncludeRoot(editor)) { insertText(text); return; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/withMarkdownShortcuts.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/withMarkdownShortcuts.ts index e6f7bb9caa..f63f899aa0 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/withMarkdownShortcuts.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/withMarkdownShortcuts.ts @@ -138,7 +138,7 @@ export const withMarkdownShortcuts = (editor: ReactEditor) => { const { insertText } = editor; editor.insertText = (text) => { - if (CustomEditor.isCodeBlock(editor)) { + if (CustomEditor.isCodeBlock(editor) || CustomEditor.selectionIncludeRoot(editor)) { insertText(text); return; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/marks/link/Link.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/marks/link/Link.tsx index 72cae31a1a..08e3404a10 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/marks/link/Link.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/marks/link/Link.tsx @@ -1,7 +1,7 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { ReactEditor, useSelected, useSlate } from 'slate-react'; import { getNodePath, moveCursorToNodeEnd, moveCursorToPoint } from '$app/components/editor/components/editor/utils'; -import { BasePoint, Transforms, Text, Range, Point } from 'slate'; +import { BasePoint, Transforms, Text, Range, Editor } from 'slate'; import { LinkEditPopover } from '$app/components/editor/components/marks/link/LinkEditPopover'; export const Link = memo(({ leaf, children }: { leaf: Text; children: React.ReactNode }) => { @@ -9,33 +9,50 @@ export const Link = memo(({ leaf, children }: { leaf: Text; children: React.Reac const editor = useSlate(); + const [selected, setSelected] = useState(false); const ref = useRef(null); const [openEditPopover, setOpenEditPopover] = useState(false); - const selected = useMemo(() => { - if (!editor.selection || !nodeSelected || !ref.current) return false; + const getSelected = useCallback( + (el: HTMLSpanElement, selection: Range) => { + const entry = Editor.node(editor, selection); + const [node, path] = entry; + const dom = ReactEditor.toDOMNode(editor, node); - const node = ReactEditor.toSlateNode(editor, ref.current); - const path = ReactEditor.findPath(editor, node); - const range = { anchor: { path, offset: 0 }, focus: { path, offset: leaf.text.length } }; - const isContained = Range.includes(range, editor.selection); - const selectionIsCollapsed = Range.isCollapsed(editor.selection); - const point = Range.start(editor.selection); + if (!dom.contains(el)) return false; - if ((selectionIsCollapsed && point && Point.equals(point, range.focus)) || Point.equals(point, range.anchor)) { - return false; - } + const offset = Editor.string(editor, path).length; + const range = { + anchor: { + path, + offset: 0, + }, + focus: { + path, + offset, + }, + }; - return isContained; - }, [editor, nodeSelected, leaf.text.length]); + return Range.equals(range, selection); + }, + [editor] + ); useEffect(() => { - if (selected) { - setOpenEditPopover(true); - } else { + if (!ref.current) return; + const selection = editor.selection; + + if (!nodeSelected || !selection) { setOpenEditPopover(false); + setSelected(false); + return; } - }, [selected]); + + const selected = getSelected(ref.current, selection); + + setOpenEditPopover(selected); + setSelected(selected); + }, [getSelected, editor, nodeSelected]); const handleClick = useCallback(() => { if (ref.current === null) { @@ -45,6 +62,7 @@ export const Link = memo(({ leaf, children }: { leaf: Text; children: React.Reac const path = getNodePath(editor, ref.current); setOpenEditPopover(true); + setSelected(true); ReactEditor.focus(editor); Transforms.select(editor, path); }, [editor]); @@ -52,6 +70,7 @@ export const Link = memo(({ leaf, children }: { leaf: Text; children: React.Reac const handleEditPopoverClose = useCallback( (at?: BasePoint) => { setOpenEditPopover(false); + setSelected(false); if (ref.current === null) { return; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.hooks.ts index 9230b0a742..688cb19dec 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.hooks.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { ReactEditor, useSlate } from 'slate-react'; import { getBlockActionsPosition } from '$app/components/editor/components/tools/block_actions/utils'; import { Element } from 'slate'; +import { EditorNodeType } from '$app/application/document/document.types'; export function useBlockActionsToolbar(ref: React.RefObject) { const editor = useSlate(); @@ -57,6 +58,6 @@ export function useBlockActionsToolbar(ref: React.RefObject) { }, [editor, ref]); return { - node, + node: node?.type === EditorNodeType.Page ? null : node, }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.ts index 13a4f929b3..bbf2a1d4e1 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.ts @@ -16,6 +16,7 @@ import { ReactComponent as ToggleListIcon } from '$app/assets/show-menu.svg'; import { ReactComponent as GridIcon } from '$app/assets/grid.svg'; import { DataObjectOutlined, FunctionsOutlined, HorizontalRuleOutlined, MenuBookOutlined } from '@mui/icons-material'; import { CustomEditor } from '$app/components/editor/command'; +import { randomEmoji } from '$app/utils/emoji'; enum SlashCommandPanelTab { BASIC = 'basic', @@ -107,7 +108,25 @@ export function useSlashCommandPanel({ if (!nodeType) return; - const data = headingTypes.includes(type) ? { level: headingTypeToLevelMap[type] } : {}; + const data = {}; + + if (headingTypes.includes(type)) { + Object.assign(data, { + level: headingTypeToLevelMap[type], + }); + } + + if (nodeType === EditorNodeType.CalloutBlock) { + Object.assign(data, { + icon: randomEmoji(), + }); + } + + if (nodeType === EditorNodeType.CodeBlock) { + Object.assign(data, { + language: 'javascript', + }); + } closePanel(true); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks.ts index 22f9e9f4e9..4df9a34e65 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks.ts @@ -2,6 +2,7 @@ import { ReactEditor, useSlate } from 'slate-react'; import { MutableRefObject, useCallback, useEffect, useRef, useState } from 'react'; import { getSelectionPosition } from '$app/components/editor/components/tools/selection_toolbar/utils'; import debounce from 'lodash-es/debounce'; +import { CustomEditor } from '$app/components/editor/command'; export function useSelectionToolbar(ref: MutableRefObject) { const editor = useSlate() as ReactEditor; @@ -9,6 +10,19 @@ export function useSelectionToolbar(ref: MutableRefObject const [visible, setVisible] = useState(false); const rangeRef = useRef(null); + const closeToolbar = useCallback(() => { + const el = ref.current; + + if (!el) { + return; + } + + rangeRef.current = null; + setVisible(false); + el.style.opacity = '0'; + el.style.pointerEvents = 'none'; + }, [ref]); + const recalculatePosition = useCallback(() => { const el = ref.current; @@ -16,17 +30,20 @@ export function useSelectionToolbar(ref: MutableRefObject return; } + if (CustomEditor.selectionIncludeRoot(editor)) { + closeToolbar(); + return; + } + if (rangeRef.current) { + closeToolbar(); return; } const position = getSelectionPosition(editor); if (!position) { - rangeRef.current = null; - setVisible(false); - el.style.opacity = '0'; - el.style.pointerEvents = 'none'; + closeToolbar(); return; } @@ -37,7 +54,7 @@ export function useSelectionToolbar(ref: MutableRefObject el.style.pointerEvents = 'auto'; el.style.top = `${position.top + slateEditorDom.offsetTop - el.offsetHeight}px`; el.style.left = `${position.left + slateEditorDom.offsetLeft - el.offsetWidth / 2 + position.width / 2}px`; - }, [editor, ref]); + }, [closeToolbar, editor, ref]); useEffect(() => { const debounceRecalculatePosition = debounce(recalculatePosition, 100); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/constants.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/constants.ts index b79b91041f..36d71ed9d6 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/constants.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/constants.ts @@ -1,5 +1,5 @@ import { EditorNodeType } from '$app/application/document/document.types'; -export const BREAK_TO_PARAGRAPH_TYPES = [EditorNodeType.HeadingBlock, EditorNodeType.QuoteBlock]; +export const BREAK_TO_PARAGRAPH_TYPES = [EditorNodeType.HeadingBlock, EditorNodeType.QuoteBlock, EditorNodeType.Page]; export const SOFT_BREAK_TYPES = [EditorNodeType.CalloutBlock, EditorNodeType.CodeBlock]; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/utils.ts index 9792f8eda8..980dd47ff6 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/utils.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/utils.ts @@ -5,11 +5,11 @@ import { ReactEditor } from 'slate-react'; export function getHeadingCssProperty(level: number) { switch (level) { case 1: - return 'text-3xl pt-4'; + return 'text-3xl pt-4 pb-2'; case 2: - return 'text-2xl pt-3'; + return 'text-2xl pt-3 pb-2'; case 3: - return 'text-xl pt-2'; + return 'text-xl pt-2 pb-2'; default: return ''; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockDeleteBackward.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockDeleteBackward.ts index 9dc0391866..afa307104e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockDeleteBackward.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockDeleteBackward.ts @@ -21,7 +21,7 @@ export function withBlockDeleteBackward(editor: ReactEditor) { const [node] = match as NodeEntry; // if the current node is not a paragraph, convert it to a paragraph - if (node.type !== EditorNodeType.Paragraph) { + if (node.type !== EditorNodeType.Paragraph && node.type !== EditorNodeType.Page) { CustomEditor.turnToBlock(editor, { type: EditorNodeType.Paragraph }); return; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockInsertBreak.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockInsertBreak.ts index a0f9720b8a..9993717962 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockInsertBreak.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockInsertBreak.ts @@ -1,6 +1,5 @@ import { ReactEditor } from 'slate-react'; import { Editor, Element, NodeEntry } from 'slate'; -import { SOFT_BREAK_TYPES } from '$app/components/editor/plugins/constants'; import { EditorNodeType } from '$app/application/document/document.types'; import { CustomEditor } from '$app/components/editor/command'; @@ -17,9 +16,8 @@ export function withBlockInsertBreak(editor: ReactEditor) { const [node] = nodeEntry as NodeEntry; const type = node.type as EditorNodeType; - // should insert a soft break, eg: code block and callout - if (SOFT_BREAK_TYPES.includes(type)) { - editor.insertText('\n'); + if (type === EditorNodeType.Page) { + insertBreak(...args); return; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockPlugins.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockPlugins.ts index aa22e1ce2a..1add4d0d86 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockPlugins.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockPlugins.ts @@ -10,8 +10,8 @@ import { withPasted } from '$app/components/editor/plugins/withPasted'; export function withBlockPlugins(editor: ReactEditor) { return withMathEquationPlugin( - withDatabaseBlockPlugin( - withPasted(withSplitNodes(withMergeNodes(withBlockInsertBreak(withBlockDeleteBackward(editor))))) + withPasted( + withDatabaseBlockPlugin(withSplitNodes(withMergeNodes(withBlockInsertBreak(withBlockDeleteBackward(editor))))) ) ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withMergeNodes.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withMergeNodes.ts index 0d7ad81489..ace85f1da8 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withMergeNodes.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withMergeNodes.ts @@ -1,43 +1,106 @@ import { ReactEditor } from 'slate-react'; -import { Editor, Element, NodeEntry, Node, Transforms, Point } from 'slate'; +import { Editor, Element, NodeEntry, Node, Transforms, Point, Path } from 'slate'; import { CustomEditor } from '$app/components/editor/command'; +import { YjsEditor } from '@slate-yjs/core'; +import { EditorNodeType } from '$app/application/document/document.types'; export function withMergeNodes(editor: ReactEditor) { - const { mergeNodes } = editor; + const { mergeNodes, removeNodes } = editor; + + editor.removeNodes = (...args) => { + const isDeleteRoot = args.some((arg) => { + return ( + arg?.at && + (arg.at as Path).length === 1 && + (arg.at as Path)[0] === 0 && + (editor.children[0] as Element).type === EditorNodeType.Page + ); + }); + + // the root node cannot be deleted + if (isDeleteRoot) return; + removeNodes(...args); + }; - // before merging nodes, check whether the node is a block and whether the selection is at the start of the block - // if so, move the children of the node to the previous node editor.mergeNodes = (...args) => { - const { selection } = editor; - const isBlock = (n: Node) => !Editor.isEditor(n) && Element.isElement(n) && n.type !== undefined && n.level !== undefined; - const [match] = Editor.nodes(editor, { + const [merged] = Editor.nodes(editor, { match: isBlock, }); - if (match && selection) { - const [node, path] = match as NodeEntry; - const start = Editor.start(editor, path); + if (!merged) { + mergeNodes(...args); + return; + } - if (Point.equals(selection.anchor, start)) { - const previous = Editor.previous(editor, { at: path }); - const [previousNode] = previous as NodeEntry; - const previousLevel = previousNode.level ?? 1; + const [mergedNode, path] = merged as NodeEntry; + const root = editor.children[0] as Element & { + blockId: string; + level: number; + }; + const selection = editor.selection; + const start = Editor.start(editor, path); - const children = CustomEditor.findNodeChildren(editor, node); + if ( + root.type === EditorNodeType.Page && + mergedNode.type === EditorNodeType.Paragraph && + selection && + Point.equals(selection.anchor, start) && + path[0] === 1 + ) { + if (Editor.isEmpty(editor, root)) { + const text = Editor.string(editor, path); - children.forEach((child) => { - const childPath = ReactEditor.findPath(editor, child); - - Transforms.setNodes(editor, { level: previousLevel + 1, parentId: previousNode.blockId }, { at: childPath }); + editor.select([0]); + editor.insertText(text); + editor.removeNodes({ at: path }); + // move children to root + moveNodes(editor, 1, root.blockId, (n) => { + return n.parentId === mergedNode.blockId; }); + + return; } } + const nextNode = editor.children[path[0] + 1] as Element & { level: number }; + mergeNodes(...args); + + if (!nextNode) { + CustomEditor.insertEmptyLineAtEnd(editor as ReactEditor & YjsEditor); + return; + } + + if (mergedNode.blockId === nextNode.parentId) { + // the node will be deleted when the node has no text + if (mergedNode.children.length === 1 && 'text' in mergedNode.children[0] && mergedNode.children[0].text === '') { + moveNodes(editor, root.level + 1, root.blockId, (n) => n.parentId === mergedNode.blockId); + } + + return; + } + + // check if the old node is removed + const oldNodeRemoved = !editor.children.some((child) => (child as Element).blockId === nextNode.parentId); + + if (oldNodeRemoved) { + // if the old node is removed, we need to move the children of the old node to the new node + moveNodes(editor, mergedNode.level + 1, mergedNode.blockId, (n) => { + return n.parentId === nextNode.parentId; + }); + } }; return editor; } + +function moveNodes(editor: ReactEditor, level: number, parentId: string, match: (n: Element) => boolean) { + editor.children.forEach((child, index) => { + if (match(child as Element)) { + Transforms.setNodes(editor, { level, parentId }, { at: [index] }); + } + }); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withPasted.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withPasted.ts index 9dbfdafdd0..83b8ac5115 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withPasted.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withPasted.ts @@ -1,86 +1,119 @@ import { ReactEditor } from 'slate-react'; -import { Editor, Element, Node, NodeEntry, Transforms } from 'slate'; -import { Log } from '$app/utils/log'; +import { convertBlockToJson } from '$app/application/document/document.service'; +import { Editor, Element } from 'slate'; import { generateId } from '$app/components/editor/provider/utils/convert'; +import { blockTypes, EditorNodeType } from '$app/application/document/document.types'; +import { InputType } from '@/services/backend'; export function withPasted(editor: ReactEditor) { - const { insertData, mergeNodes } = editor; - - editor.mergeNodes = (...args) => { - const isBlock = (n: Node) => - !Editor.isEditor(n) && Element.isElement(n) && n.type !== undefined && n.level !== undefined; - - const [match] = Editor.nodes(editor, { - match: isBlock, - }); - - const node = match ? (match[0] as Element) : null; - - if (!node) { - mergeNodes(...args); - return; - } - - // This is a hack to fix the bug that the children of the node will be moved to the previous node - const previous = Editor.previous(editor, { - match: (n) => { - return !Editor.isEditor(n) && Element.isElement(n) && n.type !== undefined && n.level === (node.level ?? 1) - 1; - }, - }); - - if (previous) { - const [previousNode] = previous as NodeEntry; - - if (previousNode && previousNode.blockId !== node.parentId) { - const children = editor.children.filter((child) => (child as Element).parentId === node.parentId); - - children.forEach((child) => { - const childIndex = editor.children.findIndex((c) => c === child); - const childPath = [childIndex]; - - Transforms.setNodes(editor, { parentId: previousNode.blockId }, { at: childPath }); - }); - } - } - - mergeNodes(...args); - }; + const { insertData, insertFragment } = editor; editor.insertData = (data) => { const fragment = data.getData('application/x-slate-fragment'); - try { - if (fragment) { - const decoded = decodeURIComponent(window.atob(fragment)); - const parsed = JSON.parse(decoded); + if (fragment) { + insertData(data); + return; + } - if (parsed instanceof Array) { - const idMap = new Map(); + const html = data.getData('text/html'); + const text = data.getData('text/plain'); + const inputType = html ? InputType.Html : InputType.PlainText; - for (const parsedElement of parsed as Element[]) { - if (!parsedElement.blockId) continue; - const newBlockId = generateId(); - - if (parsedElement.parentId) { - parsedElement.parentId = idMap.get(parsedElement.parentId) ?? parsedElement.parentId; - } - - idMap.set(parsedElement.blockId, newBlockId); - parsedElement.blockId = newBlockId; - - parsedElement.textId = generateId(); - } - - editor.insertFragment(parsed); - return; - } - } - } catch (err) { - Log.error('insertData', err); + if (html || text) { + void convertBlockToJson(html || text, inputType).then((nodes) => { + editor.insertFragment(nodes); + }); + return; } insertData(data); }; + editor.insertFragment = (fragment) => { + let rootId = (editor.children[0] as Element)?.blockId; + + if (!rootId) { + rootId = generateId(); + insertFragment([ + { + type: EditorNodeType.Paragraph, + children: [ + { + text: '', + }, + ], + data: {}, + blockId: rootId, + textId: generateId(), + parentId: '', + level: 0, + }, + ]); + } + + const [mergedMatch] = Editor.nodes(editor, { + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.type !== undefined, + }); + + const mergedNode = mergedMatch + ? (mergedMatch[0] as Element & { + blockId: string; + parentId: string; + level: number; + }) + : null; + + if (!mergedNode) return insertFragment(fragment); + + const isEmpty = Editor.isEmpty(editor, mergedNode); + + const mergedNodeId = isEmpty ? undefined : mergedNode.blockId; + + const idMap = new Map(); + const levelMap = new Map(); + + for (let i = 0; i < fragment.length; i++) { + const node = fragment[i] as Element & { + blockId: string; + parentId: string; + level: number; + }; + + const newBlockId = i === 0 && mergedNodeId ? mergedNodeId : generateId(); + + const parentId = idMap.get(node.parentId); + + if (parentId) { + node.parentId = parentId; + } else { + idMap.set(node.parentId, mergedNode.parentId); + node.parentId = mergedNode.parentId; + } + + const parentLevel = levelMap.get(node.parentId); + + if (parentLevel !== undefined) { + node.level = parentLevel + 1; + } else { + levelMap.set(node.parentId, mergedNode.level - 1); + node.level = mergedNode.level; + } + + // if the pasted fragment is not matched with the block type, we need to convert it to paragraph + // and if the pasted fragment is a page, we need to convert it to paragraph + if (!blockTypes.includes(node.type as EditorNodeType) || node.type === EditorNodeType.Page) { + node.type = EditorNodeType.Paragraph; + } + + idMap.set(node.blockId, newBlockId); + levelMap.set(newBlockId, node.level); + node.blockId = newBlockId; + node.textId = generateId(); + } + + return insertFragment(fragment); + }; + return editor; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withSplitNodes.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withSplitNodes.ts index ee72421f16..e47502f241 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withSplitNodes.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withSplitNodes.ts @@ -76,14 +76,19 @@ export function withSplitNodes(editor: ReactEditor) { return; } - // should be split to another paragraph, eg: heading and quote + // should be split to another paragraph, eg: heading and quote and page if (BREAK_TO_PARAGRAPH_TYPES.includes(nodeType)) { + const level = node.level || 1; + const parentId = (node.parentId || node.blockId) as string; + splitNodes(...args); Transforms.setNodes(editor, { type: EditorNodeType.Paragraph, data: {}, blockId: newBlockId, textId: newTextId, + level, + parentId, }); return; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/action.test.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/action.test.ts index 5f502a8b9b..fe37f99e33 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/action.test.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/action.test.ts @@ -27,7 +27,7 @@ describe('Transform events to actions', () => { const parentId = sharedType?.getAttribute('blockId') as string; const insertTextOp = generateInsertTextOp('insert text', parentId, 1); - sharedType?.applyDelta([{ retain: 1 }, insertTextOp]); + sharedType?.applyDelta([{ retain: 2 }, insertTextOp]); const actions = applyActions.mock.calls[0][1]; expect(actions).toHaveLength(2); @@ -47,7 +47,7 @@ describe('Transform events to actions', () => { const sharedType = provider.sharedType; const parentId = 'CxPil0324P'; - const yText = sharedType?.toDelta()[3].insert as Y.XmlText; + const yText = sharedType?.toDelta()[4].insert as Y.XmlText; sharedType?.doc?.transact(() => { yText.setAttribute('level', 2); yText.setAttribute('parentId', parentId); @@ -65,7 +65,7 @@ describe('Transform events to actions', () => { const sharedType = provider.sharedType; sharedType?.doc?.transact(() => { - sharedType?.applyDelta([{ retain: 3 }, { delete: 1 }]); + sharedType?.applyDelta([{ retain: 4 }, { delete: 1 }]); }); const actions = applyActions.mock.calls[0][1]; @@ -78,7 +78,7 @@ describe('Transform events to actions', () => { test('should transform update event to update action', () => { const sharedType = provider.sharedType; - const yText = sharedType?.toDelta()[3].insert as Y.XmlText; + const yText = sharedType?.toDelta()[4].insert as Y.XmlText; sharedType?.doc?.transact(() => { yText.setAttribute('data', { checked: true, @@ -96,7 +96,7 @@ describe('Transform events to actions', () => { test('should transform apply delta event to apply delta action (insert text)', () => { const sharedType = provider.sharedType; - const yText = sharedType?.toDelta()[3].insert as Y.XmlText; + const yText = sharedType?.toDelta()[4].insert as Y.XmlText; sharedType?.doc?.transact(() => { yText.applyDelta([{ retain: 1 }, { insert: 'apply delta' }]); }); @@ -112,7 +112,7 @@ describe('Transform events to actions', () => { test('should transform apply delta event to apply delta action: insert mention', () => { const sharedType = provider.sharedType; - const yText = sharedType?.toDelta()[3].insert as Y.XmlText; + const yText = sharedType?.toDelta()[4].insert as Y.XmlText; sharedType?.doc?.transact(() => { yText.applyDelta([{ retain: 1 }, genersteMentionInsertTextOp()]); }); @@ -126,7 +126,7 @@ describe('Transform events to actions', () => { test('should transform apply delta event to apply delta action: insert formula', () => { const sharedType = provider.sharedType; - const yText = sharedType?.toDelta()[3].insert as Y.XmlText; + const yText = sharedType?.toDelta()[4].insert as Y.XmlText; sharedType?.doc?.transact(() => { yText.applyDelta([{ retain: 1 }, generateFormulaInsertTextOp()]); }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/observe.test.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/observe.test.ts index 835bcaeb57..812fef288d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/observe.test.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/observe.test.ts @@ -22,7 +22,7 @@ describe('Provider connected', () => { test('should initial document', () => { const sharedType = provider.sharedType; expect(sharedType).not.toBeNull(); - expect(sharedType?.length).toBe(24); + expect(sharedType?.length).toBe(25); expect(sharedType?.getAttribute('blockId')).toBe('3EzeCrtxlh'); }); @@ -32,9 +32,9 @@ describe('Provider connected', () => { const parentId = sharedType?.getAttribute('blockId') as string; const insertTextOp = generateInsertTextOp('', parentId, 1); - sharedType?.applyDelta([{ retain: 1 }, insertTextOp]); + sharedType?.applyDelta([{ retain: 2 }, insertTextOp]); - expect(sharedType?.length).toBe(25); + expect(sharedType?.length).toBe(26); expect(applyActions).toBeCalledTimes(1); }); }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/utils/mockBackendService.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/utils/mockBackendService.ts index 52347239b6..3bd7646268 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/utils/mockBackendService.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/utils/mockBackendService.ts @@ -10,10 +10,11 @@ jest.mock('$app/application/notification', () => { jest.mock('nanoid', () => ({ nanoid: jest.fn().mockReturnValue(String(Math.random())) })); -jest.mock('$app/application/document/document_service', () => { +jest.mock('$app/application/document/document.service', () => { return { openDocument: jest.fn().mockReturnValue(Promise.resolve(read_me)), applyActions, + closeDocument: jest.fn().mockReturnValue(Promise.resolve()), }; }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/data_client.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/data_client.ts index 544bd21ff4..9ff1f89edf 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/data_client.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/data_client.ts @@ -1,4 +1,4 @@ -import { applyActions, openDocument } from '$app/application/document/document_service'; +import { applyActions, closeDocument, openDocument } from '$app/application/document/document.service'; import { slateNodesToInsertDelta } from '@slate-yjs/core'; import { convertToSlateValue } from '$app/components/editor/provider/utils/convert'; import { EventEmitter } from 'events'; @@ -23,16 +23,16 @@ export class DataClient extends EventEmitter { public disconnect() { this.off('update', this.handleReceiveMessage); - + void closeDocument(this.id); void this.unsubscribe.then((unsubscribe) => unsubscribe()); } - public async getInsertDelta() { + public async getInsertDelta(includeRoot = true) { const data = await openDocument(this.id); this.rootId = data.rootId; - return slateNodesToInsertDelta(convertToSlateValue(data)); + return slateNodesToInsertDelta(convertToSlateValue(data, includeRoot)); } public on(event: 'change', listener: (events: YDelta) => void): this; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/provider.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/provider.ts index 52af850b02..cfffe8ce99 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/provider.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/provider.ts @@ -16,17 +16,17 @@ export class Provider extends EventEmitter { idRelationMap: Y.Map = this.document.getMap('idRelationMap'); sharedType: Y.XmlText | null = null; dataClient: DataClient; - constructor(public id: string) { + constructor(public id: string, includeRoot?: boolean) { super(); this.dataClient = new DataClient(id); - void this.initialDocument(); + void this.initialDocument(includeRoot); } - initialDocument = async () => { + initialDocument = async (includeRoot = true) => { const sharedType = this.document.get('local', Y.XmlText) as Y.XmlText; // Load the initial value into the yjs document - const delta = await this.dataClient.getInsertDelta(); + const delta = await this.dataClient.getInsertDelta(includeRoot); sharedType.applyDelta(delta); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/action.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/action.ts index ab400adbfe..9b3fd5ea54 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/action.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/action.ts @@ -163,7 +163,7 @@ export function YEvent2BlockActions( return blockOps2BlockActions(sharedType, delta); } - const actions = textOps2BlockActions(yXmlText, delta); + const actions = textOps2BlockActions(sharedType, yXmlText, delta); if (keys.size > 0) { actions.push(...parentUpdatedOps2BlockActions(yXmlText, keys)); @@ -174,8 +174,19 @@ export function YEvent2BlockActions( return actions; } -function textOps2BlockActions(yXmlText: Y.XmlText, ops: YDelta): ReturnType[] { +function textOps2BlockActions( + sharedType: Y.XmlText, + yXmlText: Y.XmlText, + ops: YDelta +): ReturnType[] { if (ops.length === 0) return []; + const blockId = yXmlText.getAttribute('blockId'); + const rootId = sharedType.getAttribute('rootId'); + + if (blockId === rootId) { + return []; + } + return generateApplyTextActions(yXmlText, ops); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/convert.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/convert.ts index a33c13aa91..7fff69eef1 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/convert.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/convert.ts @@ -45,7 +45,7 @@ export function transformToInlineElement(op: Op): Element | null { return null; } -export function convertToSlateValue(data: EditorData): Element[] { +export function convertToSlateValue(data: EditorData, includeRoot: boolean): Element[] { const nodes: Element[] = []; const traverse = (id: string, level: number, isHidden?: boolean) => { const node = data.nodeMap[id]; @@ -63,7 +63,7 @@ export function convertToSlateValue(data: EditorData): Element[] { }; const inlineNodes: (Text | Element)[] = delta - ? data.deltaMap[id].map((op) => { + ? delta.map((op) => { const matchInline = transformToInlineElement(op); if (matchInline) { @@ -105,7 +105,9 @@ export function convertToSlateValue(data: EditorData): Element[] { traverse(rootId, 0); - nodes.shift(); + if (!includeRoot) { + nodes.shift(); + } return nodes; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/relation.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/relation.ts index 8772b70f6d..6c00ebd590 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/relation.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/relation.ts @@ -8,6 +8,8 @@ export function findPreviousSibling(yXmlText: Y.XmlText) { const level = yXmlText.getAttribute('level'); + if (!level) return null; + while (prev) { const prevLevel = prev.getAttribute('level'); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/Breadcrumb.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/Breadcrumb.hooks.ts index 1250d1c113..82d7854a3a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/Breadcrumb.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/Breadcrumb.hooks.ts @@ -12,6 +12,8 @@ export function useLoadExpandedPages() { const isTrash = useMemo(() => location.pathname.includes('trash'), [location.pathname]); const currentPageId = params.id; const pageMap = useAppSelector((state) => state.pages.pageMap); + const currentPage = currentPageId ? pageMap[currentPageId] : null; + const [pagePath, setPagePath] = useState< ( | Page @@ -78,5 +80,6 @@ export function useLoadExpandedPages() { return { pagePath, + currentPage, }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/index.tsx index 40439a3624..0b7a688e38 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/index.tsx @@ -9,10 +9,10 @@ import { useTranslation } from 'react-i18next'; function Breadcrumb() { const { t } = useTranslation(); - const { pagePath } = useLoadExpandedPages(); + const { pagePath, currentPage } = useLoadExpandedPages(); const navigate = useNavigate(); - const activePage = useMemo(() => pagePath[pagePath.length - 1], [pagePath]); - const parentPages = useMemo(() => pagePath.slice(1, pagePath.length - 1).filter(Boolean) as Page[], [pagePath]); + + const parentPages = useMemo(() => pagePath.slice(1, -1).filter(Boolean) as Page[], [pagePath]); const navigateToPage = useCallback( (page: Page) => { const pageType = pageTypeMap[page.layout]; @@ -36,7 +36,7 @@ function Breadcrumb() { {page.name || t('document.title.placeholder')} ))} - {activePage?.name || t('menuAppHeader.defaultNewPageName')} + {currentPage?.name || t('menuAppHeader.defaultNewPageName')} ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/async_actions.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/async_actions.ts index 55edf36545..d647bcf934 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/async_actions.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/async_actions.ts @@ -66,6 +66,8 @@ export const updatePageName = createAsyncThunk( const { id, name } = payload; const page = pageMap[id]; + if (name === page.name) return; + dispatch( pagesActions.onPageChanged({ ...page, diff --git a/frontend/appflowy_tauri/vite.config.ts b/frontend/appflowy_tauri/vite.config.ts index f202808a17..a0153b9276 100644 --- a/frontend/appflowy_tauri/vite.config.ts +++ b/frontend/appflowy_tauri/vite.config.ts @@ -65,6 +65,6 @@ export default defineConfig({ ], }, optimizeDeps: { - include: ['@mui/material/Tooltip', '@emotion/styled', '@mui/material/Unstable_Grid2'], + include: ['@mui/material/Tooltip'], }, }); diff --git a/frontend/rust-lib/flowy-document2/src/parser/constant.rs b/frontend/rust-lib/flowy-document2/src/parser/constant.rs index c13722fcd3..27e817114d 100644 --- a/frontend/rust-lib/flowy-document2/src/parser/constant.rs +++ b/frontend/rust-lib/flowy-document2/src/parser/constant.rs @@ -105,6 +105,9 @@ pub const FONT_STYLE: &str = "font-style"; pub const TEXT_DECORATION: &str = "text-decoration"; pub const BACKGROUND_COLOR: &str = "background-color"; + +pub const TRANSPARENT: &str = "transparent"; +pub const DEFAULT_FONT_COLOR: &str = "rgb(0, 0, 0)"; pub const COLOR: &str = "color"; pub const LINE_THROUGH: &str = "line-through"; diff --git a/frontend/rust-lib/flowy-document2/src/parser/document_data_parser.rs b/frontend/rust-lib/flowy-document2/src/parser/document_data_parser.rs index d92857f7b7..5d96c9b74d 100644 --- a/frontend/rust-lib/flowy-document2/src/parser/document_data_parser.rs +++ b/frontend/rust-lib/flowy-document2/src/parser/document_data_parser.rs @@ -61,7 +61,13 @@ impl DocumentDataParser { let mut children = vec![]; let mut start_found = false; let mut end_found = false; - self.block_to_nested_block(root_id, &mut children, &mut start_found, &mut end_found) + + self + .block_to_nested_block(root_id, &mut children, &mut start_found, &mut end_found) + .map(|mut root| { + root.data.clear(); + root + }) } fn block_to_nested_block( diff --git a/frontend/rust-lib/flowy-document2/src/parser/external/utils.rs b/frontend/rust-lib/flowy-document2/src/parser/external/utils.rs index d170706cd3..257f81e772 100644 --- a/frontend/rust-lib/flowy-document2/src/parser/external/utils.rs +++ b/frontend/rust-lib/flowy-document2/src/parser/external/utils.rs @@ -422,9 +422,16 @@ fn get_attributes_with_style(style: &str) -> HashMap { attributes.insert(STRIKETHROUGH.to_string(), Value::Bool(true)); }, BACKGROUND_COLOR => { + if value.eq(TRANSPARENT) { + continue; + } attributes.insert(BG_COLOR.to_string(), Value::String(value.to_string())); }, COLOR => { + if value.eq(DEFAULT_FONT_COLOR) { + continue; + } + attributes.insert(FONT_COLOR.to_string(), Value::String(value.to_string())); }, _ => {}, diff --git a/frontend/rust-lib/flowy-document2/src/parser/utils.rs b/frontend/rust-lib/flowy-document2/src/parser/utils.rs index e5365f2227..59719d7311 100644 --- a/frontend/rust-lib/flowy-document2/src/parser/utils.rs +++ b/frontend/rust-lib/flowy-document2/src/parser/utils.rs @@ -5,6 +5,7 @@ use collab_document::blocks::DocumentData; use serde_json::Value; use std::collections::HashMap; use std::sync::Arc; +use tracing::trace; use validator::ValidationError; pub fn get_delta_for_block(block_id: &str, data: &DocumentData) -> Option> { diff --git a/frontend/rust-lib/flowy-document2/tests/assets/json/google_docs.json b/frontend/rust-lib/flowy-document2/tests/assets/json/google_docs.json index 27aa86f462..a8968bc2fb 100644 --- a/frontend/rust-lib/flowy-document2/tests/assets/json/google_docs.json +++ b/frontend/rust-lib/flowy-document2/tests/assets/json/google_docs.json @@ -6,7 +6,6 @@ "delta": [ { "attributes": { - "bg_color": "transparent", "font_color": "#000000" }, "insert": "The Notion Document" @@ -23,7 +22,6 @@ "delta": [ { "attributes": { - "bg_color": "transparent", "font_color": "#000000" }, "insert": "Heading-1" @@ -40,7 +38,6 @@ "delta": [ { "attributes": { - "bg_color": "transparent", "font_color": "#000000" }, "insert": "Heading - 2" @@ -57,7 +54,6 @@ "delta": [ { "attributes": { - "bg_color": "transparent", "font_color": "#000000" }, "insert": "Heading - 3" @@ -74,7 +70,6 @@ "delta": [ { "attributes": { - "bg_color": "transparent", "font_color": "#000000" }, "insert": "Heading - 4" @@ -91,7 +86,6 @@ "delta": [ { "attributes": { - "bg_color": "transparent", "font_color": "#000000" }, "insert": "This is a paragraph" @@ -107,7 +101,6 @@ "delta": [ { "attributes": { - "bg_color": "transparent", "font_color": "#000000" }, "insert": "paragraph’s child" @@ -123,7 +116,6 @@ "delta": [ { "attributes": { - "bg_color": "transparent", "font_color": "#000000" }, "insert": "This is a bulleted list - 1" @@ -138,7 +130,6 @@ "delta": [ { "attributes": { - "bg_color": "transparent", "font_color": "#000000" }, "insert": "This is a bulleted list - 1 - 1" @@ -153,7 +144,6 @@ "delta": [ { "attributes": { - "bg_color": "transparent", "font_color": "#000000" }, "insert": "This is a bulleted list - 2" @@ -168,7 +158,6 @@ "delta": [ { "attributes": { - "bg_color": "transparent", "font_color": "#000000" }, "insert": "This is a paragraph" @@ -193,7 +182,6 @@ "delta": [ { "attributes": { - "bg_color": "transparent", "font_color": "#000000" }, "insert": "This is a todo - 1" @@ -225,7 +213,6 @@ "delta": [ { "attributes": { - "bg_color": "transparent", "font_color": "#000000" }, "insert": "This is a todo - 1-1" @@ -248,7 +235,6 @@ "delta": [ { "attributes": { - "bg_color": "transparent", "font_color": "#000000" }, "insert": "This is a paragraph" @@ -264,7 +250,6 @@ "delta": [ { "attributes": { - "bg_color": "transparent", "font_color": "#000000" }, "insert": "This is a numbered list -1" @@ -279,7 +264,6 @@ "delta": [ { "attributes": { - "bg_color": "transparent", "font_color": "#000000" }, "insert": "This is a numbered list -2" @@ -294,7 +278,6 @@ "delta": [ { "attributes": { - "bg_color": "transparent", "font_color": "#000000" }, "insert": "This is a numbered list-1-1" @@ -309,7 +292,6 @@ "delta": [ { "attributes": { - "bg_color": "transparent", "font_color": "#000000" }, "insert": "This is a paragraph" @@ -325,7 +307,6 @@ "delta": [ { "attributes": { - "bg_color": "transparent", "font_color": "#000000" }, "insert": "This is a paragraph"