Refactor tauri document (#2117)

* fix: Optimize the re-render node when the selection changes

* feat: the feature of delete block

* feat: add left tool when hover on block

* refactor: document data and update

* refactor: document component

* refactor: document controller
This commit is contained in:
qinluhe 2023-03-27 17:55:24 +08:00 committed by GitHub
parent 2a55febe62
commit 03cd9a6993
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
73 changed files with 1249 additions and 2641 deletions

View File

@ -20,6 +20,7 @@
"@mui/icons-material": "^5.11.11",
"@mui/material": "^5.11.12",
"@reduxjs/toolkit": "^1.9.2",
"@slate-yjs/core": "^0.3.1",
"@tanstack/react-virtual": "3.0.0-beta.54",
"@tauri-apps/api": "^1.2.0",
"events": "^3.3.0",
@ -42,8 +43,9 @@
"slate": "^0.91.4",
"slate-react": "^0.91.9",
"ts-results": "^3.3.0",
"ulid": "^2.3.0",
"utf8": "^3.0.0"
"utf8": "^3.0.0",
"yjs": "^13.5.51",
"y-indexeddb": "^9.0.9"
},
"devDependencies": {
"@tauri-apps/cli": "^1.2.2",
@ -53,6 +55,7 @@
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"@types/utf8": "^3.0.1",
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.51.0",
"@vitejs/plugin-react": "^3.0.0",
@ -64,6 +67,7 @@
"prettier-plugin-tailwindcss": "^0.2.2",
"tailwindcss": "^3.2.7",
"typescript": "^4.6.4",
"uuid": "^9.0.0",
"vite": "^4.0.0"
}
}

View File

@ -6,6 +6,7 @@ specifiers:
'@mui/icons-material': ^5.11.11
'@mui/material': ^5.11.12
'@reduxjs/toolkit': ^1.9.2
'@slate-yjs/core': ^0.3.1
'@tanstack/react-virtual': 3.0.0-beta.54
'@tauri-apps/api': ^1.2.0
'@tauri-apps/cli': ^1.2.2
@ -15,6 +16,7 @@ specifiers:
'@types/react': ^18.0.15
'@types/react-dom': ^18.0.6
'@types/utf8': ^3.0.1
'@types/uuid': ^9.0.1
'@typescript-eslint/eslint-plugin': ^5.51.0
'@typescript-eslint/parser': ^5.51.0
'@vitejs/plugin-react': ^3.0.0
@ -31,6 +33,7 @@ specifiers:
postcss: ^8.4.21
prettier: 2.8.4
prettier-plugin-tailwindcss: ^0.2.2
protoc-gen-ts: ^0.8.5
react: ^18.2.0
react-dom: ^18.2.0
react-error-boundary: ^3.1.4
@ -45,9 +48,11 @@ specifiers:
tailwindcss: ^3.2.7
ts-results: ^3.3.0
typescript: ^4.6.4
ulid: ^2.3.0
utf8: ^3.0.0
uuid: ^9.0.0
vite: ^4.0.0
y-indexeddb: ^9.0.9
yjs: ^13.5.51
dependencies:
'@emotion/react': 11.10.6_pmekkgnqduwlme35zpnqhenc34
@ -55,6 +60,7 @@ dependencies:
'@mui/icons-material': 5.11.11_ao76n7r2cajsoyr3cbwrn7geoi
'@mui/material': 5.11.12_xqeqsl5kvjjtyxwyi3jhw3yuli
'@reduxjs/toolkit': 1.9.3_k4ae6lp43ej6mezo3ztvx6pykq
'@slate-yjs/core': 0.3.1_slate@0.91.4+yjs@13.5.51
'@tanstack/react-virtual': 3.0.0-beta.54_react@18.2.0
'@tauri-apps/api': 1.2.0
events: 3.3.0
@ -64,6 +70,7 @@ dependencies:
is-hotkey: 0.2.0
jest: 29.5.0_@types+node@18.14.6
nanoid: 4.0.1
protoc-gen-ts: 0.8.6_ss7alqtodw6rv4lluxhr36xjoa
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
react-error-boundary: 3.1.4_react@18.2.0
@ -76,8 +83,9 @@ dependencies:
slate: 0.91.4
slate-react: 0.91.9_6tgy34rvmll7duwkm4ydcekf3u
ts-results: 3.3.0
ulid: 2.3.0
utf8: 3.0.0
y-indexeddb: 9.0.9_yjs@13.5.51
yjs: 13.5.51
devDependencies:
'@tauri-apps/cli': 1.2.3
@ -87,6 +95,7 @@ devDependencies:
'@types/react': 18.0.28
'@types/react-dom': 18.0.11
'@types/utf8': 3.0.1
'@types/uuid': 9.0.1
'@typescript-eslint/eslint-plugin': 5.54.0_6mj2wypvdnknez7kws2nfdgupi
'@typescript-eslint/parser': 5.54.0_ycpbpc6yetojsgtrx3mwntkhsu
'@vitejs/plugin-react': 3.1.0_vite@4.1.4
@ -98,6 +107,7 @@ devDependencies:
prettier-plugin-tailwindcss: 0.2.4_prettier@2.8.4
tailwindcss: 3.2.7_postcss@8.4.21
typescript: 4.9.5
uuid: 9.0.0
vite: 4.1.4_@types+node@18.14.6
packages:
@ -1308,6 +1318,17 @@ packages:
'@sinonjs/commons': 2.0.0
dev: false
/@slate-yjs/core/0.3.1_slate@0.91.4+yjs@13.5.51:
resolution: {integrity: sha512-8nvS9m5FhMNONgydAfzwDCUhuoWbgzx5Bvw1/foSe+JO331UOT1xAKbUX5FzGCOunUcbRjMPXSdNyiPc0dodJg==}
peerDependencies:
slate: '>=0.70.0'
yjs: ^13.5.29
dependencies:
slate: 0.91.4
y-protocols: 1.0.5
yjs: 13.5.51
dev: false
/@tanstack/react-virtual/3.0.0-beta.54_react@18.2.0:
resolution: {integrity: sha512-D1mDMf4UPbrtHRZZriCly5bXTBMhylslm4dhcHqTtDJ6brQcgGmk8YD9JdWBGWfGSWPKoh2x1H3e7eh+hgPXtQ==}
peerDependencies:
@ -1553,6 +1574,10 @@ packages:
resolution: {integrity: sha512-1EkWuw7rT3BMz2HpmcEOr/HL61mWNA6Ulr/KdbXR9AI0A55wD4Qfv8hizd8Q1DnknSIzzDvQmvvY/guvX7jjZA==}
dev: true
/@types/uuid/9.0.1:
resolution: {integrity: sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==}
dev: true
/@types/yargs-parser/21.0.0:
resolution: {integrity: sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==}
dev: false
@ -3050,6 +3075,10 @@ packages:
/isexe/2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
/isomorphic.js/0.2.5:
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
dev: false
/istanbul-lib-coverage/3.2.0:
resolution: {integrity: sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==}
engines: {node: '>=8'}
@ -3575,6 +3604,14 @@ packages:
type-check: 0.4.0
dev: true
/lib0/0.2.73:
resolution: {integrity: sha512-aJJIElCLWnHMcYZPtsM07QoSfHwpxCy4VUzBYGXFYEmh/h2QS5uZNbCCfL0CqnkOE30b7Tp9DVfjXag+3qzZjQ==}
engines: {node: '>=14'}
hasBin: true
dependencies:
isomorphic.js: 0.2.5
dev: false
/lilconfig/2.1.0:
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
engines: {node: '>=10'}
@ -4055,6 +4092,17 @@ packages:
object-assign: 4.1.1
react-is: 16.13.1
/protoc-gen-ts/0.8.6_ss7alqtodw6rv4lluxhr36xjoa:
resolution: {integrity: sha512-66oeorGy4QBvYjQGd/gaeOYyFqKyRmRgTpofmnw8buMG0P7A0jQjoKSvKJz5h5tNUaVkIzvGBUTRVGakrhhwpA==}
hasBin: true
peerDependencies:
google-protobuf: ^3.13.0
typescript: 4.x.x
dependencies:
google-protobuf: 3.21.2
typescript: 4.9.5
dev: false
/punycode/2.3.0:
resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==}
engines: {node: '>=6'}
@ -4678,12 +4726,6 @@ packages:
resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==}
engines: {node: '>=4.2.0'}
hasBin: true
dev: true
/ulid/2.3.0:
resolution: {integrity: sha512-keqHubrlpvT6G2wH0OEfSW4mquYRcbe/J8NMmveoQOjUqmo+hXtO+ORCpWhdbZ7k72UtY61BL7haGxW6enBnjw==}
hasBin: true
dev: false
/unbox-primitive/1.0.2:
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
@ -4726,6 +4768,11 @@ packages:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
dev: true
/uuid/9.0.0:
resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==}
hasBin: true
dev: true
/v8-to-istanbul/9.1.0:
resolution: {integrity: sha512-6z3GW9x8G1gd+JIIgQQQxXuiJtCXeAjp6RaPEPLv62mH3iPHPxV6W3robxtCzNErRo6ZwTmzWhsbNvjyEBKzKA==}
engines: {node: '>=10.12.0'}
@ -4839,6 +4886,21 @@ packages:
engines: {node: '>=0.4'}
dev: true
/y-indexeddb/9.0.9_yjs@13.5.51:
resolution: {integrity: sha512-GcJbiJa2eD5hankj46Hea9z4hbDnDjvh1fT62E5SpZRsv8GcEemw34l1hwI2eknGcv5Ih9JfusT37JLx9q3LFg==}
peerDependencies:
yjs: ^13.0.0
dependencies:
lib0: 0.2.73
yjs: 13.5.51
dev: false
/y-protocols/1.0.5:
resolution: {integrity: sha512-Wil92b7cGk712lRHDqS4T90IczF6RkcvCwAD0A2OPg+adKmOe+nOiT/N2hvpQIWS3zfjmtL4CPaH5sIW1Hkm/A==}
dependencies:
lib0: 0.2.73
dev: false
/y18n/5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@ -4872,6 +4934,13 @@ packages:
yargs-parser: 21.1.1
dev: false
/yjs/13.5.51:
resolution: {integrity: sha512-F1Nb3z3TdandD80IAeQqgqy/2n9AhDLcXoBhZvCUX1dNVe0ef7fIwi6MjSYaGAYF2Ev8VcLcsGnmuGGOl7AWbw==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
dependencies:
lib0: 0.2.73
dev: false
/yocto-queue/0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}

View File

@ -1,71 +0,0 @@
import { BaseEditor, BaseSelection, Descendant } from "slate";
import { TreeNode } from '$app/block_editor/view/tree_node';
import { Operation } from "$app/block_editor/core/operation";
import { TextBlockSelectionManager } from './text_selection';
export class TextBlockManager {
public selectionManager: TextBlockSelectionManager;
constructor(private operation: Operation) {
this.selectionManager = new TextBlockSelectionManager();
}
setSelection(node: TreeNode, selection: BaseSelection) {
// console.log(node.id, selection);
this.selectionManager.setSelection(node.id, selection)
}
update(node: TreeNode, path: string[], data: Descendant[]) {
this.operation.updateNode(node.id, path, data);
}
splitNode(node: TreeNode, editor: BaseEditor) {
const focus = editor.selection?.focus;
const path = focus?.path || [0, editor.children.length - 1];
const offset = focus?.offset || 0;
const parentIndex = path[0];
const index = path[1];
const editorNode = editor.children[parentIndex];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const children: { [key: string]: boolean | string; text: string }[] = editorNode.children;
const retainItems = children.filter((_: any, i: number) => i < index);
const splitItem: { [key: string]: boolean | string } = children[index];
const text = splitItem.text.toString();
const prevText = text.substring(0, offset);
const afterText = text.substring(offset);
retainItems.push({
...splitItem,
text: prevText
});
const removeItems = children.filter((_: any, i: number) => i > index);
const data = {
type: node.type,
data: {
...node.data,
content: [
{
...splitItem,
text: afterText
},
...removeItems
]
}
};
const newBlock = this.operation.splitNode(node.id, {
path: ['data', 'content'],
value: retainItems,
}, data);
newBlock && this.selectionManager.focusStart(newBlock.id);
}
destroy() {
this.selectionManager.destroy();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.operation = null;
}
}

View File

@ -1,35 +0,0 @@
export class TextBlockSelectionManager {
private focusId = '';
private selection?: any;
getFocusSelection() {
return {
focusId: this.focusId,
selection: this.selection
}
}
focusStart(blockId: string) {
this.focusId = blockId;
this.setSelection(blockId, {
focus: {
path: [0, 0],
offset: 0,
},
anchor: {
path: [0, 0],
offset: 0,
},
})
}
setSelection(blockId: string, selection: any) {
this.focusId = blockId;
this.selection = selection;
}
destroy() {
this.focusId = '';
this.selection = undefined;
}
}

View File

