mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: block list virtualized scroll (#2023)
* feat: block list virtualized scroll * feat: block selection * refactor: block editor * fix: block selection scroll * fix: ts error
This commit is contained in:
parent
c6ffc0057c
commit
8471bc299d
@ -20,7 +20,9 @@
|
|||||||
"@mui/icons-material": "^5.11.11",
|
"@mui/icons-material": "^5.11.11",
|
||||||
"@mui/material": "^5.11.12",
|
"@mui/material": "^5.11.12",
|
||||||
"@reduxjs/toolkit": "^1.9.2",
|
"@reduxjs/toolkit": "^1.9.2",
|
||||||
|
"@tanstack/react-virtual": "3.0.0-beta.54",
|
||||||
"@tauri-apps/api": "^1.2.0",
|
"@tauri-apps/api": "^1.2.0",
|
||||||
|
"events": "^3.3.0",
|
||||||
"google-protobuf": "^3.21.2",
|
"google-protobuf": "^3.21.2",
|
||||||
"i18next": "^22.4.10",
|
"i18next": "^22.4.10",
|
||||||
"i18next-browser-languagedetector": "^7.0.1",
|
"i18next-browser-languagedetector": "^7.0.1",
|
||||||
@ -40,6 +42,7 @@
|
|||||||
"slate": "^0.91.4",
|
"slate": "^0.91.4",
|
||||||
"slate-react": "^0.91.9",
|
"slate-react": "^0.91.9",
|
||||||
"ts-results": "^3.3.0",
|
"ts-results": "^3.3.0",
|
||||||
|
"ulid": "^2.3.0",
|
||||||
"utf8": "^3.0.0"
|
"utf8": "^3.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,28 +0,0 @@
|
|||||||
import { BlockInterface, BlockType } from '$app/interfaces/index';
|
|
||||||
|
|
||||||
|
|
||||||
export class BlockDataManager {
|
|
||||||
private head: BlockInterface<BlockType.PageBlock> | null = null;
|
|
||||||
constructor(id: string, private map: Record<string, BlockInterface<BlockType>> | null) {
|
|
||||||
if (!map) return;
|
|
||||||
this.head = map[id];
|
|
||||||
}
|
|
||||||
|
|
||||||
setBlocksMap = (id: string, map: Record<string, BlockInterface<BlockType>>) => {
|
|
||||||
this.map = map;
|
|
||||||
this.head = map[id];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* get block data
|
|
||||||
* @param blockId string
|
|
||||||
* @returns Block
|
|
||||||
*/
|
|
||||||
getBlock = (blockId: string) => {
|
|
||||||
return this.map?.[blockId] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.map = null;
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,71 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,107 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,225 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,153 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,48 +1,60 @@
|
|||||||
|
// Import dependencies
|
||||||
import { BlockInterface } from '../interfaces';
|
import { BlockInterface } from '../interfaces';
|
||||||
import { BlockDataManager } from './block';
|
import { BlockChain, BlockChangeProps } from './core/block_chain';
|
||||||
import { TreeManager } from './tree';
|
import { RenderTree } from './view/tree';
|
||||||
|
import { Operation } from './core/operation';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BlockEditor is a document data manager that operates on and renders data through managing blockData and RenderTreeManager.
|
* The BlockEditor class manages a block chain and a render tree for a document editor.
|
||||||
* The render tree will be re-render and update react component when block makes changes to the data.
|
* The block chain stores the content blocks of the document in sequence, while the
|
||||||
* RectManager updates the cache of node rect when the react component update is completed.
|
* render tree displays the document as a hierarchical tree structure.
|
||||||
*/
|
*/
|
||||||
export class BlockEditor {
|
export class BlockEditor {
|
||||||
// blockData manages document block data, including operations such as add, delete, update, and move.
|
// Public properties
|
||||||
public blockData: BlockDataManager;
|
public blockChain: BlockChain; // (local data) the block chain used to store the document
|
||||||
// RenderTreeManager manages data rendering, including the construction and updating of the render tree.
|
public renderTree: RenderTree; // the render tree used to display the document
|
||||||
public renderTree: TreeManager;
|
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);
|
||||||
|
|
||||||
constructor(private id: string, data: Record<string, BlockInterface>) {
|
this.renderTree = new RenderTree(this.blockChain);
|
||||||
this.blockData = new BlockDataManager(id, data);
|
|
||||||
this.renderTree = new TreeManager(this.blockData.getBlock);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* update id and map when the doc is change
|
* Updates the document ID and block chain when the document changes.
|
||||||
* @param id
|
* @param id - the new ID of the document
|
||||||
* @param data
|
* @param data - the updated data for the document
|
||||||
*/
|
*/
|
||||||
changeDoc = (id: string, data: Record<string, BlockInterface>) => {
|
changeDoc = (id: string, data: Record<string, BlockInterface>) => {
|
||||||
console.log('==== change document ====', id, data)
|
console.log('==== change document ====', id, data);
|
||||||
|
|
||||||
|
// Update the document ID and rebuild the block chain
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.blockData.setBlocksMap(id, data);
|
this.blockChain.rebuild(id, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys the block chain and render tree.
|
||||||
|
*/
|
||||||
destroy = () => {
|
destroy = () => {
|
||||||
|
// Destroy the block chain and render tree
|
||||||
|
this.blockChain.destroy();
|
||||||
this.renderTree.destroy();
|
this.renderTree.destroy();
|
||||||
this.blockData.destroy();
|
this.operation.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private blockChange = (command: string, data: BlockChangeProps) => {
|
||||||
|
this.renderTree.onBlockChange(command, data);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let blockEditorInstance: BlockEditor | null;
|
|
||||||
|
|
||||||
export function getBlockEditor() {
|
|
||||||
return blockEditorInstance;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createBlockEditor(id: string, data: Record<string, BlockInterface>) {
|
|
||||||
blockEditorInstance = new BlockEditor(id, data);
|
|
||||||
return blockEditorInstance;
|
|
||||||
}
|
|
@ -1,66 +0,0 @@
|
|||||||
import { TreeNodeInterface } from "../interfaces";
|
|
||||||
|
|
||||||
|
|
||||||
export function calculateBlockRect(blockId: string) {
|
|
||||||
const el = document.querySelectorAll(`[data-block-id=${blockId}]`)[0];
|
|
||||||
return el?.getBoundingClientRect();
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RectManager {
|
|
||||||
map: Map<string, DOMRect>;
|
|
||||||
|
|
||||||
orderList: Set<string>;
|
|
||||||
|
|
||||||
private updatedQueue: Set<string>;
|
|
||||||
|
|
||||||
constructor(private getTreeNode: (nodeId: string) => TreeNodeInterface | null) {
|
|
||||||
this.map = new Map();
|
|
||||||
this.orderList = new Set();
|
|
||||||
this.updatedQueue = new Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
build() {
|
|
||||||
console.log('====update all blocks position====')
|
|
||||||
this.orderList.forEach(id => this.updateNodeRect(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
getNodeRect = (nodeId: string) => {
|
|
||||||
return this.map.get(nodeId) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
update() {
|
|
||||||
// In order to avoid excessive calculation frequency
|
|
||||||
// calculate and update the block position information in the queue every frame
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
// there is nothing to do if the updated queue is empty
|
|
||||||
if (this.updatedQueue.size === 0) return;
|
|
||||||
console.log(`==== update ${this.updatedQueue.size} blocks rect cache ====`)
|
|
||||||
this.updatedQueue.forEach((id: string) => {
|
|
||||||
const rect = calculateBlockRect(id);
|
|
||||||
this.map.set(id, rect);
|
|
||||||
this.updatedQueue.delete(id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateNodeRect = (nodeId: string) => {
|
|
||||||
if (this.updatedQueue.has(nodeId)) return;
|
|
||||||
let node: TreeNodeInterface | null = this.getTreeNode(nodeId);
|
|
||||||
|
|
||||||
// When one of the blocks is updated
|
|
||||||
// the positions of all its parent and child blocks need to be updated
|
|
||||||
while(node) {
|
|
||||||
node.parent?.children.forEach(child => this.updatedQueue.add(child.id));
|
|
||||||
node = node.parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.map.clear();
|
|
||||||
this.orderList.clear();
|
|
||||||
this.updatedQueue.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,140 +0,0 @@
|
|||||||
import { RectManager } from "./rect";
|
|
||||||
import { BlockInterface, BlockData, BlockType, TreeNodeInterface } from '../interfaces/index';
|
|
||||||
|
|
||||||
export class TreeManager {
|
|
||||||
|
|
||||||
// RenderTreeManager holds RectManager, which manages the position information of each node in the render tree.
|
|
||||||
private rect: RectManager;
|
|
||||||
|
|
||||||
root: TreeNode | null = null;
|
|
||||||
|
|
||||||
map: Map<string, TreeNode> = new Map();
|
|
||||||
|
|
||||||
constructor(private getBlock: (blockId: string) => BlockInterface | null) {
|
|
||||||
this.rect = new RectManager(this.getTreeNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get render node data by nodeId
|
|
||||||
* @param nodeId string
|
|
||||||
* @returns TreeNode
|
|
||||||
*/
|
|
||||||
getTreeNode = (nodeId: string): TreeNodeInterface | null => {
|
|
||||||
return this.map.get(nodeId) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* build tree node for rendering
|
|
||||||
* @param rootId
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
build(rootId: string): TreeNode | null {
|
|
||||||
const head = this.getBlock(rootId);
|
|
||||||
|
|
||||||
if (!head) return null;
|
|
||||||
|
|
||||||
this.root = new TreeNode(head);
|
|
||||||
|
|
||||||
let node = this.root;
|
|
||||||
|
|
||||||
// loop line
|
|
||||||
while (node) {
|
|
||||||
this.map.set(node.id, node);
|
|
||||||
this.rect.orderList.add(node.id);
|
|
||||||
|
|
||||||
const block = this.getBlock(node.id)!;
|
|
||||||
const next = block.next ? this.getBlock(block.next) : null;
|
|
||||||
const firstChild = block.firstChild ? this.getBlock(block.firstChild) : null;
|
|
||||||
|
|
||||||
// find next line
|
|
||||||
if (firstChild) {
|
|
||||||
// the next line is node's first child
|
|
||||||
const child = new TreeNode(firstChild);
|
|
||||||
node.addChild(child);
|
|
||||||
node = child;
|
|
||||||
} else if (next) {
|
|
||||||
// the next line is node's sibling
|
|
||||||
const sibling = new TreeNode(next);
|
|
||||||
node.parent?.addChild(sibling);
|
|
||||||
node = sibling;
|
|
||||||
} else {
|
|
||||||
// the next line is parent's sibling
|
|
||||||
let isFind = false;
|
|
||||||
while(node.parent) {
|
|
||||||
const parentId = node.parent.id;
|
|
||||||
const parent = this.getBlock(parentId)!;
|
|
||||||
const parentNext = parent.next ? this.getBlock(parent.next) : null;
|
|
||||||
if (parentNext) {
|
|
||||||
const parentSibling = new TreeNode(parentNext);
|
|
||||||
node.parent?.parent?.addChild(parentSibling);
|
|
||||||
node = parentSibling;
|
|
||||||
isFind = true;
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
node = node.parent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isFind) {
|
|
||||||
// Exit if next line not found
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.root;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* update dom rects cache
|
|
||||||
*/
|
|
||||||
updateRects = () => {
|
|
||||||
this.rect.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* get block rect cache
|
|
||||||
* @param id string
|
|
||||||
* @returns DOMRect
|
|
||||||
*/
|
|
||||||
getNodeRect = (nodeId: string) => {
|
|
||||||
return this.rect.getNodeRect(nodeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* update block rect cache
|
|
||||||
* @param id string
|
|
||||||
*/
|
|
||||||
updateNodeRect = (nodeId: string) => {
|
|
||||||
this.rect.updateNodeRect(nodeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.rect?.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TreeNode implements TreeNodeInterface {
|
|
||||||
id: string;
|
|
||||||
type: BlockType;
|
|
||||||
parent: TreeNode | null = null;
|
|
||||||
children: TreeNode[] = [];
|
|
||||||
data: BlockData<BlockType>;
|
|
||||||
|
|
||||||
constructor({
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
data
|
|
||||||
}: BlockInterface) {
|
|
||||||
this.id = id;
|
|
||||||
this.data = data;
|
|
||||||
this.type = type;
|
|
||||||
}
|
|
||||||
|
|
||||||
addChild(node: TreeNode) {
|
|
||||||
node.parent = this;
|
|
||||||
this.children.push(node);
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,73 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,81 @@
|
|||||||
|
export interface BlockPosition {
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
height: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
interface BlockRegion {
|
||||||
|
regionX: number;
|
||||||
|
regionY: number;
|
||||||
|
blocks: BlockPosition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RegionGrid {
|
||||||
|
private regions: BlockRegion[][];
|
||||||
|
private regionSize: number;
|
||||||
|
|
||||||
|
constructor(regionSize: number) {
|
||||||
|
this.regionSize = regionSize;
|
||||||
|
this.regions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
addBlock(blockPosition: BlockPosition) {
|
||||||
|
const regionX = Math.floor(blockPosition.x / this.regionSize);
|
||||||
|
const regionY = Math.floor(blockPosition.y / this.regionSize);
|
||||||
|
|
||||||
|
let region = this.regions[regionY]?.[regionX];
|
||||||
|
if (!region) {
|
||||||
|
region = {
|
||||||
|
regionX,
|
||||||
|
regionY,
|
||||||
|
blocks: []
|
||||||
|
};
|
||||||
|
if (!this.regions[regionY]) {
|
||||||
|
this.regions[regionY] = [];
|
||||||
|
}
|
||||||
|
this.regions[regionY][regionX] = region;
|
||||||
|
}
|
||||||
|
|
||||||
|
region.blocks.push(blockPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeBlock(blockId: string) {
|
||||||
|
for (const rows of this.regions) {
|
||||||
|
for (const region of rows) {
|
||||||
|
if (!region) return;
|
||||||
|
const blockIndex = region.blocks.findIndex(b => b.id === blockId);
|
||||||
|
if (blockIndex !== -1) {
|
||||||
|
region.blocks.splice(blockIndex, 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
getIntersectBlocks(startX: number, startY: number, endX: number, endY: number): BlockPosition[] {
|
||||||
|
const selectedBlocks: BlockPosition[] = [];
|
||||||
|
|
||||||
|
const startRegionX = Math.floor(startX / this.regionSize);
|
||||||
|
const startRegionY = Math.floor(startY / this.regionSize);
|
||||||
|
const endRegionX = Math.floor(endX / this.regionSize);
|
||||||
|
const endRegionY = Math.floor(endY / this.regionSize);
|
||||||
|
|
||||||
|
for (let y = startRegionY; y <= endRegionY; y++) {
|
||||||
|
for (let x = startRegionX; x <= endRegionX; x++) {
|
||||||
|
const region = this.regions[y]?.[x];
|
||||||
|
if (region) {
|
||||||
|
for (const block of region.blocks) {
|
||||||
|
if (block.x + block.width - 1 >= startX && block.x <= endX &&
|
||||||
|
block.y + block.height - 1 >= startY && block.y <= endY) {
|
||||||
|
selectedBlocks.push(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedBlocks;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,165 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,59 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,31 +1,12 @@
|
|||||||
import { useSlate } from 'slate-react';
|
|
||||||
import { toggleFormat, isFormatActive } from '@/appflowy_app/utils/slate/format';
|
import { toggleFormat, isFormatActive } from '@/appflowy_app/utils/slate/format';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material';
|
|
||||||
import { command, iconSize } from '$app/constants/toolbar';
|
|
||||||
|
|
||||||
const FormatButton = ({ format, icon }: { format: string; icon: string }) => {
|
import { command } from '$app/constants/toolbar';
|
||||||
const editor = useSlate();
|
import FormatIcon from './FormatIcon';
|
||||||
|
import { BaseEditor } from 'slate';
|
||||||
const renderComponent = useMemo(() => {
|
|
||||||
switch (icon) {
|
|
||||||
case 'bold':
|
|
||||||
return <FormatBold sx={iconSize} />;
|
|
||||||
case 'underlined':
|
|
||||||
return <FormatUnderlined sx={iconSize} />;
|
|
||||||
case 'italic':
|
|
||||||
return <FormatItalic sx={iconSize} />;
|
|
||||||
case 'code':
|
|
||||||
return <CodeOutlined sx={iconSize} />;
|
|
||||||
case 'strikethrough':
|
|
||||||
return <StrikethroughSOutlined sx={iconSize} />;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}, [icon]);
|
|
||||||
|
|
||||||
|
const FormatButton = ({ editor, format, icon }: { editor: BaseEditor; format: string; icon: string }) => {
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
slotProps={{ tooltip: { style: { background: '#E0F8FF', borderRadius: 8 } } }}
|
slotProps={{ tooltip: { style: { background: '#E0F8FF', borderRadius: 8 } } }}
|
||||||
@ -42,7 +23,7 @@ const FormatButton = ({ format, icon }: { format: string; icon: string }) => {
|
|||||||
sx={{ color: isFormatActive(editor, format) ? '#00BCF0' : 'white' }}
|
sx={{ color: isFormatActive(editor, format) ? '#00BCF0' : 'white' }}
|
||||||
onClick={() => toggleFormat(editor, format)}
|
onClick={() => toggleFormat(editor, format)}
|
||||||
>
|
>
|
||||||
{renderComponent}
|
<FormatIcon icon={icon} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material';
|
||||||
|
import { iconSize } from '$app/constants/toolbar';
|
||||||
|
|
||||||
|
export default function FormatIcon({ icon }: { icon: string }) {
|
||||||
|
switch (icon) {
|
||||||
|
case 'bold':
|
||||||
|
return <FormatBold sx={iconSize} />;
|
||||||
|
case 'underlined':
|
||||||
|
return <FormatUnderlined sx={iconSize} />;
|
||||||
|
case 'italic':
|
||||||
|
return <FormatItalic sx={iconSize} />;
|
||||||
|
case 'code':
|
||||||
|
return <CodeOutlined sx={iconSize} />;
|
||||||
|
case 'strikethrough':
|
||||||
|
return <StrikethroughSOutlined sx={iconSize} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +0,0 @@
|
|||||||
import ReactDOM from 'react-dom';
|
|
||||||
export const Portal = ({ blockId, children }: { blockId: string; children: JSX.Element }) => {
|
|
||||||
const root = document.querySelectorAll(`[data-block-id=${blockId}]`)[0];
|
|
||||||
return typeof document === 'object' && root ? ReactDOM.createPortal(children, root) : null;
|
|
||||||
};
|
|
@ -0,0 +1,36 @@
|
|||||||
|
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
|
||||||
|
}) {
|
||||||
|
const editor = useSlate();
|
||||||
|
const inFocus = useFocused();
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const nodeRect = document.querySelector(`[data-block-id=${node.id}]`)?.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (!nodeRect) return;
|
||||||
|
const position = calcToolbarPosition(editor, el, nodeRect);
|
||||||
|
|
||||||
|
if (!position) {
|
||||||
|
el.style.opacity = '0';
|
||||||
|
el.style.zIndex = '-1';
|
||||||
|
} else {
|
||||||
|
el.style.opacity = '1';
|
||||||
|
el.style.zIndex = '1';
|
||||||
|
el.style.top = position.top;
|
||||||
|
el.style.left = position.left;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ref,
|
||||||
|
inFocus,
|
||||||
|
editor
|
||||||
|
}
|
||||||
|
}
|
@ -1,29 +1,10 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import { useFocused, useSlate } from 'slate-react';
|
|
||||||
import FormatButton from './FormatButton';
|
import FormatButton from './FormatButton';
|
||||||
import Portal from './Portal';
|
import Portal from './Portal';
|
||||||
import { calcToolbarPosition } from '@/appflowy_app/utils/slate/toolbar';
|
import { TreeNode } from '$app/block_editor/view/tree_node';
|
||||||
|
import { useHoveringToolbar } from './index.hooks';
|
||||||
const HoveringToolbar = ({ blockId }: { blockId: string }) => {
|
|
||||||
const editor = useSlate();
|
|
||||||
const inFocus = useFocused();
|
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
const position = calcToolbarPosition(editor, el, blockId);
|
|
||||||
|
|
||||||
if (!position) {
|
|
||||||
el.style.opacity = '0';
|
|
||||||
} else {
|
|
||||||
el.style.opacity = '1';
|
|
||||||
el.style.top = position.top;
|
|
||||||
el.style.left = position.left;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const HoveringToolbar = ({ blockId, node }: { blockId: string; node: TreeNode }) => {
|
||||||
|
const { inFocus, ref, editor } = useHoveringToolbar({ node });
|
||||||
if (!inFocus) return null;
|
if (!inFocus) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -40,7 +21,7 @@ const HoveringToolbar = ({ blockId }: { blockId: string }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{['bold', 'italic', 'underlined', 'strikethrough', 'code'].map((format) => (
|
{['bold', 'italic', 'underlined', 'strikethrough', 'code'].map((format) => (
|
||||||
<FormatButton key={format} format={format} icon={format} />
|
<FormatButton key={format} editor={editor} format={format} icon={format} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Portal>
|
</Portal>
|
||||||
|
@ -0,0 +1,36 @@
|
|||||||
|
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`
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,91 @@
|
|||||||
|
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);
|
@ -1,39 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { BlockType, TreeNodeInterface } from '$app/interfaces';
|
|
||||||
import PageBlock from '../PageBlock';
|
|
||||||
import TextBlock from '../TextBlock';
|
|
||||||
import HeadingBlock from '../HeadingBlock';
|
|
||||||
import ListBlock from '../ListBlock';
|
|
||||||
import CodeBlock from '../CodeBlock';
|
|
||||||
|
|
||||||
function BlockComponent({
|
|
||||||
node,
|
|
||||||
...props
|
|
||||||
}: { node: TreeNodeInterface } & React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
|
|
||||||
const renderComponent = () => {
|
|
||||||
switch (node.type) {
|
|
||||||
case BlockType.PageBlock:
|
|
||||||
return <PageBlock node={node} />;
|
|
||||||
case BlockType.TextBlock:
|
|
||||||
return <TextBlock node={node} />;
|
|
||||||
case BlockType.HeadingBlock:
|
|
||||||
return <HeadingBlock node={node} />;
|
|
||||||
case BlockType.ListBlock:
|
|
||||||
return <ListBlock node={node} />;
|
|
||||||
case BlockType.CodeBlock:
|
|
||||||
return <CodeBlock node={node} />;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='relative' data-block-id={node.id} {...props}>
|
|
||||||
{renderComponent()}
|
|
||||||
{props.children}
|
|
||||||
<div className='block-overlay'></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default React.memo(BlockComponent);
|
|
@ -0,0 +1,92 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,31 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
@ -1,43 +1,58 @@
|
|||||||
import BlockComponent from './BlockComponent';
|
import React from 'react';
|
||||||
import React, { useEffect } from 'react';
|
import { BlockListProps, useBlockList, withTextBlockManager } from './BlockList.hooks';
|
||||||
import { debounce } from '@/appflowy_app/utils/tool';
|
import { withErrorBoundary } from 'react-error-boundary';
|
||||||
import { getBlockEditor } from '../../../block_editor';
|
import ListFallbackComponent from './ListFallbackComponent';
|
||||||
|
import BlockListTitle from './BlockListTitle';
|
||||||
|
import BlockComponent from '../BlockComponent';
|
||||||
|
import BlockSelection from '../BlockSelection';
|
||||||
|
|
||||||
const RESIZE_DELAY = 200;
|
function BlockList(props: BlockListProps) {
|
||||||
|
const { root, rowVirtualizer, parentRef, blockEditor } = useBlockList(props);
|
||||||
function BlockList({ blockId }: { blockId: string }) {
|
|
||||||
const blockEditor = getBlockEditor();
|
|
||||||
if (!blockEditor) return null;
|
|
||||||
|
|
||||||
const root = blockEditor.renderTree.build(blockId);
|
|
||||||
console.log('==== build tree ====', root);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// update rect cache when did mount
|
|
||||||
blockEditor.renderTree.updateRects();
|
|
||||||
|
|
||||||
const resize = debounce(() => {
|
|
||||||
// update rect cache when window resized
|
|
||||||
blockEditor.renderTree.updateRects();
|
|
||||||
}, RESIZE_DELAY);
|
|
||||||
|
|
||||||
window.addEventListener('resize', resize);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', resize);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||||
return (
|
return (
|
||||||
<div className='min-x-[0%] p-lg w-[900px] max-w-[100%]'>
|
<div id='appflowy-block-doc' className='h-[100%] overflow-hidden'>
|
||||||
<div className='my-[50px] flex px-14 text-4xl font-bold'>{root?.data.title}</div>
|
<div
|
||||||
<div className='px-14'>
|
ref={parentRef}
|
||||||
{root && root.children.length > 0
|
className={`doc-scroller-container flex h-[100%] flex-wrap items-center justify-center overflow-auto px-20`}
|
||||||
? root.children.map((node) => <BlockComponent key={node.id} node={node} />)
|
>
|
||||||
: null}
|
<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>
|
</div>
|
||||||
|
{parentRef.current ? <BlockSelection blockEditor={blockEditor} container={parentRef.current} /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default React.memo(BlockList);
|
const ListWithErrorBoundary = withErrorBoundary(withTextBlockManager(BlockList), {
|
||||||
|
FallbackComponent: ListFallbackComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default React.memo(ListWithErrorBoundary);
|
||||||
|
@ -0,0 +1,137 @@
|
|||||||
|
import { BlockEditor } from '@/appflowy_app/block_editor';
|
||||||
|
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
export function useBlockSelection({ container, blockEditor }: { container: HTMLDivElement; blockEditor: BlockEditor }) {
|
||||||
|
const blockPositionManager = blockEditor.renderTree.blockPositionManager;
|
||||||
|
|
||||||
|
const [isDragging, setDragging] = useState(false);
|
||||||
|
const pointRef = useRef<number[]>([]);
|
||||||
|
const startScrollTopRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const [rect, setRect] = useState<{
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
endX: number;
|
||||||
|
endY: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const style = useMemo(() => {
|
||||||
|
if (!rect) return;
|
||||||
|
const { startX, endX, startY, endY } = rect;
|
||||||
|
const x = Math.min(startX, endX);
|
||||||
|
const y = Math.min(startY, endY);
|
||||||
|
const width = Math.abs(endX - startX);
|
||||||
|
const height = Math.abs(endY - startY);
|
||||||
|
return {
|
||||||
|
left: x - container.scrollLeft + 'px',
|
||||||
|
top: y - container.scrollTop + 'px',
|
||||||
|
width: width + 'px',
|
||||||
|
height: height + 'px',
|
||||||
|
};
|
||||||
|
}, [rect]);
|
||||||
|
|
||||||
|
const isPointInBlock = useCallback((target: HTMLElement | null) => {
|
||||||
|
let node = target;
|
||||||
|
while (node) {
|
||||||
|
if (node.getAttribute('data-block-id')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback((e: MouseEvent) => {
|
||||||
|
if (isPointInBlock(e.target as HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(true);
|
||||||
|
|
||||||
|
const startX = e.clientX + container.scrollLeft;
|
||||||
|
const startY = e.clientY + container.scrollTop;
|
||||||
|
pointRef.current = [startX, startY];
|
||||||
|
startScrollTopRef.current = container.scrollTop;
|
||||||
|
setRect({
|
||||||
|
startX,
|
||||||
|
startY,
|
||||||
|
endX: startX,
|
||||||
|
endY: startY,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const calcIntersectBlocks = useCallback(
|
||||||
|
(clientX: number, clientY: number) => {
|
||||||
|
if (!isDragging || !blockPositionManager) return;
|
||||||
|
const [startX, startY] = pointRef.current;
|
||||||
|
const endX = clientX + container.scrollLeft;
|
||||||
|
const endY = clientY + container.scrollTop;
|
||||||
|
|
||||||
|
setRect({
|
||||||
|
startX,
|
||||||
|
startY,
|
||||||
|
endX,
|
||||||
|
endY,
|
||||||
|
});
|
||||||
|
const selectedBlocks = blockPositionManager.getIntersectBlocks(
|
||||||
|
Math.min(startX, endX),
|
||||||
|
Math.min(startY, endY),
|
||||||
|
Math.max(startX, endX),
|
||||||
|
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;
|
||||||
|
e.preventDefault();
|
||||||
|
calcIntersectBlocks(e.clientX, e.clientY);
|
||||||
|
|
||||||
|
const { top, bottom } = container.getBoundingClientRect();
|
||||||
|
if (e.clientY >= bottom) {
|
||||||
|
const delta = e.clientY - bottom;
|
||||||
|
container.scrollBy(0, delta);
|
||||||
|
} else if (e.clientY <= top) {
|
||||||
|
const delta = e.clientY - top;
|
||||||
|
container.scrollBy(0, delta);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isDragging]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
if (isPointInBlock(e.target as HTMLElement) && !isDragging) {
|
||||||
|
blockEditor.renderTree.updateSelections([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isDragging) return;
|
||||||
|
e.preventDefault();
|
||||||
|
calcIntersectBlocks(e.clientX, e.clientY);
|
||||||
|
setDragging(false);
|
||||||
|
setRect(null);
|
||||||
|
},
|
||||||
|
[isDragging]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('mousedown', handleDragStart);
|
||||||
|
window.addEventListener('mousemove', handleDraging);
|
||||||
|
window.addEventListener('mouseup', handleDragEnd);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousedown', handleDragStart);
|
||||||
|
window.removeEventListener('mousemove', handleDraging);
|
||||||
|
window.removeEventListener('mouseup', handleDragEnd);
|
||||||
|
};
|
||||||
|
}, [handleDragStart, handleDragEnd, handleDraging]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDragging,
|
||||||
|
style,
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
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);
|
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
|
||||||
import { TreeNodeInterface } from '$app/interfaces';
|
import { BlockCommonProps } from '@/appflowy_app/interfaces';
|
||||||
|
|
||||||
export default function CodeBlock({ node }: { node: TreeNodeInterface }) {
|
export default function CodeBlock({ node }: BlockCommonProps<TreeNode>) {
|
||||||
return <div>{node.data.text}</div>;
|
return <div>{node.data.text}</div>;
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { TreeNodeInterface } from '$app/interfaces/index';
|
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
|
||||||
import BlockComponent from '../BlockList/BlockComponent';
|
|
||||||
|
import BlockComponent from '../BlockComponent';
|
||||||
|
|
||||||
export default function ColumnBlock({
|
export default function ColumnBlock({
|
||||||
node,
|
node,
|
||||||
resizerWidth,
|
resizerWidth,
|
||||||
index,
|
index,
|
||||||
}: {
|
}: {
|
||||||
node: TreeNodeInterface;
|
node: TreeNode;
|
||||||
resizerWidth: number;
|
resizerWidth: number;
|
||||||
index: number;
|
index: number;
|
||||||
}) {
|
}) {
|
||||||
@ -16,6 +17,7 @@ export default function ColumnBlock({
|
|||||||
<div className={`relative w-[46px] flex-shrink-0 flex-grow-0 transition-opacity`} style={{ opacity: 0 }}></div>
|
<div className={`relative w-[46px] flex-shrink-0 flex-grow-0 transition-opacity`} style={{ opacity: 0 }}></div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{index === 0 ? (
|
{index === 0 ? (
|
||||||
@ -41,11 +43,8 @@ export default function ColumnBlock({
|
|||||||
width: `calc((100% - ${resizerWidth}px) * ${node.data.ratio})`,
|
width: `calc((100% - ${resizerWidth}px) * ${node.data.ratio})`,
|
||||||
}}
|
}}
|
||||||
node={node}
|
node={node}
|
||||||
>
|
renderChild={(item) => <BlockComponent key={item.id} node={item} />}
|
||||||
{node.children?.map((item) => (
|
/>
|
||||||
<BlockComponent key={item.id} node={item} />
|
|
||||||
))}
|
|
||||||
</BlockComponent>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,21 +1,17 @@
|
|||||||
import React from 'react';
|
|
||||||
import TextBlock from '../TextBlock';
|
import TextBlock from '../TextBlock';
|
||||||
import { TreeNodeInterface } from '$app/interfaces/index';
|
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
|
||||||
|
import { BlockCommonProps } from '@/appflowy_app/interfaces';
|
||||||
|
|
||||||
const fontSize: Record<string, string> = {
|
const fontSize: Record<string, string> = {
|
||||||
1: 'mt-8 text-3xl',
|
1: 'mt-8 text-3xl',
|
||||||
2: 'mt-6 text-2xl',
|
2: 'mt-6 text-2xl',
|
||||||
3: 'mt-4 text-xl',
|
3: 'mt-4 text-xl',
|
||||||
};
|
};
|
||||||
export default function HeadingBlock({ node }: { node: TreeNodeInterface }) {
|
|
||||||
|
export default function HeadingBlock({ node, version }: BlockCommonProps<TreeNode>) {
|
||||||
return (
|
return (
|
||||||
<div className={`${fontSize[node.data.level]} font-semibold `}>
|
<div className={`${fontSize[node.data.level]} font-semibold `}>
|
||||||
<TextBlock
|
<TextBlock version={version} node={node} needRenderChildren={false} />
|
||||||
node={{
|
|
||||||
...node,
|
|
||||||
children: [],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import { Circle } from '@mui/icons-material';
|
import { Circle } from '@mui/icons-material';
|
||||||
|
|
||||||
import BlockComponent from '../BlockList/BlockComponent';
|
import BlockComponent from '../BlockComponent';
|
||||||
import { TreeNodeInterface } from '$app/interfaces/index';
|
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
|
||||||
|
|
||||||
export default function BulletedListBlock({ title, node }: { title: JSX.Element; node: TreeNodeInterface }) {
|
export default function BulletedListBlock({ title, node }: { title: JSX.Element; node: TreeNode }) {
|
||||||
return (
|
return (
|
||||||
<div className='bulleted-list-block relative'>
|
<div className='bulleted-list-block relative'>
|
||||||
<div className='relative flex'>
|
<div className='relative flex'>
|
||||||
<div className={`relative mb-2 min-w-[24px] leading-5`}>
|
<div className={`relative flex h-[calc(1.5em_+_3px_+_3px)] min-w-[24px] select-none items-center`}>
|
||||||
<Circle sx={{ width: 8, height: 8 }} />
|
<Circle sx={{ width: 8, height: 8 }} />
|
||||||
</div>
|
</div>
|
||||||
{title}
|
{title}
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { TreeNodeInterface } from '@/appflowy_app/interfaces';
|
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import ColumnBlock from '../ColumnBlock/index';
|
import ColumnBlock from '../ColumnBlock';
|
||||||
|
|
||||||
export default function ColumnListBlock({ node }: { node: TreeNodeInterface }) {
|
export default function ColumnListBlock({ node }: { node: TreeNode }) {
|
||||||
const resizerWidth = useMemo(() => {
|
const resizerWidth = useMemo(() => {
|
||||||
return 46 * (node.children?.length || 0);
|
return 46 * (node.children?.length || 0);
|
||||||
}, [node.children?.length]);
|
}, [node.children?.length]);
|
||||||
|
@ -1,16 +1,21 @@
|
|||||||
import { TreeNodeInterface } from '@/appflowy_app/interfaces';
|
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
|
||||||
import React, { useMemo } from 'react';
|
import BlockComponent from '../BlockComponent';
|
||||||
import BlockComponent from '../BlockList/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: TreeNodeInterface }) {
|
export default function NumberedListBlock({ title, node }: { title: JSX.Element; node: TreeNode }) {
|
||||||
const index = useMemo(() => {
|
let prev = node.block.prev;
|
||||||
const i = node.parent?.children?.findIndex((item) => item.id === node.id) || 0;
|
let index = 1;
|
||||||
return i + 1;
|
while (prev && prev.type === BlockType.ListBlock && (prev as Block<BlockType.ListBlock>).data.type === 'numbered') {
|
||||||
}, [node]);
|
index++;
|
||||||
|
prev = prev.prev;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className='numbered-list-block'>
|
<div className='numbered-list-block'>
|
||||||
<div className='relative flex'>
|
<div className='relative flex'>
|
||||||
<div className={`relative mb-2 min-w-[24px] max-w-[24px]`}>{`${index} .`}</div>
|
<div
|
||||||
|
className={`relative flex h-[calc(1.5em_+_3px_+_3px)] min-w-[24px] max-w-[24px] select-none items-center`}
|
||||||
|
>{`${index} .`}</div>
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -3,22 +3,18 @@ import TextBlock from '../TextBlock';
|
|||||||
import NumberedListBlock from './NumberedListBlock';
|
import NumberedListBlock from './NumberedListBlock';
|
||||||
import BulletedListBlock from './BulletedListBlock';
|
import BulletedListBlock from './BulletedListBlock';
|
||||||
import ColumnListBlock from './ColumnListBlock';
|
import ColumnListBlock from './ColumnListBlock';
|
||||||
import { TreeNodeInterface } from '$app/interfaces/index';
|
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
|
||||||
|
import { BlockCommonProps } from '@/appflowy_app/interfaces';
|
||||||
|
|
||||||
export default function ListBlock({ node }: { node: TreeNodeInterface }) {
|
export default function ListBlock({ node, version }: BlockCommonProps<TreeNode>) {
|
||||||
const title = useMemo(() => {
|
const title = useMemo(() => {
|
||||||
if (node.data.type === 'column') return <></>;
|
if (node.data.type === 'column') return <></>;
|
||||||
return (
|
return (
|
||||||
<div className='flex-1'>
|
<div className='flex-1'>
|
||||||
<TextBlock
|
<TextBlock version={version} node={node} needRenderChildren={false} />
|
||||||
node={{
|
|
||||||
...node,
|
|
||||||
children: [],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [node]);
|
}, [node, version]);
|
||||||
|
|
||||||
if (node.data.type === 'numbered') {
|
if (node.data.type === 'numbered') {
|
||||||
return <NumberedListBlock title={title} node={node} />;
|
return <NumberedListBlock title={title} node={node} />;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
|
||||||
import { TreeNodeInterface } from '$app/interfaces';
|
import { BlockCommonProps } from '@/appflowy_app/interfaces';
|
||||||
|
|
||||||
export default function PageBlock({ node }: { node: TreeNodeInterface }) {
|
export default function PageBlock({ node }: BlockCommonProps<TreeNode>) {
|
||||||
return <div className='cursor-pointer underline'>{node.data.title}</div>;
|
return <div className='cursor-pointer underline'>{node.data.title}</div>;
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,98 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
@ -1,77 +1,37 @@
|
|||||||
import React, { useContext, useMemo, useState } from 'react';
|
import BlockComponent from '../BlockComponent';
|
||||||
import { TreeNodeInterface } from '$app/interfaces';
|
import { Slate, Editable } from 'slate-react';
|
||||||
import BlockComponent from '../BlockList/BlockComponent';
|
|
||||||
|
|
||||||
import { createEditor } from 'slate';
|
|
||||||
import { Slate, Editable, withReact } from 'slate-react';
|
|
||||||
import Leaf from './Leaf';
|
import Leaf from './Leaf';
|
||||||
import HoveringToolbar from '$app/components/HoveringToolbar';
|
import HoveringToolbar from '$app/components/HoveringToolbar';
|
||||||
import { triggerHotkey } from '@/appflowy_app/utils/slate/hotkey';
|
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
|
||||||
import { BlockContext } from '$app/utils/block_context';
|
import { useTextBlock } from './index.hooks';
|
||||||
import { debounce } from '@/appflowy_app/utils/tool';
|
import { BlockCommonProps, TextBlockToolbarProps } from '@/appflowy_app/interfaces';
|
||||||
import { getBlockEditor } from '@/appflowy_app/block_editor/index';
|
import { toolbarDefaultProps } from '@/appflowy_app/constants/toolbar';
|
||||||
|
|
||||||
const INPUT_CHANGE_CACHE_DELAY = 300;
|
export default function TextBlock({
|
||||||
|
node,
|
||||||
export default function TextBlock({ node }: { node: TreeNodeInterface }) {
|
needRenderChildren = true,
|
||||||
const blockEditor = getBlockEditor();
|
toolbarProps,
|
||||||
if (!blockEditor) return null;
|
...props
|
||||||
|
}: {
|
||||||
const [editor] = useState(() => withReact(createEditor()));
|
needRenderChildren?: boolean;
|
||||||
|
toolbarProps?: TextBlockToolbarProps;
|
||||||
const { id } = useContext(BlockContext);
|
} & BlockCommonProps<TreeNode> &
|
||||||
|
React.HTMLAttributes<HTMLDivElement>) {
|
||||||
const debounceUpdateBlockCache = useMemo(
|
const { editor, value, onChange, onKeyDownCapture, onDOMBeforeInput } = useTextBlock({ node });
|
||||||
() => debounce(blockEditor.renderTree.updateNodeRect, INPUT_CHANGE_CACHE_DELAY),
|
const { showGroups } = toolbarProps || toolbarDefaultProps;
|
||||||
[id, node.id]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mb-2'>
|
<div {...props} className={`${props.className} py-1`}>
|
||||||
<Slate
|
<Slate editor={editor} onChange={onChange} value={value}>
|
||||||
editor={editor}
|
{showGroups.length > 0 && <HoveringToolbar node={node} blockId={node.id} />}
|
||||||
onChange={(e) => {
|
|
||||||
if (editor.operations[0].type !== 'set_selection') {
|
|
||||||
console.log('=== text op ===', e, editor.operations);
|
|
||||||
// Temporary code, in the future, it is necessary to monitor the OP changes of the document to determine whether the location cache of the block needs to be updated
|
|
||||||
debounceUpdateBlockCache(node.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
value={[
|
|
||||||
{
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
type: 'paragraph',
|
|
||||||
children: node.data.content,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<HoveringToolbar blockId={node.id} />
|
|
||||||
<Editable
|
<Editable
|
||||||
onKeyDownCapture={(event) => {
|
onKeyDownCapture={onKeyDownCapture}
|
||||||
switch (event.key) {
|
onDOMBeforeInput={onDOMBeforeInput}
|
||||||
case 'Enter': {
|
renderLeaf={(leafProps) => <Leaf {...leafProps} />}
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
triggerHotkey(event, editor);
|
|
||||||
}}
|
|
||||||
onDOMBeforeInput={(e) => {
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
renderLeaf={(props) => <Leaf {...props} />}
|
|
||||||
placeholder='Enter some text...'
|
placeholder='Enter some text...'
|
||||||
/>
|
/>
|
||||||
</Slate>
|
</Slate>
|
||||||
{node.children && node.children.length > 0 ? (
|
{needRenderChildren && node.children.length > 0 ? (
|
||||||
<div className='pl-[1.5em]'>
|
<div className='pl-[1.5em]'>
|
||||||
{node.children.map((item) => (
|
{node.children.map((item) => (
|
||||||
<BlockComponent key={item.id} node={item} />
|
<BlockComponent key={item.id} node={item} />
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { TextBlockToolbarGroup } from "../interfaces";
|
||||||
|
|
||||||
export const iconSize = { width: 18, height: 18 };
|
export const iconSize = { width: 18, height: 18 };
|
||||||
|
|
||||||
@ -22,4 +23,17 @@ export const command: Record<string, { title: string; key: string }> = {
|
|||||||
title: 'Strike through',
|
title: 'Strike through',
|
||||||
key: '⌘ + Shift + S or ⌘ + Shift + X',
|
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,
|
||||||
|
],
|
||||||
};
|
};
|
@ -16,9 +16,7 @@ export enum BlockType {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BlockData<T = BlockType> = T extends BlockType.TextBlock ? TextBlockData :
|
||||||
|
|
||||||
export type BlockData<T> = T extends BlockType.TextBlock ? TextBlockData :
|
|
||||||
T extends BlockType.PageBlock ? PageBlockData :
|
T extends BlockType.PageBlock ? PageBlockData :
|
||||||
T extends BlockType.HeadingBlock ? HeadingBlockData :
|
T extends BlockType.HeadingBlock ? HeadingBlockData :
|
||||||
T extends BlockType.ListBlock ? ListBlockData :
|
T extends BlockType.ListBlock ? ListBlockData :
|
||||||
@ -34,7 +32,7 @@ export interface BlockInterface<T = BlockType> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface TextBlockData {
|
export interface TextBlockData {
|
||||||
content: Descendant[];
|
content: Descendant[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,11 +52,61 @@ interface ColumnBlockData {
|
|||||||
ratio: string;
|
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 TreeNodeInterface {
|
|
||||||
id: string;
|
export interface BlockCommonProps<T> {
|
||||||
type: BlockType;
|
version: number;
|
||||||
parent: TreeNodeInterface | null;
|
node: T;
|
||||||
children: TreeNodeInterface[];
|
}
|
||||||
data: BlockData<BlockType>;
|
|
||||||
|
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
|
||||||
}
|
}
|
25
frontend/appflowy_tauri/src/appflowy_app/utils/block.ts
Normal file
25
frontend/appflowy_tauri/src/appflowy_app/utils/block.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
|||||||
|
|
||||||
import { createContext } from 'react';
|
|
||||||
|
|
||||||
export const BlockContext = createContext<{
|
|
||||||
id?: string;
|
|
||||||
}>({});
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
|||||||
|
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);
|
||||||
|
}
|
@ -1,25 +0,0 @@
|
|||||||
import {
|
|
||||||
Editor,
|
|
||||||
Transforms,
|
|
||||||
Text,
|
|
||||||
Node
|
|
||||||
} from 'slate';
|
|
||||||
|
|
||||||
export function toggleFormat(editor: Editor, format: string) {
|
|
||||||
const isActive = isFormatActive(editor, format)
|
|
||||||
Transforms.setNodes(
|
|
||||||
editor,
|
|
||||||
{ [format]: isActive ? null : true },
|
|
||||||
{ match: Text.isText, split: true }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isFormatActive = (editor: Editor, format: string) => {
|
|
||||||
const [match] = Editor.nodes(editor, {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
match: (n: Node) => n[format] === true,
|
|
||||||
mode: 'all',
|
|
||||||
})
|
|
||||||
return !!match
|
|
||||||
}
|
|
@ -1,22 +0,0 @@
|
|||||||
import isHotkey from 'is-hotkey';
|
|
||||||
import { toggleFormat } from './format';
|
|
||||||
import { Editor } from 'slate';
|
|
||||||
|
|
||||||
const HOTKEYS: Record<string, string> = {
|
|
||||||
'mod+b': 'bold',
|
|
||||||
'mod+i': 'italic',
|
|
||||||
'mod+u': 'underline',
|
|
||||||
'mod+e': 'code',
|
|
||||||
'mod+shift+X': 'strikethrough',
|
|
||||||
'mod+shift+S': 'strikethrough',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function triggerHotkey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
|
|
||||||
for (const hotkey in HOTKEYS) {
|
|
||||||
if (isHotkey(hotkey, event)) {
|
|
||||||
event.preventDefault()
|
|
||||||
const format = HOTKEYS[hotkey]
|
|
||||||
toggleFormat(editor, format)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,28 +0,0 @@
|
|||||||
import { Editor, Range } from 'slate';
|
|
||||||
export function calcToolbarPosition(editor: Editor, el: HTMLDivElement, blockRect: DOMRect) {
|
|
||||||
const { selection } = editor;
|
|
||||||
|
|
||||||
if (!selection || Range.isCollapsed(selection) || Editor.string(editor, selection) === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const domSelection = window.getSelection();
|
|
||||||
let domRange;
|
|
||||||
if (domSelection?.rangeCount === 0) {
|
|
||||||
domRange = document.createRange();
|
|
||||||
domRange.setStart(el, domSelection?.anchorOffset);
|
|
||||||
domRange.setEnd(el, domSelection?.anchorOffset);
|
|
||||||
} else {
|
|
||||||
domRange = domSelection?.getRangeAt(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rect = domRange?.getBoundingClientRect() || { top: 0, left: 0, width: 0, height: 0 };
|
|
||||||
|
|
||||||
const top = `${-el.offsetHeight - 5}px`;
|
|
||||||
const left = `${rect.left - blockRect.left - el.offsetWidth / 2 + rect.width / 2}px`;
|
|
||||||
return {
|
|
||||||
top,
|
|
||||||
left,
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,6 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
import { TextBlockManager } from '../../block_editor/blocks/text_block';
|
||||||
|
|
||||||
|
export const TextBlockContext = createContext<{
|
||||||
|
textBlockManager?: TextBlockManager
|
||||||
|
}>({});
|
@ -1,21 +1,11 @@
|
|||||||
import { getBlockEditor } from '@/appflowy_app/block_editor';
|
|
||||||
import { Editor, Range } from 'slate';
|
import { Editor, Range } from 'slate';
|
||||||
export function calcToolbarPosition(editor: Editor, toolbarDom: HTMLDivElement, blockId: string) {
|
export function calcToolbarPosition(editor: Editor, toolbarDom: HTMLDivElement, blockRect: DOMRect) {
|
||||||
const { selection } = editor;
|
const { selection } = editor;
|
||||||
|
|
||||||
const scrollContainer = document.querySelector('.doc-scroller-container');
|
|
||||||
if (!scrollContainer) return;
|
|
||||||
|
|
||||||
if (!selection || Range.isCollapsed(selection) || Editor.string(editor, selection) === '') {
|
if (!selection || Range.isCollapsed(selection) || Editor.string(editor, selection) === '') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blockEditor = getBlockEditor();
|
|
||||||
const blockRect = blockEditor?.renderTree.getNodeRect(blockId);
|
|
||||||
const blockDom = document.querySelector(`[data-block-id=${blockId}]`);
|
|
||||||
|
|
||||||
if (!blockDom || !blockRect) return;
|
|
||||||
|
|
||||||
const domSelection = window.getSelection();
|
const domSelection = window.getSelection();
|
||||||
let domRange;
|
let domRange;
|
||||||
if (domSelection?.rangeCount === 0) {
|
if (domSelection?.rangeCount === 0) {
|
||||||
@ -26,8 +16,8 @@ export function calcToolbarPosition(editor: Editor, toolbarDom: HTMLDivElement,
|
|||||||
|
|
||||||
const rect = domRange?.getBoundingClientRect() || { top: 0, left: 0, width: 0, height: 0 };
|
const rect = domRange?.getBoundingClientRect() || { top: 0, left: 0, width: 0, height: 0 };
|
||||||
|
|
||||||
const top = `${-toolbarDom.offsetHeight - 5 + (rect.top + scrollContainer.scrollTop - blockRect.top)}px`;
|
const top = `${-toolbarDom.offsetHeight - 5 + (rect.top - blockRect.y)}px`;
|
||||||
const left = `${rect.left - blockRect.left - toolbarDom.offsetWidth / 2 + rect.width / 2}px`;
|
const left = `${rect.left - blockRect.x - toolbarDom.offsetWidth / 2 + rect.width / 2}px`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
top,
|
top,
|
||||||
|
@ -8,3 +8,29 @@ export function debounce(fn: (...args: any[]) => void, delay: number) {
|
|||||||
}, delay)
|
}, delay)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function get(obj: any, path: string[], defaultValue?: any) {
|
||||||
|
let value = obj;
|
||||||
|
for (const prop of path) {
|
||||||
|
value = value[prop];
|
||||||
|
if (value === undefined) {
|
||||||
|
return defaultValue !== undefined ? defaultValue : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function set(obj: any, path: string[], value: any): void {
|
||||||
|
let current = obj;
|
||||||
|
for (let i = 0; i < path.length; i++) {
|
||||||
|
const prop = path[i];
|
||||||
|
if (i === path.length - 1) {
|
||||||
|
current[prop] = value;
|
||||||
|
} else {
|
||||||
|
if (!current[prop]) {
|
||||||
|
current[prop] = {};
|
||||||
|
}
|
||||||
|
current = current[prop];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
DocumentEventGetDocument,
|
DocumentEventGetDocument,
|
||||||
DocumentVersionPB,
|
DocumentVersionPB,
|
||||||
@ -6,14 +6,14 @@ import {
|
|||||||
} from '../../services/backend/events/flowy-document';
|
} from '../../services/backend/events/flowy-document';
|
||||||
import { BlockInterface, BlockType } from '../interfaces';
|
import { BlockInterface, BlockType } from '../interfaces';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { getBlockEditor, createBlockEditor } from '../block_editor';
|
import { BlockEditor } from '../block_editor';
|
||||||
|
|
||||||
const loadBlockData = async (id: string): Promise<Record<string, BlockInterface>> => {
|
const loadBlockData = async (id: string): Promise<Record<string, BlockInterface>> => {
|
||||||
return {
|
return {
|
||||||
[id]: {
|
[id]: {
|
||||||
id: id,
|
id: id,
|
||||||
type: BlockType.PageBlock,
|
type: BlockType.PageBlock,
|
||||||
data: { title: 'Document Title' },
|
data: { content: [{ text: 'Document Title' }] },
|
||||||
next: null,
|
next: null,
|
||||||
firstChild: "L1-1",
|
firstChild: "L1-1",
|
||||||
},
|
},
|
||||||
@ -202,26 +202,580 @@ const loadBlockData = async (id: string): Promise<Record<string, BlockInterface>
|
|||||||
next: null,
|
next: null,
|
||||||
firstChild: 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 = () => {
|
export const useDocument = () => {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const [blockId, setBlockId] = useState<string>();
|
const [blockId, setBlockId] = useState<string>();
|
||||||
const loadDocument = async (id: string): Promise<any> => {
|
const blockEditorRef = useRef<BlockEditor | null>(null)
|
||||||
const getDocumentResult = await DocumentEventGetDocument(
|
|
||||||
OpenDocumentPayloadPB.fromObject({
|
|
||||||
document_id: id,
|
|
||||||
version: DocumentVersionPB.V1,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (getDocumentResult.ok) {
|
|
||||||
const pb = getDocumentResult.val;
|
|
||||||
return JSON.parse(pb.content);
|
|
||||||
} else {
|
|
||||||
throw new Error('get document error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@ -229,11 +783,10 @@ export const useDocument = () => {
|
|||||||
const data = await loadBlockData(params.id);
|
const data = await loadBlockData(params.id);
|
||||||
console.log('==== enter ====', params?.id, data);
|
console.log('==== enter ====', params?.id, data);
|
||||||
|
|
||||||
const blockEditor = getBlockEditor();
|
if (!blockEditorRef.current) {
|
||||||
if (blockEditor) {
|
blockEditorRef.current = new BlockEditor(params?.id, data);
|
||||||
blockEditor.changeDoc(params?.id, data);
|
|
||||||
} else {
|
} else {
|
||||||
createBlockEditor(params?.id, data);
|
blockEditorRef.current.changeDoc(params?.id, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
setBlockId(params.id)
|
setBlockId(params.id)
|
||||||
@ -242,5 +795,5 @@ export const useDocument = () => {
|
|||||||
console.log('==== leave ====', params?.id)
|
console.log('==== leave ====', params?.id)
|
||||||
}
|
}
|
||||||
}, [params.id]);
|
}, [params.id]);
|
||||||
return { blockId };
|
return { blockId, blockEditor: blockEditorRef.current };
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { useDocument } from './DocumentPage.hooks';
|
import { useDocument } from './DocumentPage.hooks';
|
||||||
import BlockList from '../components/block/BlockList';
|
import BlockList from '../components/block/BlockList';
|
||||||
import { BlockContext } from '../utils/block_context';
|
import { BlockContext } from '../utils/block';
|
||||||
import { createTheme, ThemeProvider } from '@mui/material';
|
import { createTheme, ThemeProvider } from '@mui/material';
|
||||||
|
|
||||||
const theme = createTheme({
|
const theme = createTheme({
|
||||||
@ -9,20 +9,19 @@ const theme = createTheme({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
export const DocumentPage = () => {
|
export const DocumentPage = () => {
|
||||||
const { blockId } = useDocument();
|
const { blockId, blockEditor } = useDocument();
|
||||||
|
|
||||||
if (!blockId) return <div className='error-page'></div>;
|
if (!blockId || !blockEditor) return <div className='error-page'></div>;
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<div id='appflowy-block-doc' className='doc-scroller-container flex h-[100%] flex-col items-center overflow-auto'>
|
<BlockContext.Provider
|
||||||
<BlockContext.Provider
|
value={{
|
||||||
value={{
|
id: blockId,
|
||||||
id: blockId,
|
blockEditor,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BlockList blockId={blockId} />
|
<BlockList blockEditor={blockEditor} blockId={blockId} />
|
||||||
</BlockContext.Provider>
|
</BlockContext.Provider>
|
||||||
</div>
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user