mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
refactor: document component
This commit is contained in:
parent
df66521f13
commit
35c21c0d84
@ -1,123 +0,0 @@
|
|||||||
import { BaseEditor, BaseSelection, Descendant, Editor, Transforms } from "slate";
|
|
||||||
import { TreeNode } from '$app/block_editor/view/tree_node';
|
|
||||||
import { Operation } from "$app/block_editor/core/operation";
|
|
||||||
import { TextBlockSelectionManager } from './text_selection';
|
|
||||||
import { BlockType } from "@/appflowy_app/interfaces";
|
|
||||||
import { ReactEditor } from "slate-react";
|
|
||||||
|
|
||||||
export class TextBlockManager {
|
|
||||||
public selectionManager: TextBlockSelectionManager;
|
|
||||||
private editorMap: Map<string, BaseEditor & ReactEditor> = new Map();
|
|
||||||
|
|
||||||
constructor(private rootId: string, private operation: Operation) {
|
|
||||||
this.selectionManager = new TextBlockSelectionManager();
|
|
||||||
}
|
|
||||||
|
|
||||||
register(id: string, editor: BaseEditor & ReactEditor) {
|
|
||||||
this.editorMap.set(id, editor);
|
|
||||||
}
|
|
||||||
|
|
||||||
unregister(id: string) {
|
|
||||||
this.editorMap.delete(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteNode(node: TreeNode) {
|
|
||||||
if (node.type !== BlockType.TextBlock) {
|
|
||||||
this.operation.updateNode(node.id, ['type'], BlockType.TextBlock);
|
|
||||||
this.operation.updateNode(node.id, ['data'], { content: node.data.content });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!node.block.next && node.parent!.id !== this.rootId) {
|
|
||||||
const newParent = node.parent!.parent!;
|
|
||||||
const newPrev = node.parent;
|
|
||||||
this.operation.moveNode(node.id, newParent.id, newPrev?.id || '');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!node.prevLine) return;
|
|
||||||
|
|
||||||
const retainData = node.prevLine.data.content;
|
|
||||||
const editor = this.editorMap.get(node.prevLine.id);
|
|
||||||
if (editor) {
|
|
||||||
const index = retainData.length - 1;
|
|
||||||
const anchor = {
|
|
||||||
path: [0, index],
|
|
||||||
offset: retainData[index].text.length,
|
|
||||||
};
|
|
||||||
const selection = {
|
|
||||||
anchor,
|
|
||||||
focus: {...anchor}
|
|
||||||
};
|
|
||||||
ReactEditor.focus(editor);
|
|
||||||
Transforms.select(editor, selection);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.operation.updateNode(node.prevLine.id, ['data', 'content'], [
|
|
||||||
...retainData,
|
|
||||||
...node.data.content,
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.operation.deleteNode(node.id);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,35 +0,0 @@
|
|||||||
export class TextBlockSelectionManager {
|
|
||||||
private focusId = '';
|
|
||||||
private selection?: any;
|
|
||||||
|
|
||||||
getFocusSelection() {
|
|
||||||
return {
|
|
||||||
focusId: this.focusId,
|
|
||||||
selection: this.selection
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
focusStart(blockId: string) {
|
|
||||||
this.focusId = blockId;
|
|
||||||
this.setSelection(blockId, {
|
|
||||||
focus: {
|
|
||||||
path: [0, 0],
|
|
||||||
offset: 0,
|
|
||||||
},
|
|
||||||
anchor: {
|
|
||||||
path: [0, 0],
|
|
||||||
offset: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelection(blockId: string, selection: any) {
|
|
||||||
this.focusId = blockId;
|
|
||||||
this.selection = selection;
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.focusId = '';
|
|
||||||
this.selection = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,107 +0,0 @@
|
|||||||
import { BlockType, BlockData } from '$app/interfaces/index';
|
|
||||||
import { generateBlockId } from '$app/utils/block';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a single block of content in a document.
|
|
||||||
*/
|
|
||||||
export class Block<T extends BlockType = BlockType> {
|
|
||||||
id: string;
|
|
||||||
type: T;
|
|
||||||
data: BlockData<T>;
|
|
||||||
parent: Block<BlockType> | null = null; // Pointer to the parent block
|
|
||||||
prev: Block<BlockType> | null = null; // Pointer to the previous sibling block
|
|
||||||
next: Block<BlockType> | null = null; // Pointer to the next sibling block
|
|
||||||
firstChild: Block<BlockType> | null = null; // Pointer to the first child block
|
|
||||||
|
|
||||||
constructor(id: string, type: T, data: BlockData<T>) {
|
|
||||||
this.id = id;
|
|
||||||
this.type = type;
|
|
||||||
this.data = data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a new child block to the beginning of the current block's children list.
|
|
||||||
*
|
|
||||||
* @param {Object} content - The content of the new block, including its type and data.
|
|
||||||
* @param {string} content.type - The type of the new block.
|
|
||||||
* @param {Object} content.data - The data associated with the new block.
|
|
||||||
* @returns {Block} The newly created child block.
|
|
||||||
*/
|
|
||||||
prependChild(content: { type: T, data: BlockData<T> }): Block | null {
|
|
||||||
const id = generateBlockId();
|
|
||||||
const newBlock = new Block(id, content.type, content.data);
|
|
||||||
newBlock.reposition(this, null);
|
|
||||||
return newBlock;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a new sibling block after this block.
|
|
||||||
*
|
|
||||||
* @param content The type and data for the new sibling block.
|
|
||||||
* @returns The newly created sibling block.
|
|
||||||
*/
|
|
||||||
addSibling(content: { type: T, data: BlockData<T> }): Block | null {
|
|
||||||
const id = generateBlockId();
|
|
||||||
const newBlock = new Block(id, content.type, content.data);
|
|
||||||
newBlock.reposition(this.parent, this);
|
|
||||||
return newBlock;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove this block and its descendants from the tree.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
remove() {
|
|
||||||
this.detach();
|
|
||||||
let child = this.firstChild;
|
|
||||||
while (child) {
|
|
||||||
const next = child.next;
|
|
||||||
child.remove();
|
|
||||||
child = next;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reposition(newParent: Block<BlockType> | null, newPrev: Block<BlockType> | null) {
|
|
||||||
// Update the block's parent and siblings
|
|
||||||
this.parent = newParent;
|
|
||||||
this.prev = newPrev;
|
|
||||||
this.next = null;
|
|
||||||
|
|
||||||
if (newParent) {
|
|
||||||
const prev = newPrev;
|
|
||||||
if (!prev) {
|
|
||||||
const next = newParent.firstChild;
|
|
||||||
newParent.firstChild = this;
|
|
||||||
if (next) {
|
|
||||||
this.next = next;
|
|
||||||
next.prev = this;
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// Update the next and prev pointers of the newPrev and next blocks
|
|
||||||
if (prev.next !== this) {
|
|
||||||
const next = prev.next;
|
|
||||||
if (next) {
|
|
||||||
next.prev = this
|
|
||||||
this.next = next;
|
|
||||||
}
|
|
||||||
prev.next = this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// detach the block from its current position in the tree
|
|
||||||
detach() {
|
|
||||||
if (this.prev) {
|
|
||||||
this.prev.next = this.next;
|
|
||||||
} else if (this.parent) {
|
|
||||||
this.parent.firstChild = this.next;
|
|
||||||
}
|
|
||||||
if (this.next) {
|
|
||||||
this.next.prev = this.prev;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,226 +0,0 @@
|
|||||||
import { BlockData, BlockInterface, BlockType } from '$app/interfaces/index';
|
|
||||||
import { set } from '../../utils/tool';
|
|
||||||
import { Block } from './block';
|
|
||||||
export interface BlockChangeProps {
|
|
||||||
block?: Block,
|
|
||||||
startBlock?: Block,
|
|
||||||
endBlock?: Block,
|
|
||||||
oldParentId?: string,
|
|
||||||
oldPrevId?: string
|
|
||||||
}
|
|
||||||
export class BlockChain {
|
|
||||||
private map: Map<string, Block<BlockType>> = new Map();
|
|
||||||
public head: Block<BlockType> | null = null;
|
|
||||||
|
|
||||||
constructor(private onBlockChange: (command: string, data: BlockChangeProps) => void) {
|
|
||||||
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* generate blocks from doc data
|
|
||||||
* @param id doc id
|
|
||||||
* @param map doc data
|
|
||||||
*/
|
|
||||||
rebuild = (id: string, map: Record<string, BlockInterface<BlockType>>) => {
|
|
||||||
this.map.clear();
|
|
||||||
this.head = this.createBlock(id, map[id].type, map[id].data);
|
|
||||||
|
|
||||||
const callback = (block: Block) => {
|
|
||||||
const firstChildId = map[block.id].firstChild;
|
|
||||||
const nextId = map[block.id].next;
|
|
||||||
if (!block.firstChild && firstChildId) {
|
|
||||||
block.firstChild = this.createBlock(firstChildId, map[firstChildId].type, map[firstChildId].data);
|
|
||||||
block.firstChild.parent = block;
|
|
||||||
block.firstChild.prev = null;
|
|
||||||
}
|
|
||||||
if (!block.next && nextId) {
|
|
||||||
block.next = this.createBlock(nextId, map[nextId].type, map[nextId].data);
|
|
||||||
block.next.parent = block.parent;
|
|
||||||
block.next.prev = block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.traverse(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Traversing the block list from front to back
|
|
||||||
* @param callback It will be call when the block visited
|
|
||||||
* @param block block item, it will be equal head node when the block item is undefined
|
|
||||||
*/
|
|
||||||
traverse(callback: (_block: Block<BlockType>) => void, block?: Block<BlockType>) {
|
|
||||||
let currentBlock: Block | null = block || this.head;
|
|
||||||
while (currentBlock) {
|
|
||||||
callback(currentBlock);
|
|
||||||
if (currentBlock.firstChild) {
|
|
||||||
this.traverse(callback, currentBlock.firstChild);
|
|
||||||
}
|
|
||||||
currentBlock = currentBlock.next;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* get block data
|
|
||||||
* @param blockId string
|
|
||||||
* @returns Block
|
|
||||||
*/
|
|
||||||
getBlock = (blockId: string) => {
|
|
||||||
return this.map.get(blockId) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.map.clear();
|
|
||||||
this.head = null;
|
|
||||||
this.onBlockChange = () => null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a new child block to the beginning of the current block's children list.
|
|
||||||
*
|
|
||||||
* @param {string} parentId
|
|
||||||
* @param {Object} content - The content of the new block, including its type and data.
|
|
||||||
* @param {string} content.type - The type of the new block.
|
|
||||||
* @param {Object} content.data - The data associated with the new block.
|
|
||||||
* @returns {Block} The newly created child block.
|
|
||||||
*/
|
|
||||||
prependChild(blockId: string, content: { type: BlockType, data: BlockData<BlockType> }): Block | null {
|
|
||||||
const parent = this.getBlock(blockId);
|
|
||||||
if (!parent) return null;
|
|
||||||
const newBlock = parent.prependChild(content);
|
|
||||||
|
|
||||||
if (newBlock) {
|
|
||||||
this.map.set(newBlock?.id, newBlock);
|
|
||||||
this.onBlockChange('insert', { block: newBlock });
|
|
||||||
}
|
|
||||||
|
|
||||||
return newBlock;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a new sibling block after this block.
|
|
||||||
* @param {string} blockId
|
|
||||||
* @param content The type and data for the new sibling block.
|
|
||||||
* @returns The newly created sibling block.
|
|
||||||
*/
|
|
||||||
addSibling(blockId: string, content: { type: BlockType, data: BlockData<BlockType> }): Block | null {
|
|
||||||
const block = this.getBlock(blockId);
|
|
||||||
if (!block) return null;
|
|
||||||
const newBlock = block.addSibling(content);
|
|
||||||
if (newBlock) {
|
|
||||||
this.map.set(newBlock?.id, newBlock);
|
|
||||||
this.onBlockChange('insert', { block: newBlock });
|
|
||||||
}
|
|
||||||
return newBlock;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove this block and its descendants from the tree.
|
|
||||||
* @param {string} blockId
|
|
||||||
*/
|
|
||||||
remove(blockId: string) {
|
|
||||||
const block = this.getBlock(blockId);
|
|
||||||
if (!block) return;
|
|
||||||
const oldParentId = block.parent?.id;
|
|
||||||
block.remove();
|
|
||||||
this.map.delete(block.id);
|
|
||||||
this.onBlockChange('remove', { oldParentId });
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,16 +0,0 @@
|
|||||||
import { BackendOp, LocalOp } from "$app/interfaces";
|
|
||||||
|
|
||||||
export class OpAdapter {
|
|
||||||
|
|
||||||
toBackendOp(localOp: LocalOp): BackendOp {
|
|
||||||
const backendOp: BackendOp = { ...localOp };
|
|
||||||
// switch localOp type and generate backendOp
|
|
||||||
return backendOp;
|
|
||||||
}
|
|
||||||
|
|
||||||
toLocalOp(backendOp: BackendOp): LocalOp {
|
|
||||||
const localOp: LocalOp = { ...backendOp };
|
|
||||||
// switch backendOp type and generate localOp
|
|
||||||
return localOp;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,167 +0,0 @@
|
|||||||
import { BlockChain } from './block_chain';
|
|
||||||
import { BlockInterface, BlockType, InsertOpData, LocalOp, UpdateOpData, moveOpData, moveRangeOpData, removeOpData, BlockData } from '$app/interfaces';
|
|
||||||
import { BlockEditorSync } from './sync';
|
|
||||||
import { Block } from './block';
|
|
||||||
|
|
||||||
export class Operation {
|
|
||||||
private sync: BlockEditorSync;
|
|
||||||
|
|
||||||
constructor(private blockChain: BlockChain) {
|
|
||||||
this.sync = new BlockEditorSync();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
splitNode(
|
|
||||||
retainId: string,
|
|
||||||
retainData: { path: string[], value: any },
|
|
||||||
newBlockData: {
|
|
||||||
type: BlockType;
|
|
||||||
data: BlockData
|
|
||||||
}) {
|
|
||||||
const ops: {
|
|
||||||
type: LocalOp['type'];
|
|
||||||
data: LocalOp['data'];
|
|
||||||
}[] = [];
|
|
||||||
const newBlock = this.blockChain.addSibling(retainId, newBlockData);
|
|
||||||
const parentId = newBlock?.parent?.id;
|
|
||||||
const retainBlock = this.blockChain.getBlock(retainId);
|
|
||||||
if (!newBlock || !parentId || !retainBlock) return null;
|
|
||||||
|
|
||||||
const insertOp = this.getInsertNodeOp({
|
|
||||||
id: newBlock.id,
|
|
||||||
next: newBlock.next?.id || null,
|
|
||||||
firstChild: newBlock.firstChild?.id || null,
|
|
||||||
data: newBlock.data,
|
|
||||||
type: newBlock.type,
|
|
||||||
}, parentId, retainId);
|
|
||||||
|
|
||||||
const updateOp = this.getUpdateNodeOp(retainId, retainData.path, retainData.value);
|
|
||||||
this.blockChain.updateBlock(retainId, retainData);
|
|
||||||
|
|
||||||
ops.push(insertOp, updateOp);
|
|
||||||
const startBlock = retainBlock.firstChild;
|
|
||||||
if (startBlock) {
|
|
||||||
const startBlockId = startBlock.id;
|
|
||||||
let next: Block | null = startBlock.next;
|
|
||||||
let endBlockId = startBlockId;
|
|
||||||
while (next) {
|
|
||||||
endBlockId = next.id;
|
|
||||||
next = next.next;
|
|
||||||
}
|
|
||||||
|
|
||||||
const moveOp = this.getMoveRangeOp([startBlockId, endBlockId], newBlock.id);
|
|
||||||
this.blockChain.moveBulk(startBlockId, endBlockId, newBlock.id, '');
|
|
||||||
ops.push(moveOp);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sync.sendOps(ops);
|
|
||||||
|
|
||||||
return newBlock;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateNode<T>(blockId: string, path: string[], value: T) {
|
|
||||||
const op = this.getUpdateNodeOp(blockId, path, value);
|
|
||||||
this.blockChain.updateBlock(blockId, {
|
|
||||||
path,
|
|
||||||
value
|
|
||||||
});
|
|
||||||
this.sync.sendOps([op]);
|
|
||||||
}
|
|
||||||
|
|
||||||
moveNode(blockId: string, newParentId: string, newPrevId: string) {
|
|
||||||
const op = this.getMoveOp(blockId, newParentId, newPrevId);
|
|
||||||
this.blockChain.move(blockId, newParentId, newPrevId);
|
|
||||||
this.sync.sendOps([op]);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteNode(blockId: string) {
|
|
||||||
const op = this.getRemoveOp(blockId);
|
|
||||||
this.blockChain.remove(blockId);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,48 +0,0 @@
|
|||||||
import { BackendOp, LocalOp } from '$app/interfaces';
|
|
||||||
import { OpAdapter } from './op_adapter';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BlockEditorSync is a class that synchronizes changes made to a block chain with a server.
|
|
||||||
* It allows for adding, removing, and moving blocks in the chain, and sends pending operations to the server.
|
|
||||||
*/
|
|
||||||
export class BlockEditorSync {
|
|
||||||
private version = 0;
|
|
||||||
private opAdapter: OpAdapter;
|
|
||||||
private pendingOps: BackendOp[] = [];
|
|
||||||
private appliedOps: LocalOp[] = [];
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.opAdapter = new OpAdapter();
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyOp(op: BackendOp): void {
|
|
||||||
const localOp = this.opAdapter.toLocalOp(op);
|
|
||||||
this.appliedOps.push(localOp);
|
|
||||||
}
|
|
||||||
|
|
||||||
private receiveOps(ops: BackendOp[]): void {
|
|
||||||
// Apply the incoming operations to the local document
|
|
||||||
ops.sort((a, b) => a.version - b.version);
|
|
||||||
for (const op of ops) {
|
|
||||||
this.applyOp(op);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveConflict(): void {
|
|
||||||
// Implement conflict resolution logic here
|
|
||||||
}
|
|
||||||
|
|
||||||
public sendOps(ops: {
|
|
||||||
type: LocalOp["type"];
|
|
||||||
data: LocalOp["data"]
|
|
||||||
}[]) {
|
|
||||||
const backendOps = ops.map(op => this.opAdapter.toBackendOp({
|
|
||||||
...op,
|
|
||||||
version: this.version
|
|
||||||
}));
|
|
||||||
this.pendingOps.push(...backendOps);
|
|
||||||
// Send the pending operations to the server
|
|
||||||
console.log('==== sync pending ops ====', [...this.pendingOps]);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,60 +0,0 @@
|
|||||||
// Import dependencies
|
|
||||||
import { BlockInterface } from '../interfaces';
|
|
||||||
import { BlockChain, BlockChangeProps } from './core/block_chain';
|
|
||||||
import { RenderTree } from './view/tree';
|
|
||||||
import { Operation } from './core/operation';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The BlockEditor class manages a block chain and a render tree for a document editor.
|
|
||||||
* The block chain stores the content blocks of the document in sequence, while the
|
|
||||||
* render tree displays the document as a hierarchical tree structure.
|
|
||||||
*/
|
|
||||||
export class BlockEditor {
|
|
||||||
// Public properties
|
|
||||||
public blockChain: BlockChain; // (local data) the block chain used to store the document
|
|
||||||
public renderTree: RenderTree; // the render tree used to display the document
|
|
||||||
public operation: Operation;
|
|
||||||
/**
|
|
||||||
* Constructs a new BlockEditor object.
|
|
||||||
* @param id - the ID of the document
|
|
||||||
* @param data - the initial data for the document
|
|
||||||
*/
|
|
||||||
constructor(private id: string, data: Record<string, BlockInterface>) {
|
|
||||||
// Create the block chain and render tree
|
|
||||||
this.blockChain = new BlockChain(this.blockChange);
|
|
||||||
this.operation = new Operation(this.blockChain);
|
|
||||||
this.changeDoc(id, data);
|
|
||||||
|
|
||||||
this.renderTree = new RenderTree(this.blockChain);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the document ID and block chain when the document changes.
|
|
||||||
* @param id - the new ID of the document
|
|
||||||
* @param data - the updated data for the document
|
|
||||||
*/
|
|
||||||
changeDoc = (id: string, data: Record<string, BlockInterface>) => {
|
|
||||||
console.log('==== change document ====', id, data);
|
|
||||||
|
|
||||||
// Update the document ID and rebuild the block chain
|
|
||||||
this.id = id;
|
|
||||||
this.blockChain.rebuild(id, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroys the block chain and render tree.
|
|
||||||
*/
|
|
||||||
destroy = () => {
|
|
||||||
// Destroy the block chain and render tree
|
|
||||||
this.blockChain.destroy();
|
|
||||||
this.renderTree.destroy();
|
|
||||||
this.operation.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
private blockChange = (command: string, data: BlockChangeProps) => {
|
|
||||||
this.renderTree.onBlockChange(command, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
|||||||
import { RegionGrid, BlockPosition } from './region_grid';
|
|
||||||
export class BlockPositionManager {
|
|
||||||
private regionGrid: RegionGrid;
|
|
||||||
private viewportBlocks: Set<string> = new Set();
|
|
||||||
private blockPositions: Map<string, BlockPosition> = new Map();
|
|
||||||
private container: HTMLDivElement | null = null;
|
|
||||||
|
|
||||||
constructor(container: HTMLDivElement) {
|
|
||||||
this.container = container;
|
|
||||||
this.regionGrid = new RegionGrid(container.offsetHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
isInViewport(nodeId: string) {
|
|
||||||
return this.viewportBlocks.has(nodeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
observeBlock(node: HTMLDivElement) {
|
|
||||||
const blockId = node.getAttribute('data-block-id');
|
|
||||||
if (blockId) {
|
|
||||||
this.updateBlockPosition(blockId);
|
|
||||||
this.viewportBlocks.add(blockId);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
unobserve: () => {
|
|
||||||
if (blockId) this.viewportBlocks.delete(blockId);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
getViewportBlockByPoint(x: number, y: number): BlockPosition | null {
|
|
||||||
let blockPosition: BlockPosition | null = null;
|
|
||||||
this.viewportBlocks.forEach(id => {
|
|
||||||
this.updateBlockPosition(id);
|
|
||||||
const block = this.blockPositions.get(id);
|
|
||||||
if (!block) return;
|
|
||||||
|
|
||||||
if (block.x + block.width - 1 >= x &&
|
|
||||||
block.y + block.height - 1 >= y && block.y <= y) {
|
|
||||||
if (!blockPosition || block.y > blockPosition.y) {
|
|
||||||
blockPosition = block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return blockPosition;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.container = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,173 +0,0 @@
|
|||||||
import { BlockChain, BlockChangeProps } from '../core/block_chain';
|
|
||||||
import { Block } from '../core/block';
|
|
||||||
import { TreeNode } from "./tree_node";
|
|
||||||
import { BlockPositionManager } from './block_position';
|
|
||||||
import { filterSelections } from '@/appflowy_app/utils/block_selection';
|
|
||||||
|
|
||||||
export class RenderTree {
|
|
||||||
public blockPositionManager?: BlockPositionManager;
|
|
||||||
|
|
||||||
private map: Map<string, TreeNode> = new Map();
|
|
||||||
private root: TreeNode | null = null;
|
|
||||||
private selections: Set<string> = new Set();
|
|
||||||
constructor(private blockChain: BlockChain) {
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
createPositionManager(container: HTMLDivElement) {
|
|
||||||
this.blockPositionManager = new BlockPositionManager(container);
|
|
||||||
}
|
|
||||||
|
|
||||||
observeBlock(node: HTMLDivElement) {
|
|
||||||
return this.blockPositionManager?.observeBlock(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
getBlockPosition(nodeId: string) {
|
|
||||||
return this.blockPositionManager?.getBlockPosition(nodeId) || null;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Get the TreeNode data by nodeId
|
|
||||||
* @param nodeId string
|
|
||||||
* @returns TreeNode|null
|
|
||||||
*/
|
|
||||||
getTreeNode = (nodeId: string): TreeNode | null => {
|
|
||||||
// Return the TreeNode instance from the map or null if it does not exist
|
|
||||||
return this.map.get(nodeId) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private createNode(block: Block): TreeNode {
|
|
||||||
if (this.map.has(block.id)) {
|
|
||||||
return this.map.get(block.id)!;
|
|
||||||
}
|
|
||||||
const node = new TreeNode(block);
|
|
||||||
this.map.set(block.id, node);
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
buildDeep(rootId: string): TreeNode | null {
|
|
||||||
this.map.clear();
|
|
||||||
// Define a callback function for the blockChain.traverse() method
|
|
||||||
const callback = (block: Block) => {
|
|
||||||
// Check if the TreeNode instance already exists in the map
|
|
||||||
const node = this.createNode(block);
|
|
||||||
|
|
||||||
// Add the TreeNode instance to the map
|
|
||||||
this.map.set(block.id, node);
|
|
||||||
|
|
||||||
// Add the first child of the block as a child of the current TreeNode instance
|
|
||||||
const firstChild = block.firstChild;
|
|
||||||
if (firstChild) {
|
|
||||||
const child = this.createNode(firstChild);
|
|
||||||
node.addChild(child);
|
|
||||||
this.map.set(child.id, child);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the next block as a sibling of the current TreeNode instance
|
|
||||||
const next = block.next;
|
|
||||||
if (next) {
|
|
||||||
const nextNode = this.createNode(next);
|
|
||||||
node.parent?.addChild(nextNode);
|
|
||||||
this.map.set(next.id, nextNode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Traverse the blockChain using the callback function
|
|
||||||
this.blockChain.traverse(callback);
|
|
||||||
|
|
||||||
// Get the root node from the map and return it
|
|
||||||
const root = this.map.get(rootId)!;
|
|
||||||
this.root = root;
|
|
||||||
return root || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
forceUpdate(nodeId: string, shouldUpdateChildren = false) {
|
|
||||||
const block = this.blockChain.getBlock(nodeId);
|
|
||||||
if (!block) return null;
|
|
||||||
const node = this.createNode(block);
|
|
||||||
if (!node) return null;
|
|
||||||
if (!shouldUpdateChildren) {
|
|
||||||
node.update(node.block, node.children);
|
|
||||||
node.reRender();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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 'remove':
|
|
||||||
if (oldParentId) this.forceUpdate(oldParentId, true);
|
|
||||||
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);
|
|
||||||
|
|
||||||
const selectedBlocksSet = new Set(newSelections);
|
|
||||||
|
|
||||||
const updateNotSelected: string[] = [];
|
|
||||||
const updateSelected: string[] = [];
|
|
||||||
Array.from(this.selections).forEach((id) => {
|
|
||||||
if (!selectedBlocksSet.has(id)) {
|
|
||||||
updateNotSelected.push(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
newSelections.forEach(id => {
|
|
||||||
if (!this.selections.has(id)) {
|
|
||||||
updateSelected.push(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.selections = selectedBlocksSet;
|
|
||||||
[...updateNotSelected, ...updateSelected].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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,78 +0,0 @@
|
|||||||
import { BlockData, BlockType } from '$app/interfaces/index';
|
|
||||||
import { Block } from '../core/block';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a node in a tree structure of blocks.
|
|
||||||
*/
|
|
||||||
export class TreeNode {
|
|
||||||
id: string;
|
|
||||||
type: BlockType;
|
|
||||||
parent: TreeNode | null = null;
|
|
||||||
children: TreeNode[] = [];
|
|
||||||
data: BlockData<BlockType>;
|
|
||||||
|
|
||||||
private forceUpdate?: () => void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new TreeNode instance.
|
|
||||||
* @param block - The block data used to create the node.
|
|
||||||
*/
|
|
||||||
constructor(private _block: Block) {
|
|
||||||
this.id = _block.id;
|
|
||||||
this.data = _block.data;
|
|
||||||
this.type = _block.type;
|
|
||||||
}
|
|
||||||
|
|
||||||
registerUpdate(forceUpdate: () => void) {
|
|
||||||
this.forceUpdate = forceUpdate;
|
|
||||||
}
|
|
||||||
|
|
||||||
unregisterUpdate() {
|
|
||||||
this.forceUpdate = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
reRender() {
|
|
||||||
this.forceUpdate?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
update(block: Block, children: TreeNode[]) {
|
|
||||||
this.type = block.type;
|
|
||||||
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 lastChild() {
|
|
||||||
return this.children[this.children.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
get prevLine(): TreeNode | null {
|
|
||||||
if (!this.parent) return null;
|
|
||||||
const index = this.parent?.children.findIndex(item => item.id === this.id);
|
|
||||||
if (index === 0) {
|
|
||||||
return this.parent;
|
|
||||||
}
|
|
||||||
const prev = this.parent.children[index - 1];
|
|
||||||
let line = prev;
|
|
||||||
while(line.lastChild) {
|
|
||||||
line = line.lastChild;
|
|
||||||
}
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
|
|
||||||
get block() {
|
|
||||||
return this._block;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,36 +0,0 @@
|
|||||||
import { useEffect, useState, useRef, useContext } from 'react';
|
|
||||||
|
|
||||||
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
|
|
||||||
import { BlockContext } from '$app/utils/block';
|
|
||||||
|
|
||||||
export function useBlockComponent({
|
|
||||||
node
|
|
||||||
}: {
|
|
||||||
node: TreeNode
|
|
||||||
}) {
|
|
||||||
const { blockEditor } = useContext(BlockContext);
|
|
||||||
|
|
||||||
const [version, forceUpdate] = useState<number>(0);
|
|
||||||
const myRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const isSelected = blockEditor?.renderTree.isSelected(node.id);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!myRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const observe = blockEditor?.renderTree.observeBlock(myRef.current);
|
|
||||||
node.registerUpdate(() => forceUpdate((prev) => prev + 1));
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
node.unregisterUpdate();
|
|
||||||
observe?.unobserve();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
return {
|
|
||||||
version,
|
|
||||||
myRef,
|
|
||||||
isSelected,
|
|
||||||
className: `relative my-[1px] px-1`
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,91 +0,0 @@
|
|||||||
import React, { forwardRef } from 'react';
|
|
||||||
import { BlockCommonProps, BlockType } from '$app/interfaces';
|
|
||||||
import PageBlock from '../PageBlock';
|
|
||||||
import TextBlock from '../TextBlock';
|
|
||||||
import HeadingBlock from '../HeadingBlock';
|
|
||||||
import ListBlock from '../ListBlock';
|
|
||||||
import CodeBlock from '../CodeBlock';
|
|
||||||
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
|
|
||||||
import { withErrorBoundary } from 'react-error-boundary';
|
|
||||||
import { ErrorBoundaryFallbackComponent } from '../BlockList/BlockList.hooks';
|
|
||||||
import { useBlockComponent } from './BlockComponet.hooks';
|
|
||||||
|
|
||||||
const BlockComponent = forwardRef(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
node,
|
|
||||||
renderChild,
|
|
||||||
...props
|
|
||||||
}: { node: TreeNode; renderChild?: (_node: TreeNode) => React.ReactNode } & React.DetailedHTMLProps<
|
|
||||||
React.HTMLAttributes<HTMLDivElement>,
|
|
||||||
HTMLDivElement
|
|
||||||
>,
|
|
||||||
ref: React.ForwardedRef<HTMLDivElement>
|
|
||||||
) => {
|
|
||||||
const { myRef, className, version, isSelected } = useBlockComponent({
|
|
||||||
node,
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderComponent = () => {
|
|
||||||
let BlockComponentClass: (_: BlockCommonProps<TreeNode>) => JSX.Element | null;
|
|
||||||
switch (node.type) {
|
|
||||||
case BlockType.PageBlock:
|
|
||||||
BlockComponentClass = PageBlock;
|
|
||||||
break;
|
|
||||||
case BlockType.TextBlock:
|
|
||||||
BlockComponentClass = TextBlock;
|
|
||||||
break;
|
|
||||||
case BlockType.HeadingBlock:
|
|
||||||
BlockComponentClass = HeadingBlock;
|
|
||||||
break;
|
|
||||||
case BlockType.ListBlock:
|
|
||||||
BlockComponentClass = ListBlock;
|
|
||||||
break;
|
|
||||||
case BlockType.CodeBlock:
|
|
||||||
BlockComponentClass = CodeBlock;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blockProps: BlockCommonProps<TreeNode> = {
|
|
||||||
version,
|
|
||||||
node,
|
|
||||||
};
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
if (BlockComponentClass) {
|
|
||||||
return <BlockComponentClass {...blockProps} />;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={(el: HTMLDivElement | null) => {
|
|
||||||
myRef.current = el;
|
|
||||||
if (typeof ref === 'function') {
|
|
||||||
ref(el);
|
|
||||||
} else if (ref) {
|
|
||||||
ref.current = el;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
data-block-id={node.id}
|
|
||||||
data-block-selected={isSelected}
|
|
||||||
className={props.className ? `${props.className} ${className}` : className}
|
|
||||||
>
|
|
||||||
{renderComponent()}
|
|
||||||
{renderChild ? node.children.map(renderChild) : null}
|
|
||||||
<div className='block-overlay'></div>
|
|
||||||
{isSelected ? <div className='pointer-events-none absolute inset-0 z-[-1] rounded-[4px] bg-[#E0F8FF]' /> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const ComponentWithErrorBoundary = withErrorBoundary(BlockComponent, {
|
|
||||||
FallbackComponent: ErrorBoundaryFallbackComponent,
|
|
||||||
});
|
|
||||||
export default React.memo(ComponentWithErrorBoundary);
|
|
@ -1,91 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { BlockEditor } from '@/appflowy_app/block_editor';
|
|
||||||
import { TreeNode } from '$app/block_editor/view/tree_node';
|
|
||||||
import { Alert } from '@mui/material';
|
|
||||||
import { FallbackProps } from 'react-error-boundary';
|
|
||||||
import { TextBlockManager } from '@/appflowy_app/block_editor/blocks/text_block';
|
|
||||||
import { TextBlockContext } from '@/appflowy_app/utils/slate/context';
|
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
||||||
export interface BlockListProps {
|
|
||||||
blockId: string;
|
|
||||||
blockEditor: BlockEditor;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultSize = 45;
|
|
||||||
|
|
||||||
export function useBlockList({ blockId, blockEditor }: BlockListProps) {
|
|
||||||
const [root, setRoot] = useState<TreeNode | null>(null);
|
|
||||||
|
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const rowVirtualizer = useVirtualizer({
|
|
||||||
count: root?.children.length || 0,
|
|
||||||
getScrollElement: () => parentRef.current,
|
|
||||||
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.blockId, props.blockEditor.operation);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
textBlockManager.destroy();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TextBlockContext.Provider
|
|
||||||
value={{
|
|
||||||
textBlockManager,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Component {...props} />
|
|
||||||
</TextBlockContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,18 +0,0 @@
|
|||||||
import TextBlock from '../TextBlock';
|
|
||||||
import { TreeNode } from '$app/block_editor/view/tree_node';
|
|
||||||
|
|
||||||
export default function BlockListTitle({ node }: { node: TreeNode | null }) {
|
|
||||||
if (!node) return null;
|
|
||||||
return (
|
|
||||||
<div data-block-id={node.id} className='doc-title flex pt-[50px] text-4xl font-bold'>
|
|
||||||
<TextBlock
|
|
||||||
version={0}
|
|
||||||
toolbarProps={{
|
|
||||||
showGroups: [],
|
|
||||||
}}
|
|
||||||
node={node}
|
|
||||||
needRenderChildren={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,31 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import Typography, { TypographyProps } from '@mui/material/Typography';
|
|
||||||
import Skeleton from '@mui/material/Skeleton';
|
|
||||||
import Grid from '@mui/material/Grid';
|
|
||||||
|
|
||||||
const variants = ['h1', 'h3', 'body1', 'caption'] as readonly TypographyProps['variant'][];
|
|
||||||
|
|
||||||
export default function ListFallbackComponent() {
|
|
||||||
return (
|
|
||||||
<div id='appflowy-block-doc' className='doc-scroller-container flex h-[100%] flex-col items-center overflow-auto'>
|
|
||||||
<div className='doc-content min-x-[0%] p-lg w-[900px] max-w-[100%]'>
|
|
||||||
<div className='doc-title my-[50px] flex w-[100%] px-14 text-4xl font-bold'>
|
|
||||||
<Typography className='w-[100%]' component='div' key={'h1'} variant={'h1'}>
|
|
||||||
<Skeleton />
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
<div className='doc-body px-14' style={{ height: '100vh' }}>
|
|
||||||
<Grid container spacing={8}>
|
|
||||||
<Grid item xs>
|
|
||||||
{variants.map((variant) => (
|
|
||||||
<Typography component='div' key={variant} variant={variant}>
|
|
||||||
<Skeleton />
|
|
||||||
</Typography>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,14 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import BlockSideTools from '../BlockSideTools';
|
|
||||||
import BlockSelection from '../BlockSelection';
|
|
||||||
import { BlockEditor } from '@/appflowy_app/block_editor';
|
|
||||||
|
|
||||||
export default function Overlay({ blockEditor, container }: { container: HTMLDivElement; blockEditor: BlockEditor }) {
|
|
||||||
const [isDragging, setDragging] = useState(false);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{isDragging ? null : <BlockSideTools blockEditor={blockEditor} container={container} />}
|
|
||||||
<BlockSelection onDragging={setDragging} blockEditor={blockEditor} container={container} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,58 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { BlockListProps, useBlockList, withTextBlockManager } from './BlockList.hooks';
|
|
||||||
import { withErrorBoundary } from 'react-error-boundary';
|
|
||||||
import ListFallbackComponent from './ListFallbackComponent';
|
|
||||||
import BlockListTitle from './BlockListTitle';
|
|
||||||
import BlockComponent from '../BlockComponent';
|
|
||||||
import Overlay from './Overlay';
|
|
||||||
|
|
||||||
function BlockList(props: BlockListProps) {
|
|
||||||
const { root, rowVirtualizer, parentRef, blockEditor } = useBlockList(props);
|
|
||||||
|
|
||||||
const virtualItems = rowVirtualizer.getVirtualItems();
|
|
||||||
return (
|
|
||||||
<div id='appflowy-block-doc' className='h-[100%] overflow-hidden'>
|
|
||||||
<div
|
|
||||||
ref={parentRef}
|
|
||||||
className={`doc-scroller-container flex h-[100%] flex-wrap items-center justify-center overflow-auto px-20`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className='doc-body max-w-screen w-[900px] min-w-0'
|
|
||||||
style={{
|
|
||||||
height: rowVirtualizer.getTotalSize(),
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{root && virtualItems.length ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
transform: `translateY(${virtualItems[0].start || 0}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{virtualItems.map((virtualRow) => {
|
|
||||||
const id = root.children[virtualRow.index].id;
|
|
||||||
return (
|
|
||||||
<div className='p-[1px]' key={id} data-index={virtualRow.index} ref={rowVirtualizer.measureElement}>
|
|
||||||
{virtualRow.index === 0 ? <BlockListTitle node={root} /> : null}
|
|
||||||
<BlockComponent node={root.children[virtualRow.index]} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{parentRef.current ? <Overlay container={parentRef.current} blockEditor={blockEditor} /> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ListWithErrorBoundary = withErrorBoundary(withTextBlockManager(BlockList), {
|
|
||||||
FallbackComponent: ListFallbackComponent,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default React.memo(ListWithErrorBoundary);
|
|
@ -1,9 +0,0 @@
|
|||||||
import ReactDOM from 'react-dom';
|
|
||||||
|
|
||||||
const BlockPortal = ({ blockId, children }: { blockId: string; children: JSX.Element }) => {
|
|
||||||
const root = document.querySelectorAll(`[data-block-id=${blockId}] > .block-overlay`)[0];
|
|
||||||
|
|
||||||
return typeof document === 'object' && root ? ReactDOM.createPortal(children, root) : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BlockPortal;
|
|
@ -1,64 +0,0 @@
|
|||||||
import { BlockEditor } from '@/appflowy_app/block_editor';
|
|
||||||
import { BlockType } from '@/appflowy_app/interfaces';
|
|
||||||
import { debounce } from '@/appflowy_app/utils/tool';
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
export function useBlockSideTools({ blockEditor, container }: { container: HTMLDivElement; blockEditor: BlockEditor }) {
|
|
||||||
const [hoverBlock, setHoverBlock] = useState<string>();
|
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
|
||||||
const { clientX, clientY } = e;
|
|
||||||
const x = clientX;
|
|
||||||
const y = clientY + container.scrollTop;
|
|
||||||
const block = blockEditor.renderTree.blockPositionManager?.getViewportBlockByPoint(x, y);
|
|
||||||
|
|
||||||
if (!block) {
|
|
||||||
setHoverBlock('');
|
|
||||||
} else {
|
|
||||||
const node = blockEditor.renderTree.getTreeNode(block.id)!;
|
|
||||||
if ([BlockType.ColumnBlock].includes(node.type)) {
|
|
||||||
setHoverBlock('');
|
|
||||||
} else {
|
|
||||||
setHoverBlock(block.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const debounceMove = useMemo(() => debounce(handleMouseMove, 30), [handleMouseMove]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el) return;
|
|
||||||
if (!hoverBlock) {
|
|
||||||
el.style.opacity = '0';
|
|
||||||
el.style.zIndex = '-1';
|
|
||||||
} else {
|
|
||||||
el.style.opacity = '1';
|
|
||||||
el.style.zIndex = '1';
|
|
||||||
const node = blockEditor.renderTree.getTreeNode(hoverBlock);
|
|
||||||
el.style.top = '3px';
|
|
||||||
if (node?.type === BlockType.HeadingBlock) {
|
|
||||||
if (node.data.level === 1) {
|
|
||||||
el.style.top = '8px';
|
|
||||||
} else if (node.data.level === 2) {
|
|
||||||
el.style.top = '6px';
|
|
||||||
} else {
|
|
||||||
el.style.top = '5px';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [hoverBlock]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
container.addEventListener('mousemove', debounceMove);
|
|
||||||
return () => {
|
|
||||||
container.removeEventListener('mousemove', debounceMove);
|
|
||||||
};
|
|
||||||
}, [debounceMove]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
hoverBlock,
|
|
||||||
ref,
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
|
|
||||||
import { BlockCommonProps } from '@/appflowy_app/interfaces';
|
|
||||||
|
|
||||||
export default function CodeBlock({ node }: BlockCommonProps<TreeNode>) {
|
|
||||||
return <div>{node.data.text}</div>;
|
|
||||||
}
|
|
@ -1,17 +0,0 @@
|
|||||||
import TextBlock from '../TextBlock';
|
|
||||||
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
|
|
||||||
import { BlockCommonProps } from '@/appflowy_app/interfaces';
|
|
||||||
|
|
||||||
const fontSize: Record<string, string> = {
|
|
||||||
1: 'mt-8 text-3xl',
|
|
||||||
2: 'mt-6 text-2xl',
|
|
||||||
3: 'mt-4 text-xl',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function HeadingBlock({ node, version }: BlockCommonProps<TreeNode>) {
|
|
||||||
return (
|
|
||||||
<div className={`${fontSize[node.data.level]} font-semibold `}>
|
|
||||||
<TextBlock version={version} node={node} needRenderChildren={false} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,32 +0,0 @@
|
|||||||
import { toggleFormat, isFormatActive } from '@/appflowy_app/utils/slate/format';
|
|
||||||
import IconButton from '@mui/material/IconButton';
|
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
|
||||||
|
|
||||||
import { command } from '$app/constants/toolbar';
|
|
||||||
import FormatIcon from './FormatIcon';
|
|
||||||
import { BaseEditor } from 'slate';
|
|
||||||
|
|
||||||
const FormatButton = ({ editor, format, icon }: { editor: BaseEditor; format: string; icon: string }) => {
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
slotProps={{ tooltip: { style: { background: '#E0F8FF', borderRadius: 8 } } }}
|
|
||||||
title={
|
|
||||||
<div className='flex flex-col'>
|
|
||||||
<span className='text-base font-medium text-black'>{command[format].title}</span>
|
|
||||||
<span className='text-sm text-slate-400'>{command[format].key}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
placement='top-start'
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
size='small'
|
|
||||||
sx={{ color: isFormatActive(editor, format) ? '#00BCF0' : 'white' }}
|
|
||||||
onClick={() => toggleFormat(editor, format)}
|
|
||||||
>
|
|
||||||
<FormatIcon icon={icon} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FormatButton;
|
|
@ -1,20 +0,0 @@
|
|||||||
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,36 +0,0 @@
|
|||||||
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,31 +0,0 @@
|
|||||||
import FormatButton from './FormatButton';
|
|
||||||
import Portal from '../BlockPortal';
|
|
||||||
import { TreeNode } from '$app/block_editor/view/tree_node';
|
|
||||||
import { useHoveringToolbar } from './index.hooks';
|
|
||||||
|
|
||||||
const HoveringToolbar = ({ blockId, node }: { blockId: string; node: TreeNode }) => {
|
|
||||||
const { inFocus, ref, editor } = useHoveringToolbar({ node });
|
|
||||||
if (!inFocus) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Portal blockId={blockId}>
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
style={{
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
className='z-1 absolute mt-[-6px] inline-flex h-[32px] items-stretch overflow-hidden rounded-[8px] bg-[#333] p-2 leading-tight shadow-lg transition-opacity duration-700'
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
// prevent toolbar from taking focus away from editor
|
|
||||||
e.preventDefault();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{['bold', 'italic', 'underlined', 'strikethrough', 'code'].map((format) => (
|
|
||||||
<FormatButton key={format} editor={editor} format={format} icon={format} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HoveringToolbar;
|
|
@ -1,18 +0,0 @@
|
|||||||
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
|
|
||||||
import React, { useMemo } from 'react';
|
|
||||||
import ColumnBlock from '../ColumnBlock';
|
|
||||||
|
|
||||||
export default function ColumnListBlock({ node }: { node: TreeNode }) {
|
|
||||||
const resizerWidth = useMemo(() => {
|
|
||||||
return 46 * (node.children?.length || 0);
|
|
||||||
}, [node.children?.length]);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className='column-list-block flex-grow-1 flex flex-row'>
|
|
||||||
{node.children?.map((item, index) => (
|
|
||||||
<ColumnBlock key={item.id} index={index} resizerWidth={resizerWidth} node={item} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,31 +0,0 @@
|
|||||||
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
|
|
||||||
import BlockComponent from '../BlockComponent';
|
|
||||||
import { BlockType } from '@/appflowy_app/interfaces';
|
|
||||||
import { Block } from '@/appflowy_app/block_editor/core/block';
|
|
||||||
|
|
||||||
export default function NumberedListBlock({ title, node }: { title: JSX.Element; node: TreeNode }) {
|
|
||||||
let prev = node.block.prev;
|
|
||||||
let index = 1;
|
|
||||||
while (prev && prev.type === BlockType.ListBlock && (prev as Block<BlockType.ListBlock>).data.type === 'numbered') {
|
|
||||||
index++;
|
|
||||||
prev = prev.prev;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className='numbered-list-block'>
|
|
||||||
<div className='relative flex'>
|
|
||||||
<div
|
|
||||||
className={`relative flex h-[calc(1.5em_+_3px_+_3px)] min-w-[24px] max-w-[24px] select-none items-center`}
|
|
||||||
>{`${index} .`}</div>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='pl-[24px]'>
|
|
||||||
{node.children?.map((item) => (
|
|
||||||
<div key={item.id}>
|
|
||||||
<BlockComponent node={item} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
|
|
||||||
import { BlockCommonProps } from '@/appflowy_app/interfaces';
|
|
||||||
|
|
||||||
export default function PageBlock({ node }: BlockCommonProps<TreeNode>) {
|
|
||||||
return <div className='cursor-pointer underline'>{node.data.title}</div>;
|
|
||||||
}
|
|
@ -1,41 +0,0 @@
|
|||||||
import { BaseText } from 'slate';
|
|
||||||
import { RenderLeafProps } from 'slate-react';
|
|
||||||
|
|
||||||
const Leaf = ({
|
|
||||||
attributes,
|
|
||||||
children,
|
|
||||||
leaf,
|
|
||||||
}: RenderLeafProps & {
|
|
||||||
leaf: BaseText & {
|
|
||||||
bold?: boolean;
|
|
||||||
code?: boolean;
|
|
||||||
italic?: boolean;
|
|
||||||
underlined?: boolean;
|
|
||||||
strikethrough?: boolean;
|
|
||||||
};
|
|
||||||
}) => {
|
|
||||||
let newChildren = children;
|
|
||||||
if (leaf.bold) {
|
|
||||||
newChildren = <strong>{children}</strong>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (leaf.code) {
|
|
||||||
newChildren = <code className='rounded-sm bg-[#F2FCFF] p-1'>{newChildren}</code>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (leaf.italic) {
|
|
||||||
newChildren = <em>{newChildren}</em>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (leaf.underlined) {
|
|
||||||
newChildren = <u>{newChildren}</u>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span {...attributes} className={leaf.strikethrough ? `line-through` : ''}>
|
|
||||||
{newChildren}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Leaf;
|
|
@ -1,118 +0,0 @@
|
|||||||
import { TreeNode } from "@/appflowy_app/block_editor/view/tree_node";
|
|
||||||
import { triggerHotkey } from "@/appflowy_app/utils/slate/hotkey";
|
|
||||||
import { useCallback, useContext, useEffect, useLayoutEffect, useState } from "react";
|
|
||||||
import { Transforms, createEditor, Descendant, Range } 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;
|
|
||||||
}
|
|
||||||
case 'Backspace': {
|
|
||||||
if (!editor.selection) return;
|
|
||||||
const { anchor } = editor.selection;
|
|
||||||
const isCollapase = Range.isCollapsed(editor.selection);
|
|
||||||
if (isCollapase && anchor.offset === 0 && anchor.path.toString() === '0,0') {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
textBlockManager?.deleteNode(node);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
triggerHotkey(event, editor);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const { focusId, selection } = textBlockManager!.selectionManager.getFocusSelection();
|
|
||||||
|
|
||||||
editor.children = value;
|
|
||||||
Transforms.collapse(editor);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
textBlockManager?.register(node.id, editor);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
textBlockManager?.unregister(node.id);
|
|
||||||
}
|
|
||||||
}, [ 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,43 +0,0 @@
|
|||||||
import BlockComponent from '../BlockComponent';
|
|
||||||
import { Slate, Editable } from 'slate-react';
|
|
||||||
import Leaf from './Leaf';
|
|
||||||
import HoveringToolbar from '@/appflowy_app/components/block/HoveringToolbar';
|
|
||||||
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
|
|
||||||
import { useTextBlock } from './index.hooks';
|
|
||||||
import { BlockCommonProps, TextBlockToolbarProps } from '@/appflowy_app/interfaces';
|
|
||||||
import { toolbarDefaultProps } from '@/appflowy_app/constants/toolbar';
|
|
||||||
|
|
||||||
export default function TextBlock({
|
|
||||||
node,
|
|
||||||
needRenderChildren = true,
|
|
||||||
toolbarProps,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
needRenderChildren?: boolean;
|
|
||||||
toolbarProps?: TextBlockToolbarProps;
|
|
||||||
} & BlockCommonProps<TreeNode> &
|
|
||||||
React.HTMLAttributes<HTMLDivElement>) {
|
|
||||||
const { editor, value, onChange, onKeyDownCapture, onDOMBeforeInput } = useTextBlock({ node });
|
|
||||||
const { showGroups } = toolbarProps || toolbarDefaultProps;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div {...props} className={`${props.className || ''} py-1`}>
|
|
||||||
<Slate editor={editor} onChange={onChange} value={value}>
|
|
||||||
{showGroups.length > 0 && <HoveringToolbar node={node} blockId={node.id} />}
|
|
||||||
<Editable
|
|
||||||
onKeyDownCapture={onKeyDownCapture}
|
|
||||||
onDOMBeforeInput={onDOMBeforeInput}
|
|
||||||
renderLeaf={(leafProps) => <Leaf {...leafProps} />}
|
|
||||||
placeholder='Enter some text...'
|
|
||||||
/>
|
|
||||||
</Slate>
|
|
||||||
{needRenderChildren && node.children.length > 0 ? (
|
|
||||||
<div className='pl-[1.5em]'>
|
|
||||||
{node.children.map((item) => (
|
|
||||||
<BlockComponent key={item.id} node={item} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,7 +1,7 @@
|
|||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
|
|
||||||
const BlockPortal = ({ blockId, children }: { blockId: string; children: JSX.Element }) => {
|
const BlockPortal = ({ blockId, children }: { blockId: string; children: JSX.Element }) => {
|
||||||
const root = document.querySelectorAll(`[data-block-id=${blockId}] > .block-overlay`)[0];
|
const root = document.querySelectorAll(`[data-block-id="${blockId}"] > .block-overlay`)[0];
|
||||||
|
|
||||||
return typeof document === 'object' && root ? ReactDOM.createPortal(children, root) : null;
|
return typeof document === 'object' && root ? ReactDOM.createPortal(children, root) : null;
|
||||||
};
|
};
|
||||||
|
@ -1,17 +1,16 @@
|
|||||||
import { BlockEditor } from '@/appflowy_app/block_editor';
|
|
||||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||||
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
|
import { documentActions } from '@/appflowy_app/stores/reducers/document/slice';
|
||||||
|
|
||||||
export function useBlockSelection({
|
export function useBlockSelection({
|
||||||
container,
|
container,
|
||||||
blockEditor,
|
|
||||||
onDragging,
|
onDragging,
|
||||||
}: {
|
}: {
|
||||||
container: HTMLDivElement;
|
container: HTMLDivElement;
|
||||||
blockEditor: BlockEditor;
|
|
||||||
onDragging?: (_isDragging: boolean) => void;
|
onDragging?: (_isDragging: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const blockPositionManager = blockEditor.renderTree.blockPositionManager;
|
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
const disaptch = useAppDispatch();
|
||||||
|
|
||||||
const [isDragging, setDragging] = useState(false);
|
const [isDragging, setDragging] = useState(false);
|
||||||
const pointRef = useRef<number[]>([]);
|
const pointRef = useRef<number[]>([]);
|
||||||
@ -75,7 +74,7 @@ export function useBlockSelection({
|
|||||||
|
|
||||||
const calcIntersectBlocks = useCallback(
|
const calcIntersectBlocks = useCallback(
|
||||||
(clientX: number, clientY: number) => {
|
(clientX: number, clientY: number) => {
|
||||||
if (!isDragging || !blockPositionManager) return;
|
if (!isDragging) return;
|
||||||
const [startX, startY] = pointRef.current;
|
const [startX, startY] = pointRef.current;
|
||||||
const endX = clientX + container.scrollLeft;
|
const endX = clientX + container.scrollLeft;
|
||||||
const endY = clientY + container.scrollTop;
|
const endY = clientY + container.scrollTop;
|
||||||
@ -86,21 +85,21 @@ export function useBlockSelection({
|
|||||||
endX,
|
endX,
|
||||||
endY,
|
endY,
|
||||||
});
|
});
|
||||||
const selectedBlocks = blockPositionManager.getIntersectBlocks(
|
disaptch(
|
||||||
Math.min(startX, endX),
|
documentActions.changeSelectionByIntersectRect({
|
||||||
Math.min(startY, endY),
|
startX: Math.min(startX, endX),
|
||||||
Math.max(startX, endX),
|
startY: Math.min(startY, endY),
|
||||||
Math.max(startY, endY)
|
endX: Math.max(startX, endX),
|
||||||
|
endY: Math.max(startY, endY),
|
||||||
|
})
|
||||||
);
|
);
|
||||||
const ids = selectedBlocks.map((item) => item.id);
|
|
||||||
blockEditor.renderTree.updateSelections(ids);
|
|
||||||
},
|
},
|
||||||
[isDragging]
|
[isDragging]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDraging = useCallback(
|
const handleDraging = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
if (!isDragging || !blockPositionManager) return;
|
if (!isDragging) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
calcIntersectBlocks(e.clientX, e.clientY);
|
calcIntersectBlocks(e.clientX, e.clientY);
|
||||||
@ -120,7 +119,7 @@ export function useBlockSelection({
|
|||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
if (isPointInBlock(e.target as HTMLElement) && !isDragging) {
|
if (isPointInBlock(e.target as HTMLElement) && !isDragging) {
|
||||||
blockEditor.renderTree.updateSelections([]);
|
disaptch(documentActions.updateSelections([]));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
@ -1,19 +1,15 @@
|
|||||||
import { useBlockSelection } from './BlockSelection.hooks';
|
import { useBlockSelection } from './BlockSelection.hooks';
|
||||||
import { BlockEditor } from '$app/block_editor';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
function BlockSelection({
|
function BlockSelection({
|
||||||
container,
|
container,
|
||||||
blockEditor,
|
|
||||||
onDragging,
|
onDragging,
|
||||||
}: {
|
}: {
|
||||||
container: HTMLDivElement;
|
container: HTMLDivElement;
|
||||||
blockEditor: BlockEditor;
|
|
||||||
onDragging?: (_isDragging: boolean) => void;
|
onDragging?: (_isDragging: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { isDragging, style, ref } = useBlockSelection({
|
const { isDragging, style, ref } = useBlockSelection({
|
||||||
container,
|
container,
|
||||||
blockEditor,
|
|
||||||
onDragging,
|
onDragging,
|
||||||
});
|
});
|
||||||
|
|
@ -0,0 +1,126 @@
|
|||||||
|
import { BlockType } from '@/appflowy_app/interfaces/document';
|
||||||
|
import { useAppSelector } from '@/appflowy_app/stores/store';
|
||||||
|
import { debounce } from '@/appflowy_app/utils/tool';
|
||||||
|
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { YDocControllerContext } from '../../../stores/effects/document/document_controller';
|
||||||
|
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
|
||||||
|
import { v4 } from 'uuid';
|
||||||
|
|
||||||
|
export function useBlockSideTools({ container }: { container: HTMLDivElement }) {
|
||||||
|
const [nodeId, setHoverNodeId] = useState<string>('');
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
const nodes = useAppSelector((state) => state.document.nodes);
|
||||||
|
const { insertAfter } = useController();
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||||
|
const { clientX, clientY } = e;
|
||||||
|
const x = clientX;
|
||||||
|
const y = clientY;
|
||||||
|
const id = getNodeIdByPoint(x, y);
|
||||||
|
if (!id) {
|
||||||
|
setHoverNodeId('');
|
||||||
|
} else {
|
||||||
|
if ([BlockType.ColumnBlock].includes(nodes[id].type)) {
|
||||||
|
setHoverNodeId('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHoverNodeId(id);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const debounceMove = useMemo(() => debounce(handleMouseMove, 30), [handleMouseMove]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el || !nodeId) return;
|
||||||
|
|
||||||
|
const node = nodes[nodeId];
|
||||||
|
if (!node) {
|
||||||
|
el.style.opacity = '0';
|
||||||
|
el.style.zIndex = '-1';
|
||||||
|
} else {
|
||||||
|
el.style.opacity = '1';
|
||||||
|
el.style.zIndex = '1';
|
||||||
|
el.style.top = '1px';
|
||||||
|
if (node?.type === BlockType.HeadingBlock) {
|
||||||
|
if (node.data.style?.level === 1) {
|
||||||
|
el.style.top = '8px';
|
||||||
|
} else if (node.data.style?.level === 2) {
|
||||||
|
el.style.top = '6px';
|
||||||
|
} else {
|
||||||
|
el.style.top = '5px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [nodeId, nodes]);
|
||||||
|
|
||||||
|
const handleAddClick = useCallback(() => {
|
||||||
|
if (!nodeId) return;
|
||||||
|
insertAfter(nodes[nodeId]);
|
||||||
|
}, [nodeId, nodes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
container.addEventListener('mousemove', debounceMove);
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener('mousemove', debounceMove);
|
||||||
|
};
|
||||||
|
}, [debounceMove]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodeId,
|
||||||
|
ref,
|
||||||
|
handleAddClick,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useController() {
|
||||||
|
const controller = useContext(YDocControllerContext);
|
||||||
|
|
||||||
|
const insertAfter = useCallback((node: Node) => {
|
||||||
|
const parentId = node.parent;
|
||||||
|
if (!parentId || !controller) return;
|
||||||
|
|
||||||
|
controller.transact([
|
||||||
|
() => {
|
||||||
|
const newNode = {
|
||||||
|
id: v4(),
|
||||||
|
delta: [],
|
||||||
|
type: BlockType.TextBlock,
|
||||||
|
};
|
||||||
|
controller.insert(newNode, parentId, node.id);
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
insertAfter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeIdByPoint(x: number, y: number) {
|
||||||
|
const viewportNodes = document.querySelectorAll('[data-block-id]');
|
||||||
|
let node: {
|
||||||
|
el: Element;
|
||||||
|
rect: DOMRect;
|
||||||
|
} | null = null;
|
||||||
|
viewportNodes.forEach((el) => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (rect.x + rect.width - 1 >= x && rect.y + rect.height - 1 >= y && rect.y <= y) {
|
||||||
|
if (!node || rect.y > node.rect.y) {
|
||||||
|
node = {
|
||||||
|
el,
|
||||||
|
rect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return node
|
||||||
|
? (
|
||||||
|
node as {
|
||||||
|
el: Element;
|
||||||
|
rect: DOMRect;
|
||||||
|
}
|
||||||
|
).el.getAttribute('data-block-id')
|
||||||
|
: null;
|
||||||
|
}
|
@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useBlockSideTools } from './BlockSideTools.hooks';
|
import { useBlockSideTools } from './BlockSideTools.hooks';
|
||||||
import { BlockEditor } from '@/appflowy_app/block_editor';
|
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||||
import Portal from '../BlockPortal';
|
import Portal from '../BlockPortal';
|
||||||
@ -8,12 +7,12 @@ import { IconButton } from '@mui/material';
|
|||||||
|
|
||||||
const sx = { height: 24, width: 24 };
|
const sx = { height: 24, width: 24 };
|
||||||
|
|
||||||
export default function BlockSideTools(props: { container: HTMLDivElement; blockEditor: BlockEditor }) {
|
export default function BlockSideTools(props: { container: HTMLDivElement }) {
|
||||||
const { hoverBlock, ref } = useBlockSideTools(props);
|
const { nodeId, ref, handleAddClick } = useBlockSideTools(props);
|
||||||
|
|
||||||
if (!hoverBlock) return null;
|
if (!nodeId) return null;
|
||||||
return (
|
return (
|
||||||
<Portal blockId={hoverBlock}>
|
<Portal blockId={nodeId}>
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
style={{
|
style={{
|
||||||
@ -25,7 +24,7 @@ export default function BlockSideTools(props: { container: HTMLDivElement; block
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconButton sx={sx}>
|
<IconButton onClick={() => handleAddClick()} sx={sx}>
|
||||||
<AddIcon />
|
<AddIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton sx={sx}>
|
<IconButton sx={sx}>
|
@ -0,0 +1,3 @@
|
|||||||
|
export default function CodeBlock({ id }: { id: string }) {
|
||||||
|
return <div>{id}</div>;
|
||||||
|
}
|
@ -1,17 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
|
import NodeComponent from '../Node';
|
||||||
|
|
||||||
import BlockComponent from '../BlockComponent';
|
export default function ColumnBlock({ id, index, width }: { id: string; index: number; width: string }) {
|
||||||
|
|
||||||
export default function ColumnBlock({
|
|
||||||
node,
|
|
||||||
resizerWidth,
|
|
||||||
index,
|
|
||||||
}: {
|
|
||||||
node: TreeNode;
|
|
||||||
resizerWidth: number;
|
|
||||||
index: number;
|
|
||||||
}) {
|
|
||||||
const renderResizer = () => {
|
const renderResizer = () => {
|
||||||
return (
|
return (
|
||||||
<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>
|
||||||
@ -35,15 +25,14 @@ export default function ColumnBlock({
|
|||||||
renderResizer()
|
renderResizer()
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<BlockComponent
|
<NodeComponent
|
||||||
className={`column-block py-3`}
|
className={`column-block py-3`}
|
||||||
style={{
|
style={{
|
||||||
flexGrow: 0,
|
flexGrow: 0,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
width: `calc((100% - ${resizerWidth}px) * ${node.data.ratio})`,
|
width,
|
||||||
}}
|
}}
|
||||||
node={node}
|
id={id}
|
||||||
renderChild={(item) => <BlockComponent key={item.id} node={item} />}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
@ -1,7 +1,8 @@
|
|||||||
import { useSubscribeNode } from '../_shared/SubscribeNode.hooks';
|
import { useSubscribeNode } from '../_shared/SubscribeNode.hooks';
|
||||||
export function useDocumentTitle(id: string) {
|
export function useDocumentTitle(id: string) {
|
||||||
const { node } = useSubscribeNode(id);
|
const { node, delta } = useSubscribeNode(id);
|
||||||
return {
|
return {
|
||||||
node
|
node,
|
||||||
|
delta
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -3,12 +3,11 @@ import { useDocumentTitle } from './DocumentTitle.hooks';
|
|||||||
import TextBlock from '../TextBlock';
|
import TextBlock from '../TextBlock';
|
||||||
|
|
||||||
export default function DocumentTitle({ id }: { id: string }) {
|
export default function DocumentTitle({ id }: { id: string }) {
|
||||||
const { node } = useDocumentTitle(id);
|
const { node, delta } = useDocumentTitle(id);
|
||||||
if (!node) return null;
|
if (!node) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-block-id={node.id} className='doc-title relative pt-[50px] text-4xl font-bold'>
|
<div data-block-id={node.id} className='doc-title relative pt-[50px] text-4xl font-bold'>
|
||||||
<TextBlock placeholder='Untitled' childIds={[]} node={node} />
|
<TextBlock placeholder='Untitled' childIds={[]} delta={delta || []} node={node} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,17 @@
|
|||||||
|
import TextBlock from '../TextBlock';
|
||||||
|
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
|
||||||
|
import { TextDelta } from '@/appflowy_app/interfaces/document';
|
||||||
|
|
||||||
|
const fontSize: Record<string, string> = {
|
||||||
|
1: 'mt-8 text-3xl',
|
||||||
|
2: 'mt-6 text-2xl',
|
||||||
|
3: 'mt-4 text-xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function HeadingBlock({ node, delta }: { node: Node; delta: TextDelta[] }) {
|
||||||
|
return (
|
||||||
|
<div className={`${fontSize[node.data.style?.level]} font-semibold `}>
|
||||||
|
<TextBlock node={node} childIds={[]} delta={delta} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -11,7 +11,7 @@ export function useHoveringToolbar(id: string) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const nodeRect = document.querySelector(`[data-block-id=${id}]`)?.getBoundingClientRect();
|
const nodeRect = document.querySelector(`[data-block-id="${id}"]`)?.getBoundingClientRect();
|
||||||
|
|
||||||
if (!nodeRect) return;
|
if (!nodeRect) return;
|
||||||
const position = calcToolbarPosition(editor, el, nodeRect);
|
const position = calcToolbarPosition(editor, el, nodeRect);
|
||||||
|
@ -1,9 +1,16 @@
|
|||||||
|
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
|
||||||
import { Circle } from '@mui/icons-material';
|
import { Circle } from '@mui/icons-material';
|
||||||
|
import NodeComponent from '../Node';
|
||||||
|
|
||||||
import BlockComponent from '../BlockComponent';
|
export default function BulletedListBlock({
|
||||||
import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
|
title,
|
||||||
|
node,
|
||||||
export default function BulletedListBlock({ title, node }: { title: JSX.Element; node: TreeNode }) {
|
childIds,
|
||||||
|
}: {
|
||||||
|
title: JSX.Element;
|
||||||
|
node: Node;
|
||||||
|
childIds?: string[];
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className='bulleted-list-block relative'>
|
<div className='bulleted-list-block relative'>
|
||||||
<div className='relative flex'>
|
<div className='relative flex'>
|
||||||
@ -14,10 +21,8 @@ export default function BulletedListBlock({ title, node }: { title: JSX.Element;
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='pl-[24px]'>
|
<div className='pl-[24px]'>
|
||||||
{node.children?.map((item) => (
|
{childIds?.map((item) => (
|
||||||
<div key={item.id}>
|
<NodeComponent key={item} id={item} />
|
||||||
<BlockComponent node={item} />
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
@ -0,0 +1,23 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import ColumnBlock from '../ColumnBlock';
|
||||||
|
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
|
||||||
|
|
||||||
|
export default function ColumnListBlock({ node, childIds }: { node: Node; childIds?: string[] }) {
|
||||||
|
const resizerWidth = useMemo(() => {
|
||||||
|
return 46 * (node.children?.length || 0);
|
||||||
|
}, [node.children?.length]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='column-list-block flex-grow-1 flex flex-row'>
|
||||||
|
{childIds?.map((item, index) => (
|
||||||
|
<ColumnBlock
|
||||||
|
key={item}
|
||||||
|
index={index}
|
||||||
|
width={`calc((100% - ${resizerWidth}px) * ${node.data.style?.ratio})`}
|
||||||
|
id={item}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
|
||||||
|
import NodeComponent from '../Node';
|
||||||
|
|
||||||
|
export default function NumberedListBlock({
|
||||||
|
title,
|
||||||
|
node,
|
||||||
|
childIds,
|
||||||
|
}: {
|
||||||
|
title: JSX.Element;
|
||||||
|
node: Node;
|
||||||
|
childIds?: string[];
|
||||||
|
}) {
|
||||||
|
const index = 1;
|
||||||
|
return (
|
||||||
|
<div className='numbered-list-block'>
|
||||||
|
<div className='relative flex'>
|
||||||
|
<div
|
||||||
|
className={`relative flex h-[calc(1.5em_+_3px_+_3px)] min-w-[24px] max-w-[24px] select-none items-center`}
|
||||||
|
>{`${index} .`}</div>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='pl-[24px]'>
|
||||||
|
{childIds?.map((item) => (
|
||||||
|
<NodeComponent key={item} id={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -3,28 +3,28 @@ 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 { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
|
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
|
||||||
import { BlockCommonProps } from '@/appflowy_app/interfaces';
|
import { TextDelta } from '@/appflowy_app/interfaces/document';
|
||||||
|
|
||||||
export default function ListBlock({ node, version }: BlockCommonProps<TreeNode>) {
|
export default function ListBlock({ node, delta }: { node: Node; delta: TextDelta[] }) {
|
||||||
const title = useMemo(() => {
|
const title = useMemo(() => {
|
||||||
if (node.data.type === 'column') return <></>;
|
if (node.data.style?.type === 'column') return <></>;
|
||||||
return (
|
return (
|
||||||
<div className='flex-1'>
|
<div className='flex-1'>
|
||||||
<TextBlock version={version} node={node} needRenderChildren={false} />
|
<TextBlock delta={delta} node={node} childIds={[]} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [node, version]);
|
}, [node, delta]);
|
||||||
|
|
||||||
if (node.data.type === 'numbered') {
|
if (node.data.style?.type === 'numbered') {
|
||||||
return <NumberedListBlock title={title} node={node} />;
|
return <NumberedListBlock title={title} node={node} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.data.type === 'bulleted') {
|
if (node.data.style?.type === 'bulleted') {
|
||||||
return <BulletedListBlock title={title} node={node} />;
|
return <BulletedListBlock title={title} node={node} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.data.type === 'column') {
|
if (node.data.style?.type === 'column') {
|
||||||
return <ColumnListBlock node={node} />;
|
return <ColumnListBlock node={node} />;
|
||||||
}
|
}
|
||||||
|
|
@ -1,11 +1,36 @@
|
|||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
import { useSubscribeNode } from '../_shared/SubscribeNode.hooks';
|
import { useSubscribeNode } from '../_shared/SubscribeNode.hooks';
|
||||||
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
|
import { documentActions } from '$app/stores/reducers/document/slice';
|
||||||
|
|
||||||
export function useNode(id: string) {
|
export function useNode(id: string) {
|
||||||
const { node, childIds } = useSubscribeNode(id);
|
const { node, childIds, delta, isSelected } = useSubscribeNode(id);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const rect = ref.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
const scrollContainer = document.querySelector('.doc-scroller-container') as HTMLDivElement;
|
||||||
|
dispatch(documentActions.updateNodePosition({
|
||||||
|
id,
|
||||||
|
rect: {
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y + scrollContainer.scrollTop,
|
||||||
|
height: rect.height,
|
||||||
|
width: rect.width
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
ref,
|
||||||
node,
|
node,
|
||||||
childIds,
|
childIds,
|
||||||
|
delta,
|
||||||
|
isSelected
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -4,14 +4,17 @@ import { withErrorBoundary } from 'react-error-boundary';
|
|||||||
import { ErrorBoundaryFallbackComponent } from '../_shared/ErrorBoundaryFallbackComponent';
|
import { ErrorBoundaryFallbackComponent } from '../_shared/ErrorBoundaryFallbackComponent';
|
||||||
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
|
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
|
||||||
import TextBlock from '../TextBlock';
|
import TextBlock from '../TextBlock';
|
||||||
|
import { TextDelta } from '@/appflowy_app/interfaces/document';
|
||||||
|
|
||||||
function NodeComponent({ id }: { id: string }) {
|
function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
|
||||||
const { node, childIds } = useNode(id);
|
const { node, childIds, delta, isSelected, ref } = useNode(id);
|
||||||
|
|
||||||
const renderBlock = useCallback((props: { node: Node; childIds?: string[] }) => {
|
console.log('=====', id);
|
||||||
switch (props.node.type) {
|
const renderBlock = useCallback((_props: { node: Node; childIds?: string[]; delta?: TextDelta[] }) => {
|
||||||
|
switch (_props.node.type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
return <TextBlock {...props} />;
|
if (!_props.delta) return null;
|
||||||
|
return <TextBlock {..._props} delta={_props.delta} />;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -20,12 +23,14 @@ function NodeComponent({ id }: { id: string }) {
|
|||||||
if (!node) return null;
|
if (!node) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-block-id={node.id} className='relative my-[1px]'>
|
<div {...props} ref={ref} data-block-id={node.id} className={`relative my-[2px] px-[2px] ${props.className}`}>
|
||||||
{renderBlock({
|
{renderBlock({
|
||||||
node,
|
node,
|
||||||
childIds,
|
childIds,
|
||||||
|
delta,
|
||||||
})}
|
})}
|
||||||
<div className='block-overlay' />
|
<div className='block-overlay' />
|
||||||
|
{isSelected ? <div className='pointer-events-none absolute inset-0 z-[-1] rounded-[4px] bg-[#E0F8FF]' /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,13 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import BlockSideTools from '../BlockSideTools';
|
||||||
|
import BlockSelection from '../BlockSelection';
|
||||||
|
|
||||||
|
export default function Overlay({ container }: { container: HTMLDivElement }) {
|
||||||
|
const [isDragging, setDragging] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isDragging ? null : <BlockSideTools container={container} />}
|
||||||
|
<BlockSelection onDragging={setDragging} container={container} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -1,60 +1,20 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { DocumentData, NestedBlock } from '$app/interfaces/document';
|
import { DocumentData } from '$app/interfaces/document';
|
||||||
import { useAppDispatch } from '@/appflowy_app/stores/store';
|
import { useAppDispatch } from '@/appflowy_app/stores/store';
|
||||||
import { documentActions, Node } from '$app/stores/reducers/document/slice';
|
import { documentActions } from '$app/stores/reducers/document/slice';
|
||||||
|
|
||||||
export function useParseTree(documentData: DocumentData) {
|
export function useParseTree(documentData: DocumentData) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { blocks, ytexts, yarrays, rootId } = documentData;
|
const { blocks, ytexts, yarrays } = documentData;
|
||||||
const flattenNestedBlocks = (
|
|
||||||
block: NestedBlock
|
|
||||||
): (Node & {
|
|
||||||
children: string[];
|
|
||||||
})[] => {
|
|
||||||
const node: Node & {
|
|
||||||
children: string[];
|
|
||||||
} = {
|
|
||||||
id: block.id,
|
|
||||||
delta: ytexts[block.data.text],
|
|
||||||
data: block.data,
|
|
||||||
type: block.type,
|
|
||||||
parent: block.parent,
|
|
||||||
children: yarrays[block.children],
|
|
||||||
};
|
|
||||||
|
|
||||||
const nodes = [node];
|
|
||||||
node.children.forEach((child) => {
|
|
||||||
const childBlock = blocks[child];
|
|
||||||
nodes.push(...flattenNestedBlocks(childBlock));
|
|
||||||
});
|
|
||||||
return nodes;
|
|
||||||
};
|
|
||||||
|
|
||||||
const initializeNodeHierarchy = (parentId: string, children: string[]) => {
|
|
||||||
children.forEach((childId) => {
|
|
||||||
dispatch(documentActions.addChild({ parentId, childId }));
|
|
||||||
const child = blocks[childId];
|
|
||||||
initializeNodeHierarchy(childId, yarrays[child.children]);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = documentData.blocks[rootId];
|
dispatch(
|
||||||
|
documentActions.createTree({
|
||||||
const initialNodes = flattenNestedBlocks(root);
|
nodes: blocks,
|
||||||
|
delta: ytexts,
|
||||||
initialNodes.forEach((node) => {
|
children: yarrays,
|
||||||
const _node = {
|
})
|
||||||
id: node.id,
|
);
|
||||||
parent: node.parent,
|
|
||||||
data: node.data,
|
|
||||||
type: node.type,
|
|
||||||
delta: node.delta,
|
|
||||||
};
|
|
||||||
dispatch(documentActions.addNode(_node));
|
|
||||||
});
|
|
||||||
|
|
||||||
initializeNodeHierarchy(rootId, yarrays[root.children]);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
dispatch(documentActions.clear());
|
dispatch(documentActions.clear());
|
||||||
|
@ -46,8 +46,7 @@ export function useBindYjs(delta: TextDelta[], update: (_delta: Delta) => void)
|
|||||||
if (!yText) return;
|
if (!yText) return;
|
||||||
|
|
||||||
const textEventHandler = (event: Y.YTextEvent) => {
|
const textEventHandler = (event: Y.YTextEvent) => {
|
||||||
console.log(event.delta, event.target.toDelta());
|
update(event.changes.delta as Delta);
|
||||||
update(event.delta as Delta);
|
|
||||||
}
|
}
|
||||||
yText.applyDelta(delta);
|
yText.applyDelta(delta);
|
||||||
yText.observe(textEventHandler);
|
yText.observe(textEventHandler);
|
||||||
|
@ -1,29 +1,65 @@
|
|||||||
import { triggerHotkey } from "@/appflowy_app/utils/slate/hotkey";
|
import { triggerHotkey } from "@/appflowy_app/utils/slate/hotkey";
|
||||||
import { useCallback, useContext, useState } from "react";
|
import { useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||||
import { Descendant, Range } from "slate";
|
import { Descendant, Range } from "slate";
|
||||||
import { useBindYjs } from "./BindYjs.hooks";
|
import { useBindYjs } from "./BindYjs.hooks";
|
||||||
import { YDocControllerContext } from '../../../stores/effects/document/document_controller';
|
import { YDocControllerContext } from '../../../stores/effects/document/document_controller';
|
||||||
import { Delta } from "@slate-yjs/core/dist/model/types";
|
import { Delta } from "@slate-yjs/core/dist/model/types";
|
||||||
import { TextDelta } from '../../../interfaces/document';
|
import { TextDelta } from '../../../interfaces/document';
|
||||||
|
import { debounce } from "@/appflowy_app/utils/tool";
|
||||||
|
|
||||||
function useController(textId: string) {
|
function useController(textId: string) {
|
||||||
const docController = useContext(YDocControllerContext);
|
const docController = useContext(YDocControllerContext);
|
||||||
|
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
(delta: Delta) => {
|
(delta: Delta) => {
|
||||||
docController?.yTextApply(textId, delta)
|
docController?.yTextApply(textId, delta)
|
||||||
},
|
},
|
||||||
[textId],
|
[textId],
|
||||||
|
);
|
||||||
|
const transact = useCallback(
|
||||||
|
(actions: (() => void)[]) => {
|
||||||
|
docController?.transact(actions)
|
||||||
|
},
|
||||||
|
[textId],
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
update
|
update,
|
||||||
|
transact
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useTransact(textId: string) {
|
||||||
|
const pendingActions = useRef<(() => void)[]>([]);
|
||||||
|
const { update, transact } = useController(textId);
|
||||||
|
|
||||||
|
const sendTransact = useCallback(
|
||||||
|
() => {
|
||||||
|
const actions = pendingActions.current;
|
||||||
|
transact(actions);
|
||||||
|
},
|
||||||
|
[transact],
|
||||||
|
)
|
||||||
|
|
||||||
|
const debounceSendTransact = useMemo(() => debounce(sendTransact, 300), [transact]);
|
||||||
|
|
||||||
|
const sendDelta = useCallback(
|
||||||
|
(delta: Delta) => {
|
||||||
|
const action = () => update(delta);
|
||||||
|
pendingActions.current.push(action);
|
||||||
|
debounceSendTransact()
|
||||||
|
},
|
||||||
|
[update, debounceSendTransact],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
sendDelta
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTextBlock(text: string, delta: TextDelta[]) {
|
export function useTextBlock(text: string, delta: TextDelta[]) {
|
||||||
const { update } = useController(text);
|
const { sendDelta } = useTransact(text);
|
||||||
const { editor } = useBindYjs(delta, update);
|
|
||||||
|
const { editor } = useBindYjs(delta, sendDelta);
|
||||||
const [value, setValue] = useState<Descendant[]>([]);
|
const [value, setValue] = useState<Descendant[]>([]);
|
||||||
|
|
||||||
const onChange = useCallback(
|
const onChange = useCallback(
|
||||||
|
@ -4,21 +4,25 @@ import { useTextBlock } from './TextBlock.hooks';
|
|||||||
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
|
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
|
||||||
import NodeComponent from '../Node';
|
import NodeComponent from '../Node';
|
||||||
import HoveringToolbar from '../HoveringToolbar';
|
import HoveringToolbar from '../HoveringToolbar';
|
||||||
|
import { TextDelta } from '@/appflowy_app/interfaces/document';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
export default function TextBlock({
|
function TextBlock({
|
||||||
node,
|
node,
|
||||||
childIds,
|
childIds,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
delta,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
node: Node;
|
node: Node;
|
||||||
|
delta: TextDelta[];
|
||||||
childIds?: string[];
|
childIds?: string[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
} & React.HTMLAttributes<HTMLDivElement>) {
|
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||||
const { editor, value, onChange, onKeyDownCapture, onDOMBeforeInput } = useTextBlock(node.data.text!, node.delta);
|
const { editor, value, onChange, onKeyDownCapture, onDOMBeforeInput } = useTextBlock(node.data.text!, delta);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...props} className={props.className}>
|
<div {...props} className={`py-[2px] ${props.className}`}>
|
||||||
<Slate editor={editor} onChange={onChange} value={value}>
|
<Slate editor={editor} onChange={onChange} value={value}>
|
||||||
<HoveringToolbar id={node.id} />
|
<HoveringToolbar id={node.id} />
|
||||||
<Editable
|
<Editable
|
||||||
@ -38,3 +42,5 @@ export default function TextBlock({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default React.memo(TextBlock);
|
||||||
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { useVirtualizerList } from './VirtualizerList.hooks';
|
import { useVirtualizerList } from './VirtualizerList.hooks';
|
||||||
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
|
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
|
||||||
import DocumentTitle from '../DocumentTitle';
|
import DocumentTitle from '../DocumentTitle';
|
||||||
|
import Overlay from '../Overlay';
|
||||||
|
|
||||||
export default function VirtualizerList({
|
export default function VirtualizerList({
|
||||||
childIds,
|
childIds,
|
||||||
@ -17,36 +18,42 @@ export default function VirtualizerList({
|
|||||||
const virtualItems = rowVirtualizer.getVirtualItems();
|
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={parentRef} className={`doc-scroller-container flex h-[100%] flex-wrap justify-center overflow-auto px-20`}>
|
<>
|
||||||
<div
|
<div
|
||||||
className='doc-body max-w-screen w-[900px] min-w-0'
|
ref={parentRef}
|
||||||
style={{
|
className={`doc-scroller-container flex h-[100%] flex-wrap justify-center overflow-auto px-20`}
|
||||||
height: rowVirtualizer.getTotalSize(),
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{node && childIds && virtualItems.length ? (
|
<div
|
||||||
<div
|
className='doc-body max-w-screen w-[900px] min-w-0'
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
height: rowVirtualizer.getTotalSize(),
|
||||||
top: 0,
|
position: 'relative',
|
||||||
left: 0,
|
}}
|
||||||
width: '100%',
|
>
|
||||||
transform: `translateY(${virtualItems[0].start || 0}px)`,
|
{node && childIds && virtualItems.length ? (
|
||||||
}}
|
<div
|
||||||
>
|
style={{
|
||||||
{virtualItems.map((virtualRow) => {
|
position: 'absolute',
|
||||||
const id = childIds[virtualRow.index];
|
top: 0,
|
||||||
return (
|
left: 0,
|
||||||
<div className='p-[1px]' key={id} data-index={virtualRow.index} ref={rowVirtualizer.measureElement}>
|
width: '100%',
|
||||||
{virtualRow.index === 0 ? <DocumentTitle id={node.id} /> : null}
|
transform: `translateY(${virtualItems[0].start || 0}px)`,
|
||||||
{renderNode(id)}
|
}}
|
||||||
</div>
|
>
|
||||||
);
|
{virtualItems.map((virtualRow) => {
|
||||||
})}
|
const id = childIds[virtualRow.index];
|
||||||
</div>
|
return (
|
||||||
) : null}
|
<div className='p-[1px]' key={id} data-index={virtualRow.index} ref={rowVirtualizer.measureElement}>
|
||||||
|
{virtualRow.index === 0 ? <DocumentTitle id={node.id} /> : null}
|
||||||
|
{renderNode(id)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{parentRef.current ? <Overlay container={parentRef.current} /> : null}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,32 @@
|
|||||||
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
|
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
|
||||||
import { useAppSelector } from '@/appflowy_app/stores/store';
|
import { useAppSelector } from '@/appflowy_app/stores/store';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { TextDelta } from '@/appflowy_app/interfaces/document';
|
||||||
|
|
||||||
export function useSubscribeNode(id: string) {
|
export function useSubscribeNode(id: string) {
|
||||||
const node = useAppSelector<Node | undefined>(state => state.document.nodes[id]);
|
const node = useAppSelector<Node>(state => state.document.nodes[id]);
|
||||||
const childIds = useAppSelector<string[] | undefined>(state => state.document.children[id]);
|
const childIds = useAppSelector<string[] | undefined>(state => {
|
||||||
|
const childrenId = state.document.nodes[id]?.children;
|
||||||
|
if (!childrenId) return;
|
||||||
|
return state.document.children[childrenId];
|
||||||
|
});
|
||||||
|
const delta = useAppSelector<TextDelta[] | undefined>(state => {
|
||||||
|
const deltaId = state.document.nodes[id]?.data?.text;
|
||||||
|
if (!deltaId) return;
|
||||||
|
return state.document.delta[deltaId];
|
||||||
|
});
|
||||||
|
const isSelected = useAppSelector<boolean>(state => {
|
||||||
|
return state.document.selections?.includes(id) || false;
|
||||||
|
});
|
||||||
|
|
||||||
const memoizedNode = useMemo(() => node, [node?.id, node?.data, node?.type]);
|
const memoizedNode = useMemo(() => node, [node?.id, node?.data, node?.parent, node?.type, node?.children]);
|
||||||
const memoizedChildIds = useMemo(() => childIds, [JSON.stringify(childIds)]);
|
const memoizedChildIds = useMemo(() => childIds, [JSON.stringify(childIds)]);
|
||||||
|
const memoizedDelta = useMemo(() => delta, [JSON.stringify(delta)]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
node: memoizedNode,
|
node: memoizedNode,
|
||||||
childIds: memoizedChildIds
|
childIds: memoizedChildIds,
|
||||||
}
|
delta: memoizedDelta,
|
||||||
|
isSelected
|
||||||
|
};
|
||||||
}
|
}
|
@ -1,8 +1,9 @@
|
|||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
import { IndexeddbPersistence } from 'y-indexeddb';
|
import { IndexeddbPersistence } from 'y-indexeddb';
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
import { DocumentData } from '@/appflowy_app/interfaces/document';
|
import { DocumentData, NestedBlock } from '@/appflowy_app/interfaces/document';
|
||||||
import { createContext } from 'react';
|
import { createContext } from 'react';
|
||||||
|
import { BlockType } from '@/appflowy_app/interfaces';
|
||||||
|
|
||||||
export type DeltaAttributes = {
|
export type DeltaAttributes = {
|
||||||
retain: number;
|
retain: number;
|
||||||
@ -21,6 +22,7 @@ export type Delta = Array<
|
|||||||
DeltaRetain | DeltaDelete | DeltaInsert | DeltaAttributes
|
DeltaRetain | DeltaDelete | DeltaInsert | DeltaAttributes
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
|
||||||
export const YDocControllerContext = createContext<YDocController | null>(null);
|
export const YDocControllerContext = createContext<YDocController | null>(null);
|
||||||
|
|
||||||
export class YDocController {
|
export class YDocController {
|
||||||
@ -30,6 +32,12 @@ export class YDocController {
|
|||||||
constructor(private id: string) {
|
constructor(private id: string) {
|
||||||
this._ydoc = new Y.Doc();
|
this._ydoc = new Y.Doc();
|
||||||
this.provider = new IndexeddbPersistence(`document-${this.id}`, this._ydoc);
|
this.provider = new IndexeddbPersistence(`document-${this.id}`, this._ydoc);
|
||||||
|
this._ydoc.on('update', this.handleUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUpdate = (update: Uint8Array, origin: any) => {
|
||||||
|
const isLocal = origin === null;
|
||||||
|
Y.logUpdate(update);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -83,9 +91,7 @@ export class YDocController {
|
|||||||
open = async (): Promise<DocumentData> => {
|
open = async (): Promise<DocumentData> => {
|
||||||
await this.provider.whenSynced;
|
await this.provider.whenSynced;
|
||||||
const ydoc = this._ydoc;
|
const ydoc = this._ydoc;
|
||||||
ydoc.on('updateV2', (update) => {
|
|
||||||
console.log('======', update);
|
|
||||||
})
|
|
||||||
const blocks = ydoc.getMap('blocks');
|
const blocks = ydoc.getMap('blocks');
|
||||||
const obj: DocumentData = {
|
const obj: DocumentData = {
|
||||||
rootId: ydoc.getArray<string>('root').toArray()[0] || '',
|
rootId: ydoc.getArray<string>('root').toArray()[0] || '',
|
||||||
@ -93,28 +99,96 @@ export class YDocController {
|
|||||||
ytexts: {},
|
ytexts: {},
|
||||||
yarrays: {}
|
yarrays: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.keys(obj.blocks).forEach(key => {
|
Object.keys(obj.blocks).forEach(key => {
|
||||||
const value = obj.blocks[key];
|
const value = obj.blocks[key];
|
||||||
if (value.children) {
|
if (value.children) {
|
||||||
|
const yarray = ydoc.getArray<string>(value.children);
|
||||||
Object.assign(obj.yarrays, {
|
Object.assign(obj.yarrays, {
|
||||||
[value.children]: ydoc.getArray(value.children).toArray()
|
[value.children]: yarray.toArray()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (value.data.text) {
|
if (value.data.text) {
|
||||||
|
const ytext = ydoc.getText(value.data.text);
|
||||||
Object.assign(obj.ytexts, {
|
Object.assign(obj.ytexts, {
|
||||||
[value.data.text]: ydoc.getText(value.data.text).toDelta()
|
[value.data.text]: ytext.toDelta()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
blocks.observe(this.handleBlocksEvent);
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
insert(node: {
|
||||||
|
id: string,
|
||||||
|
type: BlockType,
|
||||||
|
delta?: Delta
|
||||||
|
}, parentId: string, prevId: string) {
|
||||||
|
const blocks = this._ydoc.getMap<NestedBlock>('blocks');
|
||||||
|
const parent = blocks.get(parentId);
|
||||||
|
if (!parent) return;
|
||||||
|
const insertNode = {
|
||||||
|
id: node.id,
|
||||||
|
type: node.type,
|
||||||
|
data: {
|
||||||
|
text: ''
|
||||||
|
},
|
||||||
|
children: '',
|
||||||
|
parent: ''
|
||||||
|
}
|
||||||
|
// create ytext
|
||||||
|
if (node.delta) {
|
||||||
|
const ytextId = v4();
|
||||||
|
const ytext = this._ydoc.getText(ytextId);
|
||||||
|
ytext.applyDelta(node.delta);
|
||||||
|
insertNode.data.text = ytextId;
|
||||||
|
}
|
||||||
|
// create children
|
||||||
|
const yArrayId = v4();
|
||||||
|
this._ydoc.getArray(yArrayId);
|
||||||
|
insertNode.children = yArrayId;
|
||||||
|
// insert in parent's children
|
||||||
|
const children = this._ydoc.getArray(parent.children);
|
||||||
|
const index = children.toArray().indexOf(prevId) + 1;
|
||||||
|
children.insert(index, [node.id]);
|
||||||
|
insertNode.parent = parentId;
|
||||||
|
// set in blocks
|
||||||
|
this._ydoc.getMap('blocks').set(node.id, insertNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
transact(actions: (() => void)[]) {
|
||||||
|
const ydoc = this._ydoc;
|
||||||
|
console.log('====transact')
|
||||||
|
ydoc.transact(() => {
|
||||||
|
actions.forEach(action => {
|
||||||
|
action();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
yTextApply = (yTextId: string, delta: Delta) => {
|
yTextApply = (yTextId: string, delta: Delta) => {
|
||||||
console.log("====", yTextId, delta);
|
|
||||||
const ydoc = this._ydoc;
|
const ydoc = this._ydoc;
|
||||||
const ytext = ydoc.getText(yTextId);
|
const ytext = ydoc.getText(yTextId);
|
||||||
ytext.applyDelta(delta);
|
ytext.applyDelta(delta);
|
||||||
|
console.log("====", yTextId, delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
close = () => {
|
||||||
|
const blocks = this._ydoc.getMap('blocks');
|
||||||
|
blocks.unobserve(this.handleBlocksEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleBlocksEvent = (mapEvent: Y.YMapEvent<unknown>) => {
|
||||||
|
console.log(mapEvent.changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleTextEvent = (textEvent: Y.YTextEvent) => {
|
||||||
|
console.log(textEvent.changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleArrayEvent = (arrayEvent: Y.YArrayEvent<string>) => {
|
||||||
|
console.log(arrayEvent.changes);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,7 @@ interface BlockRegion {
|
|||||||
export class RegionGrid {
|
export class RegionGrid {
|
||||||
private regions: BlockRegion[][];
|
private regions: BlockRegion[][];
|
||||||
private regionSize: number;
|
private regionSize: number;
|
||||||
|
private blocks = new Map();
|
||||||
|
|
||||||
constructor(regionSize: number) {
|
constructor(regionSize: number) {
|
||||||
this.regionSize = regionSize;
|
this.regionSize = regionSize;
|
||||||
@ -36,9 +37,22 @@ export class RegionGrid {
|
|||||||
}
|
}
|
||||||
this.regions[regionY][regionX] = region;
|
this.regions[regionY][regionX] = region;
|
||||||
}
|
}
|
||||||
|
this.blocks.set(blockPosition.id, blockPosition);
|
||||||
region.blocks.push(blockPosition);
|
region.blocks.push(blockPosition);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateBlock(blockId: string, position: BlockPosition) {
|
||||||
|
const prevPosition = this.blocks.get(blockId);
|
||||||
|
if (prevPosition && prevPosition.x === position.x &&
|
||||||
|
prevPosition.y === position.y &&
|
||||||
|
prevPosition.height === position.height &&
|
||||||
|
prevPosition.width === position.width) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.blocks.set(blockId, position);
|
||||||
|
this.removeBlock(blockId);
|
||||||
|
this.addBlock(position);
|
||||||
|
}
|
||||||
|
|
||||||
removeBlock(blockId: string) {
|
removeBlock(blockId: string) {
|
||||||
for (const rows of this.regions) {
|
for (const rows of this.regions) {
|
||||||
@ -51,6 +65,7 @@ export class RegionGrid {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.blocks.delete(blockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,24 +1,32 @@
|
|||||||
import { BlockType, TextDelta } from "@/appflowy_app/interfaces/document";
|
import { BlockType, TextDelta } from "@/appflowy_app/interfaces/document";
|
||||||
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
|
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
|
||||||
|
import { RegionGrid } from "./region_grid";
|
||||||
|
|
||||||
export interface Node {
|
export interface Node {
|
||||||
id: string;
|
id: string;
|
||||||
parent: string | null;
|
|
||||||
type: BlockType;
|
type: BlockType;
|
||||||
selected?: boolean;
|
|
||||||
delta: TextDelta[];
|
|
||||||
data: {
|
data: {
|
||||||
text?: string;
|
text?: string;
|
||||||
|
style?: Record<string, any>
|
||||||
};
|
};
|
||||||
|
parent: string | null;
|
||||||
|
children: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NodeState = {
|
export interface NodeState {
|
||||||
nodes: Record<string, Node>;
|
nodes: Record<string, Node>;
|
||||||
children: Record<string, string[]>;
|
children: Record<string, string[]>;
|
||||||
};
|
delta: Record<string, TextDelta[]>;
|
||||||
|
selections: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const regionGrid = new RegionGrid(50);
|
||||||
|
|
||||||
const initialState: NodeState = {
|
const initialState: NodeState = {
|
||||||
nodes: {},
|
nodes: {},
|
||||||
children: {},
|
children: {},
|
||||||
|
delta: {},
|
||||||
|
selections: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const documentSlice = createSlice({
|
export const documentSlice = createSlice({
|
||||||
@ -28,34 +36,68 @@ export const documentSlice = createSlice({
|
|||||||
clear: (state, action: PayloadAction) => {
|
clear: (state, action: PayloadAction) => {
|
||||||
return initialState;
|
return initialState;
|
||||||
},
|
},
|
||||||
addNode: (state, action: PayloadAction<Node>) => {
|
|
||||||
state.nodes[action.payload.id] = action.payload;
|
createTree: (state, action: PayloadAction<{
|
||||||
},
|
nodes: Record<string, Node>;
|
||||||
addChild: (state, action: PayloadAction<{ parentId: string, childId: string }>) => {
|
children: Record<string, string[]>;
|
||||||
const children = state.children[action.payload.parentId];
|
delta: Record<string, TextDelta[]>;
|
||||||
if (children) {
|
}>) => {
|
||||||
children.push(action.payload.childId);
|
const { nodes, children, delta } = action.payload;
|
||||||
} else {
|
state.nodes = nodes;
|
||||||
state.children[action.payload.parentId] = [action.payload.childId]
|
state.children = children;
|
||||||
}
|
state.delta = delta;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateNode: (state, action: PayloadAction<{id: string; parent?: string; type?: BlockType; data?: any }>) => {
|
updateSelections: (state, action: PayloadAction<string[]>) => {
|
||||||
|
state.selections = action.payload;
|
||||||
|
},
|
||||||
|
|
||||||
|
changeSelectionByIntersectRect: (state, action: PayloadAction<{
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
endX: number;
|
||||||
|
endY: number
|
||||||
|
}>) => {
|
||||||
|
const { startX, startY, endX, endY } = action.payload;
|
||||||
|
const blocks = regionGrid.getIntersectBlocks(startX, startY, endX, endY);
|
||||||
|
state.selections = blocks.map(block => block.id);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateNodePosition: (state, action: PayloadAction<{id: string; rect: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}}>) => {
|
||||||
|
const { id, rect } = action.payload;
|
||||||
|
const position = {
|
||||||
|
id,
|
||||||
|
...rect
|
||||||
|
};
|
||||||
|
regionGrid.updateBlock(id, position);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateNode: (state, action: PayloadAction<{id: string; type?: BlockType; data?: any }>) => {
|
||||||
state.nodes[action.payload.id] = {
|
state.nodes[action.payload.id] = {
|
||||||
...state.nodes[action.payload.id],
|
...state.nodes[action.payload.id],
|
||||||
...action.payload
|
...action.payload
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
removeNode: (state, action: PayloadAction<string>) => {
|
removeNode: (state, action: PayloadAction<string>) => {
|
||||||
const parentId = state.nodes[action.payload].parent;
|
const { children, data, parent } = state.nodes[action.payload];
|
||||||
delete state.nodes[action.payload];
|
if (parent) {
|
||||||
if (parentId) {
|
const index = state.children[state.nodes[parent].children].indexOf(action.payload);
|
||||||
const index = state.children[parentId].indexOf(action.payload);
|
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
state.children[parentId].splice(index, 1);
|
state.children[state.nodes[parent].children].splice(index, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (children) {
|
||||||
|
delete state.children[children];
|
||||||
|
}
|
||||||
|
if (data && data.text) {
|
||||||
|
delete state.delta[data.text];
|
||||||
|
}
|
||||||
|
delete state.nodes[action.payload];
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -1,34 +0,0 @@
|
|||||||
|
|
||||||
import { createContext } from 'react';
|
|
||||||
import { ulid } from "ulid";
|
|
||||||
import { BlockEditor } from '../block_editor/index';
|
|
||||||
import { BlockType } from '../interfaces';
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export interface NestedNode {
|
|
||||||
id: string;
|
|
||||||
children: string;
|
|
||||||
parent: string | null;
|
|
||||||
type: BlockType;
|
|
||||||
data: any;
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user