@ -1,107 +0,0 @@
import { BlockType, BlockData } from '$app/interfaces/index';
import { generateBlockId } from '$app/utils/block';
/**
* Represents a single block of content in a document.
*/
export class Block<T extends BlockType = BlockType> {
id: string;
type: T;
data: BlockData<T>;
parent: Block<BlockType> | null = null; // Pointer to the parent block
prev: Block<BlockType> | null = null; // Pointer to the previous sibling block
next: Block<BlockType> | null = null; // Pointer to the next sibling block
firstChild: Block<BlockType> | null = null; // Pointer to the first child block
constructor(id: string, type: T, data: BlockData<T>) {
this.id = id;
this.type = type;
this.data = data;
}
/**
* Adds a new child block to the beginning of the current block's children list.
*
* @param {Object} content - The content of the new block, including its type and data.
* @param {string} content.type - The type of the new block.
* @param {Object} content.data - The data associated with the new block.
* @returns {Block} The newly created child block.
*/
prependChild(content: { type: T, data: BlockData<T> }): Block | null {
const id = generateBlockId();
const newBlock = new Block(id, content.type, content.data);
newBlock.reposition(this, null);
return newBlock;
}
/**
* Add a new sibling block after this block.
*
* @param content The type and data for the new sibling block.
* @returns The newly created sibling block.
*/
addSibling(content: { type: T, data: BlockData<T> }): Block | null {
const id = generateBlockId();
const newBlock = new Block(id, content.type, content.data);
newBlock.reposition(this.parent, this);
return newBlock;
}
/**
* Remove this block and its descendants from the tree.
*
*/
remove() {
this.detach();
let child = this.firstChild;
while (child) {
const next = child.next;
child.remove();
child = next;
}
}
reposition(newParent: Block<BlockType> | null, newPrev: Block<BlockType> | null) {
// Update the block's parent and siblings
this.parent = newParent;
this.prev = newPrev;
this.next = null;
if (newParent) {
const prev = newPrev;
if (!prev) {
const next = newParent.firstChild;
newParent.firstChild = this;
if (next) {
this.next = next;
next.prev = this;
}
} else {
// Update the next and prev pointers of the newPrev and next blocks
if (prev.next !== this) {
const next = prev.next;
if (next) {
next.prev = this
this.next = next;
}
prev.next = this;
}
}
}
}
// detach the block from its current position in the tree
detach() {
if (this.prev) {
this.prev.next = this.next;
} else if (this.parent) {
this.parent.firstChild = this.next;
}
if (this.next) {
this.next.prev = this.prev;
}
}
}

View File

@ -1,225 +0,0 @@
import { BlockData, BlockInterface, BlockType } from '$app/interfaces/index';
import { set } from '../../utils/tool';
import { Block } from './block';
export interface BlockChangeProps {
block?: Block,
startBlock?: Block,
endBlock?: Block,
oldParentId?: string,
oldPrevId?: string
}
export class BlockChain {
private map: Map<string, Block<BlockType>> = new Map();
public head: Block<BlockType> | null = null;
constructor(private onBlockChange: (command: string, data: BlockChangeProps) => void) {
}
/**
* generate blocks from doc data
* @param id doc id
* @param map doc data
*/
rebuild = (id: string, map: Record<string, BlockInterface<BlockType>>) => {
this.map.clear();
this.head = this.createBlock(id, map[id].type, map[id].data);
const callback = (block: Block) => {
const firstChildId = map[block.id].firstChild;
const nextId = map[block.id].next;
if (!block.firstChild && firstChildId) {
block.firstChild = this.createBlock(firstChildId, map[firstChildId].type, map[firstChildId].data);
block.firstChild.parent = block;
block.firstChild.prev = null;
}
if (!block.next && nextId) {
block.next = this.createBlock(nextId, map[nextId].type, map[nextId].data);
block.next.parent = block.parent;
block.next.prev = block;
}
}
this.traverse(callback);
}
/**
* Traversing the block list from front to back
* @param callback It will be call when the block visited
* @param block block item, it will be equal head node when the block item is undefined
*/
traverse(callback: (_block: Block<BlockType>) => void, block?: Block<BlockType>) {
let currentBlock: Block | null = block || this.head;
while (currentBlock) {
callback(currentBlock);
if (currentBlock.firstChild) {
this.traverse(callback, currentBlock.firstChild);
}
currentBlock = currentBlock.next;
}
}
/**
* get block data
* @param blockId string
* @returns Block
*/
getBlock = (blockId: string) => {
return this.map.get(blockId) || null;
}
destroy() {
this.map.clear();
this.head = null;
this.onBlockChange = () => null;
}
/**
* Adds a new child block to the beginning of the current block's children list.
*
* @param {string} parentId
* @param {Object} content - The content of the new block, including its type and data.
* @param {string} content.type - The type of the new block.
* @param {Object} content.data - The data associated with the new block.
* @returns {Block} The newly created child block.
*/
prependChild(blockId: string, content: { type: BlockType, data: BlockData<BlockType> }): Block | null {
const parent = this.getBlock(blockId);
if (!parent) return null;
const newBlock = parent.prependChild(content);
if (newBlock) {
this.map.set(newBlock?.id, newBlock);
this.onBlockChange('insert', { block: newBlock });
}
return newBlock;
}
/**
* Add a new sibling block after this block.
* @param {string} blockId
* @param content The type and data for the new sibling block.
* @returns The newly created sibling block.
*/
addSibling(blockId: string, content: { type: BlockType, data: BlockData<BlockType> }): Block | null {
const block = this.getBlock(blockId);
if (!block) return null;
const newBlock = block.addSibling(content);
if (newBlock) {
this.map.set(newBlock?.id, newBlock);
this.onBlockChange('insert', { block: newBlock });
}
return newBlock;
}
/**
* Remove this block and its descendants from the tree.
* @param {string} blockId
*/
remove(blockId: string) {
const block = this.getBlock(blockId);
if (!block) return;
block.remove();
this.map.delete(block.id);
this.onBlockChange('delete', { block });
return block;
}
/**
* Move this block to a new position in the tree.
* @param {string} blockId
* @param newParentId The new parent block of this block. If null, the block becomes a top-level block.
* @param newPrevId The new previous sibling block of this block. If null, the block becomes the first child of the new parent.
* @returns This block after it has been moved.
*/
move(blockId: string, newParentId: string, newPrevId: string): Block | null {
const block = this.getBlock(blockId);
if (!block) return null;
const oldParentId = block.parent?.id;
const oldPrevId = block.prev?.id;
block.detach();
const newParent = this.getBlock(newParentId);
const newPrev = this.getBlock(newPrevId);
block.reposition(newParent, newPrev);
this.onBlockChange('move', {
block,
oldParentId,
oldPrevId
});
return block;
}
updateBlock(id: string, data: { path: string[], value: any }) {
const block = this.getBlock(id);
if (!block) return null;
set(block, data.path, data.value);
this.onBlockChange('update', {
block
});
return block;
}
moveBulk(startBlockId: string, endBlockId: string, newParentId: string, newPrevId: string): [Block, Block] | null {
const startBlock = this.getBlock(startBlockId);
const endBlock = this.getBlock(endBlockId);
if (!startBlock || !endBlock) return null;
if (startBlockId === endBlockId) {
const block = this.move(startBlockId, newParentId, '');
if (!block) return null;
return [block, block];
}
const oldParent = startBlock.parent;
const prev = startBlock.prev;
const newParent = this.getBlock(newParentId);
if (!oldParent || !newParent) return null;
if (oldParent.firstChild === startBlock) {
oldParent.firstChild = endBlock.next;
} else if (prev) {
prev.next = endBlock.next;
}
startBlock.prev = null;
endBlock.next = null;
startBlock.parent = newParent;
endBlock.parent = newParent;
const newPrev = this.getBlock(newPrevId);
if (!newPrev) {
const firstChild = newParent.firstChild;
newParent.firstChild = startBlock;
if (firstChild) {
endBlock.next = firstChild;
firstChild.prev = endBlock;
}
} else {
const next = newPrev.next;
newPrev.next = startBlock;
endBlock.next = next;
if (next) {
next.prev = endBlock;
}
}
this.onBlockChange('move', {
startBlock,
endBlock,
oldParentId: oldParent.id,
oldPrevId: prev?.id
});
return [
startBlock,
endBlock
];
}
private createBlock(id: string, type: BlockType, data: BlockData<BlockType>) {
const block = new Block(id, type, data);
this.map.set(id, block);
return block;
}
}

View File

@ -1,16 +0,0 @@
import { BackendOp, LocalOp } from "$app/interfaces";
export class OpAdapter {
toBackendOp(localOp: LocalOp): BackendOp {
const backendOp: BackendOp = { ...localOp };
// switch localOp type and generate backendOp
return backendOp;
}
toLocalOp(backendOp: BackendOp): LocalOp {
const localOp: LocalOp = { ...backendOp };
// switch backendOp type and generate localOp
return localOp;
}
}

View File

@ -1,153 +0,0 @@
import { BlockChain } from './block_chain';
import { BlockInterface, BlockType, InsertOpData, LocalOp, UpdateOpData, moveOpData, moveRangeOpData, removeOpData, BlockData } from '$app/interfaces';
import { BlockEditorSync } from './sync';
import { Block } from './block';
export class Operation {
private sync: BlockEditorSync;
constructor(private blockChain: BlockChain) {
this.sync = new BlockEditorSync();
}
splitNode(
retainId: string,
retainData: { path: string[], value: any },
newBlockData: {
type: BlockType;
data: BlockData
}) {
const ops: {
type: LocalOp['type'];
data: LocalOp['data'];
}[] = [];
const newBlock = this.blockChain.addSibling(retainId, newBlockData);
const parentId = newBlock?.parent?.id;
const retainBlock = this.blockChain.getBlock(retainId);
if (!newBlock || !parentId || !retainBlock) return null;
const insertOp = this.getInsertNodeOp({
id: newBlock.id,
next: newBlock.next?.id || null,
firstChild: newBlock.firstChild?.id || null,
data: newBlock.data,
type: newBlock.type,
}, parentId, retainId);
const updateOp = this.getUpdateNodeOp(retainId, retainData.path, retainData.value);
this.blockChain.updateBlock(retainId, retainData);
ops.push(insertOp, updateOp);
const startBlock = retainBlock.firstChild;
if (startBlock) {
const startBlockId = startBlock.id;
let next: Block | null = startBlock.next;
let endBlockId = startBlockId;
while (next) {
endBlockId = next.id;
next = next.next;
}
const moveOp = this.getMoveRangeOp([startBlockId, endBlockId], newBlock.id);
this.blockChain.moveBulk(startBlockId, endBlockId, newBlock.id, '');
ops.push(moveOp);
}
this.sync.sendOps(ops);
return newBlock;
}
updateNode<T>(blockId: string, path: string[], value: T) {
const op = this.getUpdateNodeOp(blockId, path, value);
this.blockChain.updateBlock(blockId, {
path,
value
});
this.sync.sendOps([op]);
}
private getUpdateNodeOp<T>(blockId: string, path: string[], value: T): {
type: 'update',
data: UpdateOpData
} {
return {
type: 'update',
data: {
blockId,
path: path,
value
}
};
}
private getInsertNodeOp<T extends BlockInterface>(block: T, parentId: string, prevId?: string): {
type: 'insert';
data: InsertOpData
} {
return {
type: 'insert',
data: {
block,
parentId,
prevId
}
}
}
private getMoveRangeOp(range: [string, string], newParentId: string, newPrevId?: string): {
type: 'move_range',
data: moveRangeOpData
} {
return {
type: 'move_range',
data: {
range,
newParentId,
newPrevId,
}
}
}
private getMoveOp(blockId: string, newParentId: string, newPrevId?: string): {
type: 'move',
data: moveOpData
} {
return {
type: 'move',
data: {
blockId,
newParentId,
newPrevId
}
}
}
private getRemoveOp(blockId: string): {
type: 'remove'
data: removeOpData
} {
return {
type: 'remove',
data: {
blockId
}
}
}
applyOperation(op: LocalOp) {
switch (op.type) {
case 'insert':
break;
default:
break;
}
}
destroy() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.blockChain = null;
}
}

View File

@ -1,48 +0,0 @@
import { BackendOp, LocalOp } from '$app/interfaces';
import { OpAdapter } from './op_adapter';
/**
* BlockEditorSync is a class that synchronizes changes made to a block chain with a server.
* It allows for adding, removing, and moving blocks in the chain, and sends pending operations to the server.
*/
export class BlockEditorSync {
private version = 0;
private opAdapter: OpAdapter;
private pendingOps: BackendOp[] = [];
private appliedOps: LocalOp[] = [];
constructor() {
this.opAdapter = new OpAdapter();
}
private applyOp(op: BackendOp): void {
const localOp = this.opAdapter.toLocalOp(op);
this.appliedOps.push(localOp);
}
private receiveOps(ops: BackendOp[]): void {
// Apply the incoming operations to the local document
ops.sort((a, b) => a.version - b.version);
for (const op of ops) {
this.applyOp(op);
}
}
private resolveConflict(): void {
// Implement conflict resolution logic here
}
public sendOps(ops: {
type: LocalOp["type"];
data: LocalOp["data"]
}[]) {
const backendOps = ops.map(op => this.opAdapter.toBackendOp({
...op,
version: this.version
}));
this.pendingOps.push(...backendOps);
// Send the pending operations to the server
console.log('==== sync pending ops ====', [...this.pendingOps]);
}
}

View File

@ -1,60 +0,0 @@
// Import dependencies
import { BlockInterface } from '../interfaces';
import { BlockChain, BlockChangeProps } from './core/block_chain';
import { RenderTree } from './view/tree';
import { Operation } from './core/operation';
/**
* The BlockEditor class manages a block chain and a render tree for a document editor.
* The block chain stores the content blocks of the document in sequence, while the
* render tree displays the document as a hierarchical tree structure.
*/
export class BlockEditor {
// Public properties
public blockChain: BlockChain; // (local data) the block chain used to store the document
public renderTree: RenderTree; // the render tree used to display the document
public operation: Operation;
/**
* Constructs a new BlockEditor object.
* @param id - the ID of the document
* @param data - the initial data for the document
*/
constructor(private id: string, data: Record<string, BlockInterface>) {
// Create the block chain and render tree
this.blockChain = new BlockChain(this.blockChange);
this.operation = new Operation(this.blockChain);
this.changeDoc(id, data);
this.renderTree = new RenderTree(this.blockChain);
}
/**
* Updates the document ID and block chain when the document changes.
* @param id - the new ID of the document
* @param data - the updated data for the document
*/
changeDoc = (id: string, data: Record<string, BlockInterface>) => {
console.log('==== change document ====', id, data);
// Update the document ID and rebuild the block chain
this.id = id;
this.blockChain.rebuild(id, data);
}
/**
* Destroys the block chain and render tree.
*/
destroy = () => {
// Destroy the block chain and render tree
this.blockChain.destroy();
this.renderTree.destroy();
this.operation.destroy();
}
private blockChange = (command: string, data: BlockChangeProps) => {
this.renderTree.onBlockChange(command, data);
}
}

View File

