diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.lock b/frontend/appflowy_tauri/src-tauri/Cargo.lock index ab06a12dac..16c7d33408 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.lock +++ b/frontend/appflowy_tauri/src-tauri/Cargo.lock @@ -48,6 +48,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "aho-corasick" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67fc08ce920c31afb70f013dcce1bfc3a3195de6a228474e45e1f145b36f8d04" +dependencies = [ + "memchr", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -169,7 +178,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps 6.0.4", + "system-deps 6.0.5", ] [[package]] @@ -363,9 +372,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.12.0" +version = "3.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535" +checksum = "9b1ce199063694f33ffb7dd4e0ee620741495c32833cde5aa08f02a0bf96f0c8" [[package]] name = "bytecheck" @@ -442,7 +451,7 @@ checksum = "3c55d429bef56ac9172d25fecb85dc8068307d17acd74b377866b7a1ef25d3c8" dependencies = [ "glib-sys", "libc", - "system-deps 6.0.4", + "system-deps 6.0.5", ] [[package]] @@ -500,11 +509,12 @@ dependencies = [ [[package]] name = "cfg-expr" -version = "0.14.0" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a35b255461940a32985c627ce82900867c61db1659764d3675ea81963f72a4c6" +checksum = "c8790cf1286da485c72cf5fc7aeba308438800036ec67d89425924c4807268c9" dependencies = [ "smallvec", + "target-lexicon", ] [[package]] @@ -804,9 +814,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "280a9f2d8b3a38871a3c8a46fb80db65e5e5ed97da80c4d08bf27fb63e35e181" +checksum = "3e4c1eaa2012c47becbbad2ab175484c2a84d1185b566fb2cc5b8707343dfe58" dependencies = [ "libc", ] @@ -1154,9 +1164,9 @@ dependencies = [ [[package]] name = "dunce" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bd4b30a6560bbd9b4620f4de34c3f14f60848e58a9b7216801afcb4c7b31c3c" +checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b" [[package]] name = "dyn-clone" @@ -1539,6 +1549,7 @@ dependencies = [ "serde_json", "strum", "strum_macros", + "tokio", "tracing", ] @@ -2012,7 +2023,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps 6.0.4", + "system-deps 6.0.5", ] [[package]] @@ -2029,7 +2040,7 @@ dependencies = [ "libc", "pango-sys", "pkg-config", - "system-deps 6.0.4", + "system-deps 6.0.5", ] [[package]] @@ -2041,21 +2052,21 @@ dependencies = [ "gdk-sys", "glib-sys", "libc", - "system-deps 6.0.4", + "system-deps 6.0.5", "x11", ] [[package]] name = "generator" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33a20a288a94683f5f4da0adecdbe095c94a77c295e514cc6484e9394dd8376e" +checksum = "f3e123d9ae7c02966b4d892e550bdc32164f05853cd40ab570650ad600596a8a" dependencies = [ "cc", "libc", "log", "rustversion", - "windows 0.44.0", + "windows 0.48.0", ] [[package]] @@ -2134,7 +2145,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps 6.0.4", + "system-deps 6.0.5", "winapi", ] @@ -2180,7 +2191,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef4b192f8e65e9cf76cbf4ea71fa8e3be4a0e18ffe3d68b8da6836974cc5bad4" dependencies = [ "libc", - "system-deps 6.0.4", + "system-deps 6.0.5", ] [[package]] @@ -2195,7 +2206,7 @@ version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "029d74589adefde59de1a0c4f4732695c32805624aec7b68d91503d4dba79afc" dependencies = [ - "aho-corasick", + "aho-corasick 0.7.20", "bstr", "fnv", "log", @@ -2221,7 +2232,7 @@ checksum = "0d57ce44246becd17153bd035ab4d32cfee096a657fc01f2231c9278378d1e0a" dependencies = [ "glib-sys", "libc", - "system-deps 6.0.4", + "system-deps 6.0.5", ] [[package]] @@ -2262,7 +2273,7 @@ dependencies = [ "gobject-sys", "libc", "pango-sys", - "system-deps 6.0.4", + "system-deps 6.0.5", ] [[package]] @@ -2281,9 +2292,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.3.17" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b91535aa35fea1523ad1b86cb6b53c28e0ae566ba4a460f4457e936cad7c6f" +checksum = "17f8a914c2987b688368b5138aa05321db91f4090cf26118185672ad588bce21" dependencies = [ "bytes", "fnv", @@ -2840,9 +2851,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.141" +version = "0.2.142" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3304a64d199bb964be99741b7a14d26972741915b3649639149b2479bb46f4b5" +checksum = "6a987beff54b60ffa6d51982e1aa1146bc42f19bd26be28b0586f252fccf5317" [[package]] name = "libloading" @@ -2924,9 +2935,9 @@ checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" [[package]] name = "linux-raw-sys" -version = "0.3.1" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d59d8c75012853d2e872fb56bc8a2e53718e2cafe1a4c823143141c6d90c322f" +checksum = "9b085a4f2cde5781fc4b1717f2e86c62f5cda49de7ba99a7c2eae02b61c9064c" [[package]] name = "lock_api" @@ -2959,7 +2970,7 @@ dependencies = [ "serde", "serde_json", "tracing", - "tracing-subscriber 0.3.16", + "tracing-subscriber 0.3.17", ] [[package]] @@ -3330,9 +3341,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.50" +version = "0.10.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e30d8bc91859781f0a943411186324d580f2bbeb71b452fe91ae344806af3f1" +checksum = "97ea2d98598bf9ada7ea6ee8a30fb74f9156b63bbe495d64ec2b87c269d2dda3" dependencies = [ "bitflags", "cfg-if", @@ -3362,9 +3373,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" -version = "0.9.85" +version = "0.9.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d3d193fb1488ad46ffe3aaabc912cc931d02ee8518fe2959aea8ef52718b0c0" +checksum = "992bac49bdbab4423199c654a5515bd2a6c6a23bf03f2dd3bdb7e5ae6259bc69" dependencies = [ "cc", "libc", @@ -3410,7 +3421,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps 6.0.4", + "system-deps 6.0.5", ] [[package]] @@ -4064,13 +4075,13 @@ dependencies = [ [[package]] name = "regex" -version = "1.7.3" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1f693b24f6ac912f4893ef08244d70b6067480d2f1a46e950c9691e6749d1d" +checksum = "af83e617f331cc6ae2da5443c602dfa5af81e517212d9d611a5b3ba1777b5370" dependencies = [ - "aho-corasick", + "aho-corasick 1.0.1", "memchr", - "regex-syntax", + "regex-syntax 0.7.1", ] [[package]] @@ -4079,7 +4090,7 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" dependencies = [ - "regex-syntax", + "regex-syntax 0.6.29", ] [[package]] @@ -4088,6 +4099,12 @@ version = "0.6.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" +[[package]] +name = "regex-syntax" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5996294f19bd3aae0453a862ad728f60e6600695733dd5df01da90c54363a3c" + [[package]] name = "rend" version = "0.4.0" @@ -4209,9 +4226,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.22" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a36c42d1873f9a77c53bde094f9664d9891bc604a45b4798fd2c389ed12e5b" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" [[package]] name = "rustc-hash" @@ -4230,9 +4247,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.37.11" +version = "0.37.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85597d61f83914ddeba6a47b3b8ffe7365107221c2e557ed94426489fefb5f77" +checksum = "f79bef90eb6d984c72722595b5b1348ab39275a5e5123faca6863bf07d75a4e0" dependencies = [ "bitflags", "errno", @@ -4768,11 +4785,11 @@ dependencies = [ [[package]] name = "system-deps" -version = "6.0.4" +version = "6.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555fc8147af6256f3931a36bb83ad0023240ce9cf2b319dec8236fd1f220b05f" +checksum = "d0fe581ad25d11420b873cf9aedaca0419c2b411487b134d4d21065f3d092055" dependencies = [ - "cfg-expr 0.14.0", + "cfg-expr 0.15.1", "heck 0.4.1", "pkg-config", "toml 0.7.3", @@ -4836,6 +4853,12 @@ dependencies = [ "xattr", ] +[[package]] +name = "target-lexicon" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae9980cab1db3fceee2f6c6f643d5d8de2997c58ee8d25fb0cc8a9e9e7348e5" + [[package]] name = "tauri" version = "1.2.4" @@ -5395,9 +5418,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.16" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6176eae26dd70d0c919749377897b54a9276bd7061339665dd68777926b5a70" +checksum = "30a651bc37f915e81f087d86e62a18eec5f79550c7faff886f7090b4ea757c77" dependencies = [ "matchers 0.1.0", "nu-ansi-term", @@ -5799,7 +5822,7 @@ dependencies = [ "pango-sys", "pkg-config", "soup2-sys", - "system-deps 6.0.4", + "system-deps 6.0.5", ] [[package]] @@ -5896,15 +5919,6 @@ dependencies = [ "windows_x86_64_msvc 0.39.0", ] -[[package]] -name = "windows" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows" version = "0.48.0" diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/BlockMenu.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/BlockMenu.hooks.ts new file mode 100644 index 0000000000..ceaeccbc90 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/BlockMenu.hooks.ts @@ -0,0 +1,30 @@ +import { documentActions } from '@/appflowy_app/stores/reducers/document/slice'; +import { useAppDispatch } from '@/appflowy_app/stores/store'; +import { useRef, useState, useEffect } from 'react'; + +export function useBlockMenu(nodeId: string, open: boolean) { + const ref = useRef(null); + const dispatch = useAppDispatch(); + const [style, setStyle] = useState({ top: '0px', left: '0px' }); + + useEffect(() => { + if (!open) { + return; + } + // set selection when open + dispatch(documentActions.setSelectionById(nodeId)); + // get node rect + const rect = document.querySelector(`[data-block-id="${nodeId}"]`)?.getBoundingClientRect(); + if (!rect) return; + // set menu position + setStyle({ + top: rect.top + 'px', + left: rect.left + 'px', + }); + }, [open, nodeId]); + + return { + ref, + style, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.hooks.ts new file mode 100644 index 0000000000..804a788fc9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.hooks.ts @@ -0,0 +1,31 @@ +import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; +import { useAppDispatch } from '@/appflowy_app/stores/store'; +import { useCallback, useContext } from 'react'; +import { insertAfterNodeThunk, deleteNodeThunk } from '@/appflowy_app/stores/reducers/document/async_actions'; +// eslint-disable-next-line no-shadow +export enum ActionType { + InsertAfter = 'insertAfter', + Remove = 'remove', +} +export function useActions(id: string, type: ActionType) { + const dispatch = useAppDispatch(); + const controller = useContext(DocumentControllerContext); + + const insertAfter = useCallback(async () => { + if (!controller) return; + await dispatch(insertAfterNodeThunk({ id, controller })); + }, [id, controller, dispatch]); + + const remove = useCallback(async () => { + if (!controller) return; + await dispatch(deleteNodeThunk({ id, controller })); + }, [id, dispatch]); + + if (type === ActionType.InsertAfter) { + return insertAfter; + } + if (type === ActionType.Remove) { + return remove; + } + return; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.tsx new file mode 100644 index 0000000000..29db37b151 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import DeleteIcon from '@mui/icons-material/Delete'; +import AddIcon from '@mui/icons-material/Add'; +import Button from '@mui/material/Button'; +import { ActionType, useActions } from './MenuItem.hooks'; + +const icon: Record = { + [ActionType.InsertAfter]: , + [ActionType.Remove]: , +}; + +function MenuItem({ id, type, onClick }: { id: string; type: ActionType; onClick?: () => void }) { + const action = useActions(id, type); + return ( + + ); +} + +export default MenuItem; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/index.tsx new file mode 100644 index 0000000000..a0c00dd7d6 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/index.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { useBlockMenu } from './BlockMenu.hooks'; +import MenuItem from './MenuItem'; +import { ActionType } from '$app/components/document/BlockMenu/MenuItem.hooks'; + +function BlockMenu({ open, onClose, nodeId }: { open: boolean; onClose: () => void; nodeId: string }) { + const { ref, style } = useBlockMenu(nodeId, open); + + return open ? ( +
{ + // prevent scrolling of the document when menu is open + e.stopPropagation(); + }} + onMouseDown={(e) => { + // prevent menu from taking focus away from editor + e.preventDefault(); + e.stopPropagation(); + }} + onClick={(e) => { + e.stopPropagation(); + onClose(); + }} + > +
{ + // prevent menu close when clicking on menu + e.stopPropagation(); + }} + > + + +
+
+ ) : null; +} + +export default React.memo(BlockMenu); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideTools/BlockSideTools.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideTools/BlockSideTools.hooks.tsx index 8e4485a80e..1187b53180 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideTools/BlockSideTools.hooks.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideTools/BlockSideTools.hooks.tsx @@ -1,16 +1,14 @@ import { BlockType, HeadingBlockData } from '@/appflowy_app/interfaces/document'; import { useAppSelector } from '@/appflowy_app/stores/store'; import { debounce } from '@/appflowy_app/utils/tool'; -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; -import { Node } from '@/appflowy_app/stores/reducers/document/slice'; -import { v4 } from 'uuid'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; export function useBlockSideTools({ container }: { container: HTMLDivElement }) { const [nodeId, setHoverNodeId] = useState(''); + const [menuOpen, setMenuOpen] = useState(false); const ref = useRef(null); const nodes = useAppSelector((state) => state.document.nodes); - const { insertAfter } = useController(); + const nodesRef = useRef(nodes); const handleMouseMove = useCallback((e: MouseEvent) => { const { clientX, clientY } = e; @@ -20,7 +18,7 @@ export function useBlockSideTools({ container }: { container: HTMLDivElement }) if (!id) { setHoverNodeId(''); } else { - if ([BlockType.ColumnBlock].includes(nodes[id].type)) { + if ([BlockType.ColumnBlock].includes(nodesRef.current[id].type)) { setHoverNodeId(''); return; } @@ -34,13 +32,13 @@ export function useBlockSideTools({ container }: { container: HTMLDivElement }) const el = ref.current; if (!el || !nodeId) return; - const node = nodes[nodeId]; + const node = nodesRef.current[nodeId]; if (!node) { el.style.opacity = '0'; - el.style.zIndex = '-1'; + el.style.pointerEvents = 'none'; } else { el.style.opacity = '1'; - el.style.zIndex = '1'; + el.style.pointerEvents = 'auto'; el.style.top = '1px'; if (node?.type === BlockType.HeadingBlock) { const nodeData = node.data as HeadingBlockData; @@ -53,12 +51,14 @@ export function useBlockSideTools({ container }: { container: HTMLDivElement }) } } } - }, [nodeId, nodes]); + }, [nodeId]); - const handleAddClick = useCallback(() => { - if (!nodeId) return; - insertAfter(nodes[nodeId]); - }, [nodeId, nodes]); + const handleToggleMenu = useCallback((isOpen: boolean) => { + setMenuOpen(isOpen); + if (!isOpen) { + setHoverNodeId(''); + } + }, []); useEffect(() => { container.addEventListener('mousemove', debounceMove); @@ -67,25 +67,15 @@ export function useBlockSideTools({ container }: { container: HTMLDivElement }) }; }, [debounceMove]); + useEffect(() => { + nodesRef.current = nodes; + }, [nodes]); + return { nodeId, ref, - handleAddClick, - }; -} - -function useController() { - const controller = useContext(DocumentControllerContext); - - const insertAfter = useCallback((node: Node) => { - const parentId = node.parent; - if (!parentId || !controller) return; - - // - }, []); - - return { - insertAfter, + handleToggleMenu, + menuOpen, }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideTools/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideTools/index.tsx index cf2631f474..d3e361f43f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideTools/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideTools/index.tsx @@ -1,36 +1,40 @@ import React from 'react'; import { useBlockSideTools } from './BlockSideTools.hooks'; -import AddIcon from '@mui/icons-material/Add'; -import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import ExpandCircleDownSharpIcon from '@mui/icons-material/ExpandCircleDownSharp'; +import DragIndicatorRoundedIcon from '@mui/icons-material/DragIndicatorRounded'; import Portal from '../BlockPortal'; import { IconButton } from '@mui/material'; +import BlockMenu from '../BlockMenu'; const sx = { height: 24, width: 24 }; export default function BlockSideTools(props: { container: HTMLDivElement }) { - const { nodeId, ref, handleAddClick } = useBlockSideTools(props); + const { nodeId, ref, menuOpen, handleToggleMenu } = useBlockSideTools(props); if (!nodeId) return null; return ( - -
{ - // prevent toolbar from taking focus away from editor - e.preventDefault(); - }} - > - handleAddClick()} sx={sx}> - - - - - -
-
+ <> + +
{ + // prevent toolbar from taking focus away from editor + e.preventDefault(); + }} + > + handleToggleMenu(true)} sx={sx}> + + + + + +
+
+ handleToggleMenu(false)} nodeId={nodeId} /> + ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/index.tsx index 07e77a5e3b..4092759455 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/index.tsx @@ -8,7 +8,7 @@ export default function DocumentTitle({ id }: { id: string }) { if (!node) return null; return ( -
+
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx index a72e139e4e..5477252e58 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx @@ -9,28 +9,26 @@ import { NodeContext } from '../_shared/SubscribeNode.hooks'; function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes) { const { node, childIds, isSelected, ref } = useNode(id); - console.log('=====', id); - const renderBlock = useCallback((_props: { node: Node; childIds?: string[] }) => { - switch (_props.node.type) { + const renderBlock = useCallback(() => { + switch (node.type) { case 'text': { return ; } default: break; } - }, []); + }, [node, childIds]); if (!node) return null; return ( -
- {renderBlock({ - node, - childIds, - })} +
+ {renderBlock()}
- {isSelected ?
: null} + {isSelected ? ( +
+ ) : null}
); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts index 93bf11bd51..f586500186 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts @@ -1,16 +1,29 @@ import { triggerHotkey } from '@/appflowy_app/utils/slate/hotkey'; -import { useCallback, useState } from 'react'; -import { Descendant, Range } from 'slate'; +import { useCallback, useContext, useState } from 'react'; +import { Descendant, Range, Editor, Element, Text, Location } from 'slate'; import { TextDelta } from '$app/interfaces/document'; import { useTextInput } from '../_shared/TextInput.hooks'; +import { useAppDispatch } from '@/appflowy_app/stores/store'; +import { DocumentControllerContext } from '@/appflowy_app/stores/effects/document/document_controller'; +import { + backspaceNodeThunk, + indentNodeThunk, + splitNodeThunk, +} from '@/appflowy_app/stores/reducers/document/async_actions'; +import { TextSelection } from '@/appflowy_app/stores/reducers/document/slice'; -export function useTextBlock(delta: TextDelta[]) { - const { editor } = useTextInput(delta); +export function useTextBlock(id: string, delta: TextDelta[]) { + const { editor, onSelectionChange } = useTextInput(id, delta); const [value, setValue] = useState([]); - + const { onTab, onBackSpace, onEnter } = useActions(id); const onChange = useCallback( (e: Descendant[]) => { setValue(e); + editor.operations.forEach((op) => { + if (op.type === 'set_selection') { + onSelectionChange(op.newProperties as TextSelection); + } + }); }, [editor] ); @@ -18,19 +31,40 @@ export function useTextBlock(delta: TextDelta[]) { const onKeyDownCapture = (event: React.KeyboardEvent) => { switch (event.key) { case 'Enter': { + if (!editor.selection) return; event.stopPropagation(); event.preventDefault(); + const retainRange = getRetainRangeBy(editor); + const retain = getDelta(editor, retainRange); + const insertRange = getInsertRangeBy(editor); + const insert = getDelta(editor, insertRange); + void (async () => { + await onEnter(retain, insert); + })(); return; } case 'Backspace': { if (!editor.selection) return; + const { anchor } = editor.selection; const isCollapsed = Range.isCollapsed(editor.selection); if (isCollapsed && anchor.offset === 0 && anchor.path.toString() === '0,0') { event.stopPropagation(); event.preventDefault(); - return; + void (async () => { + await onBackSpace(); + })(); } + return; + } + case 'Tab': { + event.stopPropagation(); + event.preventDefault(); + void (async () => { + await onTab(); + })(); + + return; } } triggerHotkey(event, editor); @@ -53,3 +87,65 @@ export function useTextBlock(delta: TextDelta[]) { value, }; } + +function useActions(id: string) { + const dispatch = useAppDispatch(); + const controller = useContext(DocumentControllerContext); + + const onTab = useCallback(async () => { + if (!controller) return; + await dispatch( + indentNodeThunk({ + id, + controller, + }) + ); + }, [id, controller]); + + const onBackSpace = useCallback(async () => { + if (!controller) return; + await dispatch(backspaceNodeThunk({ id, controller })); + }, [controller, id]); + + const onEnter = useCallback( + async (retain: TextDelta[], insert: TextDelta[]) => { + if (!controller) return; + await dispatch(splitNodeThunk({ id, retain, insert, controller })); + }, + [controller, id] + ); + + return { + onTab, + onBackSpace, + onEnter, + }; +} + +function getDelta(editor: Editor, at: Location): TextDelta[] { + const baseElement = Editor.fragment(editor, at)[0] as Element; + return baseElement.children.map((item) => { + const { text, ...attributes } = item as Text; + return { + insert: text, + attributes, + }; + }); +} + +function getRetainRangeBy(editor: Editor) { + const start = Editor.start(editor, editor.selection!); + return { + anchor: { path: [0, 0], offset: 0 }, + focus: start, + }; +} + +function getInsertRangeBy(editor: Editor) { + const end = Editor.end(editor, editor.selection!); + const fragment = (editor.children[0] as Element).children; + return { + anchor: end, + focus: { path: [0, fragment.length - 1], offset: (fragment[fragment.length - 1] as Text).text.length }, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx index 5dff8e199d..c838d2d9bf 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx @@ -17,19 +17,21 @@ function TextBlock({ placeholder?: string; } & React.HTMLAttributes) { const delta = useMemo(() => node.data.delta || [], [node.data.delta]); - const { editor, value, onChange, onKeyDownCapture, onDOMBeforeInput } = useTextBlock(delta); + const { editor, value, onChange, onKeyDownCapture, onDOMBeforeInput } = useTextBlock(node.id, delta); return ( -
- - - } - placeholder={placeholder || 'Please enter some text...'} - /> - + <> +
+ + + } + placeholder={placeholder || 'Please enter some text...'} + /> + +
{childIds && childIds.length > 0 ? (
{childIds.map((item) => ( @@ -37,7 +39,7 @@ function TextBlock({ ))}
) : null} -
+ ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx index e418024422..3dc35d5b19 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx @@ -42,7 +42,12 @@ export default function VirtualizedList({ {virtualItems.map((virtualRow) => { const id = childIds[virtualRow.index]; return ( -
+
{virtualRow.index === 0 ? : null} {renderNode(id)}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/index.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/index.hooks.ts index e8346c0596..0d3764522a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/index.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/index.hooks.ts @@ -16,10 +16,10 @@ export function useHoveringToolbar(id: string) { if (!position) { el.style.opacity = '0'; - el.style.zIndex = '-1'; + el.style.pointerEvents = 'none'; } else { el.style.opacity = '1'; - el.style.zIndex = '1'; + el.style.pointerEvents = 'auto'; el.style.top = position.top; el.style.left = position.left; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/index.tsx index d4a671ec83..c64e387d70 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/index.tsx @@ -13,7 +13,7 @@ const HoveringToolbar = ({ id }: { id: string }) => { style={{ opacity: 0, }} - className='z-1 absolute mt-[-6px] inline-flex h-[32px] items-stretch overflow-hidden rounded-[8px] bg-[#333] p-2 leading-tight shadow-lg transition-opacity duration-700' + className='absolute mt-[-6px] inline-flex h-[32px] items-stretch overflow-hidden rounded-[8px] bg-[#333] p-2 leading-tight shadow-lg transition-opacity duration-700' onMouseDown={(e) => { // prevent toolbar from taking focus away from editor e.preventDefault(); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts index b1f393295c..8f8aa46abd 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts @@ -1,27 +1,53 @@ import { useCallback, useContext, useMemo, useRef, useEffect } from 'react'; import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; -import { TextDelta, BlockActionType } from '$app/interfaces/document'; +import { TextDelta } from '$app/interfaces/document'; import { debounce } from '@/appflowy_app/utils/tool'; -import { createEditor } from 'slate'; -import { withReact } from 'slate-react'; +import { NodeContext } from './SubscribeNode.hooks'; +import { BlockActionTypePB } from '@/services/backend/models/flowy-document2'; +import { useAppDispatch, useAppSelector } from '@/appflowy_app/stores/store'; +import { documentActions, TextSelection } from '@/appflowy_app/stores/reducers/document/slice'; + +import { createEditor, Transforms } from 'slate'; +import { withReact, ReactEditor } from 'slate-react'; import * as Y from 'yjs'; import { withYjs, YjsEditor, slateNodesToInsertDelta } from '@slate-yjs/core'; -import { NodeContext } from './SubscribeNode.hooks'; -import { BlockActionTypePB } from '@/services/backend/models/flowy-document2'; -export function useTextInput(delta: TextDelta[]) { +export function useTextInput(id: string, delta: TextDelta[]) { const { sendDelta } = useTransact(); - const { editor } = useBindYjs(delta, sendDelta); + const { editor, yText } = useBindYjs(delta, sendDelta); + const dispatch = useAppDispatch(); + const currentSelection = useAppSelector((state) => state.document.textSelections[id]); + + useEffect(() => { + if (!currentSelection || !currentSelection.anchor || !currentSelection.focus) return; + ReactEditor.focus(editor); + Transforms.select(editor, currentSelection); + }, [currentSelection, editor]); + + const onSelectionChange = useCallback( + (selection?: TextSelection) => { + dispatch( + documentActions.setTextSelection({ + blockId: id, + selection, + }) + ); + }, + [id] + ); return { editor, + yText, + onSelectionChange, }; } function useController() { const docController = useContext(DocumentControllerContext); const node = useContext(NodeContext); + const dispatch = useAppDispatch(); const update = useCallback( async (delta: TextDelta[]) => { @@ -43,6 +69,14 @@ function useController() { }, }, ]); + dispatch( + documentActions.setBlockMap({ + ...node, + data: { + delta, + }, + }) + ); }, [docController, node] ); @@ -105,12 +139,14 @@ function useBindYjs(delta: TextDelta[], update: (_delta: TextDelta[]) => void) { useEffect(() => { const yText = yTextRef.current; if (!yText) return; - const textEventHandler = (event: Y.YTextEvent) => { const textDelta = event.target.toDelta(); update(textDelta); }; - yText.applyDelta(delta); + if (JSON.stringify(yText.toDelta()) !== JSON.stringify(delta)) { + yText.delete(0, yText.length); + yText.applyDelta(delta); + } yText.observe(textEventHandler); return () => { @@ -118,5 +154,5 @@ function useBindYjs(delta: TextDelta[], update: (_delta: TextDelta[]) => void) { }; }, [delta]); - return { editor }; + return { editor, yText: yTextRef.current }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/FolderItem.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/FolderItem.hooks.ts index 18dd6780b3..02504fdbc0 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/FolderItem.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/FolderItem.hooks.ts @@ -118,14 +118,16 @@ export const useFolderEvents = (folder: IFolder, pages: IPage[]) => { layoutType: ViewLayoutPB.Document, }); try { - await new DocumentController(newView.id).create(); + const c = new DocumentController(newView.id); + await c.create(); + await c.dispose(); appDispatch( - pagesActions.addPage({ - folderId: folder.id, - pageType: ViewLayoutPB.Document, - title: newView.name, - id: newView.id, - }) + pagesActions.addPage({ + folderId: folder.id, + pageType: ViewLayoutPB.Document, + title: newView.name, + id: newView.id, + }) ); setShowPages(true); @@ -134,7 +136,6 @@ export const useFolderEvents = (folder: IFolder, pages: IPage[]) => { } catch (e) { console.error(e); } - }; const onAddNewBoardPage = async () => { diff --git a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts index efd51152fa..1834968d68 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts @@ -47,5 +47,13 @@ export enum BlockActionType { Insert = 0, Update = 1, Delete = 2, - Move = 3 + Move = 3, +} + +export interface DeltaItem { + action: 'inserted' | 'removed' | 'updated'; + payload: { + id: string; + value?: NestedBlock | string[]; + }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts index b752bae2b0..9487be8c9b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts @@ -1,8 +1,10 @@ -import { DocumentData, BlockType } from '@/appflowy_app/interfaces/document'; -import { createContext } from 'react'; +import { DocumentData, BlockType, DeltaItem } from '@/appflowy_app/interfaces/document'; +import { createContext, Dispatch } from 'react'; import { DocumentBackendService } from './document_bd_svc'; -import { FlowyError, BlockActionPB } from '@/services/backend'; +import { FlowyError, BlockActionPB, DocEventPB, DeltaTypePB, BlockActionTypePB } from '@/services/backend'; import { DocumentObserver } from './document_observer'; +import { documentActions, Node } from '@/appflowy_app/stores/reducers/document/slice'; +import { Log } from '@/appflowy_app/utils/log'; export const DocumentControllerContext = createContext(null); @@ -10,7 +12,7 @@ export class DocumentController { private readonly backendService: DocumentBackendService; private readonly observer: DocumentObserver; - constructor(public readonly viewId: string) { + constructor(public readonly viewId: string, private dispatch?: Dispatch) { this.backendService = new DocumentBackendService(viewId); this.observer = new DocumentObserver(viewId); } @@ -66,11 +68,122 @@ export class DocumentController { await this.backendService.applyActions(actions); }; + getInsertAction = (node: Node, prevId: string | null) => { + return { + action: BlockActionTypePB.Insert, + payload: this.getActionPayloadByNode(node, prevId), + }; + }; + + getUpdateAction = (node: Node) => { + return { + action: BlockActionTypePB.Update, + payload: this.getActionPayloadByNode(node, ''), + }; + }; + + getMoveAction = (node: Node, parentId: string, prevId: string | null) => { + return { + action: BlockActionTypePB.Move, + payload: this.getActionPayloadByNode( + { + ...node, + parent: parentId, + }, + prevId + ), + }; + }; + + getDeleteAction = (node: Node) => { + return { + action: BlockActionTypePB.Delete, + payload: this.getActionPayloadByNode(node, ''), + }; + }; + dispose = async () => { await this.backendService.close(); }; + private getActionPayloadByNode = (node: Node, prevId: string | null) => { + return { + block: this.getBlockByNode(node), + parent_id: node.parent || '', + prev_id: prevId || '', + }; + }; + + private getBlockByNode = (node: Node) => { + return { + id: node.id, + parent_id: node.parent || '', + children_id: node.children, + data: JSON.stringify(node.data), + ty: node.type, + }; + }; + private updated = (payload: Uint8Array) => { - console.log('didReceiveUpdate', payload); + const dispatch = this.dispatch; + if (!dispatch) return; + const { events, is_remote } = DocEventPB.deserializeBinary(payload); + console.log('updated', events, is_remote); + if (!is_remote) return; + events.forEach((event) => { + event.event.forEach((_payload) => { + const { path, id, value, command } = _payload; + let valueJson; + try { + valueJson = JSON.parse(value); + } catch { + console.error('json parse error', value); + return; + } + if (!valueJson) return; + + if (command === DeltaTypePB.Inserted || command === DeltaTypePB.Updated) { + // set map key and value ( block map or children map) + if (path[0] === 'blocks') { + const block = blockChangeValue2Node(valueJson); + dispatch(documentActions.setBlockMap(block)); + } else { + dispatch( + documentActions.setChildrenMap({ + id, + childIds: valueJson, + }) + ); + } + } else { + // remove map key ( block map or children map) + if (path[0] === 'blocks') { + dispatch(documentActions.removeBlockMapKey(id)); + } else { + dispatch(documentActions.removeChildrenMapKey(id)); + } + } + }); + }); }; } + +function blockChangeValue2Node(value: { id: string; ty: string; parent: string; children: string; data: string }): Node { + const block = { + id: value.id, + type: value.ty as BlockType, + parent: value.parent, + children: value.children, + data: {}, + }; + if ('data' in value && typeof value.data === 'string') { + try { + Object.assign(block, { + data: JSON.parse(value.data), + }); + } catch { + Log.error('valueJson data parse error', block.data); + } + } + return block; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/backspace.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/backspace.ts new file mode 100644 index 0000000000..eedf951c2e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/backspace.ts @@ -0,0 +1,96 @@ +import { BlockType } from '@/appflowy_app/interfaces/document'; +import { DocumentController } from '$app/stores/effects/document/document_controller'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { documentActions, DocumentState } from '../slice'; +import { outdentNodeThunk } from './outdent'; + +const composeParentThunk = createAsyncThunk( + 'document/composeParent', + async (payload: { id: string; controller: DocumentController }, thunkAPI) => { + const { id, controller } = payload; + const { dispatch, getState } = thunkAPI; + const state = (getState() as { document: DocumentState }).document; + const node = state.nodes[id]; + if (!node.parent) return; + const parent = state.nodes[node.parent]; + // merge delta + const newParent = { + ...parent, + data: { + ...parent.data, + delta: [...parent.data.delta, ...node.data.delta], + }, + }; + await controller.applyActions([controller.getDeleteAction(node), controller.getUpdateAction(newParent)]); + + dispatch(documentActions.setBlockMap(newParent)); + dispatch(documentActions.removeBlockMapKey(node.id)); + dispatch(documentActions.removeChildrenMapKey(node.children)); + } +); +const composePrevNodeThunk = createAsyncThunk( + 'document/composePrevNode', + async (payload: { prevNodeId: string; id: string; controller: DocumentController }, thunkAPI) => { + const { id, prevNodeId, controller } = payload; + const { dispatch, getState } = thunkAPI; + const state = (getState() as { document: DocumentState }).document; + const node = state.nodes[id]; + const prevNode = state.nodes[prevNodeId]; + // find prev line + let prevLineId = prevNode.id; + while (prevLineId) { + const prevLineChildren = state.children[state.nodes[prevLineId].children]; + if (prevLineChildren.length === 0) break; + prevLineId = prevLineChildren[prevLineChildren.length - 1]; + } + const prevLine = state.nodes[prevLineId]; + // merge delta + const newPrevLine = { + ...prevLine, + data: { + ...prevLine.data, + delta: [...prevLine.data.delta, ...node.data.delta], + }, + }; + await controller.applyActions([controller.getDeleteAction(node), controller.getUpdateAction(newPrevLine)]); + + dispatch(documentActions.setBlockMap(newPrevLine)); + dispatch(documentActions.removeBlockMapKey(node.id)); + dispatch(documentActions.removeChildrenMapKey(node.children)); + } +); + +export const backspaceNodeThunk = createAsyncThunk( + 'document/backspaceNode', + async (payload: { id: string; controller: DocumentController }, thunkAPI) => { + const { id, controller } = payload; + const { dispatch, getState } = thunkAPI; + const state = (getState() as { document: DocumentState }).document; + const node = state.nodes[id]; + if (!node.parent) return; + const parent = state.nodes[node.parent]; + const ancestorId = parent.parent; + const children = state.children[parent.children]; + const index = children.indexOf(id); + const prevNodeId = children[index - 1]; + const nextNodeId = children[index + 1]; + // transform to text block + if (node.type !== BlockType.TextBlock) { + // todo: transform to text block + } + // compose to previous line when it has next sibling or no ancestor + if (nextNodeId || !ancestorId) { + // compose to parent when it has no previous sibling + if (!prevNodeId) { + await dispatch(composeParentThunk({ id, controller })); + return; + } + await dispatch(composePrevNodeThunk({ prevNodeId, id, controller })); + return; + } else { + // outdent when it has no next sibling + await dispatch(outdentNodeThunk({ id, controller })); + return; + } + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/delete.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/delete.ts new file mode 100644 index 0000000000..d56f0deaa9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/delete.ts @@ -0,0 +1,32 @@ +import { DocumentController } from '$app/stores/effects/document/document_controller'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { documentActions, DocumentState } from '../slice'; + +export const deleteNodeThunk = createAsyncThunk( + 'document/deleteNode', + async (payload: { id: string; controller: DocumentController }, thunkAPI) => { + const { id, controller } = payload; + const { dispatch, getState } = thunkAPI; + const state = getState() as { document: DocumentState }; + const node = state.document.nodes[id]; + if (!node) return; + await controller.applyActions([controller.getDeleteAction(node)]); + + const deleteNode = (deleteId: string) => { + const deleteItem = state.document.nodes[deleteId]; + const children = state.document.children[deleteItem.children]; + // delete children + if (children.length > 0) { + children.forEach((childId) => { + deleteNode(childId); + }); + } + dispatch(documentActions.removeBlockMapKey(deleteItem.id)); + dispatch(documentActions.removeChildrenMapKey(deleteItem.children)); + }; + deleteNode(node.id); + + if (!node.parent) return; + dispatch(documentActions.deleteChild({ id: node.parent, childId: node.id })); + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/indent.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/indent.ts new file mode 100644 index 0000000000..04c927974e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/indent.ts @@ -0,0 +1,37 @@ +import { BlockType } from '@/appflowy_app/interfaces/document'; +import { DocumentController } from '$app/stores/effects/document/document_controller'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { documentActions, DocumentState } from '../slice'; + +export const indentNodeThunk = createAsyncThunk( + 'document/indentNode', + async (payload: { id: string; controller: DocumentController }, thunkAPI) => { + const { id, controller } = payload; + const { dispatch, getState } = thunkAPI; + const state = (getState() as { document: DocumentState }).document; + const node = state.nodes[id]; + if (!node.parent) return; + // get parent + const parent = state.nodes[node.parent]; + // get prev node + const children = state.children[parent.children]; + const index = children.indexOf(id); + if (index === 0) return; + const newParentId = children[index - 1]; + const prevNode = state.nodes[newParentId]; + // check if prev node is allowed to have children + if (prevNode.type !== BlockType.TextBlock) return; + // check if prev node has children and get last child for new prev node + const prevNodeChildren = state.children[prevNode.children]; + const newPrevId = prevNodeChildren[prevNodeChildren.length - 1]; + + await controller.applyActions([controller.getMoveAction(node, newParentId, newPrevId)]); + dispatch( + documentActions.moveNode({ + id, + newParentId, + newPrevId, + }) + ); + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/index.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/index.ts new file mode 100644 index 0000000000..caeb5e7be6 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/index.ts @@ -0,0 +1,6 @@ +export * from './delete'; +export * from './indent'; +export * from './insert'; +export * from './backspace'; +export * from './outdent'; +export * from './split'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/insert.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/insert.ts new file mode 100644 index 0000000000..3724c3fb83 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/insert.ts @@ -0,0 +1,41 @@ +import { BlockType, NestedBlock } from '@/appflowy_app/interfaces/document'; +import { DocumentController } from '$app/stores/effects/document/document_controller'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { documentActions, DocumentState } from '../slice'; +import { generateId } from '@/appflowy_app/utils/block'; +export const insertAfterNodeThunk = createAsyncThunk( + 'document/insertAfterNode', + async (payload: { id: string; controller: DocumentController }, thunkAPI) => { + const { controller } = payload; + const { dispatch, getState } = thunkAPI; + const state = getState() as { document: DocumentState }; + const node = state.document.nodes[payload.id]; + if (!node) return; + const parentId = node.parent; + if (!parentId) return; + // create new node + const newNode: NestedBlock = { + id: generateId(), + parent: parentId, + type: BlockType.TextBlock, + data: {}, + children: generateId(), + }; + await controller.applyActions([controller.getInsertAction(newNode, node.id)]); + dispatch(documentActions.setBlockMap(newNode)); + dispatch( + documentActions.setChildrenMap({ + id: newNode.children, + childIds: [], + }) + ); + // insert new node to parent + dispatch( + documentActions.insertChild({ + id: parentId, + childId: newNode.id, + prevId: node.id, + }) + ); + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/outdent.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/outdent.ts new file mode 100644 index 0000000000..90ab37611b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/outdent.ts @@ -0,0 +1,26 @@ +import { DocumentController } from '$app/stores/effects/document/document_controller'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { documentActions, DocumentState } from '../slice'; + +export const outdentNodeThunk = createAsyncThunk( + 'document/outdentNode', + async (payload: { id: string; controller: DocumentController }, thunkAPI) => { + const { id, controller } = payload; + const { dispatch, getState } = thunkAPI; + const state = (getState() as { document: DocumentState }).document; + const node = state.nodes[id]; + const newPrevId = node.parent; + if (!newPrevId) return; + const parent = state.nodes[newPrevId]; + const newParentId = parent.parent; + if (!newParentId) return; + await controller.applyActions([controller.getMoveAction(node, newParentId, newPrevId)]); + dispatch( + documentActions.moveNode({ + id: node.id, + newParentId, + newPrevId, + }) + ); + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/split.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/split.ts new file mode 100644 index 0000000000..ac2be1350b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/split.ts @@ -0,0 +1,54 @@ +import { BlockType, TextDelta } from '@/appflowy_app/interfaces/document'; +import { DocumentController } from '@/appflowy_app/stores/effects/document/document_controller'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { generateId } from '@/appflowy_app/utils/block'; +import { documentActions, DocumentState } from '../slice'; + +export const splitNodeThunk = createAsyncThunk( + 'document/splitNode', + async ( + payload: { id: string; retain: TextDelta[]; insert: TextDelta[]; controller: DocumentController }, + thunkAPI + ) => { + const { id, controller, retain, insert } = payload; + const { dispatch, getState } = thunkAPI; + const state = (getState() as { document: DocumentState }).document; + const node = state.nodes[id]; + if (!node.parent) return; + const children = state.children[node.children]; + const prevId = children.length > 0 ? null : node.id; + const parent = children.length > 0 ? node : state.nodes[node.parent]; + const newNode = { + id: generateId(), + parent: parent.id, + type: BlockType.TextBlock, + data: { + delta: insert, + }, + children: generateId(), + }; + const retainNode = { + ...node, + data: { + ...node.data, + delta: retain, + }, + }; + await controller.applyActions([controller.getInsertAction(newNode, prevId), controller.getUpdateAction(retainNode)]); + dispatch(documentActions.setBlockMap(newNode)); + dispatch(documentActions.setBlockMap(retainNode)); + dispatch( + documentActions.setChildrenMap({ + id: newNode.children, + childIds: [], + }) + ); + dispatch( + documentActions.insertChild({ + id: parent.id, + childId: newNode.id, + prevId, + }) + ); + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/region_grid.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/region_grid.ts index e7c7fd38ea..5b743e48ea 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/region_grid.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/region_grid.ts @@ -55,7 +55,7 @@ export class RegionGrid { } removeBlock(blockId: string) { - for (const rows of this.regions) { + for (const rows of this.regions.filter(r => r)) { for (const region of rows) { if (!region) return; const blockIndex = region.blocks.findIndex(b => b.id === blockId); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts index d1fe6a8027..19235be4ce 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts @@ -1,31 +1,51 @@ import { BlockType, NestedBlock, TextDelta } from '@/appflowy_app/interfaces/document'; import { PayloadAction, createSlice } from '@reduxjs/toolkit'; +import { nanoid } from 'nanoid'; +import { DocumentController } from '../../effects/document/document_controller'; import { RegionGrid } from './region_grid'; export type Node = NestedBlock; -export interface NodeState { +export interface SelectionPoint { + path: [number, number]; + offset: number; +} + +export interface TextSelection { + anchor: SelectionPoint; + focus: SelectionPoint; +} + +export interface DocumentState { + // map of block id to block nodes: Record; + // map of block id to children block ids children: Record; + // selected block ids selections: string[]; + // map of block id to text selection + textSelections: Record; } const regionGrid = new RegionGrid(50); -const initialState: NodeState = { +const initialState: DocumentState = { nodes: {}, children: {}, selections: [], + textSelections: {}, }; export const documentSlice = createSlice({ name: 'document', initialState: initialState, reducers: { + // initialize the document clear: () => { return initialState; }, + // set document data create: ( state, action: PayloadAction<{ @@ -38,10 +58,18 @@ export const documentSlice = createSlice({ state.children = children; }, + // update block selections updateSelections: (state, action: PayloadAction) => { state.selections = action.payload; }, + // set block selected + setSelectionById: (state, action: PayloadAction) => { + const id = action.payload; + state.selections = [id]; + }, + + // set block selected by selection rect setSelectionByRect: ( state, action: PayloadAction<{ @@ -56,6 +84,7 @@ export const documentSlice = createSlice({ state.selections = blocks.map((block) => block.id); }, + // update block position updateNodePosition: ( state, action: PayloadAction<{ @@ -76,50 +105,85 @@ export const documentSlice = createSlice({ regionGrid.updateBlock(id, position); }, - addNode: (state, action: PayloadAction) => { - state.nodes[action.payload.id] = action.payload; - }, - - addChild: (state, action: PayloadAction<{ parentId: string; childId: string; prevId: string }>) => { - const { parentId, childId, prevId } = action.payload; - const parentChildrenId = state.nodes[parentId].children; - const children = state.children[parentChildrenId]; - const prevIndex = children.indexOf(prevId); - if (prevIndex === -1) { - children.push(childId); + // update text selections + setTextSelection: ( + state, + action: PayloadAction<{ + blockId: string; + selection?: TextSelection; + }> + ) => { + const { blockId, selection } = action.payload; + if (!selection) { + delete state.textSelections[blockId]; } else { - children.splice(prevIndex + 1, 0, childId); + state.textSelections = { + [blockId]: selection, + }; } }, - updateChildren: (state, action: PayloadAction<{ id: string; childIds: string[] }>) => { + // update block + setBlockMap: (state, action: PayloadAction) => { + state.nodes[action.payload.id] = action.payload; + }, + + // remove block + removeBlockMapKey(state, action: PayloadAction) { + if (!state.nodes[action.payload]) return; + const { id } = state.nodes[action.payload]; + regionGrid.removeBlock(id); + delete state.nodes[id]; + }, + + // set block's relationship with its children + setChildrenMap: (state, action: PayloadAction<{ id: string; childIds: string[] }>) => { const { id, childIds } = action.payload; state.children[id] = childIds; }, - updateNode: (state, action: PayloadAction<{ id: string; data: any }>) => { - state.nodes[action.payload.id] = { - ...state.nodes[action.payload.id], - ...action.payload, - }; + // remove block's relationship with its children + removeChildrenMapKey(state, action: PayloadAction) { + if (state.children[action.payload]) { + delete state.children[action.payload]; + } }, - removeNode: (state, action: PayloadAction) => { - const { children, data, parent } = state.nodes[action.payload]; - // remove from parent - if (parent) { - const index = state.children[state.nodes[parent].children].indexOf(action.payload); - if (index > -1) { - state.children[state.nodes[parent].children].splice(index, 1); - } - } - // remove children - if (children) { - delete state.children[children]; - } + // set block's relationship with its parent + insertChild: (state, action: PayloadAction<{ id: string; childId: string; prevId: string | null }>) => { + const { id, childId, prevId } = action.payload; + const parent = state.nodes[id]; + const children = state.children[parent.children]; + const index = prevId ? children.indexOf(prevId) + 1 : 0; + children.splice(index, 0, childId); + }, - // remove node - delete state.nodes[action.payload]; + // remove block's relationship with its parent + deleteChild: (state, action: PayloadAction<{ id: string; childId: string }>) => { + const { id, childId } = action.payload; + const parent = state.nodes[id]; + const children = state.children[parent.children]; + const index = children.indexOf(childId); + children.splice(index, 1); + }, + + // move block to another parent + moveNode: (state, action: PayloadAction<{ id: string; newParentId: string; newPrevId: string | null }>) => { + const { id, newParentId, newPrevId } = action.payload; + const newParent = state.nodes[newParentId]; + const oldParentId = state.nodes[id].parent; + if (!oldParentId) return; + const oldParent = state.nodes[oldParentId]; + + state.nodes[id] = { + ...state.nodes[id], + parent: newParentId, + }; + const index = state.children[oldParent.children].indexOf(id); + state.children[oldParent.children].splice(index, 1); + + const newIndex = newPrevId ? state.children[newParent.children].indexOf(newPrevId) + 1 : 0; + state.children[newParent.children].splice(newIndex, 0, id); }, }, }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/block.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/block.ts new file mode 100644 index 0000000000..6318f80616 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/block.ts @@ -0,0 +1,5 @@ +import { nanoid } from 'nanoid'; + +export function generateId() { + return nanoid(10); +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts index 01a8c78927..d1aeab23e2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts @@ -1,39 +1,41 @@ -import { useEffect, useRef, useState } from 'react'; -import { - DocumentEventGetDocument, - DocumentVersionPB, - OpenDocumentPayloadPB, -} from '../../services/backend/events/flowy-document'; +import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { DocumentData } from '../interfaces/document'; import { DocumentController } from '$app/stores/effects/document/document_controller'; - +import { useAppDispatch } from '../stores/store'; +import { Log } from '../utils/log'; export const useDocument = () => { const params = useParams(); - const [ documentId, setDocumentId ] = useState(); - const [ documentData, setDocumentData ] = useState(); - const [ controller, setController ] = useState(null); + const [documentId, setDocumentId] = useState(); + const [documentData, setDocumentData] = useState(); + const [controller, setController] = useState(null); + const dispatch = useAppDispatch(); useEffect(() => { + let documentController: DocumentController | null = null; void (async () => { if (!params?.id) return; - const c = new DocumentController(params.id); - setController(c); + Log.debug('open document', params.id); + documentController = new DocumentController(params.id, dispatch); + setController(documentController); try { - const res = await c.open(); - console.log(res) + const res = await documentController.open(); if (!res) return; setDocumentData(res); setDocumentId(params.id); } catch (e) { - console.log(e) + Log.error(e); } - })(); return () => { - console.log('==== leave ====', params?.id) - } + void (async () => { + if (documentController) { + await documentController.dispose(); + } + Log.debug('close document', params.id); + })(); + }; }, [params.id]); return { documentId, documentData, controller }; }; diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index b7b391e9d2..4014bbaaf0 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -1427,7 +1427,10 @@ dependencies = [ "serde_json", "strum", "strum_macros", + "tempfile", + "tokio", "tracing", + "tracing-subscriber 0.3.16", ] [[package]] @@ -2710,6 +2713,16 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "num-integer" version = "0.1.45" @@ -2830,6 +2843,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "owo-colors" version = "1.3.0" @@ -4434,12 +4453,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6176eae26dd70d0c919749377897b54a9276bd7061339665dd68777926b5a70" dependencies = [ "matchers 0.1.0", + "nu-ansi-term", "once_cell", "regex", "sharded-slab", + "smallvec", "thread_local", "tracing", "tracing-core", + "tracing-log", ] [[package]] diff --git a/frontend/rust-lib/flowy-core/src/lib.rs b/frontend/rust-lib/flowy-core/src/lib.rs index 984705a533..cb42420c8f 100644 --- a/frontend/rust-lib/flowy-core/src/lib.rs +++ b/frontend/rust-lib/flowy-core/src/lib.rs @@ -92,6 +92,8 @@ fn create_log_filter(level: String, with_crates: Vec) -> String { filters.push(format!("flowy_folder={}", level)); filters.push(format!("flowy_folder2={}", level)); filters.push(format!("collab_folder={}", level)); + filters.push(format!("collab_persistence={}", level)); + filters.push(format!("collab={}", level)); filters.push(format!("flowy_user={}", level)); filters.push(format!("flowy_document={}", level)); filters.push(format!("flowy_document2={}", level)); diff --git a/frontend/rust-lib/flowy-document2/Cargo.toml b/frontend/rust-lib/flowy-document2/Cargo.toml index 4d6135e811..193f77bbb7 100644 --- a/frontend/rust-lib/flowy-document2/Cargo.toml +++ b/frontend/rust-lib/flowy-document2/Cargo.toml @@ -25,10 +25,16 @@ strum_macros = "0.21" serde = { version = "1.0", features = ["derive"] } serde_json = {version = "1.0"} tracing = { version = "0.1", features = ["log"] } +tokio = { version = "1.26", features = ["full"] } + +[dev-dependencies] +tempfile = "3.4.0" +tracing-subscriber = { version = "0.3.3", features = ["env-filter"] } [build-dependencies] flowy-codegen = { path = "../flowy-codegen"} [features] dart = ["flowy-codegen/dart", "flowy-notification/dart"] -ts = ["flowy-codegen/ts", "flowy-notification/ts"] \ No newline at end of file +ts = ["flowy-codegen/ts", "flowy-notification/ts"] + diff --git a/frontend/rust-lib/flowy-document2/src/document.rs b/frontend/rust-lib/flowy-document2/src/document.rs index 46e539b70d..bc9276a9f6 100644 --- a/frontend/rust-lib/flowy-document2/src/document.rs +++ b/frontend/rust-lib/flowy-document2/src/document.rs @@ -25,6 +25,12 @@ impl Document { .map_err(|_| FlowyError::from(ErrorCode::DocumentDataInvalid))?; Ok(Self(Arc::new(Mutex::new(inner)))) } + + pub fn create_with_data(collab: Collab, data: DocumentData) -> FlowyResult { + let inner = InnerDocument::create_with_data(collab, data) + .map_err(|_| FlowyError::from(ErrorCode::DocumentDataInvalid))?; + Ok(Self(Arc::new(Mutex::new(inner)))) + } } unsafe impl Sync for Document {} diff --git a/frontend/rust-lib/flowy-document2/src/entities.rs b/frontend/rust-lib/flowy-document2/src/entities.rs index 664587873f..6873c5e1da 100644 --- a/frontend/rust-lib/flowy-document2/src/entities.rs +++ b/frontend/rust-lib/flowy-document2/src/entities.rs @@ -23,7 +23,7 @@ pub struct CloseDocumentPayloadPBV2 { // Support customize initial data } -#[derive(Default, ProtoBuf)] +#[derive(Default, ProtoBuf, Debug)] pub struct ApplyActionPayloadPBV2 { #[pb(index = 1)] pub document_id: String, @@ -44,7 +44,7 @@ pub struct DocumentDataPB2 { pub meta: MetaPB, } -#[derive(Default, ProtoBuf)] +#[derive(Default, ProtoBuf, Debug)] pub struct BlockPB { #[pb(index = 1)] pub id: String, @@ -75,7 +75,7 @@ pub struct ChildrenPB { } // Actions -#[derive(Default, ProtoBuf)] +#[derive(Default, ProtoBuf, Debug)] pub struct BlockActionPB { #[pb(index = 1)] pub action: BlockActionTypePB, @@ -84,7 +84,7 @@ pub struct BlockActionPB { pub payload: BlockActionPayloadPB, } -#[derive(Default, ProtoBuf)] +#[derive(Default, ProtoBuf, Debug)] pub struct BlockActionPayloadPB { #[pb(index = 1)] pub block: BlockPB, @@ -96,7 +96,7 @@ pub struct BlockActionPayloadPB { pub parent_id: Option, } -#[derive(ProtoBuf_Enum)] +#[derive(ProtoBuf_Enum, Debug)] pub enum BlockActionTypePB { Insert = 0, Update = 1, @@ -110,6 +110,18 @@ impl Default for BlockActionTypePB { } } +#[derive(ProtoBuf_Enum)] +pub enum DeltaTypePB { + Inserted = 0, + Updated = 1, + Removed = 2, +} +impl Default for DeltaTypePB { + fn default() -> Self { + Self::Inserted + } +} + #[derive(Default, ProtoBuf)] pub struct DocEventPB { #[pb(index = 1)] @@ -122,8 +134,20 @@ pub struct DocEventPB { #[derive(Default, ProtoBuf)] pub struct BlockEventPB { #[pb(index = 1)] - pub path: Vec, + pub event: Vec, +} + +#[derive(Default, ProtoBuf)] +pub struct BlockEventPayloadPB { + #[pb(index = 1)] + pub command: DeltaTypePB, #[pb(index = 2)] - pub delta: String, + pub path: Vec, + + #[pb(index = 3)] + pub id: String, + + #[pb(index = 4)] + pub value: String, } diff --git a/frontend/rust-lib/flowy-document2/src/event_handler.rs b/frontend/rust-lib/flowy-document2/src/event_handler.rs index a59b99fbf1..ccb6d91b0e 100644 --- a/frontend/rust-lib/flowy-document2/src/event_handler.rs +++ b/frontend/rust-lib/flowy-document2/src/event_handler.rs @@ -4,14 +4,15 @@ use crate::{ document::DocumentDataWrapper, entities::{ ApplyActionPayloadPBV2, BlockActionPB, BlockActionPayloadPB, BlockActionTypePB, BlockEventPB, - BlockPB, CloseDocumentPayloadPBV2, CreateDocumentPayloadPBV2, DocumentDataPB2, - OpenDocumentPayloadPBV2, + BlockEventPayloadPB, BlockPB, CloseDocumentPayloadPBV2, CreateDocumentPayloadPBV2, DeltaTypePB, + DocEventPB, DocumentDataPB2, OpenDocumentPayloadPBV2, }, manager::DocumentManager, }; use collab_document::blocks::{ json_str_to_hashmap, Block, BlockAction, BlockActionPayload, BlockActionType, BlockEvent, + BlockEventPayload, DeltaType, }; use flowy_error::{FlowyError, FlowyResult}; use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; @@ -109,15 +110,39 @@ impl From for Block { } impl From for BlockEventPB { - fn from(_block_event: BlockEvent) -> Self { - // let delta = serde_json::to_value(&block_event.delta).unwrap(); - // Self { - // path: block_event.path.into(), - // delta: delta.to_string(), - // } + fn from(payload: BlockEvent) -> Self { Self { - path: vec![], - delta: "".to_string(), + event: payload.iter().map(|e| e.to_owned().into()).collect(), + } + } +} + +impl From for BlockEventPayloadPB { + fn from(payload: BlockEventPayload) -> Self { + Self { + command: payload.command.into(), + path: payload.path, + id: payload.id, + value: payload.value, + } + } +} + +impl From for DeltaTypePB { + fn from(action: DeltaType) -> Self { + match action { + DeltaType::Inserted => Self::Inserted, + DeltaType::Updated => Self::Updated, + DeltaType::Removed => Self::Removed, + } + } +} + +impl DocEventPB { + pub(crate) fn get_from(events: &Vec, is_remote: bool) -> Self { + Self { + events: events.iter().map(|e| e.to_owned().into()).collect(), + is_remote, } } } diff --git a/frontend/rust-lib/flowy-document2/src/lib.rs b/frontend/rust-lib/flowy-document2/src/lib.rs index 540db70f70..33d499099e 100644 --- a/frontend/rust-lib/flowy-document2/src/lib.rs +++ b/frontend/rust-lib/flowy-document2/src/lib.rs @@ -1,8 +1,8 @@ +pub mod document; pub mod entities; pub mod event_map; pub mod manager; pub mod protobuf; -mod document; mod event_handler; mod notification; diff --git a/frontend/rust-lib/flowy-document2/src/manager.rs b/frontend/rust-lib/flowy-document2/src/manager.rs index 75ccb57963..39428047a8 100644 --- a/frontend/rust-lib/flowy-document2/src/manager.rs +++ b/frontend/rust-lib/flowy-document2/src/manager.rs @@ -7,7 +7,7 @@ use std::{collections::HashMap, sync::Arc}; use crate::{ document::{Document, DocumentDataWrapper}, - entities::{BlockEventPB, DocEventPB}, + entities::DocEventPB, notification::{send_notification, DocumentNotification}, }; @@ -37,25 +37,13 @@ impl DocumentManager { &self, doc_id: String, data: DocumentDataWrapper, - ) -> FlowyResult> { - self.get_document(doc_id, Some(data)) - } - - fn get_document( - &self, - doc_id: String, - data: Option, ) -> FlowyResult> { let collab = self.get_collab_for_doc_id(&doc_id)?; - let document = Arc::new(Document::new(collab)?); - self.documents.write().insert(doc_id, document.clone()); - if data.is_some() { - // Here use unwrap() is safe, because we have checked data.is_some() before. - // document - // .lock() - // .create_with_data(data.unwrap().0) - // .map_err(|err| FlowyError::internal().context(err))?; - } + let document = Arc::new(Document::create_with_data(collab, data.0)?); + self + .documents + .write() + .insert(doc_id.clone(), document.clone()); Ok(document) } @@ -63,25 +51,23 @@ impl DocumentManager { if let Some(doc) = self.documents.read().get(&doc_id) { return Ok(doc.clone()); } + tracing::debug!("open_document: {:?}", &doc_id); + let collab = self.get_collab_for_doc_id(&doc_id)?; + let document = Arc::new(Document::new(collab)?); - let document = self.get_document(doc_id.clone(), None)?; let clone_doc_id = doc_id.clone(); - let _document_data = document + document .lock() .open(move |events, is_remote| { - println!("events: {:?}", events); - println!("is_remote: {:?}", is_remote); send_notification(&clone_doc_id, DocumentNotification::DidReceiveUpdate) - .payload(DocEventPB { - events: events - .iter() - .map(|event| event.to_owned().into()) - .collect::>(), - is_remote: is_remote.to_owned(), - }) + .payload(DocEventPB::get_from(events, is_remote)) .send(); }) .map_err(|err| FlowyError::internal().context(err))?; + self + .documents + .write() + .insert(doc_id.clone(), document.clone()); Ok(document) } diff --git a/frontend/rust-lib/flowy-document2/tests/document/document_test.rs b/frontend/rust-lib/flowy-document2/tests/document/document_test.rs new file mode 100644 index 0000000000..363fdfc125 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/document/document_test.rs @@ -0,0 +1,210 @@ +use std::{collections::HashMap, sync::Arc, vec}; + +use collab_document::blocks::{Block, BlockAction, BlockActionPayload, BlockActionType}; +use flowy_document2::{document::DocumentDataWrapper, manager::DocumentManager}; +use nanoid::nanoid; +use serde_json::{json, to_value, Value}; + +use super::util::FakeUser; + +#[test] +fn restore_document() { + let user = FakeUser::new(); + let manager = DocumentManager::new(Arc::new(user)); + + // create a document + let doc_id: String = nanoid!(10); + let data = DocumentDataWrapper::default(); + let document_a = manager + .create_document(doc_id.clone(), data.clone()) + .unwrap(); + let data_a = document_a.lock().get_document().unwrap(); + assert_eq!(data_a, data.0); + + // open a document + let data_b = manager + .open_document(doc_id.clone()) + .unwrap() + .lock() + .get_document() + .unwrap(); + // close a document + _ = manager.close_document(doc_id.clone()); + assert_eq!(data_b, data.0); + + // restore + _ = manager.create_document(doc_id.clone(), data.clone()); + // open a document + let data_b = manager + .open_document(doc_id.clone()) + .unwrap() + .lock() + .get_document() + .unwrap(); + // close a document + _ = manager.close_document(doc_id.clone()); + + assert_eq!(data_b, data.0); +} + +#[test] +fn document_apply_insert_action() { + let user = FakeUser::new(); + let manager = DocumentManager::new(Arc::new(user)); + + let doc_id: String = nanoid!(10); + let data = DocumentDataWrapper::default(); + + // create a document + _ = manager.create_document(doc_id.clone(), data.clone()); + + // open a document + let document = manager.open_document(doc_id.clone()).unwrap(); + let page_block = document.lock().get_block(&data.0.page_id).unwrap(); + + // insert a text block + let text_block = Block { + id: nanoid!(10), + ty: "text".to_string(), + parent: page_block.id.clone(), + children: nanoid!(10), + external_id: None, + external_type: None, + data: HashMap::new(), + }; + let insert_text_action = BlockAction { + action: BlockActionType::Insert, + payload: BlockActionPayload { + block: text_block.clone(), + parent_id: None, + prev_id: None, + }, + }; + document.lock().apply_action(vec![insert_text_action]); + let data_a = document.lock().get_document().unwrap(); + // close the original document + _ = manager.close_document(doc_id.clone()); + + // re-open the document + let data_b = manager + .open_document(doc_id.clone()) + .unwrap() + .lock() + .get_document() + .unwrap(); + // close a document + _ = manager.close_document(doc_id.clone()); + + assert_eq!(data_b, data_a); +} + +#[test] +fn document_apply_update_page_action() { + let user = FakeUser::new(); + let manager = DocumentManager::new(Arc::new(user)); + + let doc_id: String = nanoid!(10); + let data = DocumentDataWrapper::default(); + + // create a document + _ = manager.create_document(doc_id.clone(), data.clone()); + + // open a document + let document = manager.open_document(doc_id.clone()).unwrap(); + let page_block = document.lock().get_block(&data.0.page_id).unwrap(); + + let mut page_block_clone = page_block.clone(); + page_block_clone.data = HashMap::new(); + page_block_clone.data.insert( + "delta".to_string(), + to_value(json!([{"insert": "Hello World!"}])).unwrap(), + ); + let action = BlockAction { + action: BlockActionType::Update, + payload: BlockActionPayload { + block: page_block_clone, + parent_id: None, + prev_id: None, + }, + }; + let actions = vec![action]; + tracing::trace!("{:?}", &actions); + document.lock().apply_action(actions); + let page_block_old = document.lock().get_block(&data.0.page_id).unwrap(); + _ = manager.close_document(doc_id.clone()); + + // re-open the document + let document = manager.open_document(doc_id.clone()).unwrap(); + let page_block_new = document.lock().get_block(&data.0.page_id).unwrap(); + assert_eq!(page_block_old, page_block_new); + assert!(page_block_new.data.contains_key("delta")); +} + +#[test] +fn document_apply_update_action() { + let user = FakeUser::new(); + let manager = DocumentManager::new(Arc::new(user)); + + let doc_id: String = nanoid!(10); + let data = DocumentDataWrapper::default(); + + // create a document + _ = manager.create_document(doc_id.clone(), data.clone()); + + // open a document + let document = manager.open_document(doc_id.clone()).unwrap(); + let page_block = document.lock().get_block(&data.0.page_id).unwrap(); + + // insert a text block + let text_block_id = nanoid!(10); + let text_block = Block { + id: text_block_id.clone(), + ty: "text".to_string(), + parent: page_block.id.clone(), + children: nanoid!(10), + external_id: None, + external_type: None, + data: HashMap::new(), + }; + let insert_text_action = BlockAction { + action: BlockActionType::Insert, + payload: BlockActionPayload { + block: text_block.clone(), + parent_id: None, + prev_id: None, + }, + }; + document.lock().apply_action(vec![insert_text_action]); + + // update the text block + let existing_text_block = document.lock().get_block(&text_block_id).unwrap(); + let mut updated_text_block_data = HashMap::new(); + updated_text_block_data.insert("delta".to_string(), Value::String("delta".to_string())); + let updated_text_block = Block { + id: existing_text_block.id, + ty: existing_text_block.ty, + parent: existing_text_block.parent, + children: existing_text_block.children, + external_id: None, + external_type: None, + data: updated_text_block_data.clone(), + }; + let update_text_action = BlockAction { + action: BlockActionType::Update, + payload: BlockActionPayload { + block: updated_text_block.clone(), + parent_id: None, + prev_id: None, + }, + }; + document.lock().apply_action(vec![update_text_action]); + // close the original document + _ = manager.close_document(doc_id.clone()); + + // re-open the document + let document = manager.open_document(doc_id.clone()).unwrap(); + let block = document.lock().get_block(&text_block_id).unwrap(); + assert_eq!(block.data, updated_text_block_data); + // close a document + _ = manager.close_document(doc_id.clone()); +} diff --git a/frontend/rust-lib/flowy-document2/tests/document/mod.rs b/frontend/rust-lib/flowy-document2/tests/document/mod.rs new file mode 100644 index 0000000000..19d9b3f04c --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/document/mod.rs @@ -0,0 +1,2 @@ +mod document_test; +mod util; diff --git a/frontend/rust-lib/flowy-document2/tests/document/util.rs b/frontend/rust-lib/flowy-document2/tests/document/util.rs new file mode 100644 index 0000000000..a18ae088b0 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/document/util.rs @@ -0,0 +1,47 @@ +use std::sync::Arc; + +use collab_persistence::CollabKV; +use flowy_document2::manager::DocumentUser; +use parking_lot::Once; +use tempfile::TempDir; +use tracing_subscriber::{fmt::Subscriber, util::SubscriberInitExt, EnvFilter}; + +pub struct FakeUser { + kv: Arc, +} + +impl FakeUser { + pub fn new() -> Self { + Self { kv: db() } + } +} + +impl DocumentUser for FakeUser { + fn user_id(&self) -> Result { + Ok(1) + } + + fn token(&self) -> Result { + Ok("1".to_string()) + } + + fn kv_db(&self) -> Result, flowy_error::FlowyError> { + Ok(self.kv.clone()) + } +} + +pub fn db() -> Arc { + static START: Once = Once::new(); + START.call_once(|| { + std::env::set_var("RUST_LOG", "collab_persistence=trace"); + let subscriber = Subscriber::builder() + .with_env_filter(EnvFilter::from_default_env()) + .with_ansi(true) + .finish(); + subscriber.try_init().unwrap(); + }); + + let tempdir = TempDir::new().unwrap(); + let path = tempdir.into_path(); + Arc::new(CollabKV::open(path).unwrap()) +} diff --git a/frontend/rust-lib/flowy-document2/tests/document_test.rs b/frontend/rust-lib/flowy-document2/tests/document_test.rs deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/frontend/rust-lib/flowy-document2/tests/main.rs b/frontend/rust-lib/flowy-document2/tests/main.rs new file mode 100644 index 0000000000..103318948c --- /dev/null +++ b/frontend/rust-lib/flowy-document2/tests/main.rs @@ -0,0 +1 @@ +mod document; diff --git a/frontend/rust-lib/flowy-folder2/src/manager.rs b/frontend/rust-lib/flowy-folder2/src/manager.rs index 1211535b03..8fa9a83b32 100644 --- a/frontend/rust-lib/flowy-folder2/src/manager.rs +++ b/frontend/rust-lib/flowy-folder2/src/manager.rs @@ -132,7 +132,6 @@ impl Folder2Manager { /// Called when the current user logout /// pub async fn clear(&self, _user_id: i64) { - todo!() } pub async fn create_workspace(&self, params: CreateWorkspaceParams) -> FlowyResult {