refactor: support nested block struct (#4200)

* refactor: support nested block struct

* fix: pasted bugs

* fix: fix lift node

* fix: unit test

* fix: selection style

* feat: support block color

* fix: turn to block bugs

* fix: code block bugs
This commit is contained in:
Kilu.He 2023-12-26 18:15:35 +08:00 committed by GitHub
parent a49b009980
commit 29e80a0f32
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
110 changed files with 1690 additions and 1518 deletions

View File

@ -22,7 +22,7 @@ import { EditorData, EditorNodeType } from '$app/application/document/document.t
import { Log } from '$app/utils/log';
import { Op } from 'quill-delta';
import { Element } from 'slate';
import { generateId, transformToInlineElement } from '$app/components/editor/provider/utils/convert';
import { getInlinesWithDelta } from '$app/components/editor/provider/utils/convert';
export function blockPB2Node(block: BlockPB) {
let data = {};
@ -33,7 +33,7 @@ export function blockPB2Node(block: BlockPB) {
Log.error('[Document Open] json parse error', block.data);
}
const node = {
return {
id: block.id,
type: block.ty as EditorNodeType,
parent: block.parent_id,
@ -42,8 +42,6 @@ export function blockPB2Node(block: BlockPB) {
externalId: block.external_id,
externalType: block.external_type,
};
return node;
}
export const BLOCK_MAP_NAME = 'blocks';
@ -51,7 +49,6 @@ export const META_NAME = 'meta';
export const CHILDREN_MAP_NAME = 'children_map';
export const TEXT_MAP_NAME = 'text_map';
export const EQUATION_PLACEHOLDER = '$';
export async function openDocument(docId: string): Promise<EditorData> {
const payload = OpenDocumentPayloadPB.fromObject({
document_id: docId,
@ -233,62 +230,37 @@ interface BlockJSON {
}
function flattenBlockJson(block: BlockJSON) {
const nodes: Element[] = [];
const traverse = (block: BlockJSON, parentId: string, level: number, isHidden: boolean) => {
const traverse = (block: BlockJSON) => {
const { delta, ...data } = block.data;
const blockId = generateId();
const node: Element = {
blockId,
const slateNode: Element = {
type: block.type,
data,
data: data,
children: [],
parentId,
level,
textId: generateId(),
isHidden,
};
node.children = delta
? delta.map((op) => {
const matchInline = transformToInlineElement(op);
const textNode: Element | null = delta
? {
type: 'text',
children: [],
}
: null;
if (matchInline) {
return matchInline;
}
const inlinesNodes = getInlinesWithDelta(delta);
textNode?.children.push(...inlinesNodes);
return {
text: op.insert as string,
...op.attributes,
};
})
: [
{
text: '',
},
];
nodes.push(node);
const children = block.children;
for (const child of children) {
let isHidden = false;
if (node.type === EditorNodeType.ToggleListBlock) {
const collapsed = (node.data as { collapsed: boolean })?.collapsed;
if (collapsed) {
isHidden = true;
}
}
traverse(child, blockId, level + 1, isHidden);
slateNode.children = children.map((child) => traverse(child));
if (textNode) {
slateNode.children.unshift(textNode);
}
return node;
return slateNode;
};
traverse(block, '', 0, false);
const root = traverse(block);
nodes.shift();
return nodes;
return root.children;
}

View File

@ -8,12 +8,18 @@ export interface EditorNode {
id: string;
type: EditorNodeType;
parent?: string | null;
data?: unknown;
data?: BlockData;
children?: string;
externalId?: string;
externalType?: string;
}
export interface TextNode extends Element {
type: EditorNodeType.Text;
textId: string;
blockId: string;
}
export interface PageNode extends Element {
type: EditorNodeType.Page;
}
@ -21,32 +27,38 @@ export interface ParagraphNode extends Element {
type: EditorNodeType.Paragraph;
}
export type BlockData = {
[key: string]: string | boolean | number | undefined;
font_color?: string;
bg_color?: string;
};
export interface HeadingNode extends Element {
type: EditorNodeType.HeadingBlock;
data: {
level: number;
};
} & BlockData;
}
export interface GridNode extends Element {
type: EditorNodeType.GridBlock;
data: {
viewId?: string;
};
} & BlockData;
}
export interface TodoListNode extends Element {
type: EditorNodeType.TodoListBlock;
data: {
checked: boolean;
};
} & BlockData;
}
export interface CodeNode extends Element {
type: EditorNodeType.CodeBlock;
data: {
language: string;
};
} & BlockData;
}
export interface QuoteNode extends Element {
@ -65,7 +77,7 @@ export interface ToggleListNode extends Element {
type: EditorNodeType.ToggleListBlock;
data: {
collapsed: boolean;
};
} & BlockData;
}
export interface DividerNode extends Element {
@ -76,18 +88,19 @@ export interface CalloutNode extends Element {
type: EditorNodeType.CalloutBlock;
data: {
icon: string;
};
} & BlockData;
}
export interface MathEquationNode extends Element {
type: EditorNodeType.EquationBlock;
data: {
formula?: string;
};
} & BlockData;
}
export interface FormulaNode extends Element {
type: EditorInlineNodeType.Formula;
data: boolean;
}
export interface MentionNode extends Element {
@ -129,6 +142,7 @@ export interface EditorProps {
}
export enum EditorNodeType {
Text = 'text',
Paragraph = 'paragraph',
Page = 'page',
HeadingBlock = 'heading',
@ -145,8 +159,6 @@ export enum EditorNodeType {
GridBlock = 'grid',
}
export const blockTypes: string[] = Object.values(EditorNodeType);
export enum EditorInlineNodeType {
Mention = 'mention',
Formula = 'formula',
@ -196,18 +208,6 @@ export enum EditorStyleFormat {
Href = 'href',
}
export const markTypes: string[] = [
EditorMarkFormat.Bold,
EditorMarkFormat.Italic,
EditorMarkFormat.Underline,
EditorMarkFormat.StrikeThrough,
EditorMarkFormat.Code,
EditorMarkFormat.Formula,
EditorStyleFormat.Href,
EditorStyleFormat.FontColor,
EditorStyleFormat.BackgroundColor,
];
export enum EditorTurnFormat {
Paragraph = 'paragraph',
Heading1 = 'heading1', // 'heading1' is a special format, it's not a slate node type, but a slate node type's data

View File

@ -113,6 +113,7 @@ export const EditFieldPopup = ({
await save();
onOutsideClick();
}}
disableRestoreFocus={true}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',

View File

@ -1,47 +1,28 @@
import { FormEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
import { FormEventHandler, useCallback } from 'react';
import { t } from 'i18next';
import { PageController } from '$app/stores/effects/workspace/page/page_controller';
import { useViewId } from '$app/hooks';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { updatePageName } from '$app_reducers/pages/async_actions';
export const DatabaseTitle = () => {
const viewId = useViewId();
const [title, setTitle] = useState('');
const controller = useMemo(() => new PageController(viewId), [viewId]);
useEffect(() => {
void controller.getPage().then((page) => {
setTitle(page.name);
});
void controller.subscribe({
onPageChanged: (page) => {
setTitle(page.name);
},
});
return () => {
void controller.unsubscribe();
};
}, [controller]);
const pageName = useAppSelector((state) => state.pages.pageMap[viewId].name);
const dispatch = useAppDispatch();
const handleInput = useCallback<FormEventHandler>(
(event) => {
const newTitle = (event.target as HTMLInputElement).value;
void controller.updatePage({
id: viewId,
name: newTitle,
});
void dispatch(updatePageName({ id: viewId, name: newTitle }));
},
[viewId, controller]
[viewId, dispatch]
);
return (
<div className='mb-6 h-[70px] px-16 pt-8'>
<input
className='text-3xl font-semibold'
value={title}
value={pageName}
placeholder={t('grid.title.placeholder')}
onInput={handleInput}
/>

View File

@ -61,6 +61,7 @@ export const SelectCell: FC<{
{open ? (
<Menu
keepMounted={false}
disableRestoreFocus={true}
className='h-full w-full'
open={open}
anchorEl={anchorEl}

View File

@ -38,7 +38,7 @@ function SettingsMenu(props: SettingsMenuProps) {
return (
<>
<Menu {...props}>
<Menu {...props} disableRestoreFocus={true}>
<MenuItem
onClick={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
@ -54,6 +54,7 @@ function SettingsMenu(props: SettingsMenuProps) {
</MenuItem>
</Menu>
<Popover
disableRestoreFocus={true}
open={openProperties}
onClose={() => {
setPropertiesAnchorElPosition(undefined);

View File

@ -39,7 +39,7 @@ function RecordActions({ anchorEl, open, onClose, rowId }: Props) {
];
return (
<Menu anchorEl={anchorEl} open={open} onClose={onClose}>
<Menu anchorEl={anchorEl} disableRestoreFocus={true} open={open} onClose={onClose}>
{menuOptions.map((option) => (
<MenuItem
key={option.label}

View File

@ -16,7 +16,7 @@ function ChecklistCellActions({
const { percentage, selectedOptions = [], options } = cell.data;
return (
<Popover {...props}>
<Popover disableRestoreFocus={true} {...props}>
<LinearProgressWithLabel className={'m-4'} value={percentage || 0} />
<div className={'p-1'}>
{options?.map((option) => {

View File

@ -42,6 +42,7 @@ function DateFormat({ value, onChange }: Props) {
<MoreSvg className={`transform text-base ${open ? '' : 'rotate-90'}`} />
</MenuItem>
<Menu
disableRestoreFocus={true}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',

View File

@ -75,6 +75,7 @@ function DateTimeCellActions({
return (
<Popover
disableRestoreFocus={true}
keepMounted={false}
anchorOrigin={{
vertical: 'bottom',

View File

@ -23,6 +23,7 @@ function DateTimeFormatSelect({ field }: Props) {
<MoreSvg className={`transform text-base ${open ? '' : 'rotate-90'}`} />
</MenuItem>
<Menu
disableRestoreFocus={true}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',

View File

@ -37,6 +37,7 @@ function TimeFormat({ value, onChange }: Props) {
<MoreSvg className={`transform text-base ${open ? '' : 'rotate-90'}`} />
</MenuItem>
<Menu
disableRestoreFocus={true}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',

View File

@ -34,6 +34,7 @@ function EditNumberCellInput({
return (
<Popover
disableRestoreFocus={true}
keepMounted={false}
open={editing}
anchorEl={anchorEl}

View File

@ -13,7 +13,7 @@ function NumberFormatMenu({
onChangeFormat: (value: NumberFormatPB) => void;
}) {
return (
<Menu {...props}>
<Menu {...props} disableRestoreFocus={true}>
{formats.map((format) => (
<MenuItem
onClick={() => {

View File

@ -79,6 +79,7 @@ export const SelectOptionMenu: FC<SelectOptionMenuProps> = ({ fieldId, option, M
}}
{...menuProps}
onClose={onClose}
disableRestoreFocus={true}
>
<ListSubheader className='my-2 leading-tight'>
<OutlinedInput

View File

@ -22,6 +22,7 @@ function EditTextCellInput({ editing, anchorEl, onClose, text, onInput }: Props)
return (
<Popover
disableRestoreFocus={true}
open={editing}
anchorEl={anchorEl}
PaperProps={{

View File

@ -119,6 +119,7 @@ function Filter({ filter, field }: Props) {
/>
{condition !== undefined && open && (
<Popover
disableRestoreFocus={true}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center',

View File

@ -33,7 +33,7 @@ function FilterActions({ filter }: { filter: Filter }) {
>
<MoreSvg />
</IconButton>
<Menu keepMounted={false} open={open} anchorEl={anchorEl} onClose={onClose}>
<Menu disableRestoreFocus={true} keepMounted={false} open={open} anchorEl={anchorEl} onClose={onClose}>
<MenuItem onClick={onDelete}>{t('grid.settings.deleteFilter')}</MenuItem>
</Menu>
</>

View File

@ -34,7 +34,7 @@ function FilterFieldsMenu({
);
return (
<Popover {...props}>
<Popover disableRestoreFocus={true} {...props}>
<PropertiesList showSearch searchPlaceholder={t('grid.settings.filterBy')} onItemClick={addFilter} />
</Popover>
);

View File

@ -35,6 +35,7 @@ export const PropertyMenu: FC<GridFieldMenuProps> = ({ field, ...props }) => {
return (
<Popover
disableRestoreFocus={true}
transformOrigin={{
vertical: -10,
horizontal: 'left',

View File

@ -40,7 +40,7 @@ export const PropertyTypeMenu: FC<
);
return (
<Menu {...props} PopoverClasses={PopoverClasses}>
<Menu {...props} disableRestoreFocus={true} PopoverClasses={PopoverClasses}>
{FieldTypeGroup.map((group, index) => [
<MenuItem key={group.name} dense disabled>
{group.name}

View File

@ -28,7 +28,7 @@ const SortFieldsMenu: FC<
);
return (
<Popover keepMounted={false} {...props}>
<Popover disableRestoreFocus={true} keepMounted={false} {...props}>
<PropertiesList showSearch={true} onItemClick={addSort} searchPlaceholder={t('grid.settings.sortBy')} />
</Popover>
);

View File

@ -30,6 +30,7 @@ export const SortMenu: FC<MenuProps> = (props) => {
return (
<>
<Menu
disableRestoreFocus={true}
keepMounted={false}
MenuListProps={{
className: 'py-1',

View File

@ -43,7 +43,7 @@ function ViewActions({ view, ...props }: { view: Page } & MenuProps) {
return (
<>
<Menu keepMounted={false} {...props}>
<Menu keepMounted={false} disableRestoreFocus={true} {...props}>
{options.map((option) => (
<MenuItem key={option.id} onClick={option.action}>
<div className={'mr-1.5'}>{option.icon}</div>

View File

@ -15,6 +15,7 @@ function GridFieldMenu({ field, onOpenPropertyMenu, onOpenMenu, ...props }: Prop
return (
<Portal>
<Popover
disableRestoreFocus={true}
transformOrigin={{
vertical: 'top',
horizontal: 'left',

View File

@ -75,6 +75,7 @@ function GridRowMenu({ rowId, ...props }: Props) {
return (
<Popover
disableRestoreFocus={true}
keepMounted={false}
anchorReference={'anchorPosition'}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}

View File

@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React, { memo, useCallback } from 'react';
import { Page, PageIcon } from '$app_reducers/pages/slice';
import { useAppDispatch } from '$app/stores/store';
import ViewTitle from '$app/components/_shared/ViewTitle';
@ -32,4 +32,4 @@ export function DocumentHeader({ page }: DocumentHeaderProps) {
);
}
export default DocumentHeader;
export default memo(DocumentHeader);

View File

@ -1,7 +1,7 @@
import { ReactEditor } from 'slate-react';
import { BasePoint, Editor, Element, Node, NodeEntry, Transforms } from 'slate';
import { Editor, Element, Node, NodeEntry, Point, Range, Transforms, Location } from 'slate';
import { LIST_TYPES, tabBackward, tabForward } from '$app/components/editor/command/tab';
import { isMarkActive, toggleMark } from '$app/components/editor/command/mark';
import { isMarkActive, removeMarks, toggleMark } from '$app/components/editor/command/mark';
import { insertFormula, isFormulaActive, unwrapFormula, updateFormula } from '$app/components/editor/command/formula';
import {
EditorInlineNodeType,
@ -16,60 +16,69 @@ import { generateId } from '$app/components/editor/provider/utils/convert';
import { YjsEditor } from '@slate-yjs/core';
export const CustomEditor = {
getBlock: (editor: ReactEditor, at?: Location): NodeEntry<Element> | undefined => {
return Editor.above(editor, {
at,
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined,
});
},
/**
* turn the current block to a new block
* 1. clone the current block to a new block
* 2. remove the current block
* 3. insert the new block
* 4. lift the children of the new block if the new block doesn't allow has children
* @param editor
* @param newProperties
*/
turnToBlock: (editor: ReactEditor, newProperties: Partial<Element>) => {
const selection = editor.selection;
if (!selection) return;
const [match] = Editor.nodes(editor, {
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined && n.type !== undefined,
});
const match = CustomEditor.getBlock(editor);
if (!match) return;
const [node, path] = match as NodeEntry<Element>;
const parentId = node.parentId;
const cloneNode = {
...cloneDeep(node),
blockId: generateId(),
textId: generateId(),
type: newProperties.type || EditorNodeType.Paragraph,
data: newProperties.data || {},
};
const isListType = LIST_TYPES.includes(cloneNode.type as EditorNodeType);
const extendId = isListType ? cloneNode.blockId : parentId;
const subordinates = CustomEditor.findNodeSubordinate(editor, node);
Transforms.insertNodes(editor, cloneNode, { at: [path[0] + 1] });
subordinates.forEach((subordinate) => {
const subordinatePath = ReactEditor.findPath(editor, subordinate);
const level = subordinate.level ?? 2;
const newProperties = {
level: isListType ? level : level - 1,
};
if (subordinate.parentId === node.blockId) {
Object.assign(newProperties, {
parentId: extendId,
});
}
Transforms.setNodes(editor, newProperties, {
at: [subordinatePath[0] + 1],
});
});
const cloneNode = CustomEditor.cloneBlock(editor, node);
Transforms.removeNodes(editor, {
at: path,
});
Transforms.select(editor, selection);
Object.assign(cloneNode, newProperties);
const [, ...children] = cloneNode.children;
Transforms.insertNodes(editor, cloneNode, { at: path });
const isListType = LIST_TYPES.includes(cloneNode.type as EditorNodeType);
// if node doesn't allow has children, the children should be lifted
if (!isListType) {
const length = children.length;
for (let i = 0; i < length; i++) {
editor.liftNodes({
at: [...path, length - i],
});
}
}
const isSelectable = editor.isSelectable(cloneNode);
if (isSelectable) {
Transforms.select(editor, selection);
} else {
Transforms.select(editor, path);
}
},
tabForward,
tabBackward,
toggleMark,
removeMarks,
isMarkActive,
isFormulaActive,
updateFormula,
@ -82,17 +91,17 @@ export const CustomEditor = {
}
}
},
isBlockActive(editor: ReactEditor, format?: string) {
const [match] = Editor.nodes(editor, {
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined && n.type !== undefined,
});
if (format !== undefined) {
return match && (match[0] as Element).type === format;
isBlockActive(editor: ReactEditor, format?: string) {
const match = CustomEditor.getBlock(editor);
if (match && format !== undefined) {
return match[0].type === format;
}
return !!match;
},
insertMention(editor: ReactEditor, mention: Mention) {
const mentionElement = {
type: EditorInlineNodeType.Mention,
@ -106,62 +115,6 @@ export const CustomEditor = {
Transforms.move(editor);
},
splitToParagraph(editor: ReactEditor) {
Transforms.splitNodes(editor, { always: true });
Transforms.setNodes(editor, { type: EditorNodeType.Paragraph });
},
findParentNode(editor: ReactEditor, node: Element) {
const parentId = node.parentId;
if (!parentId) return null;
return editor.children.find((child) => (child as Element).blockId === parentId) as Element;
},
findNodeSubordinate(editor: ReactEditor, node: Element) {
const index = editor.children.findIndex((child) => (child as Element).blockId === node.blockId);
const level = node.level ?? 1;
const subordinateNodes: (Element & { level: number })[] = [];
if (index === editor.children.length - 1) return subordinateNodes;
for (let i = index + 1; i < editor.children.length; i++) {
const nextNode = editor.children[i] as Element & { level: number };
if (nextNode.level > level) {
subordinateNodes.push(nextNode);
} else {
break;
}
}
return subordinateNodes;
},
findNextNode(editor: ReactEditor, node: Element, level: number) {
const index = editor.children.findIndex((child) => (child as Element).blockId === node.blockId);
let nextIndex = -1;
if (index === editor.children.length - 1) return null;
for (let i = index + 1; i < editor.children.length; i++) {
const nextNode = editor.children[i] as Element & { level: number };
if (nextNode.level === level) {
nextIndex = i;
break;
}
if (nextNode.level < level) break;
}
const nextNode = editor.children[nextIndex] as Element & { level: number };
return nextNode;
},
toggleTodo(editor: ReactEditor, node: TodoListNode) {
const checked = node.data.checked;
const path = ReactEditor.findPath(editor, node);
@ -175,38 +128,19 @@ export const CustomEditor = {
},
toggleToggleList(editor: ReactEditor, node: ToggleListNode) {
if (!node.level) return;
const collapsed = !node.data.collapsed;
const collapsed = node.data.collapsed;
const path = ReactEditor.findPath(editor, node);
const newProperties = {
data: {
collapsed,
collapsed: !collapsed,
},
} as Partial<Element>;
Transforms.select(editor, path);
Transforms.collapse(editor, { edge: 'end' });
Transforms.setNodes(editor, newProperties, { at: path });
// hide or show the children
const index = path[0];
if (index === editor.children.length - 1) return;
for (let i = index + 1; i < editor.children.length; i++) {
const nextNode = editor.children[i] as Element & { level: number };
if (nextNode.level === node.level) break;
if (nextNode.level > node.level) {
const nextPath = ReactEditor.findPath(editor, nextNode);
const nextProperties = {
isHidden: collapsed,
} as Partial<Element>;
Transforms.setNodes(editor, nextProperties, { at: nextPath });
}
}
editor.select(path);
editor.collapse({
edge: 'start',
});
},
setCalloutIcon(editor: ReactEditor, node: CalloutNode, newIcon: string) {
@ -242,60 +176,55 @@ export const CustomEditor = {
Transforms.setNodes(editor, newProperties, { at: path });
},
findNodeChildren(editor: ReactEditor, node: Node) {
const nodeId = (node as Element).blockId;
return editor.children.filter((child) => (child as Element).parentId === nodeId) as Element[];
},
duplicateNode(editor: ReactEditor, node: Node) {
const children = CustomEditor.findNodeChildren(editor, node);
const newBlockId = generateId();
const newTextId = generateId();
const cloneNode = {
...cloneDeep(node),
blockId: newBlockId,
textId: newTextId,
cloneBlock(editor: ReactEditor, block: Element): Element {
const cloneNode: Element = {
...cloneDeep(block),
blockId: generateId(),
children: [],
};
const [firstTextNode, ...children] = block.children as Element[];
const isSelectable = editor.isSelectable(cloneNode);
const textNode =
firstTextNode && firstTextNode.type === EditorNodeType.Text && isSelectable
? {
textId: generateId(),
type: EditorNodeType.Text,
children: cloneDeep(firstTextNode.children),
}
: undefined;
if (textNode) {
cloneNode.children.push(textNode);
}
const cloneChildren = children.map((child) => {
const childBlockId = generateId();
const childTextId = generateId();
return {
...cloneDeep(child),
blockId: childBlockId,
textId: childTextId,
parentId: newBlockId,
};
return CustomEditor.cloneBlock(editor, child);
});
const path = ReactEditor.findPath(editor, node);
const endPath = children.length ? ReactEditor.findPath(editor, children[children.length - 1]) : null;
cloneNode.children.push(...cloneChildren);
Transforms.insertNodes(editor, [cloneNode, ...cloneChildren], { at: [endPath ? endPath[0] + 1 : path[0] + 1] });
Transforms.move(editor);
return cloneNode;
},
duplicateNode(editor: ReactEditor, node: Element) {
const cloneNode = CustomEditor.cloneBlock(editor, node);
const path = ReactEditor.findPath(editor, node);
Transforms.insertNodes(editor, cloneNode, { at: path });
},
deleteNode(editor: ReactEditor, node: Node) {
const children = CustomEditor.findNodeChildren(editor, node);
const path = ReactEditor.findPath(editor, node);
const endPath = children.length ? ReactEditor.findPath(editor, children[children.length - 1]) : null;
Transforms.removeNodes(editor, {
at: {
anchor: { path, offset: 0 },
focus: { path: endPath ?? path, offset: 0 },
},
at: path,
});
Transforms.move(editor);
},
getBlockType: (editor: ReactEditor) => {
const [match] = Editor.nodes(editor, {
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined && n.type !== undefined,
});
const match = CustomEditor.getBlock(editor);
if (!match) return null;
@ -304,10 +233,6 @@ export const CustomEditor = {
return node.type as EditorNodeType;
},
isGridBlock: (editor: ReactEditor) => {
return CustomEditor.getBlockType(editor) === EditorNodeType.GridBlock;
},
selectionIncludeRoot: (editor: ReactEditor) => {
const [match] = Editor.nodes(editor, {
match: (n) => Element.isElement(n) && n.blockId !== undefined && n.type === EditorNodeType.Page,
@ -324,12 +249,19 @@ export const CustomEditor = {
editor.insertNode(
{
type: EditorNodeType.Paragraph,
level: 1,
data: {},
blockId: generateId(),
textId: generateId(),
parentId: editor.sharedRoot.getAttribute('blockId'),
children: [{ text: '' }],
children: [
{
type: EditorNodeType.Text,
textId: generateId(),
children: [
{
text: '',
},
],
},
],
},
{
select: true,
@ -340,40 +272,68 @@ export const CustomEditor = {
Transforms.move(editor);
},
basePointToIndexLength(editor: ReactEditor, point: BasePoint, toStart = false) {
const { path, offset } = point;
focusAtStartOfBlock(editor: ReactEditor) {
const { selection } = editor;
const node = editor.children[path[0]] as Element;
const blockId = node.blockId;
if (selection && Range.isCollapsed(selection)) {
const match = CustomEditor.getBlock(editor);
const [, path] = match as NodeEntry<Element>;
const start = Editor.start(editor, path);
if (!blockId) return;
const beforeText = Editor.string(editor, {
anchor: {
path: [path[0], 0],
offset: 0,
},
focus: {
path,
offset,
},
});
return match && Point.equals(selection.anchor, start);
}
const index = beforeText.length;
const fullText = Editor.string(editor, [path[0]]);
const length = fullText.length - index;
return false;
},
if (toStart) {
return {
index: 0,
length: index,
blockId,
};
} else {
return {
index,
length,
blockId,
};
setBlockColor(
editor: ReactEditor,
node: Element,
data: {
font_color?: string;
bg_color?: string;
}
) {
const path = ReactEditor.findPath(editor, node);
const newProperties = {
data,
} as Partial<Element>;
Transforms.setNodes(editor, newProperties, { at: path });
},
deleteAllText(editor: ReactEditor, node: Element) {
const [textNode] = (node.children || []) as Element[];
const hasTextNode = textNode && textNode.type === EditorNodeType.Text;
if (!hasTextNode) return;
const path = ReactEditor.findPath(editor, textNode);
const textLength = editor.string(path).length;
const start = Editor.start(editor, path);
for (let i = 0; i < textLength; i++) {
editor.select(start);
editor.deleteForward('character');
}
},
getNodeText: (editor: ReactEditor, node: Element) => {
const [textNode] = (node.children || []) as Element[];
const hasTextNode = textNode && textNode.type === EditorNodeType.Text;
if (!hasTextNode) return '';
const path = ReactEditor.findPath(editor, textNode);
return editor.string(path);
},
isEmptyText: (editor: ReactEditor, node: Element) => {
const [textNode] = (node.children || []) as Element[];
const hasTextNode = textNode && textNode.type === EditorNodeType.Text;
if (!hasTextNode) return false;
return editor.isEmpty(textNode);
},
};

View File

@ -24,3 +24,13 @@ export function isMarkActive(editor: ReactEditor, format: string) {
return marks ? !!marks[format] : false;
}
export function removeMarks(editor: ReactEditor) {
const marks = Editor.marks(editor);
if (!marks) return;
for (const key in marks) {
Editor.removeMark(editor, key);
}
}

View File

@ -1,4 +1,4 @@
import { Editor, Element, NodeEntry, Transforms } from 'slate';
import { Path, Element, NodeEntry } from 'slate';
import { ReactEditor } from 'slate-react';
import { EditorNodeType } from '$app/application/document/document.types';
import { CustomEditor } from '$app/components/editor/command/index';
@ -35,34 +35,21 @@ const LIST_ITEM_TYPES = [
* @param editor
*/
export function tabForward(editor: ReactEditor) {
const [match] = Editor.nodes(editor, {
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && Editor.isBlock(editor, n),
});
const match = CustomEditor.getBlock(editor);
if (!match) return;
const [node, path] = match as NodeEntry<Element>;
if (!node.level) return;
// the node is not a list item
if (!LIST_ITEM_TYPES.includes(node.type as EditorNodeType)) {
return;
}
let previousNode;
const previousPath = Path.previous(path);
for (let i = path[0] - 1; i >= 0; i--) {
const ancestor = editor.children[i] as Element & { level: number };
if (ancestor.level === node.level) {
previousNode = ancestor;
break;
}
if (ancestor.level < node.level) {
break;
}
}
const previous = editor.node(previousPath);
const [previousNode] = previous as NodeEntry<Element>;
if (!previousNode) return;
@ -71,93 +58,38 @@ export function tabForward(editor: ReactEditor) {
// the previous node is not a list
if (!LIST_TYPES.includes(type)) return;
const previousNodeLevel = previousNode.level;
const toPath = [...previousPath, previousNode.children.length];
if (!previousNodeLevel) return;
const newParentId = previousNode.blockId;
const children = CustomEditor.findNodeChildren(editor, node);
children.forEach((child) => {
const childPath = ReactEditor.findPath(editor, child);
Transforms.setNodes(
editor,
{
parentId: newParentId,
},
{
at: childPath,
}
);
editor.moveNodes({
at: path,
to: toPath,
});
const newProperties = { level: previousNodeLevel + 1, parentId: newParentId };
node.children.forEach((child, index) => {
if (index === 0) return;
Transforms.setNodes(editor, newProperties);
editor.liftNodes({
at: [...toPath, index],
});
});
}
/**
* Outdent the current list item
* Conditions:
* 1. The current node must be a list item
* 2. The current node must be indented
* Result:
* 1. The current node will be the sibling of the parent node
* 2. The current node will be outdented
* 3. The children of the parent node will be moved to the children of the current node
* @param editor
*/
export function tabBackward(editor: ReactEditor) {
const [match] = Editor.nodes(editor, {
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && Editor.isBlock(editor, n),
});
const match = CustomEditor.getBlock(editor);
if (!match) return;
const [node] = match as NodeEntry<Element & { level: number }>;
const [node, path] = match as NodeEntry<Element & { level: number }>;
const level = node.level;
if (level <= 1) return;
const parent = CustomEditor.findParentNode(editor, node);
if (!parent) return;
const newParentId = parent.parentId;
if (!newParentId) return;
const newProperties = { level: level - 1, parentId: newParentId };
const subordinates = CustomEditor.findNodeSubordinate(editor, node);
subordinates.forEach((subordinate) => {
const subordinatePath = ReactEditor.findPath(editor, subordinate);
const subordinateLevel = subordinate.level;
Transforms.setNodes(
editor,
{
level: subordinateLevel - 1,
},
{
at: subordinatePath,
}
);
});
const parentChildren = CustomEditor.findNodeChildren(editor, parent);
const nodeIndex = parentChildren.findIndex((child) => child.blockId === node.blockId);
Transforms.setNodes(editor, newProperties);
for (let i = nodeIndex + 1; i < parentChildren.length; i++) {
const child = parentChildren[i];
const childPath = ReactEditor.findPath(editor, child);
Transforms.setNodes(editor, { parentId: node.blockId }, { at: childPath });
if (node.type === EditorNodeType.Page) return;
if (node.type !== EditorNodeType.Paragraph) {
CustomEditor.turnToBlock(editor, {
type: EditorNodeType.Paragraph,
});
return;
}
editor.liftNodes({
at: path,
});
}

View File

@ -1,17 +1,13 @@
import React, { CSSProperties } from 'react';
import { Editor, Element } from 'slate';
import { useSlateStatic } from 'slate-react';
import React from 'react';
import { Element } from 'slate';
import PlaceholderContent from '$app/components/editor/components/blocks/_shared/PlaceholderContent';
function Placeholder({ node, ...props }: { node: Element; className?: string; style?: CSSProperties }) {
const editor = useSlateStatic();
const isEmpty = Editor.isEmpty(editor, node);
function Placeholder({ node, isEmpty }: { node: Element; isEmpty: boolean }) {
if (!isEmpty) {
return null;
}
return <PlaceholderContent node={node} {...props} />;
return <PlaceholderContent node={node} />;
}
export default React.memo(Placeholder);

View File

@ -1,28 +1,35 @@
import React, { CSSProperties, useMemo } from 'react';
import { useSelected, useSlateStatic } from 'slate-react';
import { Element } from 'slate';
import { ReactEditor, useSelected, useSlateStatic } from 'slate-react';
import { Editor, Element } from 'slate';
import { EditorNodeType, HeadingNode } from '$app/application/document/document.types';
import { useTranslation } from 'react-i18next';
function PlaceholderContent({ node, ...attributes }: { node: Element; className?: string; style?: CSSProperties }) {
const { t } = useTranslation();
const selected = useSelected();
const editor = useSlateStatic();
const selected = useSelected();
const block = useMemo(() => {
const path = ReactEditor.findPath(editor, node);
const match = Editor.above(editor, {
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined,
at: path,
});
const justOneParagraph = useMemo(() => {
const root = editor.children[0] as Element;
if (!match) return null;
if (node.type !== EditorNodeType.Paragraph) return false;
return match[0] as Element;
}, [editor, node]);
if (editor.children.length === 1) return true;
return root.type === EditorNodeType.Page && editor.children.length === 2;
}, [editor, node.type]);
const className = useMemo(() => {
return `pointer-events-none absolute left-0.5 top-0 whitespace-nowrap text-text-placeholder ${
attributes.className ?? ''
}`;
}, [attributes.className]);
const unSelectedPlaceholder = useMemo(() => {
switch (node.type) {
switch (block?.type) {
case EditorNodeType.Paragraph: {
if (justOneParagraph) {
if (editor.children.length === 1) {
return t('editor.slashPlaceHolder');
}
@ -40,7 +47,7 @@ function PlaceholderContent({ node, ...attributes }: { node: Element; className?
case EditorNodeType.BulletedListBlock:
return t('document.plugins.bulletedList');
case EditorNodeType.HeadingBlock: {
const level = (node as HeadingNode).data.level;
const level = (block as HeadingNode).data.level;
switch (level) {
case 1:
@ -56,27 +63,29 @@ function PlaceholderContent({ node, ...attributes }: { node: Element; className?
case EditorNodeType.Page:
return t('document.title.placeholder');
case EditorNodeType.CalloutBlock:
case EditorNodeType.CodeBlock:
return t('editor.typeSomething');
default:
return '';
}
}, [justOneParagraph, node, t]);
}, [block, t, editor.children.length]);
const selectedPlaceholder = useMemo(() => {
switch (node.type) {
switch (block?.type) {
case EditorNodeType.HeadingBlock:
return unSelectedPlaceholder;
case EditorNodeType.Page:
return t('document.title.placeholder');
case EditorNodeType.GridBlock:
case EditorNodeType.EquationBlock:
case EditorNodeType.CodeBlock:
return '';
default:
return t('editor.slashPlaceHolder');
}
}, [node.type, t, unSelectedPlaceholder]);
const className = useMemo(() => {
return `pointer-events-none absolute left-0.5 top-0 whitespace-nowrap text-text-placeholder ${
attributes.className ?? ''
}`;
}, [attributes.className]);
}, [block?.type, t, unSelectedPlaceholder]);
return (
<span contentEditable={false} {...attributes} className={className}>

View File

@ -1,19 +1,19 @@
import React, { forwardRef, memo } from 'react';
import { EditorElementProps, BulletedListNode } from '$app/application/document/document.types';
import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder';
export const BulletedList = memo(
forwardRef<HTMLDivElement, EditorElementProps<BulletedListNode>>(({ node, children, ...attributes }, ref) => {
return (
<div {...attributes} className={`${attributes.className ?? ''} relative`} ref={ref}>
<span contentEditable={false} className={'pr-2 font-medium'}>
</span>
<span className={'relative'}>
<Placeholder node={node} />
{children}
</span>
</div>
);
})
forwardRef<HTMLDivElement, EditorElementProps<BulletedListNode>>(
({ node: _, children, className, ...attributes }, ref) => {
return (
<>
<span contentEditable={false} className={'absolute flex w-6 select-none justify-center font-medium'}>
</span>
<div ref={ref} {...attributes} className={`${className} ml-6`}>
{children}
</div>
</>
);
}
)
);

View File

@ -5,16 +5,20 @@ import CalloutIcon from '$app/components/editor/components/blocks/callout/Callou
export const Callout = memo(
forwardRef<HTMLDivElement, EditorElementProps<CalloutNode>>(({ node, children, ...attributes }, ref) => {
return (
<div
{...attributes}
className={`${
attributes.className ?? ''
} relative my-2 flex w-full items-start gap-3 rounded border border-solid border-line-divider bg-content-blue-50 p-2`}
ref={ref}
>
<CalloutIcon node={node} />
<div className={'flex-1 py-1.5'}>{children}</div>
</div>
<>
<div contentEditable={false} className={'absolute w-full select-none px-2 pt-3'}>
<CalloutIcon node={node} />
</div>
<div
{...attributes}
ref={ref}
className={`${
attributes.className ?? ''
} my-2 flex w-full flex-col rounded border border-solid border-line-divider bg-content-blue-50 py-2 pl-10`}
>
{children}
</div>
</>
);
})
);

View File

@ -9,21 +9,22 @@ export const Code = memo(
const { language, handleChangeLanguage } = useCodeBlock(node);
return (
<div
{...attributes}
ref={ref}
className={`${
attributes.className ?? ''
} my-2 w-full rounded border border-solid border-line-divider bg-content-blue-50 p-6`}
>
<div contentEditable={false} className={'mb-2 w-full'}>
<>
<div contentEditable={false} className={'absolute w-full select-none px-7 py-6'}>
<LanguageSelect language={language} onChangeLanguage={handleChangeLanguage} />
</div>
<pre className='code-block-element'>
<code>{children}</code>
</pre>
</div>
<div
{...attributes}
ref={ref}
className={`${
attributes.className ?? ''
} my-2 flex w-full rounded border border-solid border-line-divider bg-content-blue-50 p-6 pt-14`}
>
<pre>
<code>{children}</code>
</pre>
</div>
</>
);
})
);

View File

@ -1,22 +1,33 @@
import React, { forwardRef, memo } from 'react';
import React, { forwardRef, memo, useCallback, useContext } from 'react';
import { EditorElementProps, GridNode } from '$app/application/document/document.types';
import GridView from '$app/components/editor/components/blocks/database/GridView';
import DatabaseEmpty from '$app/components/editor/components/blocks/database/DatabaseEmpty';
import { EditorSelectedBlockContext } from '$app/components/editor/components/editor/Editor.hooks';
export const GridBlock = memo(
forwardRef<HTMLDivElement, EditorElementProps<GridNode>>(({ node, children }, ref) => {
forwardRef<HTMLDivElement, EditorElementProps<GridNode>>(({ node, children, className = '', ...attributes }, ref) => {
const viewId = node.data.viewId;
return (
<div
contentEditable={false}
className='relative flex h-[400px] overflow-hidden border-b border-t border-line-divider caret-text-title'
ref={ref}
>
{viewId ? <GridView viewId={viewId} /> : <DatabaseEmpty node={node} />}
const blockId = node.blockId;
const selectedBlockContext = useContext(EditorSelectedBlockContext);
const onClick = useCallback(() => {
if (!blockId) return;
selectedBlockContext.clear();
selectedBlockContext.add(blockId);
}, [blockId, selectedBlockContext]);
<div className={'invisible absolute'}>{children}</div>
return (
<div {...attributes} onClick={onClick} className={`${className} relative my-2`}>
<div ref={ref} className={'absolute left-0 top-0 h-full w-full caret-transparent'}>
{children}
</div>
<div
contentEditable={false}
className='flex h-[400px] overflow-hidden border-b border-t border-line-divider bg-bg-body py-3 caret-text-title'
>
{viewId ? <GridView viewId={viewId} /> : <DatabaseEmpty node={node} />}
</div>
</div>
);
})

View File

@ -1,2 +1 @@
export * from './GridBlock';
export * from './withDatabaseBlockPlugin';

View File

@ -1,20 +0,0 @@
import { ReactEditor } from 'slate-react';
import { EditorNodeType } from '$app/application/document/document.types';
export function withDatabaseBlockPlugin(editor: ReactEditor) {
const { isElementReadOnly, isSelectable, isEmpty } = editor;
editor.isElementReadOnly = (element) => {
return element.type === EditorNodeType.GridBlock || isElementReadOnly(element);
};
editor.isSelectable = (element) => {
return element.type !== EditorNodeType.GridBlock || isSelectable(element);
};
editor.isEmpty = (element) => {
return element.type !== EditorNodeType.GridBlock && isEmpty(element);
};
return editor;
}

View File

@ -3,18 +3,15 @@ import { EditorElementProps, DividerNode as DividerNodeType } from '$app/applica
export const DividerNode = memo(
forwardRef<HTMLDivElement, EditorElementProps<DividerNodeType>>(
({ node: _node, children: children, ...attributes }, ref) => {
({ node: _node, children: children, className, ...attributes }, ref) => {
return (
<div
{...attributes}
ref={ref}
contentEditable={false}
className={`${attributes.className ?? ''} relative w-full`}
>
<div className={'w-full py-2.5 text-line-divider'}>
<div {...attributes} className={`${className} relative`}>
<div contentEditable={false} className={'w-full py-2.5 text-line-divider'}>
<hr />
</div>
<span className={'absolute left-0 top-0 h-0 w-0 opacity-0'}>{children}</span>
<div ref={ref} className={`absolute h-full w-full caret-transparent`}>
{children}
</div>
</div>
);
}

View File

@ -1,6 +1,5 @@
import React, { forwardRef, memo } from 'react';
import { EditorElementProps, HeadingNode } from '$app/application/document/document.types';
import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder';
import { getHeadingCssProperty } from '$app/components/editor/plugins/utils';
export const Heading = memo(
@ -8,13 +7,10 @@ export const Heading = memo(
const level = node.data.level;
const fontSizeCssProperty = getHeadingCssProperty(level);
const className = `${attributes.className ?? ''} font-bold ${fontSizeCssProperty}`;
return (
<div
{...attributes}
ref={ref}
className={`${attributes.className ?? ''} leading-1 relative font-bold ${fontSizeCssProperty}`}
>
<Placeholder node={node} className={fontSizeCssProperty} />
<div {...attributes} ref={ref} className={className}>
{children}
</div>
);

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import Popover from '@mui/material/Popover';
import { PopoverCommonProps } from '$app/components/editor/components/tools/popover';
import { TextareaAutosize } from '@mui/material';
@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
import { CustomEditor } from '$app/components/editor/command';
import { Element } from 'slate';
import { KeyboardReturnOutlined } from '@mui/icons-material';
import { useSlateStatic } from 'slate-react';
import { ReactEditor, useSlateStatic } from 'slate-react';
function EditPopover({
open,
@ -28,10 +28,19 @@ function EditPopover({
setValue(event.currentTarget.value);
};
const handleClose = useCallback(() => {
onClose();
if (!node) return;
ReactEditor.focus(editor);
const path = ReactEditor.findPath(editor, node);
editor.select(path);
}, [onClose, editor, node]);
const handleDone = () => {
if (!node) return;
CustomEditor.setMathEquationBlockFormula(editor, node, value);
onClose();
handleClose();
};
const handleEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@ -58,7 +67,7 @@ function EditPopover({
vertical: 'bottom',
horizontal: 'center',
}}
onClose={onClose}
onClose={handleClose}
>
<div className={'flex flex-col gap-3 p-4'}>
<TextareaAutosize

View File

@ -16,25 +16,28 @@ export const MathEquation = memo(
return (
<>
<div
contentEditable={false}
ref={ref}
{...attributes}
onClick={(e) => {
setAnchorEl(e.currentTarget);
}}
className={`${
className ?? ''
} relative cursor-pointer rounded border border-line-divider bg-content-blue-50 px-3 `}
className={`${className} relative my-2 cursor-pointer`}
>
{formula ? (
<KatexMath latex={formula} />
) : (
<div className={'relative flex h-[48px] w-full items-center gap-[10px] text-text-caption'}>
<FunctionsOutlined />
{t('document.plugins.mathEquation.addMathEquation')}
</div>
)}
<div className={'invisible absolute'}>{children}</div>
<div
contentEditable={false}
className={`w-full select-none rounded border border-line-divider bg-content-blue-50 px-3`}
>
{formula ? (
<KatexMath latex={formula} />
) : (
<div className={'flex h-[48px] w-full items-center gap-[10px] text-text-caption'}>
<FunctionsOutlined />
{t('document.plugins.mathEquation.addMathEquation')}
</div>
)}
</div>
<div ref={ref} className={'absolute left-0 top-0 h-full w-full caret-transparent'}>
{children}
</div>
</div>
{open && (
<EditPopover

View File

@ -1,2 +1 @@
export * from './withMathEquationPlugin';
export * from './MathEquation';

View File

@ -1,20 +0,0 @@
import { ReactEditor } from 'slate-react';
import { EditorNodeType } from '$app/application/document/document.types';
export function withMathEquationPlugin(editor: ReactEditor) {
const { isElementReadOnly, isSelectable, isEmpty } = editor;
editor.isElementReadOnly = (element) => {
return element.type === EditorNodeType.EquationBlock || isElementReadOnly(element);
};
editor.isSelectable = (element) => {
return element.type !== EditorNodeType.EquationBlock || isSelectable(element);
};
editor.isEmpty = (element) => {
return element.type !== EditorNodeType.EquationBlock && isEmpty(element);
};
return editor;
}

View File

@ -1,51 +1,47 @@
import React, { forwardRef, memo, useMemo } from 'react';
import { EditorElementProps, NumberedListNode } from '$app/application/document/document.types';
import { ReactEditor, useSlateStatic } from 'slate-react';
import { Editor, Element } from 'slate';
import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder';
import { ReactEditor, useSlate } from 'slate-react';
import { Element, Path } from 'slate';
export const NumberedList = memo(
forwardRef<HTMLDivElement, EditorElementProps<NumberedListNode>>(({ node, children, ...attributes }, ref) => {
const editor = useSlateStatic();
forwardRef<HTMLDivElement, EditorElementProps<NumberedListNode>>(
({ node, children, className, ...attributes }, ref) => {
const editor = useSlate();
const index = useMemo(() => {
let index = 1;
const path = ReactEditor.findPath(editor, node);
const index = useMemo(() => {
let index = 1;
let prevEntry = Editor.previous(editor, {
at: path,
});
let prevPath = Path.previous(path);
while (prevEntry) {
const prevNode = prevEntry[0];
while (prevPath) {
const prev = editor.node(prevPath);
if (Element.isElement(prevNode) && !Editor.isEditor(prevNode)) {
if (prevNode.type === node.type && prevNode.level === node.level) {
const prevNode = prev[0] as Element;
if (prevNode.type === node.type) {
index += 1;
} else {
break;
}
prevPath = Path.previous(prevPath);
}
prevEntry = Editor.previous(editor, {
at: prevEntry[1],
});
}
return index;
}, [editor, node, path]);
return index;
}, [editor, node]);
return (
<div {...attributes} className={`${attributes.className ?? ''} relative`} ref={ref}>
<span contentEditable={false} className={'pr-2 font-medium'}>
{index}.
</span>
<span className={'relative'}>
<Placeholder node={node} />
{children}
</span>
</div>
);
})
return (
<>
<span contentEditable={false} className={'absolute flex w-6 select-none justify-center font-medium'}>
{index}.
</span>
<div ref={ref} {...attributes} className={`${className} ml-6`}>
{children}
</div>
</>
);
}
)
);

View File

@ -1,19 +1,15 @@
import React, { forwardRef, memo, useMemo } from 'react';
import { EditorElementProps, PageNode } from '$app/application/document/document.types';
import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder';
export const Page = memo(
forwardRef<HTMLDivElement, EditorElementProps<PageNode>>(({ node, children, ...attributes }, ref) => {
forwardRef<HTMLDivElement, EditorElementProps<PageNode>>(({ node: _, children, ...attributes }, ref) => {
const className = useMemo(() => {
return `${attributes.className ?? ''} mb-2 text-4xl font-bold`;
return `${attributes.className ?? ''} text-4xl font-bold`;
}, [attributes.className]);
return (
<div ref={ref} {...attributes} className={className}>
<span className={'relative'}>
<Placeholder className={'top-1.5'} node={node} />
{children}
</span>
{children}
</div>
);
})

View File

@ -1,16 +1,12 @@
import React, { forwardRef, memo } from 'react';
import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder';
import { EditorElementProps, ParagraphNode } from '$app/application/document/document.types';
export const Paragraph = memo(
forwardRef<HTMLDivElement, EditorElementProps<ParagraphNode>>(({ node, children, ...attributes }, ref) => {
forwardRef<HTMLDivElement, EditorElementProps<ParagraphNode>>(({ node: _, children, ...attributes }, ref) => {
{
return (
<div ref={ref} {...attributes} className={`${attributes.className ?? ''}`}>
<span className={'relative'}>
<Placeholder node={node} />
{children}
</span>
{children}
</div>
);
}

View File

@ -1,19 +1,15 @@
import React, { forwardRef, memo, useMemo } from 'react';
import { EditorElementProps, QuoteNode } from '$app/application/document/document.types';
import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder';
export const QuoteList = memo(
forwardRef<HTMLDivElement, EditorElementProps<QuoteNode>>(({ node, children, ...attributes }, ref) => {
forwardRef<HTMLDivElement, EditorElementProps<QuoteNode>>(({ node: _, children, ...attributes }, ref) => {
const className = useMemo(() => {
return `${attributes.className ?? ''} relative border-l-4 border-fill-default`;
return `flex w-full flex-col ml-2.5 border-l-[4px] border-fill-default pl-2.5 ${attributes.className ?? ''}`;
}, [attributes.className]);
return (
<div {...attributes} ref={ref} className={className}>
<span className={'relative left-2'}>
<Placeholder node={node} />
{children}
</span>
{children}
</div>
);
})

View File

@ -0,0 +1,20 @@
import React, { forwardRef, memo } from 'react';
import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder';
import { EditorElementProps, TextNode } from '$app/application/document/document.types';
import { useSlateStatic } from 'slate-react';
export const Text = memo(
forwardRef<HTMLDivElement, EditorElementProps<TextNode>>(({ node, children, ...attributes }, ref) => {
const editor = useSlateStatic();
const isEmpty = editor.isEmpty(node);
return (
<div ref={ref} {...attributes} className={`text-element mx-1 ${!isEmpty ? 'flex' : ''} relative h-full`}>
<Placeholder isEmpty={isEmpty} node={node} />
<span>{children}</span>
</div>
);
})
);
export default Text;

View File

@ -0,0 +1 @@
export * from './Text';

View File

@ -3,7 +3,6 @@ import { EditorElementProps, TodoListNode } from '$app/application/document/docu
import { ReactComponent as CheckboxCheckSvg } from '$app/assets/database/checkbox-check.svg';
import { ReactComponent as CheckboxUncheckSvg } from '$app/assets/database/checkbox-uncheck.svg';
import { useSlateStatic } from 'slate-react';
import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder';
import { CustomEditor } from '$app/components/editor/command';
export const TodoList = memo(
@ -11,28 +10,26 @@ export const TodoList = memo(
const { checked } = node.data;
const editor = useSlateStatic();
const className = useMemo(() => {
return `relative ${attributes.className ?? ''}`;
}, [attributes.className]);
return `flex w-full flex-col pl-6 ${checked ? 'checked' : ''} ${attributes.className ?? ''}`;
}, [attributes.className, checked]);
const toggleTodo = useCallback(() => {
CustomEditor.toggleTodo(editor, node);
}, [editor, node]);
return (
<div {...attributes} ref={ref} className={className}>
<>
<span
data-playwright-selected={false}
contentEditable={false}
onClick={toggleTodo}
className='absolute left-0 top-0 inline-flex cursor-pointer text-xl text-fill-default'
className='absolute cursor-pointer select-none text-xl text-fill-default'
>
{checked ? <CheckboxCheckSvg /> : <CheckboxUncheckSvg />}
</span>
<span className={`relative ml-6 ${checked ? 'text-text-caption line-through' : ''}`}>
<Placeholder node={node} />
<div {...attributes} ref={ref} className={className}>
{children}
</span>
</div>
</div>
</>
);
})
);

View File

@ -2,7 +2,6 @@ import React, { forwardRef, memo, useCallback, useMemo } from 'react';
import { EditorElementProps, ToggleListNode } from '$app/application/document/document.types';
import { ReactEditor, useSlateStatic } from 'slate-react';
import { ReactComponent as RightSvg } from '$app/assets/more.svg';
import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder';
import { CustomEditor } from '$app/components/editor/command';
export const ToggleList = memo(
@ -10,27 +9,26 @@ export const ToggleList = memo(
const { collapsed } = node.data;
const editor = useSlateStatic() as ReactEditor;
const className = useMemo(() => {
return `relative ${attributes.className ?? ''}`;
}, [attributes.className]);
return `pl-6 ${attributes.className ?? ''} ${collapsed ? 'collapsed' : ''}`;
}, [attributes.className, collapsed]);
const toggleToggleList = useCallback(() => {
CustomEditor.toggleToggleList(editor, node);
}, [editor, node]);
return (
<div {...attributes} ref={ref} className={className}>
<>
<span
data-playwright-selected={false}
contentEditable={false}
onClick={toggleToggleList}
className='absolute left-0 top-0 inline-block cursor-pointer rounded text-xl text-text-title hover:bg-fill-list-hover'
className='absolute cursor-pointer select-none text-xl hover:text-fill-default'
>
{collapsed ? <RightSvg /> : <RightSvg className={'rotate-90 transform'} />}
</span>
<span className={'z-1 relative ml-6'}>
<Placeholder node={node} />
<div {...attributes} ref={ref} className={className}>
{children}
</span>
</div>
</div>
</>
);
})
);

View File

@ -1,11 +1,12 @@
import { useEffect, useMemo, useState } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
import Editor from '$app/components/editor/components/editor/Editor';
import { EditorProps } from '$app/application/document/document.types';
import { Provider } from '$app/components/editor/provider';
import { YXmlText } from 'yjs/dist/src/types/YXmlText';
import { getInsertTarget, getYTarget } from '$app/components/editor/provider/utils/relation';
export const CollaborativeEditor = ({ id, title, showTitle = true, onTitleChange }: EditorProps) => {
export const CollaborativeEditor = memo(({ id, title, showTitle = true, onTitleChange }: EditorProps) => {
const [sharedType, setSharedType] = useState<YXmlText | null>(null);
const provider = useMemo(() => {
setSharedType(null);
@ -13,18 +14,25 @@ export const CollaborativeEditor = ({ id, title, showTitle = true, onTitleChange
}, [id, showTitle]);
const root = useMemo(() => {
return showTitle ? (sharedType?.toDelta()[0].insert as YXmlText | null) : null;
if (!showTitle || !sharedType || !sharedType.doc) return null;
return getYTarget(sharedType?.doc, [0]);
}, [sharedType, showTitle]);
useEffect(() => {
if (!root || root.toString() === title) return;
const rootText = useMemo(() => {
if (!root) return null;
return getInsertTarget(root, [0]);
}, [root]);
if (root.length > 0) {
root.delete(0, root.length);
useEffect(() => {
if (!rootText || rootText.toString() === title) return;
if (rootText.length > 0) {
rootText.delete(0, rootText.length);
}
root.insert(0, title || '');
}, [title, root]);
rootText.insert(0, title || '');
}, [title, rootText]);
useEffect(() => {
if (!root) return;
@ -32,8 +40,8 @@ export const CollaborativeEditor = ({ id, title, showTitle = true, onTitleChange
onTitleChange?.(root.toString());
};
root.observe(onChange);
return () => root.unobserve(onChange);
root.observeDeep(onChange);
return () => root.unobserveDeep(onChange);
}, [onTitleChange, root]);
useEffect(() => {
@ -55,4 +63,4 @@ export const CollaborativeEditor = ({ id, title, showTitle = true, onTitleChange
}
return <Editor sharedType={sharedType} id={id} />;
};
});

View File

@ -1,4 +1,4 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { createContext, useCallback, useEffect, useMemo, useState } from 'react';
import { EditorNodeType, CodeNode } from '$app/application/document/document.types';
import { createEditor, NodeEntry, BaseRange, Editor, Element } from 'slate';
@ -10,6 +10,7 @@ import { withInlines } from '$app/components/editor/components/inline_nodes';
import { withYjs, YjsEditor, withYHistory } from '@slate-yjs/core';
import * as Y from 'yjs';
import { CustomEditor } from '$app/components/editor/command';
import { proxySet, subscribeKey } from 'valtio/utils';
export function useEditor(sharedType: Y.XmlText) {
const editor = useMemo(() => {
@ -77,56 +78,60 @@ export function useDecorate(editor: ReactEditor) {
);
}
export const EditorSelectedBlockContext = createContext<string[]>([]);
export function useEditorState(editor: ReactEditor) {
const selectedBlocks = useMemo(() => proxySet([]), []);
export function useSelectedBlock(blockId?: string) {
const blockIds = useContext(EditorSelectedBlockContext);
const [selectedLength, setSelectedLength] = useState(0);
if (blockId === undefined) {
return false;
}
return blockIds.includes(blockId);
}
export const EditorSelectedBlockProvider = EditorSelectedBlockContext.Provider;
export function useEditorSelectedBlock(editor: ReactEditor) {
const [selectedBlockId, setSelectedBlockId] = useState<string[]>([]);
const onSelectedBlock = useCallback(
(blockId: string) => {
const children = editor.children.filter((node) => (node as Element).parentId === blockId);
const blockIds = [blockId, ...children.map((node) => (node as Element).blockId as string)];
const node = editor.children.find((node) => (node as Element).blockId === blockId);
if (node) {
const path = ReactEditor.findPath(editor, node);
ReactEditor.focus(editor);
editor.select(path);
editor.collapse({
edge: 'start',
});
}
setSelectedBlockId(blockIds);
},
[editor]
);
subscribeKey(selectedBlocks, 'size', (v) => setSelectedLength(v));
useEffect(() => {
const handleClick = () => {
if (selectedBlockId.length === 0) return;
setSelectedBlockId([]);
const { onChange } = editor;
const onKeydown = (e: KeyboardEvent) => {
if (!ReactEditor.isFocused(editor) && selectedLength > 0) {
e.preventDefault();
e.stopPropagation();
const selectedBlockId = selectedBlocks.values().next().value;
const [selectedBlock] = editor.nodes({
at: [],
match: (n) => Element.isElement(n) && n.blockId === selectedBlockId,
});
const [, path] = selectedBlock;
editor.select(path);
ReactEditor.focus(editor);
}
};
document.addEventListener('click', handleClick);
if (selectedLength > 0) {
editor.onChange = (...args) => {
const isSelectionChange = editor.operations.every((arg) => arg.type === 'set_selection');
if (isSelectionChange) {
selectedBlocks.clear();
}
onChange(...args);
};
document.addEventListener('keydown', onKeydown);
} else {
editor.onChange = onChange;
document.removeEventListener('keydown', onKeydown);
}
return () => {
document.removeEventListener('click', handleClick);
editor.onChange = onChange;
document.removeEventListener('keydown', onKeydown);
};
}, [selectedBlockId]);
}, [editor, selectedBlocks, selectedLength]);
return {
selectedBlockId,
onSelectedBlock,
selectedBlocks,
};
}
export const EditorSelectedBlockContext = createContext<Set<string>>(new Set());
export const EditorSelectedBlockProvider = EditorSelectedBlockContext.Provider;

View File

@ -1,9 +1,9 @@
import React from 'react';
import React, { memo } from 'react';
import {
EditorSelectedBlockProvider,
useDecorate,
useEditor,
useEditorSelectedBlock,
useEditorState,
} from '$app/components/editor/components/editor/Editor.hooks';
import { Slate } from 'slate-react';
import { CustomEditable } from '$app/components/editor/components/editor/CustomEditable';
@ -19,24 +19,23 @@ function Editor({ sharedType }: { sharedType: Y.XmlText; id: string }) {
const { editor, initialValue, handleOnClickEnd, ...props } = useEditor(sharedType);
const decorate = useDecorate(editor);
const { onDOMBeforeInput, onKeyDown: onShortcutsKeyDown } = useShortcuts(editor);
const { onSelectedBlock, selectedBlockId } = useEditorSelectedBlock(editor);
const { selectedBlocks } = useEditorState(editor);
if (editor.sharedRoot.length === 0) {
return <CircularProgress className='m-auto' />;
}
return (
<EditorSelectedBlockProvider value={selectedBlockId}>
<EditorSelectedBlockProvider value={selectedBlocks}>
<Slate editor={editor} initialValue={initialValue}>
<SelectionToolbar />
<BlockActionsToolbar onSelectedBlock={onSelectedBlock} />
<BlockActionsToolbar />
<CustomEditable
{...props}
onDOMBeforeInput={onDOMBeforeInput}
onKeyDown={onShortcutsKeyDown}
decorate={decorate}
className={'caret-text-title outline-none focus:outline-none'}
className={'px-16 caret-text-title outline-none focus:outline-none'}
/>
<SlashCommandPanel />
<MentionPanel />
@ -46,4 +45,4 @@ function Editor({ sharedType }: { sharedType: Y.XmlText; id: string }) {
);
}
export default Editor;
export default memo(Editor);

View File

@ -0,0 +1,31 @@
import { Element } from 'slate';
import { useContext, useEffect, useMemo } from 'react';
import { EditorSelectedBlockContext } from '$app/components/editor/components/editor/Editor.hooks';
import { useSnapshot } from 'valtio';
import { useSelected, useSlateStatic } from 'slate-react';
export function useElementState(element: Element) {
const blockId = element.blockId;
const editor = useSlateStatic();
const selectedBlockContext = useContext(EditorSelectedBlockContext);
const selected = useSelected();
useEffect(() => {
if (!blockId) return;
if (selected && !editor.isSelectable(element)) {
selectedBlockContext.add(blockId);
} else {
selectedBlockContext.delete(blockId);
}
}, [blockId, editor, element, selected, selectedBlockContext]);
const selectedBlockIds = useSnapshot(selectedBlockContext);
const isSelected = useMemo(() => {
if (!blockId) return false;
return selectedBlockIds.has(blockId);
}, [blockId, selectedBlockIds]);
return {
isSelected,
};
}

View File

@ -1,6 +1,12 @@
import React, { FC, HTMLAttributes, useMemo } from 'react';
import { RenderElementProps } from 'slate-react';
import { EditorElementProps, EditorInlineNodeType, EditorNodeType } from '$app/application/document/document.types';
import {
BlockData,
EditorElementProps,
EditorInlineNodeType,
EditorNodeType,
TextNode,
} from '$app/application/document/document.types';
import { Paragraph } from '$app/components/editor/components/blocks/paragraph';
import { Heading } from '$app/components/editor/components/blocks/heading';
import { TodoList } from '$app/components/editor/components/blocks/todo_list';
@ -15,8 +21,9 @@ import { Callout } from '$app/components/editor/components/blocks/callout';
import { Mention } from '$app/components/editor/components/inline_nodes/mention';
import { GridBlock } from '$app/components/editor/components/blocks/database';
import { MathEquation } from '$app/components/editor/components/blocks/math_equation';
import { useSelectedBlock } from '$app/components/editor/components/editor/Editor.hooks';
import Page from '../blocks/page/Page';
import { Text as TextComponent } from '../blocks/text';
import { Page } from '../blocks/page';
import { useElementState } from '$app/components/editor/components/editor/Element.hooks';
function Element({ element, attributes, children }: RenderElementProps) {
const node = element;
@ -65,13 +72,20 @@ function Element({ element, attributes, children }: RenderElementProps) {
}
}, [node.type]) as FC<EditorElementProps & HTMLAttributes<HTMLElement>>;
const marginLeft = useMemo(() => {
if (!node.level) return;
const { isSelected } = useElementState(node);
return (node.level - 1) * 24;
}, [node.level]);
const className = useMemo(() => {
return `block-element my-1 flex rounded ${isSelected ? 'bg-content-blue-100' : ''}`;
}, [isSelected]);
const isSelected = useSelectedBlock(node.blockId);
const style = useMemo(() => {
const data = (node.data as BlockData) || {};
return {
backgroundColor: data.bg_color,
color: data.font_color,
};
}, [node.data]);
if (InlineComponent) {
return (
@ -81,15 +95,17 @@ function Element({ element, attributes, children }: RenderElementProps) {
);
}
if (node.type === EditorNodeType.Text) {
return (
<TextComponent {...attributes} node={node as TextNode}>
{children}
</TextComponent>
);
}
return (
<div
{...attributes}
style={{
marginLeft,
}}
className={`${node.isHidden ? 'hidden' : 'inline-block'} block-element leading-1 my-0.5 w-full px-16`}
>
<Component className={`${isSelected ? 'bg-content-blue-100' : ''}`} node={node}>
<div {...attributes} data-block-type={node.type} className={className}>
<Component style={style} className={`flex w-full flex-col`} node={node}>
{children}
</Component>
</div>

View File

@ -86,7 +86,9 @@ export function useShortcuts(editor: ReactEditor) {
if (isHotkey('shift+Enter', e) && node) {
e.preventDefault();
if (SOFT_BREAK_TYPES.includes(node.type as EditorNodeType)) {
CustomEditor.splitToParagraph(editor);
editor.splitNodes({
always: true,
});
} else {
editor.insertText('\n');
}

View File

@ -1,7 +1,5 @@
import { ReactEditor } from 'slate-react';
import { Editor, Range } from 'slate';
import { getBlockEntry, isDeleteBackwardAtStartOfBlock } from '$app/components/editor/plugins/utils';
import { EditorNodeType } from '$app/application/document/document.types';
import { CustomEditor } from '$app/components/editor/command';
export enum EditorCommand {
@ -36,7 +34,7 @@ export function withCommandShortcuts(editor: ReactEditor) {
});
if (endOfPanelChar !== undefined && selection && Range.isCollapsed(selection)) {
const block = getBlockEntry(editor);
const block = CustomEditor.getBlock(editor);
const path = block ? block[1] : [];
const { anchor } = selection;
const beforeText = Editor.string(editor, { anchor, focus: Editor.start(editor, path) }) + text.slice(0, -1);
@ -67,7 +65,7 @@ export function withCommandShortcuts(editor: ReactEditor) {
if (selection && Range.isCollapsed(selection)) {
const { anchor } = selection;
const block = getBlockEntry(editor);
const block = CustomEditor.getBlock(editor);
const path = block ? block[1] : [];
const beforeText = Editor.string(editor, { anchor, focus: Editor.start(editor, path) });
@ -81,7 +79,7 @@ export function withCommandShortcuts(editor: ReactEditor) {
}
// if delete backward at start of paragraph, and then it will be deleted, so we should close the panel if it is open
if (isDeleteBackwardAtStartOfBlock(editor, EditorNodeType.Paragraph)) {
if (CustomEditor.focusAtStartOfBlock(editor)) {
const slateDom = ReactEditor.toDOMNode(editor, editor);
commands.forEach((char) => {

View File

@ -25,7 +25,13 @@ const regexMap: Record<
],
[EditorNodeType.QuoteBlock]: [
{
pattern: /^("|“|”)$/,
pattern: /^”$/,
},
{
pattern: /^“$/,
},
{
pattern: /^"$/,
},
],
[EditorNodeType.TodoListBlock]: [
@ -218,7 +224,7 @@ export const withMarkdownShortcuts = (editor: ReactEditor) => {
if (text.endsWith(' ') || text.endsWith('-')) {
const endChar = text.slice(-1);
const [match] = Editor.nodes(editor, {
match: (n) => !Editor.isEditor(n) && SlateElement.isElement(n) && n.type !== undefined,
match: (n) => !Editor.isEditor(n) && SlateElement.isElement(n) && n.type === EditorNodeType.Text,
});
if (!match) {

View File

@ -1,9 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
ColorPicker,
ColorPickerProps,
} from '$app/components/editor/components/tools/selection_toolbar/sub_menu/ColorPicker';
import { ColorPicker, ColorPickerProps } from '$app/components/editor/components/tools/_shared/ColorPicker';
export function BgColorPicker(props: ColorPickerProps) {
const { t } = useTranslation();

View File

@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CustomColorPicker } from '$app/components/editor/components/tools/selection_toolbar/sub_menu/CustomColorPicker';
import { CustomColorPicker } from '$app/components/editor/components/tools/_shared/CustomColorPicker';
import Typography from '@mui/material/Typography';
import { MenuItem, MenuList } from '@mui/material';
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';

View File

@ -1,9 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
ColorPicker,
ColorPickerProps,
} from '$app/components/editor/components/tools/selection_toolbar/sub_menu/ColorPicker';
import { ColorPicker, ColorPickerProps } from '$app/components/editor/components/tools/_shared/ColorPicker';
export function FontColorPicker(props: ColorPickerProps) {
const { t } = useTranslation();

View File

@ -6,11 +6,11 @@ import { PopoverPreventBlurProps } from '$app/components/editor/components/tools
import SlashCommandPanelContent from '$app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanelContent';
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
import { useTranslation } from 'react-i18next';
import { Editor, Element, Transforms } from 'slate';
import { EditorNodeType } from '$app/application/document/document.types';
import { Element } from 'slate';
import { CustomEditor } from '$app/components/editor/command';
import { EditorNodeType } from '$app/application/document/document.types';
function AddBlockBelow({ node }: { node: Element }) {
function AddBlockBelow({ node }: { node?: Element }) {
const { t } = useTranslation();
const [nodeEl, setNodeEl] = useState<HTMLElement | null>(null);
const editor = useSlate();
@ -19,23 +19,12 @@ function AddBlockBelow({ node }: { node: Element }) {
const handleSlashCommandPanelClose = useCallback(
(deleteText?: boolean) => {
if (!nodeEl) return;
const node = ReactEditor.toSlateNode(editor, nodeEl);
const node = ReactEditor.toSlateNode(editor, nodeEl) as Element;
if (!node) return;
if (deleteText) {
const path = ReactEditor.findPath(editor, node);
Transforms.select(editor, path);
Transforms.insertNodes(
editor,
[
{
text: '',
},
],
{
select: true,
}
);
CustomEditor.deleteAllText(editor, node);
}
setNodeEl(null);
@ -47,44 +36,53 @@ function AddBlockBelow({ node }: { node: Element }) {
if (!node) return;
ReactEditor.focus(editor);
const path = ReactEditor.findPath(editor, node);
const [textNode] = node.children as Element[];
const hasTextNode = textNode && textNode.type === EditorNodeType.Text;
editor.select(path);
const nodePath = ReactEditor.findPath(editor, node);
const textPath = ReactEditor.findPath(editor, textNode);
const focusPath = hasTextNode ? textPath : nodePath;
editor.select(focusPath);
editor.collapse({
edge: 'end',
});
const isEmptyNode = editor.isEmpty(node);
const isEmptyNode = CustomEditor.isEmptyText(editor, node);
if (isEmptyNode) {
const nodeDom = ReactEditor.toDOMNode(editor, node);
setNodeEl(nodeDom);
} else {
CustomEditor.splitToParagraph(editor);
return;
}
requestAnimationFrame(() => {
const nextNodeEntry = Editor.next(editor, {
at: path,
match: (n) => Element.isElement(n) && Editor.isBlock(editor, n) && n.type === EditorNodeType.Paragraph,
});
editor.insertBreak();
CustomEditor.turnToBlock(editor, {
type: EditorNodeType.Paragraph,
});
if (!nextNodeEntry) return;
const nextNode = nextNodeEntry[0] as Element;
requestAnimationFrame(() => {
const block = CustomEditor.getBlock(editor);
const nodeDom = ReactEditor.toDOMNode(editor, nextNode);
if (block) {
const [node] = block;
const nodeDom = ReactEditor.toDOMNode(editor, node);
setNodeEl(nodeDom);
});
}
}
});
};
const searchText = useMemo(() => {
if (!nodeEl) return '';
const node = ReactEditor.toSlateNode(editor, nodeEl);
const path = ReactEditor.findPath(editor, node);
const node = ReactEditor.toSlateNode(editor, nodeEl) as Element;
return Editor.string(editor, path);
if (!node) return '';
return CustomEditor.getNodeText(editor, node);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor, nodeEl, editor.selection]);
@ -100,13 +98,12 @@ function AddBlockBelow({ node }: { node: Element }) {
{...PopoverPreventBlurProps}
anchorOrigin={{
vertical: 30,
horizontal: 64,
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
onMouseMove={(e) => e.stopPropagation()}
open={openSlashCommandPanel}
anchorEl={nodeEl}
onClose={() => handleSlashCommandPanelClose(false)}

View File

@ -2,13 +2,13 @@ import React from 'react';
import { Element } from 'slate';
import AddBlockBelow from '$app/components/editor/components/tools/block_actions/AddBlockBelow';
import DragBlock from '$app/components/editor/components/tools/block_actions/DragBlock';
import BlockMenu from '$app/components/editor/components/tools/block_actions/BlockMenu';
export function BlockActions({ node, onSelectedBlock }: { node: Element; onSelectedBlock: (blockId: string) => void }) {
export function BlockActions({ node }: { node?: Element }) {
return (
<>
<AddBlockBelow node={node} />
<DragBlock node={node} onSelectedBlock={onSelectedBlock} />
<BlockMenu node={node} />
</>
);
}

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { ReactEditor, useSlate } from 'slate-react';
import { getBlockActionsPosition } from '$app/components/editor/components/tools/block_actions/utils';
import { Element } from 'slate';
import { Element, Editor } from 'slate';
import { EditorNodeType } from '$app/application/document/document.types';
export function useBlockActionsToolbar(ref: React.RefObject<HTMLDivElement>) {
@ -16,16 +16,34 @@ export function useBlockActionsToolbar(ref: React.RefObject<HTMLDivElement>) {
const target = e.target as HTMLElement;
if (target.closest('.block-actions')) return;
const blockElement = target ? (target.closest('.block-element') as HTMLElement) : null;
if (target.closest(`[contenteditable="false"]`)) {
return;
}
if (!blockElement) {
const range = ReactEditor.findEventRange(editor, e);
if (!range) return;
const match = editor.above({
match: (n) => {
return !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined;
},
at: range,
});
if (!match) {
el.style.opacity = '0';
el.style.pointerEvents = 'none';
setNode(null);
return;
}
const node = match[0] as Element;
if (node.type === EditorNodeType.Page) return;
const blockElement = ReactEditor.toDOMNode(editor, node);
if (!blockElement) return;
const { top, left } = getBlockActionsPosition(editor, blockElement);
const slateEditorDom = ReactEditor.toDOMNode(editor, editor);
@ -33,7 +51,7 @@ export function useBlockActionsToolbar(ref: React.RefObject<HTMLDivElement>) {
el.style.opacity = '1';
el.style.pointerEvents = 'auto';
el.style.top = `${top + slateEditorDom.offsetTop}px`;
el.style.left = `${left + slateEditorDom.offsetLeft}px`;
el.style.left = `${left + slateEditorDom.offsetLeft - 64}px`;
const slateNode = ReactEditor.toSlateNode(editor, blockElement) as Element;
setNode(slateNode);
@ -49,11 +67,13 @@ export function useBlockActionsToolbar(ref: React.RefObject<HTMLDivElement>) {
setNode(null);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseleave', handleMouseLeave);
const dom = ReactEditor.toDOMNode(editor, editor);
dom.addEventListener('mousemove', handleMouseMove);
dom.parentElement?.addEventListener('mouseleave', handleMouseLeave);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseleave', handleMouseLeave);
dom.removeEventListener('mousemove', handleMouseMove);
dom.parentElement?.removeEventListener('mouseleave', handleMouseLeave);
};
}, [editor, ref]);

View File

@ -4,7 +4,7 @@ import BlockActions from '$app/components/editor/components/tools/block_actions/
import { getBlockCssProperty } from '$app/components/editor/components/tools/block_actions/utils';
export function BlockActionsToolbar({ onSelectedBlock }: { onSelectedBlock: (blockId: string) => void }) {
export function BlockActionsToolbar() {
const ref = useRef<HTMLDivElement | null>(null);
const { node } = useBlockActionsToolbar(ref);
@ -19,6 +19,7 @@ export function BlockActionsToolbar({ onSelectedBlock }: { onSelectedBlock: (blo
onMouseDown={(e) => {
// prevent toolbar from taking focus away from editor
e.preventDefault();
e.stopPropagation();
}}
onMouseUp={(e) => {
e.stopPropagation();
@ -26,7 +27,7 @@ export function BlockActionsToolbar({ onSelectedBlock }: { onSelectedBlock: (blo
>
{/* Ensure the toolbar in middle */}
<div className={'invisible'}>0</div>
{node && <BlockActions node={node} onSelectedBlock={onSelectedBlock} />}
{<BlockActions node={node || undefined} />}
</div>
);
}

View File

@ -0,0 +1,27 @@
import { useCallback, KeyboardEvent } from 'react';
export function useBlockMenuKeyDown({ onClose }: { onClose: () => void }) {
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
switch (e.key) {
case 'Escape':
onClose();
break;
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowLeft':
case 'ArrowRight':
break;
default:
return;
}
},
[onClose]
);
return {
onKeyDown,
};
}

View File

@ -1,24 +1,28 @@
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useContext, useRef, useState } from 'react';
import { IconButton, Tooltip } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { ReactComponent as DragSvg } from '$app/assets/drag.svg';
import BlockOperationMenu from '$app/components/editor/components/tools/block_actions/BlockOperationMenu';
import { Element } from 'slate';
import { EditorSelectedBlockContext } from '$app/components/editor/components/editor/Editor.hooks';
function DragBlock({ node, onSelectedBlock }: { node: Element; onSelectedBlock: (blockId: string) => void }) {
function BlockMenu({ node }: { node?: Element }) {
const dragBtnRef = useRef<HTMLButtonElement>(null);
const [openMenu, setOpenMenu] = useState(false);
const { t } = useTranslation();
const [selectedNode, setSelectedNode] = useState<Element>();
const selectedBlockContext = useContext(EditorSelectedBlockContext);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
setOpenMenu(true);
if (!node || !node.blockId) return;
onSelectedBlock(node.blockId);
setSelectedNode(node);
selectedBlockContext.clear();
selectedBlockContext.add(node.blockId);
},
[node, onSelectedBlock]
[node, selectedBlockContext]
);
return (
@ -28,27 +32,34 @@ function DragBlock({ node, onSelectedBlock }: { node: Element; onSelectedBlock:
<DragSvg />
</IconButton>
</Tooltip>
{openMenu && node && (
{openMenu && selectedNode && (
<BlockOperationMenu
onMouseMove={(e) => {
e.stopPropagation();
}}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'center',
horizontal: 'left',
}}
node={node}
transformOrigin={{
vertical: 'center',
horizontal: 'right',
}}
PaperProps={{
onClick: (e) => {
e.stopPropagation();
},
}}
node={selectedNode}
open={openMenu}
anchorEl={dragBtnRef.current}
onClose={() => setOpenMenu(false)}
onClose={() => {
setOpenMenu(false);
}}
/>
)}
</>
);
}
export default DragBlock;
export default BlockMenu;

View File

@ -1,30 +1,79 @@
import React, { useMemo } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import Popover, { PopoverProps } from '@mui/material/Popover';
import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg';
import { ReactComponent as CopySvg } from '$app/assets/copy.svg';
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
import { useTranslation } from 'react-i18next';
import { Button } from '@mui/material';
import { Button, Divider, MenuProps, Menu } from '@mui/material';
import { PopoverCommonProps } from '$app/components/editor/components/tools/popover';
import { Element } from 'slate';
import { useSlateStatic } from 'slate-react';
import { ReactEditor, useSlateStatic } from 'slate-react';
import { CustomEditor } from '$app/components/editor/command';
import { FontColorPicker, BgColorPicker } from '$app/components/editor/components/tools/_shared';
import Typography from '@mui/material/Typography';
import { useBlockMenuKeyDown } from '$app/components/editor/components/tools/block_actions/BlockMenu.hooks';
enum SubMenuType {
TextColor = 'textColor',
BackgroundColor = 'backgroundColor',
}
const subMenuProps: Partial<MenuProps> = {
anchorOrigin: {
vertical: 'top',
horizontal: 'right',
},
transformOrigin: {
vertical: 'top',
horizontal: 'left',
},
};
export function BlockOperationMenu({
node,
...props
}: {
node: Element;
} & PopoverProps) {
const optionsRef = React.useRef<HTMLDivElement>(null);
const editor = useSlateStatic();
const { t } = useTranslation();
const options = useMemo(
const handleClose = useCallback(() => {
props.onClose?.({}, 'backdropClick');
ReactEditor.focus(editor);
const path = ReactEditor.findPath(editor, node);
editor.select(path);
if (editor.isSelectable(node)) {
editor.collapse({
edge: 'start',
});
}
}, [editor, node, props]);
const { onKeyDown } = useBlockMenuKeyDown({
onClose: handleClose,
});
const [subMenuType, setSubMenuType] = useState<null | SubMenuType>(null);
const subMenuAnchorEl = useMemo(() => {
if (!subMenuType) return null;
return optionsRef.current?.querySelector(`[data-submenu-type="${subMenuType}"]`);
}, [subMenuType]);
const subMenuOpen = Boolean(subMenuAnchorEl);
const operationOptions = useMemo(
() => [
{
icon: <DeleteSvg />,
text: t('button.delete'),
onClick: () => {
CustomEditor.deleteNode(editor, node);
props.onClose?.({}, 'backdropClick');
handleClose();
},
},
{
@ -32,17 +81,69 @@ export function BlockOperationMenu({
text: t('button.duplicate'),
onClick: () => {
CustomEditor.duplicateNode(editor, node);
props.onClose?.({}, 'backdropClick');
handleClose();
},
},
],
[editor, node, props, t]
[editor, node, handleClose, t]
);
const colorOptions = useMemo(
() => [
{
type: SubMenuType.TextColor,
text: t('editor.textColor'),
onClick: () => {
setSubMenuType(SubMenuType.TextColor);
},
},
{
type: SubMenuType.BackgroundColor,
text: t('editor.backgroundColor'),
onClick: () => {
setSubMenuType(SubMenuType.BackgroundColor);
},
},
],
[t]
);
const subMenuContent = useMemo(() => {
switch (subMenuType) {
case SubMenuType.TextColor:
return (
<FontColorPicker
onChange={(color) => {
CustomEditor.setBlockColor(editor, node, { font_color: color });
handleClose();
}}
/>
);
case SubMenuType.BackgroundColor:
return (
<BgColorPicker
onChange={(color) => {
CustomEditor.setBlockColor(editor, node, { bg_color: color });
handleClose();
}}
/>
);
default:
return null;
}
}, [editor, node, handleClose, subMenuType]);
return (
<Popover {...PopoverCommonProps} {...props}>
<Popover
{...PopoverCommonProps}
disableAutoFocus={false}
onKeyDown={onKeyDown}
onMouseDown={(e) => e.stopPropagation()}
{...props}
onClose={handleClose}
>
<div className={'flex flex-col p-2'}>
{options.map((option, index) => (
{operationOptions.map((option, index) => (
<Button
color={'inherit'}
onClick={option.onClick}
@ -55,6 +156,35 @@ export function BlockOperationMenu({
</Button>
))}
</div>
<Divider className={'my-1'} />
<div ref={optionsRef} className={'flex flex-col p-2'}>
<Typography variant={'body2'} className={'mb-1 text-text-caption'}>
{t('editor.color')}
</Typography>
{colorOptions.map((option, index) => (
<Button
data-submenu-type={option.type}
color={'inherit'}
onClick={option.onClick}
size={'small'}
endIcon={<MoreSvg />}
className={'w-full justify-between'}
key={index}
>
<div className={'flex-1 text-left'}>{option.text}</div>
</Button>
))}
</div>
<Menu
container={optionsRef.current}
{...PopoverCommonProps}
{...subMenuProps}
open={subMenuOpen}
anchorEl={subMenuAnchorEl}
onClose={() => setSubMenuType(null)}
>
{subMenuContent}
</Menu>
</Popover>
);
}

View File

@ -2,7 +2,7 @@ import { EditorNodeType } from '$app/application/document/document.types';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSlate } from 'slate-react';
import { Editor, Transforms } from 'slate';
import { Transforms } from 'slate';
import { getBlock } from '$app/components/editor/plugins/utils';
import { ReactComponent as TextIcon } from '$app/assets/text.svg';
import { ReactComponent as TodoListIcon } from '$app/assets/todo-list.svg';
@ -134,18 +134,16 @@ export function useSlashCommandPanel({
if (!newNode) return;
const isEmpty = Editor.isEmpty(editor, newNode);
const isEmpty = CustomEditor.isEmptyText(editor, newNode);
if (isEmpty) {
CustomEditor.turnToBlock(editor, {
type: nodeType,
data,
});
return;
if (!isEmpty) {
Transforms.splitNodes(editor, { always: true });
}
Transforms.splitNodes(editor, { always: true });
Transforms.setNodes(editor, { type: nodeType, data });
CustomEditor.turnToBlock(editor, {
type: nodeType,
data,
});
},
[editor, closePanel]
);

View File

@ -5,7 +5,7 @@ import { PopoverPreventBlurProps } from '$app/components/editor/components/tools
import { PopoverProps } from '@mui/material/Popover';
import { commandPanelShowProperty } from '$app/components/editor/components/editor/shortcuts/withCommandShortcuts';
import { Editor, Point, Transforms } from 'slate';
import { getBlockEntry } from '$app/components/editor/plugins/utils';
import { CustomEditor } from '$app/components/editor/command';
export const PanelPopoverProps: Partial<PopoverProps> = {
...PopoverPreventBlurProps,
@ -66,7 +66,7 @@ export function usePanel(ref: RefObject<HTMLDivElement | null>) {
return;
}
const nodeEntry = getBlockEntry(editor);
const nodeEntry = CustomEditor.getBlock(editor);
if (!nodeEntry) return;
@ -128,10 +128,8 @@ export function usePanel(ref: RefObject<HTMLDivElement | null>) {
const isSelectionChange = editor.operations.every((op) => op.type === 'set_selection');
const currentPoint = Editor.end(editor, editor.selection);
const isBackward = currentPoint.offset < startPoint.current.offset;
const isAnotherBlock =
currentPoint.path[0] !== startPoint.current.path[0] || currentPoint.path[1] !== startPoint.current.path[1];
if (isAnotherBlock || isBackward) {
if (isBackward) {
closePanel(false);
return;
}

View File

@ -30,8 +30,8 @@ import Functions from '@mui/icons-material/Functions';
import { ReactEditor } from 'slate-react';
import React, { useCallback, useMemo } from 'react';
import { getBlock, getBlockEntry } from '$app/components/editor/plugins/utils';
import { FontColorPicker, BgColorPicker } from '$app/components/editor/components/tools/selection_toolbar/sub_menu';
import { getBlock } from '$app/components/editor/plugins/utils';
import { FontColorPicker, BgColorPicker } from '$app/components/editor/components/tools/_shared';
import { useTranslation } from 'react-i18next';
import { addMark, Editor } from 'slate';
import { CustomEditor } from '$app/components/editor/command';
@ -257,7 +257,7 @@ export function useBlockFormatActionMap(editor: ReactEditor) {
},
Icon: TodoListSvg,
isActive: () => {
const entry = getBlockEntry(editor);
const entry = CustomEditor.getBlock(editor);
if (!entry) return false;
@ -279,7 +279,7 @@ export function useBlockFormatActionMap(editor: ReactEditor) {
},
Icon: QuoteSvg,
isActive: () => {
const entry = getBlockEntry(editor);
const entry = CustomEditor.getBlock(editor);
if (!entry) return false;
@ -302,7 +302,7 @@ export function useBlockFormatActionMap(editor: ReactEditor) {
},
Icon: ToggleListSvg,
isActive: () => {
const entry = getBlockEntry(editor);
const entry = CustomEditor.getBlock(editor);
if (!entry) return false;
@ -325,7 +325,7 @@ export function useBlockFormatActionMap(editor: ReactEditor) {
},
Icon: NumberedListSvg,
isActive: () => {
const entry = getBlockEntry(editor);
const entry = CustomEditor.getBlock(editor);
if (!entry) return false;
@ -348,7 +348,7 @@ export function useBlockFormatActionMap(editor: ReactEditor) {
},
Icon: BulletedListSvg,
isActive: () => {
const entry = getBlockEntry(editor);
const entry = CustomEditor.getBlock(editor);
if (!entry) return false;

View File

@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ReactEditor, useSlate } from 'slate-react';
import IconButton from '@mui/material/IconButton';
import { Range } from 'slate';
import {
SelectionAction,
useBlockFormatActions,
@ -14,6 +14,7 @@ import Popover from '@mui/material/Popover';
import { EditorStyleFormat } from '$app/application/document/document.types';
import { PopoverPreventBlurProps } from '$app/components/editor/components/tools/popover';
import { Tooltip } from '@mui/material';
import { CustomEditor } from '$app/components/editor/command';
function SelectionActions({
toolbarVisible,
@ -48,7 +49,32 @@ function SelectionActions({
handleBlur();
}, [handleBlur]);
const isMultiple = editor.getFragment().length > 1;
const [isMultiple, setIsMultiple] = useState(false);
const getIsMultiple = useCallback(() => {
if (!editor.selection) return false;
const selection = editor.selection;
const start = selection.anchor;
const end = selection.focus;
if (!start || !end) return false;
if (!Range.isExpanded(selection)) return false;
const startNode = CustomEditor.getBlock(editor, start);
const endNode = CustomEditor.getBlock(editor, end);
return Boolean(startNode && endNode && startNode[0].blockId !== endNode[0].blockId);
}, [editor]);
useEffect(() => {
if (toolbarVisible) {
setIsMultiple(getIsMultiple());
} else {
setIsMultiple(false);
}
}, [editor, getIsMultiple, toolbarVisible]);
const markOptions = useSelectionMarkFormatActions(editor);
const textOptions = useSelectionTextFormatActions(editor);
const blockOptions = useBlockFormatActions(editor);

View File

@ -53,7 +53,22 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
el.style.opacity = '1';
el.style.pointerEvents = 'auto';
el.style.top = `${position.top + slateEditorDom.offsetTop - el.offsetHeight}px`;
el.style.left = `${position.left + slateEditorDom.offsetLeft - el.offsetWidth / 2 + position.width / 2}px`;
const left = position.left + slateEditorDom.offsetLeft - el.offsetWidth / 2 + position.width / 2;
if (left < 0) {
el.style.left = '0';
return;
}
const right = left + el.offsetWidth;
if (right > slateEditorDom.offsetWidth) {
el.style.left = `${slateEditorDom.offsetWidth - el.offsetWidth}px`;
return;
}
el.style.left = `${left}px`;
}, [closeToolbar, editor, ref]);
useEffect(() => {

View File

@ -11,7 +11,7 @@ export const SelectionToolbar = memo(() => {
<div
ref={ref}
className={
'selection-toolbar pointer-events-none absolute z-10 flex w-fit flex-grow transform items-center rounded-lg bg-[var(--fill-toolbar)] p-2 opacity-0 shadow-lg transition-opacity'
'selection-toolbar pointer-events-none absolute z-[100] flex w-fit flex-grow transform items-center rounded-lg bg-[var(--fill-toolbar)] p-2 opacity-0 shadow-lg transition-opacity'
}
onMouseDown={(e) => {
// prevent toolbar from taking focus away from editor

View File

@ -1,5 +1,3 @@
import { EditorNodeType } from '$app/application/document/document.types';
export const BREAK_TO_PARAGRAPH_TYPES = [EditorNodeType.HeadingBlock, EditorNodeType.QuoteBlock, EditorNodeType.Page];
export const SOFT_BREAK_TYPES = [EditorNodeType.CalloutBlock, EditorNodeType.CodeBlock];

View File

@ -1,6 +1,6 @@
import { Editor, Element, Location, NodeEntry, Point, Range } from 'slate';
import { EditorNodeType } from '$app/application/document/document.types';
import { Element, NodeEntry } from 'slate';
import { ReactEditor } from 'slate-react';
import { CustomEditor } from '$app/components/editor/command';
export function getHeadingCssProperty(level: number) {
switch (level) {
@ -15,45 +15,16 @@ export function getHeadingCssProperty(level: number) {
}
}
export function isDeleteBackwardAtStartOfBlock(editor: ReactEditor, type?: EditorNodeType) {
const { selection } = editor;
export function getBlock(editor: ReactEditor) {
const match = CustomEditor.getBlock(editor);
if (selection && Range.isCollapsed(selection)) {
const [match] = Editor.nodes(editor, {
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && Editor.isBlock(editor, n),
});
if (match) {
const [node] = match as NodeEntry<Element>;
if (match) {
const [node, path] = match as NodeEntry<Element>;
if (type !== undefined && node.type !== type) return false;
const start = Editor.start(editor, path);
if (Point.equals(selection.anchor, start)) {
return true;
}
}
return node;
}
return false;
}
export function getBlockEntry(editor: ReactEditor, at?: Location) {
if (!editor.selection) return null;
const entry = Editor.above(editor, {
at,
match: (n) => !Editor.isEditor(n) && Element.isElement(n),
});
return entry as NodeEntry<Element>;
}
export function getBlock(editor: ReactEditor, at?: Location) {
const entry = getBlockEntry(editor, at);
return entry?.[0];
return;
}
export function getEditorDomNode(editor: ReactEditor) {

View File

@ -1,24 +1,24 @@
import { ReactEditor } from 'slate-react';
import { isDeleteBackwardAtStartOfBlock } from '$app/components/editor/plugins/utils';
import { EditorNodeType } from '$app/application/document/document.types';
import { Editor, Element, NodeEntry } from 'slate';
import { CustomEditor } from '$app/components/editor/command';
export function withBlockDeleteBackward(editor: ReactEditor) {
const { deleteBackward } = editor;
const { deleteBackward, removeNodes } = editor;
editor.deleteBackward = (...args) => {
if (!isDeleteBackwardAtStartOfBlock(editor)) {
deleteBackward(...args);
editor.removeNodes = (...args) => {
removeNodes(...args);
};
editor.deleteBackward = (unit) => {
const match = CustomEditor.getBlock(editor);
if (!match || !CustomEditor.focusAtStartOfBlock(editor)) {
deleteBackward(unit);
return;
}
const [match] = Editor.nodes(editor, {
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && Editor.isBlock(editor, n),
});
const [node] = match as NodeEntry<Element>;
const [node, path] = match;
// if the current node is not a paragraph, convert it to a paragraph
if (node.type !== EditorNodeType.Paragraph && node.type !== EditorNodeType.Page) {
@ -26,26 +26,24 @@ export function withBlockDeleteBackward(editor: ReactEditor) {
return;
}
const level = node.level;
const next = editor.next({
at: path,
});
if (!level) {
deleteBackward(...args);
return;
}
const nextNode = CustomEditor.findNextNode(editor, node, level);
if (nextNode) {
deleteBackward(...args);
return;
}
if (level > 1) {
if (!next && path.length > 1) {
CustomEditor.tabBackward(editor);
return;
}
deleteBackward(...args);
const [, ...children] = node.children;
deleteBackward(unit);
children.forEach((child, index) => {
editor.liftNodes({
at: [...path, index],
});
});
};
return editor;

View File

@ -1,5 +1,4 @@
import { ReactEditor } from 'slate-react';
import { Editor, Element, NodeEntry } from 'slate';
import { EditorNodeType } from '$app/application/document/document.types';
import { CustomEditor } from '$app/components/editor/command';
@ -7,24 +6,17 @@ export function withBlockInsertBreak(editor: ReactEditor) {
const { insertBreak } = editor;
editor.insertBreak = (...args) => {
const nodeEntry = Editor.above(editor, {
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && Editor.isBlock(editor, n),
});
const block = CustomEditor.getBlock(editor);
if (!nodeEntry) return insertBreak(...args);
if (!block) return insertBreak(...args);
const [node] = nodeEntry as NodeEntry<Element>;
const [node] = block;
const type = node.type as EditorNodeType;
if (type === EditorNodeType.Page) {
insertBreak(...args);
return;
}
const isEmpty = Editor.isEmpty(editor, node);
const isEmpty = CustomEditor.isEmptyText(editor, node);
// if the node is empty, convert it to a paragraph
if (isEmpty && type !== EditorNodeType.Paragraph) {
if (isEmpty && type !== EditorNodeType.Paragraph && type !== EditorNodeType.Page) {
CustomEditor.turnToBlock(editor, { type: EditorNodeType.Paragraph });
return;
}

View File

@ -0,0 +1,120 @@
import { ReactEditor } from 'slate-react';
import { generateId } from '$app/components/editor/provider/utils/convert';
import { Editor, Element, Location, NodeEntry, Path, Transforms } from 'slate';
import { EditorNodeType } from '$app/application/document/document.types';
export function withBlockMove(editor: ReactEditor) {
const { moveNodes } = editor;
editor.moveNodes = (args) => {
const { to } = args;
moveNodes(args);
replaceId(editor, to);
};
editor.liftNodes = (args = {}) => {
Editor.withoutNormalizing(editor, () => {
const { at = editor.selection, mode = 'lowest', voids = false } = args;
let { match } = args;
if (!match) {
match = (n) => Element.isElement(n) && Editor.isBlock(editor, n) && n.blockId !== undefined;
}
if (!at) {
return;
}
const matches = Editor.nodes(editor, { at, match, mode, voids });
const pathRefs = Array.from(matches, ([, p]) => Editor.pathRef(editor, p));
for (const pathRef of pathRefs) {
const path = pathRef.unref();
if (!path) return;
if (path.length < 2) {
throw new Error(`Cannot lift node at a path [${path}] because it has a depth of less than \`2\`.`);
}
const parentNodeEntry = Editor.node(editor, Path.parent(path));
const [parent, parentPath] = parentNodeEntry as NodeEntry<Element>;
const index = path[path.length - 1];
const { length } = parent.children;
if (length === 1) {
const toPath = Path.next(parentPath);
Transforms.moveNodes(editor, { at: path, to: toPath, voids });
Transforms.removeNodes(editor, { at: parentPath, voids });
} else if (index === 0) {
Transforms.moveNodes(editor, { at: path, to: parentPath, voids });
} else if (index === length - 1) {
const toPath = Path.next(parentPath);
Transforms.moveNodes(editor, { at: path, to: toPath, voids });
} else {
const toPath = Path.next(parentPath);
parent.children.forEach((child, childIndex) => {
if (childIndex > index) {
Transforms.moveNodes(editor, {
at: [...parentPath, index + 1],
to: [...path, childIndex - index],
mode: 'all',
});
}
});
Transforms.moveNodes(editor, { at: path, to: toPath, voids });
}
}
});
};
return editor;
}
function replaceId(editor: Editor, at?: Location) {
const newBlockId = generateId();
const newTextId = generateId();
const selection = editor.selection;
const location = at || selection;
if (!location) return;
const [node, path] = editor.node(location) as NodeEntry<Element>;
if (node.blockId === undefined) {
return;
}
const [textNode, ...children] = node.children as Element[];
editor.setNodes(
{
blockId: newBlockId,
},
{
at,
}
);
if (textNode.type === EditorNodeType.Text) {
editor.setNodes(
{
textId: newTextId,
},
{
at: [...path, 0],
}
);
}
children.forEach((_, index) => {
replaceId(editor, [...path, index + 1]);
});
}

View File

@ -2,16 +2,27 @@ import { ReactEditor } from 'slate-react';
import { withBlockDeleteBackward } from '$app/components/editor/plugins/withBlockDeleteBackward';
import { withBlockInsertBreak } from '$app/components/editor/plugins/withBlockInsertBreak';
import { withMergeNodes } from '$app/components/editor/plugins/withMergeNodes';
import { withSplitNodes } from '$app/components/editor/plugins/withSplitNodes';
import { withDatabaseBlockPlugin } from '$app/components/editor/components/blocks/database';
import { withMathEquationPlugin } from '$app/components/editor/components/blocks/math_equation';
import { withPasted } from '$app/components/editor/plugins/withPasted';
import { withBlockMove } from '$app/components/editor/plugins/withBlockMove';
import { EditorNodeType } from '$app/application/document/document.types';
const EmbedTypes: string[] = [EditorNodeType.DividerBlock, EditorNodeType.EquationBlock, EditorNodeType.GridBlock];
export function withBlockPlugins(editor: ReactEditor) {
return withMathEquationPlugin(
withPasted(
withDatabaseBlockPlugin(withSplitNodes(withMergeNodes(withBlockInsertBreak(withBlockDeleteBackward(editor)))))
)
);
const { isElementReadOnly, isSelectable, isEmpty } = editor;
editor.isElementReadOnly = (element) => {
return EmbedTypes.includes(element.type) || isElementReadOnly(element);
};
editor.isSelectable = (element) => {
return !EmbedTypes.includes(element.type) && isSelectable(element);
};
editor.isEmpty = (element) => {
return !EmbedTypes.includes(element.type) && isEmpty(element);
};
return withPasted(withBlockMove(withSplitNodes(withBlockInsertBreak(withBlockDeleteBackward(editor)))));
}

View File

@ -1,106 +0,0 @@
import { ReactEditor } from 'slate-react';
import { Editor, Element, NodeEntry, Node, Transforms, Point, Path } from 'slate';
import { CustomEditor } from '$app/components/editor/command';
import { YjsEditor } from '@slate-yjs/core';
import { EditorNodeType } from '$app/application/document/document.types';
export function withMergeNodes(editor: ReactEditor) {
const { mergeNodes, removeNodes } = editor;
editor.removeNodes = (...args) => {
const isDeleteRoot = args.some((arg) => {
return (
arg?.at &&
(arg.at as Path).length === 1 &&
(arg.at as Path)[0] === 0 &&
(editor.children[0] as Element).type === EditorNodeType.Page
);
});
// the root node cannot be deleted
if (isDeleteRoot) return;
removeNodes(...args);
};
editor.mergeNodes = (...args) => {
const isBlock = (n: Node) =>
!Editor.isEditor(n) && Element.isElement(n) && n.type !== undefined && n.level !== undefined;
const [merged] = Editor.nodes(editor, {
match: isBlock,
});
if (!merged) {
mergeNodes(...args);
return;
}
const [mergedNode, path] = merged as NodeEntry<Element & { level: number; blockId: string }>;
const root = editor.children[0] as Element & {
blockId: string;
level: number;
};
const selection = editor.selection;
const start = Editor.start(editor, path);
if (
root.type === EditorNodeType.Page &&
mergedNode.type === EditorNodeType.Paragraph &&
selection &&
Point.equals(selection.anchor, start) &&
path[0] === 1
) {
if (Editor.isEmpty(editor, root)) {
const text = Editor.string(editor, path);
editor.select([0]);
editor.insertText(text);
editor.removeNodes({ at: path });
// move children to root
moveNodes(editor, 1, root.blockId, (n) => {
return n.parentId === mergedNode.blockId;
});
return;
}
}
const nextNode = editor.children[path[0] + 1] as Element & { level: number };
mergeNodes(...args);
if (!nextNode) {
CustomEditor.insertEmptyLineAtEnd(editor as ReactEditor & YjsEditor);
return;
}
if (mergedNode.blockId === nextNode.parentId) {
// the node will be deleted when the node has no text
if (mergedNode.children.length === 1 && 'text' in mergedNode.children[0] && mergedNode.children[0].text === '') {
moveNodes(editor, root.level + 1, root.blockId, (n) => n.parentId === mergedNode.blockId);
}
return;
}
// check if the old node is removed
const oldNodeRemoved = !editor.children.some((child) => (child as Element).blockId === nextNode.parentId);
if (oldNodeRemoved) {
// if the old node is removed, we need to move the children of the old node to the new node
moveNodes(editor, mergedNode.level + 1, mergedNode.blockId, (n) => {
return n.parentId === nextNode.parentId;
});
}
};
return editor;
}
function moveNodes(editor: ReactEditor, level: number, parentId: string, match: (n: Element) => boolean) {
editor.children.forEach((child, index) => {
if (match(child as Element)) {
Transforms.setNodes(editor, { level, parentId }, { at: [index] });
}
});
}

View File

@ -1,12 +1,12 @@
import { ReactEditor } from 'slate-react';
import { convertBlockToJson } from '$app/application/document/document.service';
import { Editor, Element } from 'slate';
import { Editor, Element, NodeEntry, Path, Location, Range } from 'slate';
import { generateId } from '$app/components/editor/provider/utils/convert';
import { blockTypes, EditorNodeType } from '$app/application/document/document.types';
import { EditorNodeType } from '$app/application/document/document.types';
import { InputType } from '@/services/backend';
export function withPasted(editor: ReactEditor) {
const { insertData, insertFragment } = editor;
const { insertData } = editor;
editor.insertData = (data) => {
const fragment = data.getData('application/x-slate-fragment');
@ -30,90 +30,113 @@ export function withPasted(editor: ReactEditor) {
insertData(data);
};
editor.insertFragment = (fragment) => {
let rootId = (editor.children[0] as Element)?.blockId;
editor.insertFragment = (fragment, options = {}) => {
Editor.withoutNormalizing(editor, () => {
const { at = getDefaultInsertLocation(editor) } = options;
if (!rootId) {
rootId = generateId();
insertFragment([
{
type: EditorNodeType.Paragraph,
children: [
{
text: '',
},
],
data: {},
blockId: rootId,
textId: generateId(),
parentId: '',
level: 0,
},
]);
}
if (!fragment.length) {
return;
}
const [mergedMatch] = Editor.nodes(editor, {
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.type !== undefined,
});
if (Range.isRange(at) && !Range.isCollapsed(at)) {
editor.delete({
unit: 'character',
});
}
const mergedNode = mergedMatch
? (mergedMatch[0] as Element & {
blockId: string;
parentId: string;
level: number;
})
: null;
const mergedText = editor.above({
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.type === EditorNodeType.Text,
}) as NodeEntry<
Element & {
textId: string;
}
>;
if (!mergedNode) return insertFragment(fragment);
if (!mergedText) return;
const isEmpty = Editor.isEmpty(editor, mergedNode);
const [mergedTextNode, mergedTextNodePath] = mergedText;
const mergedNodeId = isEmpty ? undefined : mergedNode.blockId;
const traverse = (node: Element) => {
if (node.type === EditorNodeType.Text) {
node.textId = generateId();
return;
}
const idMap = new Map<string, string>();
const levelMap = new Map<string, number>();
for (let i = 0; i < fragment.length; i++) {
const node = fragment[i] as Element & {
blockId: string;
parentId: string;
level: number;
node.blockId = generateId();
node.children?.forEach((child) => traverse(child as Element));
};
const newBlockId = i === 0 && mergedNodeId ? mergedNodeId : generateId();
fragment?.forEach((node) => traverse(node as Element));
const parentId = idMap.get(node.parentId);
const firstNode = fragment[0] as Element;
if (parentId) {
node.parentId = parentId;
} else {
idMap.set(node.parentId, mergedNode.parentId);
node.parentId = mergedNode.parentId;
if (firstNode && firstNode.type !== 'text') {
if (firstNode.children && firstNode.children.length > 0) {
const [textNode, ...children] = firstNode.children;
fragment[0] = textNode;
fragment.splice(1, 0, ...children);
} else {
fragment.unshift(getEmptyText());
}
}
const parentLevel = levelMap.get(node.parentId);
editor.insertNodes((fragment[0] as Element).children, {
at: [...mergedTextNodePath, mergedTextNode.children.length],
});
editor.select(mergedTextNodePath);
editor.collapse({
edge: 'end',
});
const otherNodes = fragment.slice(1);
if (parentLevel !== undefined) {
node.level = parentLevel + 1;
} else {
levelMap.set(node.parentId, mergedNode.level - 1);
node.level = mergedNode.level;
if (otherNodes.length > 0) {
const parentPath = Path.parent(mergedTextNodePath);
const nextPath = Path.next(parentPath);
const lastNodeText = (otherNodes[otherNodes.length - 1] as Element).children?.[0] as Element;
let canSelect = true;
if (!lastNodeText || lastNodeText.type !== EditorNodeType.Text) {
canSelect = false;
}
editor.insertNodes(otherNodes, {
at: nextPath,
select: canSelect,
});
if (canSelect) {
editor.collapse({
edge: 'end',
});
}
}
// if the pasted fragment is not matched with the block type, we need to convert it to paragraph
// and if the pasted fragment is a page, we need to convert it to paragraph
if (!blockTypes.includes(node.type as EditorNodeType) || node.type === EditorNodeType.Page) {
node.type = EditorNodeType.Paragraph;
}
idMap.set(node.blockId, newBlockId);
levelMap.set(newBlockId, node.level);
node.blockId = newBlockId;
node.textId = generateId();
}
return insertFragment(fragment);
});
};
return editor;
}
function getEmptyText(): Element {
return {
type: EditorNodeType.Text,
textId: generateId(),
children: [
{
text: '',
},
],
};
}
export const getDefaultInsertLocation = (editor: Editor): Location => {
if (editor.selection) {
return editor.selection;
} else if (editor.children.length > 0) {
return Editor.end(editor, []);
} else {
return [0];
}
};

View File

@ -1,9 +1,10 @@
import { ReactEditor } from 'slate-react';
import { Editor, Element, NodeEntry, Transforms } from 'slate';
import { EditorMarkFormat, EditorNodeType, markTypes, ToggleListNode } from '$app/application/document/document.types';
import { Transforms, Editor, Element, NodeEntry, Path } from 'slate';
import { EditorNodeType, ToggleListNode } from '$app/application/document/document.types';
import { CustomEditor } from '$app/components/editor/command';
import { BREAK_TO_PARAGRAPH_TYPES } from '$app/components/editor/plugins/constants';
import { generateId } from '$app/components/editor/provider/utils/convert';
import cloneDeep from 'lodash-es/cloneDeep';
import { SOFT_BREAK_TYPES } from '$app/components/editor/plugins/constants';
export function withSplitNodes(editor: ReactEditor) {
const { splitNodes } = editor;
@ -16,102 +17,90 @@ export function withSplitNodes(editor: ReactEditor) {
return;
}
// This is a workaround for the bug that the new paragraph will inherit the marks of the previous paragraph
// remove all marks in current selection, otherwise the new paragraph will inherit the marks
markTypes.forEach((markType) => {
const isActive = CustomEditor.isMarkActive(editor, markType as EditorMarkFormat);
if (isActive) {
editor.removeMark(markType as EditorMarkFormat);
}
});
const [match] = Editor.nodes(editor, {
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined && n.type !== undefined,
});
const match = CustomEditor.getBlock(editor);
if (!match) {
splitNodes(...args);
return;
}
const [node, path] = match as NodeEntry<Element>;
const [node, path] = match;
const nodeType = node.type as EditorNodeType;
const newBlockId = generateId();
const newTextId = generateId();
const nodeType = node.type as EditorNodeType;
// should be split to a new paragraph for the first child of the toggle list
if (nodeType === EditorNodeType.ToggleListBlock) {
const collapsed = (node as ToggleListNode).data.collapsed;
const level = node.level ?? 1;
const blockId = node.blockId as string;
const parentId = node.parentId as string;
// if the toggle list is collapsed, split to a new paragraph append to the children of the toggle list
if (!collapsed) {
splitNodes(...args);
Transforms.setNodes(editor, {
type: EditorNodeType.Paragraph,
data: {},
level: level + 1,
blockId: newBlockId,
parentId: blockId,
textId: newTextId,
});
} else {
// if the toggle list is not collapsed, split to a toggle list after the toggle list
const nextNode = CustomEditor.findNextNode(editor, node, level);
const nextIndex = nextNode ? ReactEditor.findPath(editor, nextNode)[0] : null;
const index = path[0];
splitNodes(...args);
Transforms.setNodes(editor, { level, data: {}, blockId: newBlockId, parentId, textId: newTextId });
if (nextIndex) {
Transforms.moveNodes(editor, { at: [index + 1], to: [nextIndex] });
}
}
return;
}
// should be split to another paragraph, eg: heading and quote and page
if (BREAK_TO_PARAGRAPH_TYPES.includes(nodeType)) {
const level = node.level || 1;
const parentId = (node.parentId || node.blockId) as string;
splitNodes(...args);
Transforms.setNodes(editor, {
type: EditorNodeType.Paragraph,
data: {},
blockId: newBlockId,
textId: newTextId,
level,
parentId,
});
return;
}
splitNodes(...args);
Transforms.setNodes(editor, { blockId: newBlockId, data: {}, textId: newTextId });
const children = CustomEditor.findNodeChildren(editor, node);
children.forEach((child) => {
const childPath = ReactEditor.findPath(editor, child);
Transforms.setNodes(
editor,
{
parentId: newBlockId,
},
{
at: [childPath[0] + 1],
}
);
const matchTextNode = editor.above({
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.type === EditorNodeType.Text,
});
if (!matchTextNode) return;
const [textNode, textNodePath] = matchTextNode as NodeEntry<Element>;
editor.removeNodes({
at: textNodePath,
});
const newNodeType = [
EditorNodeType.HeadingBlock,
EditorNodeType.QuoteBlock,
EditorNodeType.Page,
...SOFT_BREAK_TYPES,
].includes(node.type as EditorNodeType)
? EditorNodeType.Paragraph
: node.type;
const newNode: Element = {
type: newNodeType,
data: {},
blockId: newBlockId,
children: [
{
...cloneDeep(textNode),
textId: newTextId,
},
],
};
let newNodePath;
if (nodeType === EditorNodeType.ToggleListBlock) {
const collapsed = (node as ToggleListNode).data.collapsed;
if (!collapsed) {
newNode.type = EditorNodeType.Paragraph;
newNodePath = textNodePath;
} else {
newNode.type = EditorNodeType.ToggleListBlock;
newNodePath = Path.next(path);
}
Transforms.insertNodes(editor, newNode, {
at: newNodePath,
select: true,
});
CustomEditor.removeMarks(editor);
return;
}
newNodePath = textNodePath;
Transforms.insertNodes(editor, newNode, {
at: newNodePath,
});
editor.select(newNodePath);
editor.collapse({
edge: 'start',
});
editor.liftNodes({
at: newNodePath,
});
CustomEditor.removeMarks(editor);
};
return editor;

View File

@ -24,8 +24,7 @@ describe('Transform events to actions', () => {
test('should transform insert event to insert action', () => {
const sharedType = provider.sharedType;
const parentId = sharedType?.getAttribute('blockId') as string;
const insertTextOp = generateInsertTextOp('insert text', parentId, 1);
const insertTextOp = generateInsertTextOp('insert text');
sharedType?.applyDelta([{ retain: 2 }, insertTextOp]);
@ -43,24 +42,6 @@ describe('Transform events to actions', () => {
expect(actions[1].payload.prev_id).toBe('2qonPRrNTO');
});
test('should transform move event to move action', () => {
const sharedType = provider.sharedType;
const parentId = 'CxPil0324P';
const yText = sharedType?.toDelta()[4].insert as Y.XmlText;
sharedType?.doc?.transact(() => {
yText.setAttribute('level', 2);
yText.setAttribute('parentId', parentId);
});
const actions = applyActions.mock.calls[0][1];
expect(actions).toHaveLength(1);
expect(actions[0].action).toBe(BlockActionTypePB.Move);
expect(actions[0].payload.block.id).toBe('Fn4KACkt1i');
expect(actions[0].payload.parent_id).toBe('CxPil0324P');
expect(actions[0].payload.prev_id).toBe('');
});
test('should transform delete event to delete action', () => {
const sharedType = provider.sharedType;
@ -72,7 +53,6 @@ describe('Transform events to actions', () => {
expect(actions).toHaveLength(1);
expect(actions[0].action).toBe(BlockActionTypePB.Delete);
expect(actions[0].payload.block.id).toBe('Fn4KACkt1i');
expect(actions[0].payload.parent_id).toBe('3EzeCrtxlh');
});
test('should transform update event to update action', () => {
@ -90,17 +70,17 @@ describe('Transform events to actions', () => {
expect(actions[0].action).toBe(BlockActionTypePB.Update);
expect(actions[0].payload.block.id).toBe('Fn4KACkt1i');
expect(actions[0].payload.block.data).toBe('{"checked":true}');
expect(actions[0].payload.parent_id).toBe('3EzeCrtxlh');
});
test('should transform apply delta event to apply delta action (insert text)', () => {
const sharedType = provider.sharedType;
const yText = sharedType?.toDelta()[4].insert as Y.XmlText;
const blockYText = sharedType?.toDelta()[4].insert as Y.XmlText;
const textYText = blockYText.toDelta()[0].insert as Y.XmlText;
sharedType?.doc?.transact(() => {
yText.applyDelta([{ retain: 1 }, { insert: 'apply delta' }]);
textYText.applyDelta([{ retain: 1 }, { insert: 'apply delta' }]);
});
const textId = yText.getAttribute('textId');
const textId = textYText.getAttribute('textId');
const actions = applyActions.mock.calls[0][1];
expect(actions).toHaveLength(1);
@ -112,7 +92,8 @@ describe('Transform events to actions', () => {
test('should transform apply delta event to apply delta action: insert mention', () => {
const sharedType = provider.sharedType;
const yText = sharedType?.toDelta()[4].insert as Y.XmlText;
const blockYText = sharedType?.toDelta()[4].insert as Y.XmlText;
const yText = blockYText.toDelta()[0].insert as Y.XmlText;
sharedType?.doc?.transact(() => {
yText.applyDelta([{ retain: 1 }, genersteMentionInsertTextOp()]);
});
@ -126,7 +107,8 @@ describe('Transform events to actions', () => {
test('should transform apply delta event to apply delta action: insert formula', () => {
const sharedType = provider.sharedType;
const yText = sharedType?.toDelta()[4].insert as Y.XmlText;
const blockYText = sharedType?.toDelta()[4].insert as Y.XmlText;
const yText = blockYText.toDelta()[0].insert as Y.XmlText;
sharedType?.doc?.transact(() => {
yText.applyDelta([{ retain: 1 }, generateFormulaInsertTextOp()]);
});

View File

@ -30,7 +30,7 @@ describe('Provider connected', () => {
const sharedType = provider.sharedType;
const parentId = sharedType?.getAttribute('blockId') as string;
const insertTextOp = generateInsertTextOp('', parentId, 1);
const insertTextOp = generateInsertTextOp('');
sharedType?.applyDelta([{ retain: 2 }, insertTextOp]);

View File

@ -26,20 +26,26 @@ export function slateElementToYText({
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function generateInsertTextOp(text: string, parentId: string, level: number, attributes?: Record<string, any>) {
export function generateInsertTextOp(text: string) {
const insertYText = slateElementToYText({
children: [{ text: text }],
children: [
{
type: 'text',
textId: generateId(),
children: [
{
text,
},
],
},
],
type: 'paragraph',
data: {},
blockId: generateId(),
parentId,
textId: generateId(),
level,
});
return {
insert: insertYText,
attributes,
};
}

View File

@ -32,7 +32,9 @@ export class DataClient extends EventEmitter {
this.rootId = data.rootId;
return slateNodesToInsertDelta(convertToSlateValue(data, includeRoot));
const slateValue = convertToSlateValue(data, includeRoot);
return slateNodesToInsertDelta(slateValue);
}
public on(event: 'change', listener: (events: YDelta) => void): this;

View File

@ -1,7 +1,6 @@
import * as Y from 'yjs';
import { DataClient } from '$app/components/editor/provider/data_client';
import { convertToIdList, fillIdRelationMap } from '$app/components/editor/provider/utils/relation';
import { YDelta } from '$app/components/editor/provider/types/y_event';
import { YEvents2BlockActions } from '$app/components/editor/provider/utils/action';
import { EventEmitter } from 'events';
@ -10,34 +9,27 @@ const REMOTE_ORIGIN = 'remote';
export class Provider extends EventEmitter {
document: Y.Doc = new Y.Doc();
// id order
idList: Y.XmlText = this.document.get('idList', Y.XmlText) as Y.XmlText;
// id -> parentId
idRelationMap: Y.Map<string> = this.document.getMap('idRelationMap');
sharedType: Y.XmlText | null = null;
dataClient: DataClient;
// get origin data after document updated
backupDoc: Y.Doc = new Y.Doc();
constructor(public id: string, includeRoot?: boolean) {
super();
this.dataClient = new DataClient(id);
void this.initialDocument(includeRoot);
this.document.on('update', this.documentUpdate);
}
initialDocument = async (includeRoot = true) => {
const sharedType = this.document.get('local', Y.XmlText) as Y.XmlText;
const sharedType = this.document.get('sharedType', Y.XmlText) as Y.XmlText;
// Load the initial value into the yjs document
const delta = await this.dataClient.getInsertDelta(includeRoot);
sharedType.applyDelta(delta);
this.idList.applyDelta(convertToIdList(delta));
delta.forEach((op) => {
if (op.insert instanceof Y.XmlText) {
fillIdRelationMap(op.insert, this.idRelationMap);
}
});
const rootId = this.dataClient.rootId as string;
sharedType.setAttribute('blockId', this.dataClient.rootId);
sharedType.setAttribute('blockId', rootId);
this.sharedType = sharedType;
this.sharedType?.observeDeep(this.onChange);
@ -63,7 +55,7 @@ export class Provider extends EventEmitter {
if (!this.sharedType || !events.length) return;
// transform events to actions
this.dataClient.emit('update', YEvents2BlockActions(this.sharedType, events));
this.dataClient.emit('update', YEvents2BlockActions(this.backupDoc, events));
};
onRemoteChange = (delta: YDelta) => {
@ -73,4 +65,8 @@ export class Provider extends EventEmitter {
this.sharedType?.applyDelta(delta);
}, REMOTE_ORIGIN);
};
documentUpdate = (update: Uint8Array) => {
Y.applyUpdate(this.backupDoc, update);
};
}

View File

@ -3,9 +3,175 @@ import { BlockActionPB, BlockActionTypePB } from '@/services/backend';
import { generateId } from '$app/components/editor/provider/utils/convert';
import { YDelta2Delta } from '$app/components/editor/provider/utils/delta';
import { YDelta } from '$app/components/editor/provider/types/y_event';
import { convertToIdList, fillIdRelationMap, findPreviousSibling } from '$app/components/editor/provider/utils/relation';
import { getInsertTarget, getYTarget } from '$app/components/editor/provider/utils/relation';
import { EditorNodeType } from '$app/application/document/document.types';
import { Log } from '$app/utils/log';
export function generateUpdateDataActions(yXmlText: Y.XmlText, data: Record<string, string | boolean>) {
export function YEvents2BlockActions(
backupDoc: Readonly<Y.Doc>,
events: Y.YEvent<Y.XmlText>[]
): ReturnType<typeof BlockActionPB.prototype.toObject>[] {
const actions: ReturnType<typeof BlockActionPB.prototype.toObject>[] = [];
events.forEach((event) => {
const eventActions = YEvent2BlockActions(backupDoc, event);
if (eventActions.length === 0) return;
actions.push(...eventActions);
});
return actions;
}
export function YEvent2BlockActions(
backupDoc: Readonly<Y.Doc>,
event: Y.YEvent<Y.XmlText>
): ReturnType<typeof BlockActionPB.prototype.toObject>[] {
const { target: yXmlText, keys, delta, path } = event;
const isBlockEvent = !!yXmlText.getAttribute('blockId');
const sharedType = backupDoc.get('sharedType', Y.XmlText) as Readonly<Y.XmlText>;
const rootId = sharedType.getAttribute('blockId');
const backupTarget = getYTarget(backupDoc, path) as Readonly<Y.XmlText>;
const actions = [];
if (yXmlText.getAttribute('type') === 'text') {
actions.push(...textOps2BlockActions(rootId, yXmlText, delta));
}
if (keys.size > 0) {
actions.push(...dataOps2BlockActions(yXmlText, keys));
}
if (isBlockEvent) {
actions.push(...blockOps2BlockActions(backupTarget, delta));
}
return actions;
}
function textOps2BlockActions(
rootId: string,
yXmlText: Y.XmlText,
ops: YDelta
): ReturnType<typeof BlockActionPB.prototype.toObject>[] {
if (ops.length === 0) return [];
const blockYXmlText = yXmlText.parent as Y.XmlText;
const blockId = blockYXmlText.getAttribute('blockId');
if (blockId === rootId) {
return [];
}
return generateApplyTextActions(yXmlText, ops);
}
function dataOps2BlockActions(
yXmlText: Y.XmlText,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
keys: Map<string, { action: 'update' | 'add' | 'delete'; oldValue: any; newValue: any }>
) {
const dataUpdated = keys.has('data');
if (!dataUpdated) return [];
const data = yXmlText.getAttribute('data');
return generateUpdateActions(yXmlText, {
data,
});
}
function blockOps2BlockActions(
blockYXmlText: Readonly<Y.XmlText>,
ops: YDelta
): ReturnType<typeof BlockActionPB.prototype.toObject>[] {
const actions: ReturnType<typeof BlockActionPB.prototype.toObject>[] = [];
let index = 0;
let newOps = ops;
if (ops.length > 1) {
const [deleteOp, insertOp, ...otherOps] = newOps;
const insert = insertOp.insert;
if (deleteOp.delete === 1 && insert && insert instanceof Y.XmlText) {
const textNode = getInsertTarget(blockYXmlText, [0]);
const textId = textNode.getAttribute('textId');
if (textId) {
const length = textNode.length;
insert.setAttribute('textId', textId);
actions.push(
...generateApplyTextActions(insert, [
{
delete: length,
},
...insert.toDelta(),
])
);
}
newOps = [
{
retain: 1,
},
...otherOps,
];
}
}
newOps.forEach((op) => {
if (op.insert) {
if (op.insert instanceof Y.XmlText) {
const insertYXmlText = op.insert;
const blockId = insertYXmlText.getAttribute('blockId');
const textId = insertYXmlText.getAttribute('textId');
if (!blockId && !textId) {
throw new Error('blockId and textId is not exist');
}
actions.push(...generateInsertBlockActions(insertYXmlText));
index += 1;
}
} else if (op.retain) {
index += op.retain;
} else if (op.delete) {
for (let i = index; i < op.delete + index; i++) {
const target = getInsertTarget(blockYXmlText, [i]);
if (target) {
const deletedId = target.getAttribute('blockId') as string;
if (deletedId) {
actions.push(
...generateDeleteBlockActions({
ids: [deletedId],
})
);
}
}
}
}
});
return actions;
}
export function generateUpdateActions(
yXmlText: Y.XmlText,
{
data,
}: {
data?: Record<string, string | boolean>;
external_id?: string;
}
) {
const id = yXmlText.getAttribute('blockId');
const parentId = yXmlText.getAttribute('parentId');
@ -16,8 +182,6 @@ export function generateUpdateDataActions(yXmlText: Y.XmlText, data: Record<stri
block: {
id,
data: JSON.stringify(data),
parent: parentId,
children: '',
},
parent_id: parentId,
},
@ -43,15 +207,28 @@ export function generateApplyTextActions(yXmlText: Y.XmlText, delta: YDelta) {
];
}
export function generateDeleteBlockActions({ id, parentId }: { id: string; parentId: string }) {
export function generateDeleteBlockActions({ ids }: { ids: string[] }) {
return ids.map((id) => ({
action: BlockActionTypePB.Delete,
payload: {
block: {
id,
},
parent_id: '',
},
}));
}
export function generateInsertTextActions(insertYXmlText: Y.XmlText) {
const textId = insertYXmlText.getAttribute('textId');
const delta = YDelta2Delta(insertYXmlText.toDelta());
return [
{
action: BlockActionTypePB.Delete,
action: BlockActionTypePB.InsertText,
payload: {
block: {
id,
},
parent_id: parentId,
text_id: textId,
delta: JSON.stringify(delta),
},
},
];
@ -61,24 +238,29 @@ export function generateInsertBlockActions(
insertYXmlText: Y.XmlText
): ReturnType<typeof BlockActionPB.prototype.toObject>[] {
const childrenId = generateId();
const prev = findPreviousSibling(insertYXmlText);
const [textInsert, ...childrenInserts] = (insertYXmlText.toDelta() as YDelta).map((op) => op.insert);
const textInsertActions = textInsert instanceof Y.XmlText ? generateInsertTextActions(textInsert) : [];
const externalId = textInsertActions[0]?.payload.text_id;
const prev = insertYXmlText.prevSibling;
const prevId = prev ? prev.getAttribute('blockId') : null;
const parentId = insertYXmlText.getAttribute('parentId');
const delta = YDelta2Delta(insertYXmlText.toDelta());
const parentId = (insertYXmlText.parent as Y.XmlText).getAttribute('blockId');
const data = insertYXmlText.getAttribute('data');
const type = insertYXmlText.getAttribute('type');
const id = insertYXmlText.getAttribute('blockId');
const externalId = insertYXmlText.getAttribute('textId');
return [
{
action: BlockActionTypePB.InsertText,
payload: {
text_id: externalId,
delta: JSON.stringify(delta),
},
},
if (!id) {
Log.error('generateInsertBlockActions', 'id is not exist');
return [];
}
if (!type || type === 'text' || Object.values(EditorNodeType).indexOf(type) === -1) {
Log.error('generateInsertBlockActions', 'type is error: ' + type);
return [];
}
const actions: ReturnType<typeof BlockActionPB.prototype.toObject>[] = [
...textInsertActions,
{
action: BlockActionTypePB.Insert,
payload: {
@ -89,180 +271,19 @@ export function generateInsertBlockActions(
parent_id: parentId,
children_id: childrenId,
external_id: externalId,
external_type: 'text',
external_type: externalId ? 'text' : undefined,
},
prev_id: prevId,
parent_id: parentId,
},
},
];
}
export function generateMoveBlockActions(yXmlText: Y.XmlText, parentId: string, prevId: string | null) {
const id = yXmlText.getAttribute('blockId');
const blockParentId = yXmlText.getAttribute('parentId');
return [
{
action: BlockActionTypePB.Move,
payload: {
block: {
id,
parent_id: blockParentId,
},
parent_id: parentId,
prev_id: prevId || '',
},
},
];
}
export function YEvents2BlockActions(
sharedType: Y.XmlText,
events: Y.YEvent<Y.XmlText>[]
): ReturnType<typeof BlockActionPB.prototype.toObject>[] {
const actions: ReturnType<typeof BlockActionPB.prototype.toObject>[] = [];
events.forEach((event) => {
const eventActions = YEvent2BlockActions(sharedType, event);
if (eventActions.length === 0) return;
actions.push(...eventActions);
});
const deleteActions = actions.filter((action) => action.action === BlockActionTypePB.Delete);
const otherActions = actions.filter((action) => action.action !== BlockActionTypePB.Delete);
const filteredDeleteActions = filterDeleteActions(deleteActions);
return [...otherActions, ...filteredDeleteActions];
}
function filterDeleteActions(actions: ReturnType<typeof BlockActionPB.prototype.toObject>[]) {
return actions.filter((deleteAction) => {
const { payload } = deleteAction;
if (payload === undefined) return true;
const { parent_id } = payload;
return !actions.some((action) => action.payload?.block?.id === parent_id);
});
}
export function YEvent2BlockActions(
sharedType: Y.XmlText,
event: Y.YEvent<Y.XmlText>
): ReturnType<typeof BlockActionPB.prototype.toObject>[] {
const { target: yXmlText, keys, delta } = event;
// when the target is equal to the sharedType, it means that the change type is insert/delete block
const isBlockEvent = yXmlText === sharedType;
if (isBlockEvent) {
return blockOps2BlockActions(sharedType, delta);
}
const actions = textOps2BlockActions(sharedType, yXmlText, delta);
if (keys.size > 0) {
actions.push(...parentUpdatedOps2BlockActions(yXmlText, keys));
actions.push(...dataOps2BlockActions(yXmlText, keys));
}
return actions;
}
function textOps2BlockActions(
sharedType: Y.XmlText,
yXmlText: Y.XmlText,
ops: YDelta
): ReturnType<typeof BlockActionPB.prototype.toObject>[] {
if (ops.length === 0) return [];
const blockId = yXmlText.getAttribute('blockId');
const rootId = sharedType.getAttribute('rootId');
if (blockId === rootId) {
return [];
}
return generateApplyTextActions(yXmlText, ops);
}
function parentUpdatedOps2BlockActions(
yXmlText: Y.XmlText,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
keys: Map<string, { action: 'update' | 'add' | 'delete'; oldValue: any; newValue: any }>
) {
const parentUpdated = keys.has('parentId');
if (!parentUpdated) return [];
const parentId = yXmlText.getAttribute('parentId');
const prev = findPreviousSibling(yXmlText) as Y.XmlText;
const prevId = prev?.getAttribute('blockId');
fillIdRelationMap(yXmlText, yXmlText.doc?.getMap('idRelationMap') as Y.Map<string>);
return generateMoveBlockActions(yXmlText, parentId, prevId);
}
function dataOps2BlockActions(
yXmlText: Y.XmlText,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
keys: Map<string, { action: 'update' | 'add' | 'delete'; oldValue: any; newValue: any }>
) {
const dataUpdated = keys.has('data');
if (!dataUpdated) return [];
const data = yXmlText.getAttribute('data');
return generateUpdateDataActions(yXmlText, data);
}
function blockOps2BlockActions(
sharedType: Y.XmlText,
ops: YDelta
): ReturnType<typeof BlockActionPB.prototype.toObject>[] {
const actions: ReturnType<typeof BlockActionPB.prototype.toObject>[] = [];
const idList = sharedType.doc?.get('idList') as Y.XmlText;
const idRelationMap = sharedType.doc?.getMap('idRelationMap') as Y.Map<string>;
let index = 0;
ops.forEach((op) => {
if (op.insert) {
if (op.insert instanceof Y.XmlText) {
const insertYXmlText = op.insert;
actions.push(...generateInsertBlockActions(insertYXmlText));
}
index++;
} else if (op.retain) {
index += op.retain;
} else if (op.delete) {
const deletedDelta = idList.toDelta().slice(index, index + op.delete) as {
insert: {
id: string;
};
}[];
deletedDelta.forEach((delta) => {
const parentId = idRelationMap.get(delta.insert.id);
actions.push(
...generateDeleteBlockActions({
id: delta.insert.id,
parentId: parentId || '',
})
);
});
childrenInserts.forEach((insert) => {
if (insert instanceof Y.XmlText) {
actions.push(...generateInsertBlockActions(insert));
}
});
idList.applyDelta(convertToIdList(ops));
return actions;
}

View File

@ -1,5 +1,5 @@
import { nanoid } from 'nanoid';
import { EditorData, EditorInlineNodeType, EditorNodeType, Mention } from '$app/application/document/document.types';
import { EditorData, EditorInlineNodeType, Mention } from '$app/application/document/document.types';
import { Element, Text } from 'slate';
import { Op } from 'quill-delta';
@ -45,57 +45,57 @@ export function transformToInlineElement(op: Op): Element | null {
return null;
}
export function getInlinesWithDelta(delta?: Op[]): (Text | Element)[] {
return delta && delta.length > 0
? delta.map((op) => {
const matchInline = transformToInlineElement(op);
if (matchInline) {
return matchInline;
}
return {
text: op.insert as string,
...op.attributes,
};
})
: [
{
text: '',
},
];
}
export function convertToSlateValue(data: EditorData, includeRoot: boolean): Element[] {
const nodes: Element[] = [];
const traverse = (id: string, level: number, isHidden?: boolean) => {
const traverse = (id: string, isRoot = false) => {
const node = data.nodeMap[id];
const delta = data.deltaMap[id];
const slateNode: Element = {
type: node.type,
data: node.data,
level,
children: [],
isHidden,
blockId: id,
parentId: node.parent || '',
textId: node.externalId || '',
};
const inlineNodes: (Text | Element)[] = delta
? delta.map((op) => {
const matchInline = transformToInlineElement(op);
if (matchInline) {
return matchInline;
const textNode: Element | null =
!isRoot && node.externalId
? {
type: 'text',
children: [],
textId: node.externalId,
}
: null;
return {
text: op.insert as string,
...op.attributes,
};
})
: [];
const inlineNodes = getInlinesWithDelta(delta);
slateNode.children.push(...inlineNodes);
textNode?.children.push(...inlineNodes);
nodes.push(slateNode);
const children = data.childrenMap[id];
if (children) {
for (const childId of children) {
let isHidden = false;
if (node.type === EditorNodeType.ToggleListBlock) {
const collapsed = (node.data as { collapsed: boolean })?.collapsed;
if (collapsed) {
isHidden = true;
}
}
traverse(childId, level + 1, isHidden);
}
slateNode.children = children.map((childId) => traverse(childId));
if (textNode) {
slateNode.children.unshift(textNode);
}
return slateNode;
@ -103,10 +103,24 @@ export function convertToSlateValue(data: EditorData, includeRoot: boolean): Ele
const rootId = data.rootId;
traverse(rootId, 0);
const root = traverse(rootId, true);
if (!includeRoot) {
nodes.shift();
const nodes = root.children as Element[];
if (includeRoot) {
nodes.unshift({
...root,
children: [
{
type: 'text',
children: [
{
text: '',
},
],
},
],
});
}
return nodes;

View File

@ -5,31 +5,46 @@ import { inlineNodeTypes } from '$app/application/document/document.types';
import { DocEventPB } from '@/services/backend';
export function YDelta2Delta(yDelta: YDelta): Op[] {
return yDelta.map((op) => {
const ops: Op[] = [];
yDelta.forEach((op) => {
if (op.insert instanceof Y.XmlText) {
const type = op.insert.getAttribute('type');
if (inlineNodeTypes.includes(type)) {
return YInlineOp2Op(op);
ops.push(...YInlineOp2Op(op));
return;
}
}
return op as Op;
ops.push(op as Op);
});
return ops;
}
export function YInlineOp2Op(yOp: YOp): Op {
if (!(yOp.insert instanceof Y.XmlText)) return yOp as Op;
export function YInlineOp2Op(yOp: YOp): Op[] {
if (!(yOp.insert instanceof Y.XmlText)) {
return [
{
insert: yOp.insert as string,
attributes: yOp.attributes,
},
];
}
const type = yOp.insert.getAttribute('type');
const data = yOp.insert.getAttribute('data');
return {
insert: yOp.insert.toJSON(),
const delta = yOp.insert.toDelta() as Op[];
return delta.map((op) => ({
insert: op.insert,
attributes: {
[type]: data,
...op.attributes,
},
};
}));
}
export function DocEvent2YDelta(events: DocEventPB): YDelta {

View File

@ -1,48 +1,24 @@
import * as Y from 'yjs';
import { YDelta } from '$app/components/editor/provider/types/y_event';
export function findPreviousSibling(yXmlText: Y.XmlText) {
let prev = yXmlText.prevSibling;
export function getInsertTarget(root: Y.XmlText, path: (string | number)[]): Y.XmlText {
const delta = root.toDelta();
const index = path[0];
if (!prev) return null;
const current = delta[index];
const level = yXmlText.getAttribute('level');
if (!level) return null;
while (prev) {
const prevLevel = prev.getAttribute('level');
if (prevLevel === level) return prev;
if (prevLevel < level) return null;
prev = prev.prevSibling;
}
return prev;
}
export function fillIdRelationMap(yXmlText: Y.XmlText, idRelationMap: Y.Map<string>) {
const id = yXmlText.getAttribute('blockId');
const parentId = yXmlText.getAttribute('parentId');
if (id && parentId) {
idRelationMap.set(id, parentId);
}
}
export function convertToIdList(ops: YDelta) {
return ops.map((op) => {
if (op.insert instanceof Y.XmlText) {
const id = op.insert.getAttribute('blockId');
return {
insert: {
id,
},
};
if (current && current.insert instanceof Y.XmlText) {
if (path.length === 1) {
return current.insert;
}
return op;
});
return getInsertTarget(current.insert, path.slice(1));
}
return root;
}
export function getYTarget(doc: Y.Doc, path: (string | number)[]) {
const sharedType = doc.get('sharedType', Y.XmlText) as Y.XmlText;
return getInsertTarget(sharedType, path);
}

View File

@ -34,7 +34,7 @@ function NestedPage({ pageId }: { pageId: string }) {
});
const className = useMemo(() => {
const defaultClassName = 'relative flex flex-col w-full';
const defaultClassName = 'relative flex-1 flex flex-col w-full';
if (isDragging) {
return `${defaultClassName} opacity-40`;

View File

@ -29,7 +29,7 @@ function SideBar() {
<div className={'flex h-[36px] items-center'}>
<UserInfo />
</div>
<div className={'flex-1'}>
<div className={'flex-1 overflow-hidden'}>
<WorkspaceManager />
</div>
</div>

View File

@ -8,7 +8,7 @@ function WorkspaceNestedPages({ workspaceId }: { workspaceId: string }) {
});
return (
<div className={'h-[100%] overflow-y-auto overflow-x-hidden'}>
<div className={'h-full'}>
{pageIds?.map((pageId) => (
<NestedPage key={pageId} pageId={pageId} />
))}

View File

@ -32,7 +32,7 @@ function TrashButton() {
onDragLeave={onDragLeave}
data-page-id={'trash'}
onClick={navigateToTrash}
className={`mx-1 flex w-[100%] items-center rounded-lg p-2 hover:bg-fill-list-hover ${
className={`mx-1 my-3 flex h-[32px] w-[100%] items-center rounded-lg p-2 hover:bg-fill-list-hover ${
selected ? 'bg-fill-list-active' : ''
} ${isDraggingOver ? 'bg-fill-list-hover' : ''}`}
>

Some files were not shown because too many files have changed in this diff Show More