@ -1,73 +0,0 @@
import { RegionGrid, BlockPosition } from './region_grid';
export class BlockPositionManager {
private regionGrid: RegionGrid;
private viewportBlocks: Set<string> = new Set();
private blockPositions: Map<string, BlockPosition> = new Map();
private observer: IntersectionObserver;
private container: HTMLDivElement | null = null;
constructor(container: HTMLDivElement) {
this.container = container;
this.regionGrid = new RegionGrid(container.offsetHeight);
this.observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
const blockId = entry.target.getAttribute('data-block-id');
if (!blockId) return;
if (entry.isIntersecting) {
this.updateBlockPosition(blockId);
this.viewportBlocks.add(blockId);
} else {
this.viewportBlocks.delete(blockId);
}
}
}, { root: container });
}
observeBlock(node: HTMLDivElement) {
this.observer.observe(node);
return {
unobserve: () => this.observer.unobserve(node),
}
}
getBlockPosition(blockId: string) {
if (!this.blockPositions.has(blockId)) {
this.updateBlockPosition(blockId);
}
return this.blockPositions.get(blockId);
}
updateBlockPosition(blockId: string) {
if (!this.container) return;
const node = document.querySelector(`[data-block-id=${blockId}]`) as HTMLDivElement;
if (!node) return;
const rect = node.getBoundingClientRect();
const position = {
id: blockId,
x: rect.x,
y: rect.y + this.container.scrollTop,
height: rect.height,
width: rect.width
};
const prevPosition = this.blockPositions.get(blockId);
if (prevPosition && prevPosition.x === position.x &&
prevPosition.y === position.y &&
prevPosition.height === position.height &&
prevPosition.width === position.width) {
return;
}
this.blockPositions.set(blockId, position);
this.regionGrid.removeBlock(blockId);
this.regionGrid.addBlock(position);
}
getIntersectBlocks(startX: number, startY: number, endX: number, endY: number): BlockPosition[] {
return this.regionGrid.getIntersectBlocks(startX, startY, endX, endY);
}
destroy() {
this.container = null;
this.observer.disconnect();
}
}

View File

@ -1,165 +0,0 @@
import { BlockChain, BlockChangeProps } from '../core/block_chain';
import { Block } from '../core/block';
import { TreeNode } from "./tree_node";
import { BlockPositionManager } from './block_position';
import { filterSelections } from '@/appflowy_app/utils/block_selection';
export class RenderTree {
public blockPositionManager?: BlockPositionManager;
private map: Map<string, TreeNode> = new Map();
private root: TreeNode | null = null;
private selections: Set<string> = new Set();
constructor(private blockChain: BlockChain) {
}
createPositionManager(container: HTMLDivElement) {
this.blockPositionManager = new BlockPositionManager(container);
}
observeBlock(node: HTMLDivElement) {
return this.blockPositionManager?.observeBlock(node);
}
getBlockPosition(nodeId: string) {
return this.blockPositionManager?.getBlockPosition(nodeId) || null;
}
/**
* Get the TreeNode data by nodeId
* @param nodeId string
* @returns TreeNode|null
*/
getTreeNode = (nodeId: string): TreeNode | null => {
// Return the TreeNode instance from the map or null if it does not exist
return this.map.get(nodeId) || null;
}
private createNode(block: Block): TreeNode {
if (this.map.has(block.id)) {
return this.map.get(block.id)!;
}
const node = new TreeNode(block);
this.map.set(block.id, node);
return node;
}
buildDeep(rootId: string): TreeNode | null {
this.map.clear();
// Define a callback function for the blockChain.traverse() method
const callback = (block: Block) => {
// Check if the TreeNode instance already exists in the map
const node = this.createNode(block);
// Add the TreeNode instance to the map
this.map.set(block.id, node);
// Add the first child of the block as a child of the current TreeNode instance
const firstChild = block.firstChild;
if (firstChild) {
const child = this.createNode(firstChild);
node.addChild(child);
this.map.set(child.id, child);
}
// Add the next block as a sibling of the current TreeNode instance
const next = block.next;
if (next) {
const nextNode = this.createNode(next);
node.parent?.addChild(nextNode);
this.map.set(next.id, nextNode);
}
}
// Traverse the blockChain using the callback function
this.blockChain.traverse(callback);
// Get the root node from the map and return it
const root = this.map.get(rootId)!;
this.root = root;
return root || null;
}
forceUpdate(nodeId: string, shouldUpdateChildren = false) {
const block = this.blockChain.getBlock(nodeId);
if (!block) return null;
const node = this.createNode(block);
if (!node) return null;
if (shouldUpdateChildren) {
const children: TreeNode[] = [];
let childBlock = block.firstChild;
while(childBlock) {
const child = this.createNode(childBlock);
child.update(childBlock, child.children);
children.push(child);
childBlock = childBlock.next;
}
node.update(block, children);
node?.reRender();
node?.children.forEach(child => {
child.reRender();
})
} else {
node.update(block, node.children);
node?.reRender();
}
}
onBlockChange(command: string, data: BlockChangeProps) {
const { block, startBlock, endBlock, oldParentId = '', oldPrevId = '' } = data;
switch (command) {
case 'insert':
if (block?.parent) this.forceUpdate(block.parent.id, true);
break;
case 'update':
this.forceUpdate(block!.id);
break;
case 'move':
if (oldParentId) this.forceUpdate(oldParentId, true);
if (block?.parent) this.forceUpdate(block.parent.id, true);
if (startBlock?.parent) this.forceUpdate(startBlock.parent.id, true);
break;
default:
break;
}
}
updateSelections(selections: string[]) {
const newSelections = filterSelections<TreeNode>(selections, this.map);
let isDiff = false;
if (newSelections.length !== this.selections.size) {
isDiff = true;
}
const selectedBlocksSet = new Set(newSelections);
if (Array.from(this.selections).some((id) => !selectedBlocksSet.has(id))) {
isDiff = true;
}
if (isDiff) {
const shouldUpdateIds = new Set([...this.selections, ...newSelections]);
this.selections = selectedBlocksSet;
shouldUpdateIds.forEach((id) => this.forceUpdate(id));
}
}
isSelected(nodeId: string) {
return this.selections.has(nodeId);
}
/**
* Destroy the RenderTreeRectManager instance
*/
destroy() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.blockChain = null;
}
}

View File

@ -1,59 +0,0 @@
import { BlockData, BlockType } from '$app/interfaces/index';
import { Block } from '../core/block';
/**
* Represents a node in a tree structure of blocks.
*/
export class TreeNode {
id: string;
type: BlockType;
parent: TreeNode | null = null;
children: TreeNode[] = [];
data: BlockData<BlockType>;
private forceUpdate?: () => void;
/**
* Create a new TreeNode instance.
* @param block - The block data used to create the node.
*/
constructor(private _block: Block) {
this.id = _block.id;
this.data = _block.data;
this.type = _block.type;
}
registerUpdate(forceUpdate: () => void) {
this.forceUpdate = forceUpdate;
}
unregisterUpdate() {
this.forceUpdate = undefined;
}
reRender() {
this.forceUpdate?.();
}
update(block: Block, children: TreeNode[]) {
this.data = block.data;
this.children = [];
children.forEach(child => {
this.addChild(child);
})
}
/**
* Add a child node to the current node.
* @param node - The child node to add.
*/
addChild(node: TreeNode) {
node.parent = this;
this.children.push(node);
}
get block() {
return this._block;
}
}

View File

@ -1,9 +0,0 @@
import ReactDOM from 'react-dom';
const Portal = ({ blockId, children }: { blockId: string; children: JSX.Element }) => {
const root = document.querySelectorAll(`[data-block-id=${blockId}] > .block-overlay`)[0];
return typeof document === 'object' && root ? ReactDOM.createPortal(children, root) : null;
};
export default Portal;

View File

@ -1,36 +0,0 @@
import { useEffect, useState, useRef, useContext } from 'react';
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
import { BlockContext } from '$app/utils/block';
export function useBlockComponent({
node
}: {
node: TreeNode
}) {
const { blockEditor } = useContext(BlockContext);
const [version, forceUpdate] = useState<number>(0);
const myRef = useRef<HTMLDivElement | null>(null);
const isSelected = blockEditor?.renderTree.isSelected(node.id);
useEffect(() => {
if (!myRef.current) {
return;
}
const observe = blockEditor?.renderTree.observeBlock(myRef.current);
node.registerUpdate(() => forceUpdate((prev) => prev + 1));
return () => {
node.unregisterUpdate();
observe?.unobserve();
};
}, []);
return {
version,
myRef,
isSelected,
className: `relative my-[1px] px-1`
}
}

View File

@ -1,91 +0,0 @@
import React, { forwardRef } from 'react';
import { BlockCommonProps, BlockType } from '$app/interfaces';
import PageBlock from '../PageBlock';
import TextBlock from '../TextBlock';
import HeadingBlock from '../HeadingBlock';
import ListBlock from '../ListBlock';
import CodeBlock from '../CodeBlock';
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
import { withErrorBoundary } from 'react-error-boundary';
import { ErrorBoundaryFallbackComponent } from '../BlockList/BlockList.hooks';
import { useBlockComponent } from './BlockComponet.hooks';
const BlockComponent = forwardRef(
(
{
node,
renderChild,
...props
}: { node: TreeNode; renderChild?: (_node: TreeNode) => React.ReactNode } & React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>,
ref: React.ForwardedRef<HTMLDivElement>
) => {
const { myRef, className, version, isSelected } = useBlockComponent({
node,
});
const renderComponent = () => {
let BlockComponentClass: (_: BlockCommonProps<TreeNode>) => JSX.Element | null;
switch (node.type) {
case BlockType.PageBlock:
BlockComponentClass = PageBlock;
break;
case BlockType.TextBlock:
BlockComponentClass = TextBlock;
break;
case BlockType.HeadingBlock:
BlockComponentClass = HeadingBlock;
break;
case BlockType.ListBlock:
BlockComponentClass = ListBlock;
break;
case BlockType.CodeBlock:
BlockComponentClass = CodeBlock;
break;
default:
break;
}
const blockProps: BlockCommonProps<TreeNode> = {
version,
node,
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (BlockComponentClass) {
return <BlockComponentClass {...blockProps} />;
}
return null;
};
return (
<div
ref={(el: HTMLDivElement | null) => {
myRef.current = el;
if (typeof ref === 'function') {
ref(el);
} else if (ref) {
ref.current = el;
}
}}
{...props}
data-block-id={node.id}
data-block-selected={isSelected}
className={props.className ? `${props.className} ${className}` : className}
>
{renderComponent()}
{renderChild ? node.children.map(renderChild) : null}
<div className='block-overlay'></div>
{isSelected ? <div className='pointer-events-none absolute inset-0 z-[-1] rounded-[4px] bg-[#E0F8FF]' /> : null}
</div>
);
}
);
const ComponentWithErrorBoundary = withErrorBoundary(BlockComponent, {
FallbackComponent: ErrorBoundaryFallbackComponent,
});
export default React.memo(ComponentWithErrorBoundary);

View File

@ -1,92 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { BlockEditor } from '@/appflowy_app/block_editor';
import { TreeNode } from '$app/block_editor/view/tree_node';
import { Alert } from '@mui/material';
import { FallbackProps } from 'react-error-boundary';
import { TextBlockManager } from '@/appflowy_app/block_editor/blocks/text_block';
import { TextBlockContext } from '@/appflowy_app/utils/slate/context';
import { useVirtualizer } from '@tanstack/react-virtual';
export interface BlockListProps {
blockId: string;
blockEditor: BlockEditor;
}
const defaultSize = 45;
export function useBlockList({ blockId, blockEditor }: BlockListProps) {
const [root, setRoot] = useState<TreeNode | null>(null);
const parentRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: root?.children.length || 0,
getScrollElement: () => parentRef.current,
overscan: 5,
estimateSize: () => {
return defaultSize;
},
});
const [version, forceUpdate] = useState<number>(0);
const buildDeepTree = useCallback(() => {
const treeNode = blockEditor.renderTree.buildDeep(blockId);
setRoot(treeNode);
}, [blockEditor]);
useEffect(() => {
if (!parentRef.current) return;
blockEditor.renderTree.createPositionManager(parentRef.current);
buildDeepTree();
return () => {
blockEditor.destroy();
};
}, [blockId, blockEditor]);
useEffect(() => {
root?.registerUpdate(() => forceUpdate((prev) => prev + 1));
return () => {
root?.unregisterUpdate();
};
}, [root]);
return {
root,
rowVirtualizer,
parentRef,
blockEditor,
};
}
export function ErrorBoundaryFallbackComponent({ error, resetErrorBoundary }: FallbackProps) {
return (
<Alert severity='error' className='mb-2'>
<p>Something went wrong:</p>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</Alert>
);
}
export function withTextBlockManager(Component: (props: BlockListProps) => React.ReactElement) {
return (props: BlockListProps) => {
const textBlockManager = new TextBlockManager(props.blockEditor.operation);
useEffect(() => {
return () => {
textBlockManager.destroy();
};
}, []);
return (
<TextBlockContext.Provider
value={{
textBlockManager,
}}
>
<Component {...props} />
</TextBlockContext.Provider>
);
};
}

View File

@ -1,18 +0,0 @@
import TextBlock from '../TextBlock';
import { TreeNode } from '$app/block_editor/view/tree_node';
export default function BlockListTitle({ node }: { node: TreeNode | null }) {
if (!node) return null;
return (
<div data-block-id={node.id} className='doc-title flex pt-[50px] text-4xl font-bold'>
<TextBlock
version={0}
toolbarProps={{
showGroups: [],
}}
node={node}
needRenderChildren={false}
/>
</div>
);
}

View File

@ -1,31 +0,0 @@
import * as React from 'react';
import Typography, { TypographyProps } from '@mui/material/Typography';
import Skeleton from '@mui/material/Skeleton';
import Grid from '@mui/material/Grid';
const variants = ['h1', 'h3', 'body1', 'caption'] as readonly TypographyProps['variant'][];
export default function ListFallbackComponent() {
return (
<div id='appflowy-block-doc' className='doc-scroller-container flex h-[100%] flex-col items-center overflow-auto'>
<div className='doc-content min-x-[0%] p-lg w-[900px] max-w-[100%]'>
<div className='doc-title my-[50px] flex w-[100%] px-14 text-4xl font-bold'>
<Typography className='w-[100%]' component='div' key={'h1'} variant={'h1'}>
<Skeleton />
</Typography>
</div>
<div className='doc-body px-14' style={{ height: '100vh' }}>
<Grid container spacing={8}>
<Grid item xs>
{variants.map((variant) => (
<Typography component='div' key={variant} variant={variant}>
<Skeleton />
</Typography>
))}
</Grid>
</Grid>
</div>
</div>
</div>
);
}

View File

@ -1,58 +0,0 @@
import React from 'react';
import { BlockListProps, useBlockList, withTextBlockManager } from './BlockList.hooks';
import { withErrorBoundary } from 'react-error-boundary';
import ListFallbackComponent from './ListFallbackComponent';
import BlockListTitle from './BlockListTitle';
import BlockComponent from '../BlockComponent';
import BlockSelection from '../BlockSelection';
function BlockList(props: BlockListProps) {
const { root, rowVirtualizer, parentRef, blockEditor } = useBlockList(props);
const virtualItems = rowVirtualizer.getVirtualItems();
return (
<div id='appflowy-block-doc' className='h-[100%] overflow-hidden'>
<div
ref={parentRef}
className={`doc-scroller-container flex h-[100%] flex-wrap items-center justify-center overflow-auto px-20`}
>
<div
className='doc-body max-w-screen w-[900px] min-w-0'
style={{
height: rowVirtualizer.getTotalSize(),
position: 'relative',
}}
>
{root && virtualItems.length ? (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItems[0].start || 0}px)`,
}}
>
{virtualItems.map((virtualRow) => {
const id = root.children[virtualRow.index].id;
return (
<div className='p-[1px]' key={id} data-index={virtualRow.index} ref={rowVirtualizer.measureElement}>
{virtualRow.index === 0 ? <BlockListTitle node={root} /> : null}
<BlockComponent node={root.children[virtualRow.index]} />
</div>
);
})}
</div>
) : null}
</div>
</div>
{parentRef.current ? <BlockSelection blockEditor={blockEditor} container={parentRef.current} /> : null}
</div>
);
}
const ListWithErrorBoundary = withErrorBoundary(withTextBlockManager(BlockList), {
FallbackComponent: ListFallbackComponent,
});
export default React.memo(ListWithErrorBoundary);

View File

@ -1,18 +0,0 @@
import { useBlockSelection } from './BlockSelection.hooks';
import { BlockEditor } from '$app/block_editor';
import React from 'react';
function BlockSelection({ container, blockEditor }: { container: HTMLDivElement; blockEditor: BlockEditor }) {
const { isDragging, style } = useBlockSelection({
container,
blockEditor,
});
return (
<div className='appflowy-block-selection-overlay z-1 pointer-events-none fixed inset-0 overflow-hidden'>
{isDragging ? <div className='z-99 absolute bg-[#00d5ff] opacity-25' style={style} /> : null}
</div>
);
}
export default React.memo(BlockSelection);

View File

@ -1,6 +0,0 @@
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
import { BlockCommonProps } from '@/appflowy_app/interfaces';
export default function CodeBlock({ node }: BlockCommonProps<TreeNode>) {
return <div>{node.data.text}</div>;
}

View File

@ -1,17 +0,0 @@
import TextBlock from '../TextBlock';
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
import { BlockCommonProps } from '@/appflowy_app/interfaces';
const fontSize: Record<string, string> = {
1: 'mt-8 text-3xl',
2: 'mt-6 text-2xl',
3: 'mt-4 text-xl',
};
export default function HeadingBlock({ node, version }: BlockCommonProps<TreeNode>) {
return (
<div className={`${fontSize[node.data.level]} font-semibold `}>
<TextBlock version={version} node={node} needRenderChildren={false} />
</div>
);
}

View File

@ -1,18 +0,0 @@
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
import React, { useMemo } from 'react';
import ColumnBlock from '../ColumnBlock';
export default function ColumnListBlock({ node }: { node: TreeNode }) {
const resizerWidth = useMemo(() => {
return 46 * (node.children?.length || 0);
}, [node.children?.length]);
return (
<>
<div className='column-list-block flex-grow-1 flex flex-row'>
{node.children?.map((item, index) => (
<ColumnBlock key={item.id} index={index} resizerWidth={resizerWidth} node={item} />
))}
</div>
</>
);
}

View File

@ -1,31 +0,0 @@
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
import BlockComponent from '../BlockComponent';
import { BlockType } from '@/appflowy_app/interfaces';
import { Block } from '@/appflowy_app/block_editor/core/block';
export default function NumberedListBlock({ title, node }: { title: JSX.Element; node: TreeNode }) {
let prev = node.block.prev;
let index = 1;
while (prev && prev.type === BlockType.ListBlock && (prev as Block<BlockType.ListBlock>).data.type === 'numbered') {
index++;
prev = prev.prev;
}
return (
<div className='numbered-list-block'>
<div className='relative flex'>
<div
className={`relative flex h-[calc(1.5em_+_3px_+_3px)] min-w-[24px] max-w-[24px] select-none items-center`}
>{`${index} .`}</div>
{title}
</div>
<div className='pl-[24px]'>
{node.children?.map((item) => (
<div key={item.id}>
<BlockComponent node={item} />
</div>
))}
</div>
</div>
);
}

View File

@ -1,6 +0,0 @@
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
import { BlockCommonProps } from '@/appflowy_app/interfaces';
export default function PageBlock({ node }: BlockCommonProps<TreeNode>) {
return <div className='cursor-pointer underline'>{node.data.title}</div>;
}

View File

@ -1,98 +0,0 @@
import { TreeNode } from "@/appflowy_app/block_editor/view/tree_node";
import { triggerHotkey } from "@/appflowy_app/utils/slate/hotkey";
import { useCallback, useContext, useLayoutEffect, useState } from "react";
import { Transforms, createEditor, Descendant } from 'slate';
import { ReactEditor, withReact } from 'slate-react';
import { TextBlockContext } from '$app/utils/slate/context';
export function useTextBlock({
node,
}: {
node: TreeNode;
}) {
const [editor] = useState(() => withReact(createEditor()));
const { textBlockManager } = useContext(TextBlockContext);
const value = [
{
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
type: 'paragraph',
children: node.data.content,
},
];
const onChange = useCallback(
(e: Descendant[]) => {
if (!editor.operations || editor.operations.length === 0) return;
if (editor.operations[0].type !== 'set_selection') {
console.log('====text block ==== ', editor.operations)
const children = 'children' in e[0] ? e[0].children : [];
textBlockManager?.update(node, ['data', 'content'], children);
} else {
const newProperties = editor.operations[0].newProperties;
textBlockManager?.setSelection(node, editor.selection);
}
},
[node.id, editor],
);
const onKeyDownCapture = (event: React.KeyboardEvent<HTMLDivElement>) => {
switch (event.key) {
case 'Enter': {
event.stopPropagation();
event.preventDefault();
textBlockManager?.splitNode(node, editor);
return;
}
}
triggerHotkey(event, editor);
}
const { focusId, selection } = textBlockManager!.selectionManager.getFocusSelection();
editor.children = value;
Transforms.collapse(editor);
useLayoutEffect(() => {
let timer: NodeJS.Timeout;
if (focusId === node.id && selection) {
ReactEditor.focus(editor);
Transforms.select(editor, selection);
// Use setTimeout to delay setting the selection
// until Slate has fully loaded and rendered all components and contents,
// to ensure that the operation succeeds.
timer = setTimeout(() => {
Transforms.select(editor, selection);
}, 100);
}
return () => timer && clearTimeout(timer)
}, [editor]);
const onDOMBeforeInput = useCallback((e: InputEvent) => {
// COMPAT: in Apple, `compositionend` is dispatched after the
// `beforeinput` for "insertFromComposition". It will cause repeated characters when inputting Chinese.
// Here, prevent the beforeInput event and wait for the compositionend event to take effect
if (e.inputType === 'insertFromComposition') {
e.preventDefault();
}
}, []);
return {
editor,
value,
onChange,
onKeyDownCapture,
onDOMBeforeInput,
}
}

View File

@ -1,43 +0,0 @@
import BlockComponent from '../BlockComponent';
import { Slate, Editable } from 'slate-react';
import Leaf from './Leaf';
import HoveringToolbar from '$app/components/HoveringToolbar';
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
import { useTextBlock } from './index.hooks';
import { BlockCommonProps, TextBlockToolbarProps } from '@/appflowy_app/interfaces';
import { toolbarDefaultProps } from '@/appflowy_app/constants/toolbar';
export default function TextBlock({
node,
needRenderChildren = true,
toolbarProps,
...props
}: {
needRenderChildren?: boolean;
toolbarProps?: TextBlockToolbarProps;
} & BlockCommonProps<TreeNode> &
React.HTMLAttributes<HTMLDivElement>) {
const { editor, value, onChange, onKeyDownCapture, onDOMBeforeInput } = useTextBlock({ node });
const { showGroups } = toolbarProps || toolbarDefaultProps;
return (
<div {...props} className={`${props.className} py-1`}>
<Slate editor={editor} onChange={onChange} value={value}>
{showGroups.length > 0 && <HoveringToolbar node={node} blockId={node.id} />}
<Editable
onKeyDownCapture={onKeyDownCapture}
onDOMBeforeInput={onDOMBeforeInput}
renderLeaf={(leafProps) => <Leaf {...leafProps} />}
placeholder='Enter some text...'
/>
</Slate>
{needRenderChildren && node.children.length > 0 ? (
<div className='pl-[1.5em]'>
{node.children.map((item) => (
<BlockComponent key={item.id} node={item} />
))}
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,9 @@
import ReactDOM from 'react-dom';
const BlockPortal = ({ blockId, children }: { blockId: string; children: JSX.Element }) => {
const root = document.querySelectorAll(`[data-block-id="${blockId}"] > .block-overlay`)[0];
return typeof document === 'object' && root ? ReactDOM.createPortal(children, root) : null;
};
export default BlockPortal;

View File

@ -1,13 +1,25 @@
import { BlockEditor } from '@/appflowy_app/block_editor';
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useAppDispatch } from '$app/stores/store';
import { documentActions } from '@/appflowy_app/stores/reducers/document/slice';
export function useBlockSelection({ container, blockEditor }: { container: HTMLDivElement; blockEditor: BlockEditor }) {
const blockPositionManager = blockEditor.renderTree.blockPositionManager;
export function useBlockSelection({
container,
onDragging,
}: {
container: HTMLDivElement;
onDragging?: (_isDragging: boolean) => void;
}) {
const ref = useRef<HTMLDivElement | null>(null);
const disaptch = useAppDispatch();
const [isDragging, setDragging] = useState(false);
const pointRef = useRef<number[]>([]);
const startScrollTopRef = useRef<number>(0);
useEffect(() => {
onDragging?.(isDragging);
}, [isDragging]);
const [rect, setRect] = useState<{
startX: number;
startY: number;
@ -62,7 +74,7 @@ export function useBlockSelection({ container, blockEditor }: { container: HTMLD
const calcIntersectBlocks = useCallback(
(clientX: number, clientY: number) => {
if (!isDragging || !blockPositionManager) return;
if (!isDragging) return;
const [startX, startY] = pointRef.current;
const endX = clientX + container.scrollLeft;
const endY = clientY + container.scrollTop;
@ -73,22 +85,23 @@ export function useBlockSelection({ container, blockEditor }: { container: HTMLD
endX,
endY,
});
const selectedBlocks = blockPositionManager.getIntersectBlocks(
Math.min(startX, endX),
Math.min(startY, endY),
Math.max(startX, endX),
Math.max(startY, endY)
disaptch(
documentActions.changeSelectionByIntersectRect({
startX: Math.min(startX, endX),
startY: Math.min(startY, endY),
endX: Math.max(startX, endX),
endY: Math.max(startY, endY),
})
);
const ids = selectedBlocks.map((item) => item.id);
blockEditor.renderTree.updateSelections(ids);
},
[isDragging]
);
const handleDraging = useCallback(
(e: MouseEvent) => {
if (!isDragging || !blockPositionManager) return;
if (!isDragging) return;
e.preventDefault();
e.stopPropagation();
calcIntersectBlocks(e.clientX, e.clientY);
const { top, bottom } = container.getBoundingClientRect();
@ -106,7 +119,7 @@ export function useBlockSelection({ container, blockEditor }: { container: HTMLD
const handleDragEnd = useCallback(
(e: MouseEvent) => {
if (isPointInBlock(e.target as HTMLElement) && !isDragging) {
blockEditor.renderTree.updateSelections([]);
disaptch(documentActions.updateSelections([]));
return;
}
if (!isDragging) return;
@ -119,19 +132,21 @@ export function useBlockSelection({ container, blockEditor }: { container: HTMLD
);
useEffect(() => {
window.addEventListener('mousedown', handleDragStart);
window.addEventListener('mousemove', handleDraging);
window.addEventListener('mouseup', handleDragEnd);
if (!ref.current) return;
document.addEventListener('mousedown', handleDragStart);
document.addEventListener('mousemove', handleDraging);
document.addEventListener('mouseup', handleDragEnd);
return () => {
window.removeEventListener('mousedown', handleDragStart);
window.removeEventListener('mousemove', handleDraging);
window.removeEventListener('mouseup', handleDragEnd);
document.removeEventListener('mousedown', handleDragStart);
document.removeEventListener('mousemove', handleDraging);
document.removeEventListener('mouseup', handleDragEnd);
};
}, [handleDragStart, handleDragEnd, handleDraging]);
return {
isDragging,
style,
ref,
};
}

View File

@ -0,0 +1,23 @@
import { useBlockSelection } from './BlockSelection.hooks';
import React from 'react';
function BlockSelection({
container,
onDragging,
}: {
container: HTMLDivElement;
onDragging?: (_isDragging: boolean) => void;
}) {
const { isDragging, style, ref } = useBlockSelection({
container,
onDragging,
});
return (
<div ref={ref} className='appflowy-block-selection-overlay z-1 pointer-events-none fixed inset-0 overflow-hidden'>
{isDragging ? <div className='z-99 absolute bg-[#00d5ff] opacity-25' style={style} /> : null}
</div>
);
}
export default React.memo(BlockSelection);

View File

@ -0,0 +1,126 @@
import { BlockType } 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';
export function useBlockSideTools({ container }: { container: HTMLDivElement }) {
const [nodeId, setHoverNodeId] = useState<string>('');
const ref = useRef<HTMLDivElement | null>(null);
const nodes = useAppSelector((state) => state.document.nodes);
const { insertAfter } = useController();
const handleMouseMove = useCallback((e: MouseEvent) => {
const { clientX, clientY } = e;
const x = clientX;
const y = clientY;
const id = getNodeIdByPoint(x, y);
if (!id) {
setHoverNodeId('');
} else {
if ([BlockType.ColumnBlock].includes(nodes[id].type)) {
setHoverNodeId('');
return;
}
setHoverNodeId(id);
}
}, []);
const debounceMove = useMemo(() => debounce(handleMouseMove, 30), [handleMouseMove]);
useEffect(() => {
const el = ref.current;
if (!el || !nodeId) return;
const node = nodes[nodeId];
if (!node) {
el.style.opacity = '0';
el.style.zIndex = '-1';
} else {
el.style.opacity = '1';
el.style.zIndex = '1';
el.style.top = '1px';
if (node?.type === BlockType.HeadingBlock) {
if (node.data.style?.level === 1) {
el.style.top = '8px';
} else if (node.data.style?.level === 2) {
el.style.top = '6px';
} else {
el.style.top = '5px';
}
}
}
}, [nodeId, nodes]);
const handleAddClick = useCallback(() => {
if (!nodeId) return;
insertAfter(nodes[nodeId]);
}, [nodeId, nodes]);
useEffect(() => {
container.addEventListener('mousemove', debounceMove);
return () => {
container.removeEventListener('mousemove', debounceMove);
};
}, [debounceMove]);
return {
nodeId,
ref,
handleAddClick,
};
}
function useController() {
const controller = useContext(DocumentControllerContext);
const insertAfter = useCallback((node: Node) => {
const parentId = node.parent;
if (!parentId || !controller) return;
controller.transact([
() => {
const newNode = {
id: v4(),
delta: [],
type: BlockType.TextBlock,
};
controller.insert(newNode, parentId, node.id);
},
]);
}, []);
return {
insertAfter,
};
}
function getNodeIdByPoint(x: number, y: number) {
const viewportNodes = document.querySelectorAll('[data-block-id]');
let node: {
el: Element;
rect: DOMRect;
} | null = null;
viewportNodes.forEach((el) => {
const rect = el.getBoundingClientRect();
if (rect.x + rect.width - 1 >= x && rect.y + rect.height - 1 >= y && rect.y <= y) {
if (!node || rect.y > node.rect.y) {
node = {
el,
rect,
};
}
}
});
return node
? (
node as {
el: Element;
rect: DOMRect;
}
).el.getAttribute('data-block-id')
: null;
}

View File

@ -0,0 +1,36 @@
import React from 'react';
import { useBlockSideTools } from './BlockSideTools.hooks';
import AddIcon from '@mui/icons-material/Add';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import Portal from '../BlockPortal';
import { IconButton } from '@mui/material';
const sx = { height: 24, width: 24 };
export default function BlockSideTools(props: { container: HTMLDivElement }) {
const { nodeId, ref, handleAddClick } = useBlockSideTools(props);
if (!nodeId) return null;
return (
<Portal blockId={nodeId}>
<div
ref={ref}
style={{
opacity: 0,
}}
className='z-1 absolute left-[-50px] inline-flex h-[calc(1.5em_+_3px)] transition-opacity duration-500'
onMouseDown={(e) => {
// prevent toolbar from taking focus away from editor
e.preventDefault();
}}
>
<IconButton onClick={() => handleAddClick()} sx={sx}>
<AddIcon />
</IconButton>
<IconButton sx={sx}>
<DragIndicatorIcon />
</IconButton>
</div>
</Portal>
);
}

View File

@ -0,0 +1,3 @@
export default function CodeBlock({ id }: { id: string }) {
return <div>{id}</div>;
}

View File

@ -1,17 +1,7 @@
import React from 'react';
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
import NodeComponent from '../Node';
import BlockComponent from '../BlockComponent';
export default function ColumnBlock({
node,
resizerWidth,
index,
}: {
node: TreeNode;
resizerWidth: number;
index: number;
}) {
export default function ColumnBlock({ id, index, width }: { id: string; index: number; width: string }) {
const renderResizer = () => {
return (
<div className={`relative w-[46px] flex-shrink-0 flex-grow-0 transition-opacity`} style={{ opacity: 0 }}></div>
@ -35,15 +25,14 @@ export default function ColumnBlock({
renderResizer()
)}
<BlockComponent
<NodeComponent
className={`column-block py-3`}
style={{
flexGrow: 0,
flexShrink: 0,
width: `calc((100% - ${resizerWidth}px) * ${node.data.ratio})`,
width,
}}
node={node}
renderChild={(item) => <BlockComponent key={item.id} node={item} />}
id={id}
/>
</>
);

View File

@ -0,0 +1,8 @@
import { useSubscribeNode } from '../_shared/SubscribeNode.hooks';
export function useDocumentTitle(id: string) {
const { node, delta } = useSubscribeNode(id);
return {
node,
delta
}
}

View File

@ -0,0 +1,13 @@
import React from 'react';
import { useDocumentTitle } from './DocumentTitle.hooks';
import TextBlock from '../TextBlock';
export default function DocumentTitle({ id }: { id: string }) {
const { node, delta } = useDocumentTitle(id);
if (!node) return null;
return (
<div data-block-id={node.id} className='doc-title relative pt-[50px] text-4xl font-bold'>
<TextBlock placeholder='Untitled' childIds={[]} delta={delta || []} node={node} />
</div>
);
}

View File

@ -0,0 +1,17 @@
import TextBlock from '../TextBlock';
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
import { TextDelta } from '@/appflowy_app/interfaces/document';
const fontSize: Record<string, string> = {
1: 'mt-8 text-3xl',
2: 'mt-6 text-2xl',
3: 'mt-4 text-xl',
};
export default function HeadingBlock({ node, delta }: { node: Node; delta: TextDelta[] }) {
return (
<div className={`${fontSize[node.data.style?.level]} font-semibold `}>
<TextBlock node={node} childIds={[]} delta={delta} />
</div>
);
}

View File

@ -1,11 +1,9 @@
import { useEffect, useRef } from 'react';
import { useFocused, useSlate } from 'slate-react';
import { calcToolbarPosition } from '@/appflowy_app/utils/slate/toolbar';
import { TreeNode } from '$app/block_editor/view/tree_node';
export function useHoveringToolbar({node}: {
node: TreeNode
}) {
export function useHoveringToolbar(id: string) {
const editor = useSlate();
const inFocus = useFocused();
const ref = useRef<HTMLDivElement | null>(null);
@ -13,7 +11,7 @@ export function useHoveringToolbar({node}: {
useEffect(() => {
const el = ref.current;
if (!el) return;
const nodeRect = document.querySelector(`[data-block-id=${node.id}]`)?.getBoundingClientRect();
const nodeRect = document.querySelector(`[data-block-id="${id}"]`)?.getBoundingClientRect();
if (!nodeRect) return;
const position = calcToolbarPosition(editor, el, nodeRect);

View File

@ -1,14 +1,13 @@
import FormatButton from './FormatButton';
import Portal from './Portal';
import { TreeNode } from '$app/block_editor/view/tree_node';
import Portal from '../BlockPortal';
import { useHoveringToolbar } from './index.hooks';
const HoveringToolbar = ({ blockId, node }: { blockId: string; node: TreeNode }) => {
const { inFocus, ref, editor } = useHoveringToolbar({ node });
const HoveringToolbar = ({ id }: { id: string }) => {
const { inFocus, ref, editor } = useHoveringToolbar(id);
if (!inFocus) return null;
return (
<Portal blockId={blockId}>
<Portal blockId={id}>
<div
ref={ref}
style={{

View File

@ -1,9 +1,16 @@
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
import { Circle } from '@mui/icons-material';
import NodeComponent from '../Node';
import BlockComponent from '../BlockComponent';
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
export default function BulletedListBlock({ title, node }: { title: JSX.Element; node: TreeNode }) {
export default function BulletedListBlock({
title,
node,
childIds,
}: {
title: JSX.Element;
node: Node;
childIds?: string[];
}) {
return (
<div className='bulleted-list-block relative'>
<div className='relative flex'>
@ -14,10 +21,8 @@ export default function BulletedListBlock({ title, node }: { title: JSX.Element;
</div>
<div className='pl-[24px]'>
{node.children?.map((item) => (
<div key={item.id}>
<BlockComponent node={item} />
</div>
{childIds?.map((item) => (
<NodeComponent key={item} id={item} />
))}
</div>
</div>

View File

@ -0,0 +1,23 @@
import React, { useMemo } from 'react';
import ColumnBlock from '../ColumnBlock';
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
export default function ColumnListBlock({ node, childIds }: { node: Node; childIds?: string[] }) {
const resizerWidth = useMemo(() => {
return 46 * (node.children?.length || 0);
}, [node.children?.length]);
return (
<>
<div className='column-list-block flex-grow-1 flex flex-row'>
{childIds?.map((item, index) => (
<ColumnBlock
key={item}
index={index}
width={`calc((100% - ${resizerWidth}px) * ${node.data.style?.ratio})`}
id={item}
/>
))}
</div>
</>
);
}

View File

@ -0,0 +1,30 @@
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
import NodeComponent from '../Node';
export default function NumberedListBlock({
title,
node,
childIds,
}: {
title: JSX.Element;
node: Node;
childIds?: string[];
}) {
const index = 1;
return (
<div className='numbered-list-block'>
<div className='relative flex'>
<div
className={`relative flex h-[calc(1.5em_+_3px_+_3px)] min-w-[24px] max-w-[24px] select-none items-center`}
>{`${index} .`}</div>
{title}
</div>
<div className='pl-[24px]'>
{childIds?.map((item) => (
<NodeComponent key={item} id={item} />
))}
</div>
</div>
);
}

View File

@ -3,28 +3,28 @@ import TextBlock from '../TextBlock';
import NumberedListBlock from './NumberedListBlock';
import BulletedListBlock from './BulletedListBlock';
import ColumnListBlock from './ColumnListBlock';
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
import { BlockCommonProps } from '@/appflowy_app/interfaces';
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
import { TextDelta } from '@/appflowy_app/interfaces/document';
export default function ListBlock({ node, version }: BlockCommonProps<TreeNode>) {
export default function ListBlock({ node, delta }: { node: Node; delta: TextDelta[] }) {
const title = useMemo(() => {
if (node.data.type === 'column') return <></>;
if (node.data.style?.type === 'column') return <></>;
return (
<div className='flex-1'>
<TextBlock version={version} node={node} needRenderChildren={false} />
<TextBlock delta={delta} node={node} childIds={[]} />
</div>
);
}, [node, version]);
}, [node, delta]);
if (node.data.type === 'numbered') {
if (node.data.style?.type === 'numbered') {
return <NumberedListBlock title={title} node={node} />;
}
if (node.data.type === 'bulleted') {
if (node.data.style?.type === 'bulleted') {
return <BulletedListBlock title={title} node={node} />;
}
if (node.data.type === 'column') {
if (node.data.style?.type === 'column') {
return <ColumnListBlock node={node} />;
}

View File

@ -0,0 +1,36 @@
import { useEffect, useRef } from 'react';
import { useSubscribeNode } from '../_shared/SubscribeNode.hooks';
import { useAppDispatch } from '$app/stores/store';
import { documentActions } from '$app/stores/reducers/document/slice';
export function useNode(id: string) {
const { node, childIds, delta, isSelected } = useSubscribeNode(id);
const ref = useRef<HTMLDivElement>(null);
const dispatch = useAppDispatch();
useEffect(() => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
const scrollContainer = document.querySelector('.doc-scroller-container') as HTMLDivElement;
dispatch(documentActions.updateNodePosition({
id,
rect: {
x: rect.x,
y: rect.y + scrollContainer.scrollTop,
height: rect.height,
width: rect.width
}
}))
}, [])
return {
ref,
node,
childIds,
delta,
isSelected
}
}

View File

@ -0,0 +1,42 @@
import React, { useCallback } from 'react';
import { useNode } from './Node.hooks';
import { withErrorBoundary } from 'react-error-boundary';
import { ErrorBoundaryFallbackComponent } from '../_shared/ErrorBoundaryFallbackComponent';
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
import TextBlock from '../TextBlock';
import { TextDelta } from '@/appflowy_app/interfaces/document';
function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
const { node, childIds, delta, isSelected, ref } = useNode(id);
console.log('=====', id);
const renderBlock = useCallback((_props: { node: Node; childIds?: string[]; delta?: TextDelta[] }) => {
switch (_props.node.type) {
case 'text':
if (!_props.delta) return null;
return <TextBlock {..._props} delta={_props.delta} />;
default:
break;
}
}, []);
if (!node) return null;
return (
<div {...props} ref={ref} data-block-id={node.id} className={`relative my-[2px] px-[2px] ${props.className}`}>
{renderBlock({
node,
childIds,
delta,
})}
<div className='block-overlay' />
{isSelected ? <div className='pointer-events-none absolute inset-0 z-[-1] rounded-[4px] bg-[#E0F8FF]' /> : null}
</div>
);
}
const NodeWithErrorBoundary = withErrorBoundary(NodeComponent, {
FallbackComponent: ErrorBoundaryFallbackComponent,
});
export default React.memo(NodeWithErrorBoundary);

View File

@ -0,0 +1,13 @@
import React, { useState } from 'react';
import BlockSideTools from '../BlockSideTools';
import BlockSelection from '../BlockSelection';
export default function Overlay({ container }: { container: HTMLDivElement }) {
const [isDragging, setDragging] = useState(false);
return (
<>
{isDragging ? null : <BlockSideTools container={container} />}
<BlockSelection onDragging={setDragging} container={container} />
</>
);
}

View File

@ -0,0 +1,16 @@
import { DocumentData } from '$app/interfaces/document';
import { useSubscribeNode } from '../_shared/SubscribeNode.hooks';
import { useParseTree } from './Tree.hooks';
export function useRoot({ documentData }: { documentData: DocumentData }) {
const { rootId } = documentData;
useParseTree(documentData);
const { node: rootNode, childIds: rootChildIds } = useSubscribeNode(rootId);
return {
node: rootNode,
childIds: rootChildIds,
};
}

View File

@ -0,0 +1,23 @@
import { useEffect } from 'react';
import { DocumentData } from '$app/interfaces/document';
import { useAppDispatch } from '@/appflowy_app/stores/store';
import { documentActions } from '$app/stores/reducers/document/slice';
export function useParseTree(documentData: DocumentData) {
const dispatch = useAppDispatch();
const { blocks, ytexts, yarrays } = documentData;
useEffect(() => {
dispatch(
documentActions.createTree({
nodes: blocks,
delta: ytexts,
children: yarrays,
})
);
return () => {
dispatch(documentActions.clear());
};
}, [documentData]);
}

View File

@ -0,0 +1,32 @@
import { DocumentData } from '@/appflowy_app/interfaces/document';
import React, { useCallback } from 'react';
import { useRoot } from './Root.hooks';
import Node from '../Node';
import { withErrorBoundary } from 'react-error-boundary';
import { ErrorBoundaryFallbackComponent } from '../_shared/ErrorBoundaryFallbackComponent';
import VirtualizerList from '../VirtualizerList';
import { Skeleton } from '@mui/material';
function Root({ documentData }: { documentData: DocumentData }) {
const { node, childIds } = useRoot({ documentData });
const renderNode = useCallback((nodeId: string) => {
return <Node key={nodeId} id={nodeId} />;
}, []);
if (!node || !childIds) {
return <Skeleton />;
}
return (
<div id='appflowy-block-doc' className='h-[100%] overflow-hidden'>
<VirtualizerList node={node} childIds={childIds} renderNode={renderNode} />
</div>
);
}
const RootWithErrorBoundary = withErrorBoundary(Root, {
FallbackComponent: ErrorBoundaryFallbackComponent,
});
export default React.memo(RootWithErrorBoundary);

View File

@ -0,0 +1,61 @@
import { useEffect, useMemo, useRef } from "react";
import { createEditor } from "slate";
import { withReact } from "slate-react";
import * as Y from 'yjs';
import { withYjs, YjsEditor, slateNodesToInsertDelta } from '@slate-yjs/core';
import { Delta } from '@slate-yjs/core/dist/model/types';
import { TextDelta } from '@/appflowy_app/interfaces/document';
const initialValue = [{
type: 'paragraph',
children: [{ text: '' }],
}];
export function useBindYjs(delta: TextDelta[], update: (_delta: TextDelta[]) => void) {
const yTextRef = useRef<Y.XmlText>();
// Create a yjs document and get the shared type
const sharedType = useMemo(() => {
const ydoc = new Y.Doc()
const _sharedType = ydoc.get('content', Y.XmlText) as Y.XmlText;
const insertDelta = slateNodesToInsertDelta(initialValue);
// Load the initial value into the yjs document
_sharedType.applyDelta(insertDelta);
const yText = insertDelta[0].insert as Y.XmlText;
yTextRef.current = yText;
return _sharedType;
}, []);
const editor = useMemo(() => withYjs(withReact(createEditor()), sharedType), []);
useEffect(() => {
YjsEditor.connect(editor);
return () => {
yTextRef.current = undefined;
YjsEditor.disconnect(editor);
}
}, [editor]);
useEffect(() => {
const yText = yTextRef.current;
if (!yText) return;
const textEventHandler = (event: Y.YTextEvent) => {
update(event.changes.delta as TextDelta[]);
}
yText.applyDelta(delta);
yText.observe(textEventHandler);
return () => {
yText.unobserve(textEventHandler);
}
}, [delta])
return { editor }
}

View File

@ -0,0 +1,110 @@
import { triggerHotkey } from "@/appflowy_app/utils/slate/hotkey";
import { useCallback, useContext, useMemo, useRef, useState } from "react";
import { Descendant, Range } from "slate";
import { useBindYjs } from "./BindYjs.hooks";
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { TextDelta } from '$app/interfaces/document';
import { debounce } from "@/appflowy_app/utils/tool";
function useController(textId: string) {
const docController = useContext(DocumentControllerContext);
const update = useCallback(
(delta: TextDelta[]) => {
docController?.yTextApply(textId, delta)
},
[textId],
);
const transact = useCallback(
(actions: (() => void)[]) => {
docController?.transact(actions)
},
[textId],
)
return {
update,
transact
}
}
function useTransact(textId: string) {
const pendingActions = useRef<(() => void)[]>([]);
const { update, transact } = useController(textId);
const sendTransact = useCallback(
() => {
const actions = pendingActions.current;
transact(actions);
},
[transact],
)
const debounceSendTransact = useMemo(() => debounce(sendTransact, 300), [transact]);
const sendDelta = useCallback(
(delta: TextDelta[]) => {
const action = () => update(delta);
pendingActions.current.push(action);
debounceSendTransact()
},
[update, debounceSendTransact],
);
return {
sendDelta
}
}
export function useTextBlock(text: string, delta: TextDelta[]) {
const { sendDelta } = useTransact(text);
const { editor } = useBindYjs(delta, sendDelta);
const [value, setValue] = useState<Descendant[]>([]);
const onChange = useCallback(
(e: Descendant[]) => {
setValue(e);
},
[editor],
);
const onKeyDownCapture = (event: React.KeyboardEvent<HTMLDivElement>) => {
switch (event.key) {
case 'Enter': {
event.stopPropagation();
event.preventDefault();
return;
}
case 'Backspace': {
if (!editor.selection) return;
const { anchor } = editor.selection;
const isCollapase = Range.isCollapsed(editor.selection);
if (isCollapase && anchor.offset === 0 && anchor.path.toString() === '0,0') {
event.stopPropagation();
event.preventDefault();
return;
}
}
}
triggerHotkey(event, editor);
}
const onDOMBeforeInput = useCallback((e: InputEvent) => {
// COMPAT: in Apple, `compositionend` is dispatched after the
// `beforeinput` for "insertFromComposition". It will cause repeated characters when inputting Chinese.
// Here, prevent the beforeInput event and wait for the compositionend event to take effect
if (e.inputType === 'insertFromComposition') {
e.preventDefault();
}
}, []);
return {
onChange,
onKeyDownCapture,
onDOMBeforeInput,
editor,
value
}
}

View File

@ -0,0 +1,46 @@
import { Slate, Editable } from 'slate-react';
import Leaf from './Leaf';
import { useTextBlock } from './TextBlock.hooks';
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
import NodeComponent from '../Node';
import HoveringToolbar from '../HoveringToolbar';
import { TextDelta } from '@/appflowy_app/interfaces/document';
import React from 'react';
function TextBlock({
node,
childIds,
placeholder,
delta,
...props
}: {
node: Node;
delta: TextDelta[];
childIds?: string[];
placeholder?: string;
} & React.HTMLAttributes<HTMLDivElement>) {
const { editor, value, onChange, onKeyDownCapture, onDOMBeforeInput } = useTextBlock(node.data.text!, delta);
return (
<div {...props} className={`py-[2px] ${props.className}`}>
<Slate editor={editor} onChange={onChange} value={value}>
<HoveringToolbar id={node.id} />
<Editable
onKeyDownCapture={onKeyDownCapture}
onDOMBeforeInput={onDOMBeforeInput}
renderLeaf={(leafProps) => <Leaf {...leafProps} />}
placeholder={placeholder || 'Please enter some text...'}
/>
</Slate>
{childIds && childIds.length > 0 ? (
<div className='pl-[1.5em]'>
{childIds.map((item) => (
<NodeComponent key={item} id={item} />
))}
</div>
) : null}
</div>
);
}
export default React.memo(TextBlock);

View File

@ -0,0 +1,21 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
const defaultSize = 60;
export function useVirtualizerList(count: number) {
const parentRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count,
getScrollElement: () => parentRef.current,
estimateSize: () => {
return defaultSize;
},
});
return {
rowVirtualizer,
parentRef,
};
}

View File

@ -0,0 +1,59 @@
import React from 'react';
import { useVirtualizerList } from './VirtualizerList.hooks';
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
import DocumentTitle from '../DocumentTitle';
import Overlay from '../Overlay';
export default function VirtualizerList({
childIds,
node,
renderNode,
}: {
childIds: string[];
node: Node;
renderNode: (nodeId: string) => JSX.Element;
}) {
const { rowVirtualizer, parentRef } = useVirtualizerList(childIds.length);
const virtualItems = rowVirtualizer.getVirtualItems();
return (
<>
<div
ref={parentRef}
className={`doc-scroller-container flex h-[100%] flex-wrap justify-center overflow-auto px-20`}
>
<div
className='doc-body max-w-screen w-[900px] min-w-0'
style={{
height: rowVirtualizer.getTotalSize(),
position: 'relative',
}}
>
{node && childIds && virtualItems.length ? (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItems[0].start || 0}px)`,
}}
>
{virtualItems.map((virtualRow) => {
const id = childIds[virtualRow.index];
return (
<div className='p-[1px]' key={id} data-index={virtualRow.index} ref={rowVirtualizer.measureElement}>
{virtualRow.index === 0 ? <DocumentTitle id={node.id} /> : null}
{renderNode(id)}
</div>
);
})}
</div>
) : null}
</div>
</div>
{parentRef.current ? <Overlay container={parentRef.current} /> : null}
</>
);
}

View File

@ -0,0 +1,12 @@
import { Alert } from '@mui/material';
import { FallbackProps } from 'react-error-boundary';
export function ErrorBoundaryFallbackComponent({ error, resetErrorBoundary }: FallbackProps) {
return (
<Alert severity='error' className='mb-2'>
<p>Something went wrong:</p>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</Alert>
);
}

View File

@ -0,0 +1,32 @@
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
import { useAppSelector } from '@/appflowy_app/stores/store';
import { useMemo } from 'react';
import { TextDelta } from '@/appflowy_app/interfaces/document';
export function useSubscribeNode(id: string) {
const node = useAppSelector<Node>(state => state.document.nodes[id]);
const childIds = useAppSelector<string[] | undefined>(state => {
const childrenId = state.document.nodes[id]?.children;
if (!childrenId) return;
return state.document.children[childrenId];
});
const delta = useAppSelector<TextDelta[] | undefined>(state => {
const deltaId = state.document.nodes[id]?.data?.text;
if (!deltaId) return;
return state.document.delta[deltaId];
});
const isSelected = useAppSelector<boolean>(state => {
return state.document.selections?.includes(id) || false;
});
const memoizedNode = useMemo(() => node, [node?.id, node?.data, node?.parent, node?.type, node?.children]);
const memoizedChildIds = useMemo(() => childIds, [JSON.stringify(childIds)]);
const memoizedDelta = useMemo(() => delta, [JSON.stringify(delta)]);
return {
node: memoizedNode,
childIds: memoizedChildIds,
delta: memoizedDelta,
isSelected
};
}

View File

@ -1,4 +1,3 @@
import { TextBlockToolbarGroup } from "../interfaces";
export const iconSize = { width: 18, height: 18 };
@ -24,16 +23,3 @@ export const command: Record<string, { title: string; key: string }> = {
key: '⌘ + Shift + S or ⌘ + Shift + X',
},
};
export const toolbarDefaultProps = {
showGroups: [
TextBlockToolbarGroup.ASK_AI,
TextBlockToolbarGroup.BLOCK_SELECT,
TextBlockToolbarGroup.ADD_LINK,
TextBlockToolbarGroup.COMMENT,
TextBlockToolbarGroup.TEXT_FORMAT,
TextBlockToolbarGroup.TEXT_COLOR,
TextBlockToolbarGroup.MENTION,
TextBlockToolbarGroup.MORE,
],
};

View File

@ -0,0 +1,31 @@
// eslint-disable-next-line no-shadow
export enum BlockType {
PageBlock = 'page',
HeadingBlock = 'heading',
ListBlock = 'list',
TextBlock = 'text',
CodeBlock = 'code',
EmbedBlock = 'embed',
QuoteBlock = 'quote',
DividerBlock = 'divider',
MediaBlock = 'media',
TableBlock = 'table',
ColumnBlock = 'column'
}
export interface NestedBlock {
id: string;
type: BlockType;
data: Record<string, any>;
parent: string | null;
children: string;
}
export interface TextDelta {
insert: string;
attributes?: Record<string, string | boolean>;
}
export interface DocumentData {
rootId: string;
blocks: Record<string, NestedBlock>;
ytexts: Record<string, TextDelta[]>;
yarrays: Record<string, string[]>;
}

View File

@ -1,112 +1 @@
import { Descendant } from "slate";
// eslint-disable-next-line no-shadow
export enum BlockType {
PageBlock = 'page',
HeadingBlock = 'heading',
ListBlock = 'list',
TextBlock = 'text',
CodeBlock = 'code',
EmbedBlock = 'embed',
QuoteBlock = 'quote',
DividerBlock = 'divider',
MediaBlock = 'media',
TableBlock = 'table',
ColumnBlock = 'column'
}
export type BlockData<T = BlockType> = T extends BlockType.TextBlock ? TextBlockData :
T extends BlockType.PageBlock ? PageBlockData :
T extends BlockType.HeadingBlock ? HeadingBlockData :
T extends BlockType.ListBlock ? ListBlockData :
T extends BlockType.ColumnBlock ? ColumnBlockData : any;
export interface BlockInterface<T = BlockType> {
id: string;
type: BlockType;
data: BlockData<T>;
next: string | null;
firstChild: string | null;
}
export interface TextBlockData {
content: Descendant[];
}
interface PageBlockData {
title: string;
}
interface ListBlockData extends TextBlockData {
type: 'numbered' | 'bulleted' | 'column';
}
interface HeadingBlockData extends TextBlockData {
level: number;
}
interface ColumnBlockData {
ratio: string;
}
// eslint-disable-next-line no-shadow
export enum TextBlockToolbarGroup {
ASK_AI,
BLOCK_SELECT,
ADD_LINK,
COMMENT,
TEXT_FORMAT,
TEXT_COLOR,
MENTION,
MORE
}
export interface TextBlockToolbarProps {
showGroups: TextBlockToolbarGroup[]
}
export interface BlockCommonProps<T> {
version: number;
node: T;
}
export interface BackendOp {
type: 'update' | 'insert' | 'remove' | 'move' | 'move_range';
version: number;
data: UpdateOpData | InsertOpData | moveRangeOpData | moveOpData | removeOpData;
}
export interface LocalOp {
type: 'update' | 'insert' | 'remove' | 'move' | 'move_range';
version: number;
data: UpdateOpData | InsertOpData | moveRangeOpData | moveOpData | removeOpData;
}
export interface UpdateOpData {
blockId: string;
value: BlockData;
path: string[];
}
export interface InsertOpData {
block: BlockInterface;
parentId: string;
prevId?: string
}
export interface moveRangeOpData {
range: [string, string];
newParentId: string;
newPrevId?: string
}
export interface moveOpData {
blockId: string;
newParentId: string;
newPrevId?: string
}
export interface removeOpData {
blockId: string
}
export interface Document {}

View File

@ -0,0 +1,50 @@
import { DocumentData, BlockType, TextDelta } from '@/appflowy_app/interfaces/document';
import { createContext } from 'react';
import { DocumentBackendService } from './document_bd_svc';
import { Err } from 'ts-results';
import { FlowyError } from '@/services/backend';
export const DocumentControllerContext = createContext<DocumentController | null>(null);
export class DocumentController {
private readonly backendService: DocumentBackendService;
constructor(public readonly viewId: string) {
this.backendService = new DocumentBackendService(viewId);
}
open = async (): Promise<DocumentData | null> => {
const openDocumentResult = await this.backendService.open();
if (openDocumentResult.ok) {
return {
rootId: '',
blocks: {},
ytexts: {},
yarrays: {}
};
} else {
return null;
}
};
insert(node: {
id: string,
type: BlockType,
delta?: TextDelta[]
}, parentId: string, prevId: string) {
//
}
transact(actions: (() => void)[]) {
//
}
yTextApply = (yTextId: string, delta: TextDelta[]) => {
//
}
dispose = async () => {
await this.backendService.close();
};
}

View File

@ -14,6 +14,7 @@ interface BlockRegion {
export class RegionGrid {
private regions: BlockRegion[][];
private regionSize: number;
private blocks = new Map();
constructor(regionSize: number) {
this.regionSize = regionSize;
@ -36,9 +37,22 @@ export class RegionGrid {
}
this.regions[regionY][regionX] = region;
}
this.blocks.set(blockPosition.id, blockPosition);
region.blocks.push(blockPosition);
}
updateBlock(blockId: string, position: BlockPosition) {
const prevPosition = this.blocks.get(blockId);
if (prevPosition && prevPosition.x === position.x &&
prevPosition.y === position.y &&
prevPosition.height === position.height &&
prevPosition.width === position.width) {
return;
}
this.blocks.set(blockId, position);
this.removeBlock(blockId);
this.addBlock(position);
}
removeBlock(blockId: string) {
for (const rows of this.regions) {
@ -51,6 +65,7 @@ export class RegionGrid {
}
}
}
this.blocks.delete(blockId);
}

View File

@ -0,0 +1,132 @@
import { BlockType, TextDelta } from "@/appflowy_app/interfaces/document";
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { RegionGrid } from "./region_grid";
export interface Node {
id: string;
type: BlockType;
data: {
text?: string;
style?: Record<string, any>
};
parent: string | null;
children: string;
}
export interface NodeState {
nodes: Record<string, Node>;
children: Record<string, string[]>;
delta: Record<string, TextDelta[]>;
selections: string[];
}
const regionGrid = new RegionGrid(50);
const initialState: NodeState = {
nodes: {},
children: {},
delta: {},
selections: [],
};
export const documentSlice = createSlice({
name: 'document',
initialState: initialState,
reducers: {
clear: (state, action: PayloadAction) => {
return initialState;
},
createTree: (state, action: PayloadAction<{
nodes: Record<string, Node>;
children: Record<string, string[]>;
delta: Record<string, TextDelta[]>;
}>) => {
const { nodes, children, delta } = action.payload;
state.nodes = nodes;
state.children = children;
state.delta = delta;
},
updateSelections: (state, action: PayloadAction<string[]>) => {
state.selections = action.payload;
},
changeSelectionByIntersectRect: (state, action: PayloadAction<{
startX: number;
startY: number;
endX: number;
endY: number
}>) => {
const { startX, startY, endX, endY } = action.payload;
const blocks = regionGrid.getIntersectBlocks(startX, startY, endX, endY);
state.selections = blocks.map(block => block.id);
},
updateNodePosition: (state, action: PayloadAction<{id: string; rect: {
x: number;
y: number;
width: number;
height: number;
}}>) => {
const { id, rect } = action.payload;
const position = {
id,
...rect
};
regionGrid.updateBlock(id, position);
},
addNode: (state, action: PayloadAction<Node>) => {
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)
} else {
children.splice(prevIndex + 1, 0, childId);
}
},
updateChildren: (state, action: PayloadAction<{ id: string; childIds: string[] }>) => {
const { id, childIds } = action.payload;
state.children[id] = childIds;
},
updateDelta: (state, action: PayloadAction<{ id: string; delta: TextDelta[] }>) => {
const { id, delta } = action.payload;
state.delta[id] = delta;
},
updateNode: (state, action: PayloadAction<{id: string; type?: BlockType; data?: any }>) => {
state.nodes[action.payload.id] = {
...state.nodes[action.payload.id],
...action.payload
}
},
removeNode: (state, action: PayloadAction<string>) => {
const { children, data, parent } = state.nodes[action.payload];
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);
}
}
if (children) {
delete state.children[children];
}
if (data && data.text) {
delete state.delta[data.text];
}
delete state.nodes[action.payload];
},
},
});
export const documentActions = documentSlice.actions;

View File

@ -14,6 +14,7 @@ import { currentUserSlice } from './reducers/current-user/slice';
import { gridSlice } from './reducers/grid/slice';
import { workspaceSlice } from './reducers/workspace/slice';
import { databaseSlice } from './reducers/database/slice';
import { documentSlice } from './reducers/document/slice';
import { boardSlice } from './reducers/board/slice';
import { errorSlice } from './reducers/error/slice';
import { activePageIdSlice } from './reducers/activePageId/slice';
@ -32,6 +33,7 @@ const store = configureStore({
[gridSlice.name]: gridSlice.reducer,
[databaseSlice.name]: databaseSlice.reducer,
[boardSlice.name]: boardSlice.reducer,
[documentSlice.name]: documentSlice.reducer,
[workspaceSlice.name]: workspaceSlice.reducer,
[errorSlice.name]: errorSlice.reducer,
},

View File

@ -1,25 +0,0 @@
import { createContext } from 'react';
import { ulid } from "ulid";
import { BlockEditor } from '../block_editor/index';
export const BlockContext = createContext<{
id?: string;
blockEditor?: BlockEditor;
}>({});
export function generateBlockId() {
const blockId = ulid()
return `block-id-${blockId}`;
}
const AVERAGE_BLOCK_HEIGHT = 30;
export function calculateViewportBlockMaxCount() {
const viewportHeight = window.innerHeight;
const viewportBlockCount = Math.ceil(viewportHeight / AVERAGE_BLOCK_HEIGHT);
return viewportBlockCount;
}

View File

@ -1,36 +0,0 @@
import { BlockData, BlockType } from "../interfaces";
export function filterSelections<TreeNode extends {
id: string;
children: TreeNode[];
parent: TreeNode | null;
type: BlockType;
data: BlockData;
}>(ids: string[], nodeMap: Map<string, TreeNode>): string[] {
const selected = new Set(ids);
const newSelected = new Set<string>();
ids.forEach(selectedId => {
const node = nodeMap.get(selectedId);
if (!node) return;
if (node.type === BlockType.ListBlock && node.data.type === 'column') {
return;
}
if (node.children.length === 0) {
newSelected.add(selectedId);
return;
}
const hasChildSelected = node.children.some(i => selected.has(i.id));
if (!hasChildSelected) {
newSelected.add(selectedId);
return;
}
const hasSiblingSelected = node.parent?.children.filter(i => i.id !== selectedId).some(i => selected.has(i.id));
if (hasChildSelected && hasSiblingSelected) {
newSelected.add(selectedId);
return;
}
});
return Array.from(newSelected);
}

View File

@ -1,6 +0,0 @@
import { createContext } from "react";
import { TextBlockManager } from '../../block_editor/blocks/text_block';
export const TextBlockContext = createContext<{
textBlockManager?: TextBlockManager
}>({});

View File

@ -9,6 +9,21 @@ export function debounce(fn: (...args: any[]) => void, delay: number) {
}
}
export function throttle(fn: (...args: any[]) => void, delay: number, immediate = true) {
let timeout: NodeJS.Timeout | null = null
return (...args: any[]) => {
if (!timeout) {
timeout = setTimeout(() => {
timeout = null
// eslint-disable-next-line prefer-spread
!immediate && fn.apply(undefined, args)
}, delay)
// eslint-disable-next-line prefer-spread
immediate && fn.apply(undefined, args)
}
}
}
export function get(obj: any, path: string[], defaultValue?: any) {
let value = obj;
for (const prop of path) {
@ -34,3 +49,40 @@ export function set(obj: any, path: string[], value: any): void {
}
}
}
export function isEqual<T>(value1: T, value2: T): boolean {
if (typeof value1 !== 'object' || value1 === null || typeof value2 !== 'object' || value2 === null) {
return value1 === value2;
}
if (Array.isArray(value1)) {
if (!Array.isArray(value2) || value1.length !== value2.length) {
return false;
}
for (let i = 0; i < value1.length; i++) {
if (!isEqual(value1[i], value2[i])) {
return false;
}
}
return true;
}
const keys1 = Object.keys(value1);
const keys2 = Object.keys(value2);
if (keys1.length !== keys2.length) {
return false;
}
for (const key of keys1) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
if (!isEqual(value1[key], value2[key])) {
return false;
}
}
return true;
}

View File

@ -4,796 +4,32 @@ import {
DocumentVersionPB,
OpenDocumentPayloadPB,
} from '../../services/backend/events/flowy-document';
import { BlockInterface, BlockType } from '../interfaces';
import { useParams } from 'react-router-dom';
import { BlockEditor } from '../block_editor';
import { DocumentData } from '../interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller';
const loadBlockData = async (id: string): Promise<Record<string, BlockInterface>> => {
return {
[id]: {
id: id,
type: BlockType.PageBlock,
data: { content: [{ text: 'Document Title' }] },
next: null,
firstChild: "L1-1",
},
"L1-1": {
id: "L1-1",
type: BlockType.HeadingBlock,
data: { level: 1, content: [{ text: 'Heading 1' }] },
next: "L1-2",
firstChild: null,
},
"L1-2": {
id: "L1-2",
type: BlockType.HeadingBlock,
data: { level: 2, content: [{ text: 'Heading 2' }] },
next: "L1-3",
firstChild: null,
},
"L1-3": {
id: "L1-3",
type: BlockType.HeadingBlock,
data: { level: 3, content: [{ text: 'Heading 3' }] },
next: "L1-4",
firstChild: null,
},
"L1-4": {
id: "L1-4",
type: BlockType.TextBlock,
data: { content: [
{
text:
'This example shows how you can make a hovering menu appear above your content, which you can use to make text ',
},
{ text: 'bold', bold: true },
{ text: ', ' },
{ text: 'italic', italic: true },
{ text: ', or anything else you might want to do!' },
] },
next: "L1-5",
firstChild: null,
},
"L1-5": {
id: "L1-5",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
{ text: 'select any piece of text and the menu will appear', bold: true },
{ text: '.' },
] },
next: "L1-6",
firstChild: "L1-5-1",
},
"L1-5-1": {
id: "L1-5-1",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: "L1-5-2",
firstChild: null,
},
"L1-5-2": {
id: "L1-5-2",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: null,
firstChild: null,
},
"L1-6": {
id: "L1-6",
type: BlockType.ListBlock,
data: { type: 'bulleted', content: [
{
text:
"Since it's rich text, you can do things like turn a selection of text ",
},
{ text: 'bold', bold: true },
{
text:
', or add a semantically rendered block quote in the middle of the page, like this:',
},
] },
next: "L1-7",
firstChild: "L1-6-1",
},
"L1-6-1": {
id: "L1-6-1",
type: BlockType.ListBlock,
data: { type: 'numbered', content: [
{
text:
"Since it's rich text, you can do things like turn a selection of text ",
},
] },
next: "L1-6-2",
firstChild: null,
},
"L1-6-2": {
id: "L1-6-2",
type: BlockType.ListBlock,
data: { type: 'numbered', content: [
{
text:
"Since it's rich text, you can do things like turn a selection of text ",
},
] },
next: "L1-6-3",
firstChild: null,
},
"L1-6-3": {
id: "L1-6-3",
type: BlockType.TextBlock,
data: { content: [{ text: 'A wise quote.' }] },
next: null,
firstChild: null,
},
"L1-7": {
id: "L1-7",
type: BlockType.ListBlock,
data: { type: 'column' },
next: "L1-8",
firstChild: "L1-7-1",
},
"L1-7-1": {
id: "L1-7-1",
type: BlockType.ColumnBlock,
data: { ratio: '0.33' },
next: "L1-7-2",
firstChild: "L1-7-1-1",
},
"L1-7-1-1": {
id: "L1-7-1-1",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: null,
firstChild: null,
},
"L1-7-2": {
id: "L1-7-2",
type: BlockType.ColumnBlock,
data: { ratio: '0.33' },
next: "L1-7-3",
firstChild: "L1-7-2-1",
},
"L1-7-2-1": {
id: "L1-7-2-1",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: "L1-7-2-2",
firstChild: null,
},
"L1-7-2-2": {
id: "L1-7-2-2",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: null,
firstChild: null,
},
"L1-7-3": {
id: "L1-7-3",
type: BlockType.ColumnBlock,
data: { ratio: '0.33' },
next: null,
firstChild: "L1-7-3-1",
},
"L1-7-3-1": {
id: "L1-7-3-1",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: null,
firstChild: null,
},
"L1-8": {
id: "L1-8",
type: BlockType.HeadingBlock,
data: { level: 1, content: [{ text: 'Heading 1' }] },
next: "L1-9",
firstChild: null,
},
"L1-9": {
id: "L1-9",
type: BlockType.HeadingBlock,
data: { level: 2, content: [{ text: 'Heading 2' }] },
next: "L1-10",
firstChild: null,
},
"L1-10": {
id: "L1-10",
type: BlockType.HeadingBlock,
data: { level: 3, content: [{ text: 'Heading 3' }] },
next: "L1-11",
firstChild: null,
},
"L1-11": {
id: "L1-11",
type: BlockType.TextBlock,
data: { content: [
{
text:
'This example shows how you can make a hovering menu appear above your content, which you can use to make text ',
},
{ text: 'bold', bold: true },
{ text: ', ' },
{ text: 'italic', italic: true },
{ text: ', or anything else you might want to do!' },
] },
next: "L1-12",
firstChild: null,
},
"L1-12": {
id: "L1-12",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
{ text: 'select any piece of text and the menu will appear', bold: true },
{ text: '.' },
] },
next: "L2-1",
firstChild: "L1-12-1",
},
"L1-12-1": {
id: "L1-12-1",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: "L1-12-2",
firstChild: null,
},
"L1-12-2": {
id: "L1-12-2",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: null,
firstChild: null,
},
"L2-1": {
id: "L2-1",
type: BlockType.HeadingBlock,
data: { level: 1, content: [{ text: 'Heading 1' }] },
next: "L2-2",
firstChild: null,
},
"L2-2": {
id: "L2-2",
type: BlockType.HeadingBlock,
data: { level: 2, content: [{ text: 'Heading 2' }] },
next: "L2-3",
firstChild: null,
},
"L2-3": {
id: "L2-3",
type: BlockType.HeadingBlock,
data: { level: 3, content: [{ text: 'Heading 3' }] },
next: "L2-4",
firstChild: null,
},
"L2-4": {
id: "L2-4",
type: BlockType.TextBlock,
data: { content: [
{
text:
'This example shows how you can make a hovering menu appear above your content, which you can use to make text ',
},
{ text: 'bold', bold: true },
{ text: ', ' },
{ text: 'italic', italic: true },
{ text: ', or anything else you might want to do!' },
] },
next: "L2-5",
firstChild: null,
},
"L2-5": {
id: "L2-5",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
{ text: 'select any piece of text and the menu will appear', bold: true },
{ text: '.' },
] },
next: "L2-6",
firstChild: "L2-5-1",
},
"L2-5-1": {
id: "L2-5-1",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: "L2-5-2",
firstChild: null,
},
"L2-5-2": {
id: "L2-5-2",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: null,
firstChild: null,
},
"L2-6": {
id: "L2-6",
type: BlockType.ListBlock,
data: { type: 'bulleted', content: [
{
text:
"Since it's rich text, you can do things like turn a selection of text ",
},
{ text: 'bold', bold: true },
{
text:
', or add a semantically rendered block quote in the middle of the page, like this:',
},
] },
next: "L2-7",
firstChild: "L2-6-1",
},
"L2-6-1": {
id: "L2-6-1",
type: BlockType.ListBlock,
data: { type: 'numbered', content: [
{
text:
"Since it's rich text, you can do things like turn a selection of text ",
},
] },
next: "L2-6-2",
firstChild: null,
},
"L2-6-2": {
id: "L2-6-2",
type: BlockType.ListBlock,
data: { type: 'numbered', content: [
{
text:
"Since it's rich text, you can do things like turn a selection of text ",
},
] },
next: "L2-6-3",
firstChild: null,
},
"L2-6-3": {
id: "L2-6-3",
type: BlockType.TextBlock,
data: { content: [{ text: 'A wise quote.' }] },
next: null,
firstChild: null,
},
"L2-7": {
id: "L2-7",
type: BlockType.ListBlock,
data: { type: 'column' },
next: "L2-8",
firstChild: "L2-7-1",
},
"L2-7-1": {
id: "L2-7-1",
type: BlockType.ColumnBlock,
data: { ratio: '0.33' },
next: "L2-7-2",
firstChild: "L2-7-1-1",
},
"L2-7-1-1": {
id: "L2-7-1-1",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: null,
firstChild: null,
},
"L2-7-2": {
id: "L2-7-2",
type: BlockType.ColumnBlock,
data: { ratio: '0.33' },
next: "L2-7-3",
firstChild: "L2-7-2-1",
},
"L2-7-2-1": {
id: "L2-7-2-1",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: "L2-7-2-2",
firstChild: null,
},
"L2-7-2-2": {
id: "L2-7-2-2",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: null,
firstChild: null,
},
"L2-7-3": {
id: "L2-7-3",
type: BlockType.ColumnBlock,
data: { ratio: '0.33' },
next: null,
firstChild: "L2-7-3-1",
},
"L2-7-3-1": {
id: "L2-7-3-1",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: null,
firstChild: null,
},
"L2-8": {
id: "L2-8",
type: BlockType.HeadingBlock,
data: { level: 1, content: [{ text: 'Heading 1' }] },
next: "L2-9",
firstChild: null,
},
"L2-9": {
id: "L2-9",
type: BlockType.HeadingBlock,
data: { level: 2, content: [{ text: 'Heading 2' }] },
next: "L2-10",
firstChild: null,
},
"L2-10": {
id: "L2-10",
type: BlockType.HeadingBlock,
data: { level: 3, content: [{ text: 'Heading 3' }] },
next: "L2-11",
firstChild: null,
},
"L2-11": {
id: "L2-11",
type: BlockType.TextBlock,
data: { content: [
{
text:
'This example shows how you can make a hovering menu appear above your content, which you can use to make text ',
},
{ text: 'bold', bold: true },
{ text: ', ' },
{ text: 'italic', italic: true },
{ text: ', or anything else you might want to do!' },
] },
next: "L2-12",
firstChild: null,
},
"L2-12": {
id: "L2-12",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
{ text: 'select any piece of text and the menu will appear', bold: true },
{ text: '.' },
] },
next: "L3-1",
firstChild: "L2-12-1",
},
"L2-12-1": {
id: "L2-12-1",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: "L2-12-2",
firstChild: null,
},
"L2-12-2": {
id: "L2-12-2",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: null,
firstChild: null,
},"L3-1": {
id: "L3-1",
type: BlockType.HeadingBlock,
data: { level: 1, content: [{ text: 'Heading 1' }] },
next: "L3-2",
firstChild: null,
},
"L3-2": {
id: "L3-2",
type: BlockType.HeadingBlock,
data: { level: 2, content: [{ text: 'Heading 2' }] },
next: "L3-3",
firstChild: null,
},
"L3-3": {
id: "L3-3",
type: BlockType.HeadingBlock,
data: { level: 3, content: [{ text: 'Heading 3' }] },
next: "L3-4",
firstChild: null,
},
"L3-4": {
id: "L3-4",
type: BlockType.TextBlock,
data: { content: [
{
text:
'This example shows how you can make a hovering menu appear above your content, which you can use to make text ',
},
{ text: 'bold', bold: true },
{ text: ', ' },
{ text: 'italic', italic: true },
{ text: ', or anything else you might want to do!' },
] },
next: "L3-5",
firstChild: null,
},
"L3-5": {
id: "L3-5",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
{ text: 'select any piece of text and the menu will appear', bold: true },
{ text: '.' },
] },
next: "L3-6",
firstChild: "L3-5-1",
},
"L3-5-1": {
id: "L3-5-1",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: "L3-5-2",
firstChild: null,
},
"L3-5-2": {
id: "L3-5-2",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: null,
firstChild: null,
},
"L3-6": {
id: "L3-6",
type: BlockType.ListBlock,
data: { type: 'bulleted', content: [
{
text:
"Since it's rich text, you can do things like turn a selection of text ",
},
{ text: 'bold', bold: true },
{
text:
', or add a semantically rendered block quote in the middle of the page, like this:',
},
] },
next: "L3-7",
firstChild: "L3-6-1",
},
"L3-6-1": {
id: "L3-6-1",
type: BlockType.ListBlock,
data: { type: 'numbered', content: [
{
text:
"Since it's rich text, you can do things like turn a selection of text ",
},
] },
next: "L3-6-2",
firstChild: null,
},
"L3-6-2": {
id: "L3-6-2",
type: BlockType.ListBlock,
data: { type: 'numbered', content: [
{
text:
"Since it's rich text, you can do things like turn a selection of text ",
},
] },
next: "L3-6-3",
firstChild: null,
},
"L3-6-3": {
id: "L3-6-3",
type: BlockType.TextBlock,
data: { content: [{ text: 'A wise quote.' }] },
next: null,
firstChild: null,
},
"L3-7": {
id: "L3-7",
type: BlockType.ListBlock,
data: { type: 'column' },
next: "L3-8",
firstChild: "L3-7-1",
},
"L3-7-1": {
id: "L3-7-1",
type: BlockType.ColumnBlock,
data: { ratio: '0.33' },
next: "L3-7-2",
firstChild: "L3-7-1-1",
},
"L3-7-1-1": {
id: "L3-7-1-1",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: null,
firstChild: null,
},
"L3-7-2": {
id: "L3-7-2",
type: BlockType.ColumnBlock,
data: { ratio: '0.33' },
next: "L3-7-3",
firstChild: "L3-7-2-1",
},
"L3-7-2-1": {
id: "L3-7-2-1",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: "L3-7-2-2",
firstChild: null,
},
"L3-7-2-2": {
id: "L3-7-2-2",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: null,
firstChild: null,
},
"L3-7-3": {
id: "L3-7-3",
type: BlockType.ColumnBlock,
data: { ratio: '0.33' },
next: null,
firstChild: "L3-7-3-1",
},
"L3-7-3-1": {
id: "L3-7-3-1",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: null,
firstChild: null,
},
"L3-8": {
id: "L3-8",
type: BlockType.HeadingBlock,
data: { level: 1, content: [{ text: 'Heading 1' }] },
next: "L3-9",
firstChild: null,
},
"L3-9": {
id: "L3-9",
type: BlockType.HeadingBlock,
data: { level: 2, content: [{ text: 'Heading 2' }] },
next: "L3-10",
firstChild: null,
},
"L3-10": {
id: "L3-10",
type: BlockType.HeadingBlock,
data: { level: 3, content: [{ text: 'Heading 3' }] },
next: "L3-11",
firstChild: null,
},
"L3-11": {
id: "L3-11",
type: BlockType.TextBlock,
data: { content: [
{
text:
'This example shows how you can make a hovering menu appear above your content, which you can use to make text ',
},
{ text: 'bold', bold: true },
{ text: ', ' },
{ text: 'italic', italic: true },
{ text: ', or anything else you might want to do!' },
] },
next: "L3-12",
firstChild: null,
},
"L3-12": {
id: "L3-12",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
{ text: 'select any piece of text and the menu will appear', bold: true },
{ text: '.' },
] },
next: null,
firstChild: "L3-12-1",
},
"L3-12-1": {
id: "L3-12-1",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: "L3-12-2",
firstChild: null,
},
"L3-12-2": {
id: "L3-12-2",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: null,
firstChild: null,
},
}
}
export const useDocument = () => {
const params = useParams();
const [blockId, setBlockId] = useState<string>();
const blockEditorRef = useRef<BlockEditor | null>(null)
const [ documentId, setDocumentId ] = useState<string>();
const [ documentData, setDocumentData ] = useState<DocumentData>();
const [ controller, setController ] = useState<DocumentController | null>(null);
useEffect(() => {
void (async () => {
if (!params?.id) return;
const data = await loadBlockData(params.id);
console.log('==== enter ====', params?.id, data);
if (!blockEditorRef.current) {
blockEditorRef.current = new BlockEditor(params?.id, data);
} else {
blockEditorRef.current.changeDoc(params?.id, data);
}
setBlockId(params.id)
const c = new DocumentController(params.id);
setController(c);
const res = await c.open();
console.log(res)
if (!res) return;
setDocumentData(res)
setDocumentId(params.id)
})();
return () => {
console.log('==== leave ====', params?.id)
}
}, [params.id]);
return { blockId, blockEditor: blockEditorRef.current };
return { documentId, documentData, controller };
};

View File

@ -1,27 +1,23 @@
import { useDocument } from './DocumentPage.hooks';
import BlockList from '../components/block/BlockList';
import { BlockContext } from '../utils/block';
import { createTheme, ThemeProvider } from '@mui/material';
import Root from '../components/document/Root';
import { DocumentControllerContext } from '../stores/effects/document/document_controller';
const theme = createTheme({
typography: {
fontFamily: ['Poppins'].join(','),
},
});
export const DocumentPage = () => {
const { blockId, blockEditor } = useDocument();
if (!blockId || !blockEditor) return <div className='error-page'></div>;
export const DocumentPage = () => {
const { documentId, documentData, controller } = useDocument();
if (!documentId || !documentData || !controller) return null;
return (
<ThemeProvider theme={theme}>
<BlockContext.Provider
value={{
id: blockId,
blockEditor,
}}
>
<BlockList blockEditor={blockEditor} blockId={blockId} />
</BlockContext.Provider>
<DocumentControllerContext.Provider value={controller}>
<Root documentData={documentData} />
</DocumentControllerContext.Provider>
</ThemeProvider>
);
};