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

View File

@ -8,12 +8,18 @@ export interface EditorNode {
id: string; id: string;
type: EditorNodeType; type: EditorNodeType;
parent?: string | null; parent?: string | null;
data?: unknown; data?: BlockData;
children?: string; children?: string;
externalId?: string; externalId?: string;
externalType?: string; externalType?: string;
} }
export interface TextNode extends Element {
type: EditorNodeType.Text;
textId: string;
blockId: string;
}
export interface PageNode extends Element { export interface PageNode extends Element {
type: EditorNodeType.Page; type: EditorNodeType.Page;
} }
@ -21,32 +27,38 @@ export interface ParagraphNode extends Element {
type: EditorNodeType.Paragraph; type: EditorNodeType.Paragraph;
} }
export type BlockData = {
[key: string]: string | boolean | number | undefined;
font_color?: string;
bg_color?: string;
};
export interface HeadingNode extends Element { export interface HeadingNode extends Element {
type: EditorNodeType.HeadingBlock; type: EditorNodeType.HeadingBlock;
data: { data: {
level: number; level: number;
}; } & BlockData;
} }
export interface GridNode extends Element { export interface GridNode extends Element {
type: EditorNodeType.GridBlock; type: EditorNodeType.GridBlock;
data: { data: {
viewId?: string; viewId?: string;
}; } & BlockData;
} }
export interface TodoListNode extends Element { export interface TodoListNode extends Element {
type: EditorNodeType.TodoListBlock; type: EditorNodeType.TodoListBlock;
data: { data: {
checked: boolean; checked: boolean;
}; } & BlockData;
} }
export interface CodeNode extends Element { export interface CodeNode extends Element {
type: EditorNodeType.CodeBlock; type: EditorNodeType.CodeBlock;
data: { data: {
language: string; language: string;
}; } & BlockData;
} }
export interface QuoteNode extends Element { export interface QuoteNode extends Element {
@ -65,7 +77,7 @@ export interface ToggleListNode extends Element {
type: EditorNodeType.ToggleListBlock; type: EditorNodeType.ToggleListBlock;
data: { data: {
collapsed: boolean; collapsed: boolean;
}; } & BlockData;
} }
export interface DividerNode extends Element { export interface DividerNode extends Element {
@ -76,18 +88,19 @@ export interface CalloutNode extends Element {
type: EditorNodeType.CalloutBlock; type: EditorNodeType.CalloutBlock;
data: { data: {
icon: string; icon: string;
}; } & BlockData;
} }
export interface MathEquationNode extends Element { export interface MathEquationNode extends Element {
type: EditorNodeType.EquationBlock; type: EditorNodeType.EquationBlock;
data: { data: {
formula?: string; formula?: string;
}; } & BlockData;
} }
export interface FormulaNode extends Element { export interface FormulaNode extends Element {
type: EditorInlineNodeType.Formula; type: EditorInlineNodeType.Formula;
data: boolean;
} }
export interface MentionNode extends Element { export interface MentionNode extends Element {
@ -129,6 +142,7 @@ export interface EditorProps {
} }
export enum EditorNodeType { export enum EditorNodeType {
Text = 'text',
Paragraph = 'paragraph', Paragraph = 'paragraph',
Page = 'page', Page = 'page',
HeadingBlock = 'heading', HeadingBlock = 'heading',
@ -145,8 +159,6 @@ export enum EditorNodeType {
GridBlock = 'grid', GridBlock = 'grid',
} }
export const blockTypes: string[] = Object.values(EditorNodeType);
export enum EditorInlineNodeType { export enum EditorInlineNodeType {
Mention = 'mention', Mention = 'mention',
Formula = 'formula', Formula = 'formula',
@ -196,18 +208,6 @@ export enum EditorStyleFormat {
Href = 'href', 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 { export enum EditorTurnFormat {
Paragraph = 'paragraph', Paragraph = 'paragraph',
Heading1 = 'heading1', // 'heading1' is a special format, it's not a slate node type, but a slate node type's data 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(); await save();
onOutsideClick(); onOutsideClick();
}} }}
disableRestoreFocus={true}
anchorOrigin={{ anchorOrigin={{
vertical: 'bottom', vertical: 'bottom',
horizontal: 'left', 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 { t } from 'i18next';
import { PageController } from '$app/stores/effects/workspace/page/page_controller';
import { useViewId } from '$app/hooks'; import { useViewId } from '$app/hooks';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { updatePageName } from '$app_reducers/pages/async_actions';
export const DatabaseTitle = () => { export const DatabaseTitle = () => {
const viewId = useViewId(); const viewId = useViewId();
const [title, setTitle] = useState(''); const pageName = useAppSelector((state) => state.pages.pageMap[viewId].name);
const dispatch = useAppDispatch();
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 handleInput = useCallback<FormEventHandler>( const handleInput = useCallback<FormEventHandler>(
(event) => { (event) => {
const newTitle = (event.target as HTMLInputElement).value; const newTitle = (event.target as HTMLInputElement).value;
void controller.updatePage({ void dispatch(updatePageName({ id: viewId, name: newTitle }));
id: viewId,
name: newTitle,
});
}, },
[viewId, controller] [viewId, dispatch]
); );
return ( return (
<div className='mb-6 h-[70px] px-16 pt-8'> <div className='mb-6 h-[70px] px-16 pt-8'>
<input <input
className='text-3xl font-semibold' className='text-3xl font-semibold'
value={title} value={pageName}
placeholder={t('grid.title.placeholder')} placeholder={t('grid.title.placeholder')}
onInput={handleInput} onInput={handleInput}
/> />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -33,7 +33,7 @@ function FilterActions({ filter }: { filter: Filter }) {
> >
<MoreSvg /> <MoreSvg />
</IconButton> </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> <MenuItem onClick={onDelete}>{t('grid.settings.deleteFilter')}</MenuItem>
</Menu> </Menu>
</> </>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -75,6 +75,7 @@ function GridRowMenu({ rowId, ...props }: Props) {
return ( return (
<Popover <Popover
disableRestoreFocus={true}
keepMounted={false} keepMounted={false}
anchorReference={'anchorPosition'} anchorReference={'anchorPosition'}
transformOrigin={{ vertical: 'top', horizontal: 'left' }} 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 { Page, PageIcon } from '$app_reducers/pages/slice';
import { useAppDispatch } from '$app/stores/store'; import { useAppDispatch } from '$app/stores/store';
import ViewTitle from '$app/components/_shared/ViewTitle'; 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 { 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 { 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 { insertFormula, isFormulaActive, unwrapFormula, updateFormula } from '$app/components/editor/command/formula';
import { import {
EditorInlineNodeType, EditorInlineNodeType,
@ -16,60 +16,69 @@ import { generateId } from '$app/components/editor/provider/utils/convert';
import { YjsEditor } from '@slate-yjs/core'; import { YjsEditor } from '@slate-yjs/core';
export const CustomEditor = { 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>) => { turnToBlock: (editor: ReactEditor, newProperties: Partial<Element>) => {
const selection = editor.selection; const selection = editor.selection;
if (!selection) return; if (!selection) return;
const [match] = Editor.nodes(editor, { const match = CustomEditor.getBlock(editor);
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined && n.type !== undefined,
});
if (!match) return; if (!match) return;
const [node, path] = match as NodeEntry<Element>; const [node, path] = match as NodeEntry<Element>;
const parentId = node.parentId; const cloneNode = CustomEditor.cloneBlock(editor, node);
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],
});
});
Transforms.removeNodes(editor, { Transforms.removeNodes(editor, {
at: path, at: path,
}); });
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); Transforms.select(editor, selection);
} else {
Transforms.select(editor, path);
}
}, },
tabForward, tabForward,
tabBackward, tabBackward,
toggleMark, toggleMark,
removeMarks,
isMarkActive, isMarkActive,
isFormulaActive, isFormulaActive,
updateFormula, 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) { isBlockActive(editor: ReactEditor, format?: string) {
return match && (match[0] as Element).type === format; const match = CustomEditor.getBlock(editor);
if (match && format !== undefined) {
return match[0].type === format;
} }
return !!match; return !!match;
}, },
insertMention(editor: ReactEditor, mention: Mention) { insertMention(editor: ReactEditor, mention: Mention) {
const mentionElement = { const mentionElement = {
type: EditorInlineNodeType.Mention, type: EditorInlineNodeType.Mention,
@ -106,62 +115,6 @@ export const CustomEditor = {
Transforms.move(editor); 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) { toggleTodo(editor: ReactEditor, node: TodoListNode) {
const checked = node.data.checked; const checked = node.data.checked;
const path = ReactEditor.findPath(editor, node); const path = ReactEditor.findPath(editor, node);
@ -175,38 +128,19 @@ export const CustomEditor = {
}, },
toggleToggleList(editor: ReactEditor, node: ToggleListNode) { 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 path = ReactEditor.findPath(editor, node);
const newProperties = { const newProperties = {
data: { data: {
collapsed, collapsed: !collapsed,
}, },
} as Partial<Element>; } as Partial<Element>;
Transforms.select(editor, path);
Transforms.collapse(editor, { edge: 'end' });
Transforms.setNodes(editor, newProperties, { at: path }); Transforms.setNodes(editor, newProperties, { at: path });
editor.select(path);
// hide or show the children editor.collapse({
const index = path[0]; edge: 'start',
});
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 });
}
}
}, },
setCalloutIcon(editor: ReactEditor, node: CalloutNode, newIcon: string) { setCalloutIcon(editor: ReactEditor, node: CalloutNode, newIcon: string) {
@ -242,60 +176,55 @@ export const CustomEditor = {
Transforms.setNodes(editor, newProperties, { at: path }); Transforms.setNodes(editor, newProperties, { at: path });
}, },
findNodeChildren(editor: ReactEditor, node: Node) { cloneBlock(editor: ReactEditor, block: Element): Element {
const nodeId = (node as Element).blockId; const cloneNode: Element = {
...cloneDeep(block),
return editor.children.filter((child) => (child as Element).parentId === nodeId) as Element[]; blockId: generateId(),
}, children: [],
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,
}; };
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 cloneChildren = children.map((child) => {
const childBlockId = generateId(); return CustomEditor.cloneBlock(editor, child);
const childTextId = generateId();
return {
...cloneDeep(child),
blockId: childBlockId,
textId: childTextId,
parentId: newBlockId,
};
}); });
const path = ReactEditor.findPath(editor, node); cloneNode.children.push(...cloneChildren);
const endPath = children.length ? ReactEditor.findPath(editor, children[children.length - 1]) : null;
Transforms.insertNodes(editor, [cloneNode, ...cloneChildren], { at: [endPath ? endPath[0] + 1 : path[0] + 1] }); return cloneNode;
Transforms.move(editor); },
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) { deleteNode(editor: ReactEditor, node: Node) {
const children = CustomEditor.findNodeChildren(editor, node);
const path = ReactEditor.findPath(editor, node); const path = ReactEditor.findPath(editor, node);
const endPath = children.length ? ReactEditor.findPath(editor, children[children.length - 1]) : null;
Transforms.removeNodes(editor, { Transforms.removeNodes(editor, {
at: { at: path,
anchor: { path, offset: 0 },
focus: { path: endPath ?? path, offset: 0 },
},
}); });
Transforms.move(editor);
}, },
getBlockType: (editor: ReactEditor) => { getBlockType: (editor: ReactEditor) => {
const [match] = Editor.nodes(editor, { const match = CustomEditor.getBlock(editor);
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined && n.type !== undefined,
});
if (!match) return null; if (!match) return null;
@ -304,10 +233,6 @@ export const CustomEditor = {
return node.type as EditorNodeType; return node.type as EditorNodeType;
}, },
isGridBlock: (editor: ReactEditor) => {
return CustomEditor.getBlockType(editor) === EditorNodeType.GridBlock;
},
selectionIncludeRoot: (editor: ReactEditor) => { selectionIncludeRoot: (editor: ReactEditor) => {
const [match] = Editor.nodes(editor, { const [match] = Editor.nodes(editor, {
match: (n) => Element.isElement(n) && n.blockId !== undefined && n.type === EditorNodeType.Page, match: (n) => Element.isElement(n) && n.blockId !== undefined && n.type === EditorNodeType.Page,
@ -324,12 +249,19 @@ export const CustomEditor = {
editor.insertNode( editor.insertNode(
{ {
type: EditorNodeType.Paragraph, type: EditorNodeType.Paragraph,
level: 1,
data: {}, data: {},
blockId: generateId(), blockId: generateId(),
children: [
{
type: EditorNodeType.Text,
textId: generateId(), textId: generateId(),
parentId: editor.sharedRoot.getAttribute('blockId'), children: [
children: [{ text: '' }], {
text: '',
},
],
},
],
}, },
{ {
select: true, select: true,
@ -340,40 +272,68 @@ export const CustomEditor = {
Transforms.move(editor); Transforms.move(editor);
}, },
basePointToIndexLength(editor: ReactEditor, point: BasePoint, toStart = false) { focusAtStartOfBlock(editor: ReactEditor) {
const { path, offset } = point; const { selection } = editor;
const node = editor.children[path[0]] as Element; if (selection && Range.isCollapsed(selection)) {
const blockId = node.blockId; const match = CustomEditor.getBlock(editor);
const [, path] = match as NodeEntry<Element>;
const start = Editor.start(editor, path);
if (!blockId) return; return match && Point.equals(selection.anchor, start);
const beforeText = Editor.string(editor, { }
anchor: {
path: [path[0], 0], return false;
offset: 0,
}, },
focus: {
path, setBlockColor(
offset, 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 });
}, },
});
const index = beforeText.length; deleteAllText(editor: ReactEditor, node: Element) {
const fullText = Editor.string(editor, [path[0]]); const [textNode] = (node.children || []) as Element[];
const length = fullText.length - index; const hasTextNode = textNode && textNode.type === EditorNodeType.Text;
if (toStart) { if (!hasTextNode) return;
return { const path = ReactEditor.findPath(editor, textNode);
index: 0, const textLength = editor.string(path).length;
length: index, const start = Editor.start(editor, path);
blockId,
}; for (let i = 0; i < textLength; i++) {
} else { editor.select(start);
return { editor.deleteForward('character');
index,
length,
blockId,
};
} }
}, },
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; 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 { ReactEditor } from 'slate-react';
import { EditorNodeType } from '$app/application/document/document.types'; import { EditorNodeType } from '$app/application/document/document.types';
import { CustomEditor } from '$app/components/editor/command/index'; import { CustomEditor } from '$app/components/editor/command/index';
@ -35,34 +35,21 @@ const LIST_ITEM_TYPES = [
* @param editor * @param editor
*/ */
export function tabForward(editor: ReactEditor) { export function tabForward(editor: ReactEditor) {
const [match] = Editor.nodes(editor, { const match = CustomEditor.getBlock(editor);
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && Editor.isBlock(editor, n),
});
if (!match) return; if (!match) return;
const [node, path] = match as NodeEntry<Element>; const [node, path] = match as NodeEntry<Element>;
if (!node.level) return;
// the node is not a list item // the node is not a list item
if (!LIST_ITEM_TYPES.includes(node.type as EditorNodeType)) { if (!LIST_ITEM_TYPES.includes(node.type as EditorNodeType)) {
return; return;
} }
let previousNode; const previousPath = Path.previous(path);
for (let i = path[0] - 1; i >= 0; i--) { const previous = editor.node(previousPath);
const ancestor = editor.children[i] as Element & { level: number }; const [previousNode] = previous as NodeEntry<Element>;
if (ancestor.level === node.level) {
previousNode = ancestor;
break;
}
if (ancestor.level < node.level) {
break;
}
}
if (!previousNode) return; if (!previousNode) return;
@ -71,93 +58,38 @@ export function tabForward(editor: ReactEditor) {
// the previous node is not a list // the previous node is not a list
if (!LIST_TYPES.includes(type)) return; if (!LIST_TYPES.includes(type)) return;
const previousNodeLevel = previousNode.level; const toPath = [...previousPath, previousNode.children.length];
if (!previousNodeLevel) return; editor.moveNodes({
at: path,
const newParentId = previousNode.blockId; to: toPath,
const children = CustomEditor.findNodeChildren(editor, node);
children.forEach((child) => {
const childPath = ReactEditor.findPath(editor, child);
Transforms.setNodes(
editor,
{
parentId: newParentId,
},
{
at: childPath,
}
);
}); });
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) { export function tabBackward(editor: ReactEditor) {
const [match] = Editor.nodes(editor, { const match = CustomEditor.getBlock(editor);
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && Editor.isBlock(editor, n),
});
if (!match) return; 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 (node.type === EditorNodeType.Page) return;
if (node.type !== EditorNodeType.Paragraph) {
if (level <= 1) return; CustomEditor.turnToBlock(editor, {
const parent = CustomEditor.findParentNode(editor, node); type: EditorNodeType.Paragraph,
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,
}
);
}); });
return;
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 });
} }
editor.liftNodes({
at: path,
});
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -9,21 +9,22 @@ export const Code = memo(
const { language, handleChangeLanguage } = useCodeBlock(node); const { language, handleChangeLanguage } = useCodeBlock(node);
return ( return (
<>
<div contentEditable={false} className={'absolute w-full select-none px-7 py-6'}>
<LanguageSelect language={language} onChangeLanguage={handleChangeLanguage} />
</div>
<div <div
{...attributes} {...attributes}
ref={ref} ref={ref}
className={`${ className={`${
attributes.className ?? '' attributes.className ?? ''
} my-2 w-full rounded border border-solid border-line-divider bg-content-blue-50 p-6`} } my-2 flex w-full rounded border border-solid border-line-divider bg-content-blue-50 p-6 pt-14`}
> >
<div contentEditable={false} className={'mb-2 w-full'}> <pre>
<LanguageSelect language={language} onChangeLanguage={handleChangeLanguage} />
</div>
<pre className='code-block-element'>
<code>{children}</code> <code>{children}</code>
</pre> </pre>
</div> </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 { EditorElementProps, GridNode } from '$app/application/document/document.types';
import GridView from '$app/components/editor/components/blocks/database/GridView'; import GridView from '$app/components/editor/components/blocks/database/GridView';
import DatabaseEmpty from '$app/components/editor/components/blocks/database/DatabaseEmpty'; import DatabaseEmpty from '$app/components/editor/components/blocks/database/DatabaseEmpty';
import { EditorSelectedBlockContext } from '$app/components/editor/components/editor/Editor.hooks';
export const GridBlock = memo( 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; const viewId = node.data.viewId;
const blockId = node.blockId;
const selectedBlockContext = useContext(EditorSelectedBlockContext);
const onClick = useCallback(() => {
if (!blockId) return;
selectedBlockContext.clear();
selectedBlockContext.add(blockId);
}, [blockId, selectedBlockContext]);
return ( 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 <div
contentEditable={false} contentEditable={false}
className='relative flex h-[400px] overflow-hidden border-b border-t border-line-divider caret-text-title' className='flex h-[400px] overflow-hidden border-b border-t border-line-divider bg-bg-body py-3 caret-text-title'
ref={ref}
> >
{viewId ? <GridView viewId={viewId} /> : <DatabaseEmpty node={node} />} {viewId ? <GridView viewId={viewId} /> : <DatabaseEmpty node={node} />}
</div>
<div className={'invisible absolute'}>{children}</div>
</div> </div>
); );
}) })

View File

@ -1,2 +1 @@
export * from './GridBlock'; 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( export const DividerNode = memo(
forwardRef<HTMLDivElement, EditorElementProps<DividerNodeType>>( forwardRef<HTMLDivElement, EditorElementProps<DividerNodeType>>(
({ node: _node, children: children, ...attributes }, ref) => { ({ node: _node, children: children, className, ...attributes }, ref) => {
return ( return (
<div <div {...attributes} className={`${className} relative`}>
{...attributes} <div contentEditable={false} className={'w-full py-2.5 text-line-divider'}>
ref={ref}
contentEditable={false}
className={`${attributes.className ?? ''} relative w-full`}
>
<div className={'w-full py-2.5 text-line-divider'}>
<hr /> <hr />
</div> </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> </div>
); );
} }

View File

@ -1,6 +1,5 @@
import React, { forwardRef, memo } from 'react'; import React, { forwardRef, memo } from 'react';
import { EditorElementProps, HeadingNode } from '$app/application/document/document.types'; 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'; import { getHeadingCssProperty } from '$app/components/editor/plugins/utils';
export const Heading = memo( export const Heading = memo(
@ -8,13 +7,10 @@ export const Heading = memo(
const level = node.data.level; const level = node.data.level;
const fontSizeCssProperty = getHeadingCssProperty(level); const fontSizeCssProperty = getHeadingCssProperty(level);
const className = `${attributes.className ?? ''} font-bold ${fontSizeCssProperty}`;
return ( return (
<div <div {...attributes} ref={ref} className={className}>
{...attributes}
ref={ref}
className={`${attributes.className ?? ''} leading-1 relative font-bold ${fontSizeCssProperty}`}
>
<Placeholder node={node} className={fontSizeCssProperty} />
{children} {children}
</div> </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 Popover from '@mui/material/Popover';
import { PopoverCommonProps } from '$app/components/editor/components/tools/popover'; import { PopoverCommonProps } from '$app/components/editor/components/tools/popover';
import { TextareaAutosize } from '@mui/material'; import { TextareaAutosize } from '@mui/material';
@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
import { CustomEditor } from '$app/components/editor/command'; import { CustomEditor } from '$app/components/editor/command';
import { Element } from 'slate'; import { Element } from 'slate';
import { KeyboardReturnOutlined } from '@mui/icons-material'; import { KeyboardReturnOutlined } from '@mui/icons-material';
import { useSlateStatic } from 'slate-react'; import { ReactEditor, useSlateStatic } from 'slate-react';
function EditPopover({ function EditPopover({
open, open,
@ -28,10 +28,19 @@ function EditPopover({
setValue(event.currentTarget.value); 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 = () => { const handleDone = () => {
if (!node) return; if (!node) return;
CustomEditor.setMathEquationBlockFormula(editor, node, value); CustomEditor.setMathEquationBlockFormula(editor, node, value);
onClose(); handleClose();
}; };
const handleEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@ -58,7 +67,7 @@ function EditPopover({
vertical: 'bottom', vertical: 'bottom',
horizontal: 'center', horizontal: 'center',
}} }}
onClose={onClose} onClose={handleClose}
> >
<div className={'flex flex-col gap-3 p-4'}> <div className={'flex flex-col gap-3 p-4'}>
<TextareaAutosize <TextareaAutosize

View File

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

View File

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

View File

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

View File

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

View File

@ -1,19 +1,15 @@
import React, { forwardRef, memo, useMemo } from 'react'; import React, { forwardRef, memo, useMemo } from 'react';
import { EditorElementProps, QuoteNode } from '$app/application/document/document.types'; import { EditorElementProps, QuoteNode } from '$app/application/document/document.types';
import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder';
export const QuoteList = memo( export const QuoteList = memo(
forwardRef<HTMLDivElement, EditorElementProps<QuoteNode>>(({ node, children, ...attributes }, ref) => { forwardRef<HTMLDivElement, EditorElementProps<QuoteNode>>(({ node: _, children, ...attributes }, ref) => {
const className = useMemo(() => { 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]); }, [attributes.className]);
return ( return (
<div {...attributes} ref={ref} className={className}> <div {...attributes} ref={ref} className={className}>
<span className={'relative left-2'}>
<Placeholder node={node} />
{children} {children}
</span>
</div> </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 CheckboxCheckSvg } from '$app/assets/database/checkbox-check.svg';
import { ReactComponent as CheckboxUncheckSvg } from '$app/assets/database/checkbox-uncheck.svg'; import { ReactComponent as CheckboxUncheckSvg } from '$app/assets/database/checkbox-uncheck.svg';
import { useSlateStatic } from 'slate-react'; import { useSlateStatic } from 'slate-react';
import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder';
import { CustomEditor } from '$app/components/editor/command'; import { CustomEditor } from '$app/components/editor/command';
export const TodoList = memo( export const TodoList = memo(
@ -11,28 +10,26 @@ export const TodoList = memo(
const { checked } = node.data; const { checked } = node.data;
const editor = useSlateStatic(); const editor = useSlateStatic();
const className = useMemo(() => { const className = useMemo(() => {
return `relative ${attributes.className ?? ''}`; return `flex w-full flex-col pl-6 ${checked ? 'checked' : ''} ${attributes.className ?? ''}`;
}, [attributes.className]); }, [attributes.className, checked]);
const toggleTodo = useCallback(() => { const toggleTodo = useCallback(() => {
CustomEditor.toggleTodo(editor, node); CustomEditor.toggleTodo(editor, node);
}, [editor, node]); }, [editor, node]);
return ( return (
<div {...attributes} ref={ref} className={className}> <>
<span <span
data-playwright-selected={false} data-playwright-selected={false}
contentEditable={false} contentEditable={false}
onClick={toggleTodo} 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 />} {checked ? <CheckboxCheckSvg /> : <CheckboxUncheckSvg />}
</span> </span>
<div {...attributes} ref={ref} className={className}>
<span className={`relative ml-6 ${checked ? 'text-text-caption line-through' : ''}`}>
<Placeholder node={node} />
{children} {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 { EditorElementProps, ToggleListNode } from '$app/application/document/document.types';
import { ReactEditor, useSlateStatic } from 'slate-react'; import { ReactEditor, useSlateStatic } from 'slate-react';
import { ReactComponent as RightSvg } from '$app/assets/more.svg'; 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'; import { CustomEditor } from '$app/components/editor/command';
export const ToggleList = memo( export const ToggleList = memo(
@ -10,27 +9,26 @@ export const ToggleList = memo(
const { collapsed } = node.data; const { collapsed } = node.data;
const editor = useSlateStatic() as ReactEditor; const editor = useSlateStatic() as ReactEditor;
const className = useMemo(() => { const className = useMemo(() => {
return `relative ${attributes.className ?? ''}`; return `pl-6 ${attributes.className ?? ''} ${collapsed ? 'collapsed' : ''}`;
}, [attributes.className]); }, [attributes.className, collapsed]);
const toggleToggleList = useCallback(() => { const toggleToggleList = useCallback(() => {
CustomEditor.toggleToggleList(editor, node); CustomEditor.toggleToggleList(editor, node);
}, [editor, node]); }, [editor, node]);
return ( return (
<div {...attributes} ref={ref} className={className}> <>
<span <span
data-playwright-selected={false}
contentEditable={false} contentEditable={false}
onClick={toggleToggleList} 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'} />} {collapsed ? <RightSvg /> : <RightSvg className={'rotate-90 transform'} />}
</span> </span>
<span className={'z-1 relative ml-6'}> <div {...attributes} ref={ref} className={className}>
<Placeholder node={node} />
{children} {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 Editor from '$app/components/editor/components/editor/Editor';
import { EditorProps } from '$app/application/document/document.types'; import { EditorProps } from '$app/application/document/document.types';
import { Provider } from '$app/components/editor/provider'; import { Provider } from '$app/components/editor/provider';
import { YXmlText } from 'yjs/dist/src/types/YXmlText'; 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 [sharedType, setSharedType] = useState<YXmlText | null>(null);
const provider = useMemo(() => { const provider = useMemo(() => {
setSharedType(null); setSharedType(null);
@ -13,18 +14,25 @@ export const CollaborativeEditor = ({ id, title, showTitle = true, onTitleChange
}, [id, showTitle]); }, [id, showTitle]);
const root = useMemo(() => { 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]); }, [sharedType, showTitle]);
useEffect(() => { const rootText = useMemo(() => {
if (!root || root.toString() === title) return; if (!root) return null;
return getInsertTarget(root, [0]);
}, [root]);
if (root.length > 0) { useEffect(() => {
root.delete(0, root.length); if (!rootText || rootText.toString() === title) return;
if (rootText.length > 0) {
rootText.delete(0, rootText.length);
} }
root.insert(0, title || ''); rootText.insert(0, title || '');
}, [title, root]); }, [title, rootText]);
useEffect(() => { useEffect(() => {
if (!root) return; if (!root) return;
@ -32,8 +40,8 @@ export const CollaborativeEditor = ({ id, title, showTitle = true, onTitleChange
onTitleChange?.(root.toString()); onTitleChange?.(root.toString());
}; };
root.observe(onChange); root.observeDeep(onChange);
return () => root.unobserve(onChange); return () => root.unobserveDeep(onChange);
}, [onTitleChange, root]); }, [onTitleChange, root]);
useEffect(() => { useEffect(() => {
@ -55,4 +63,4 @@ export const CollaborativeEditor = ({ id, title, showTitle = true, onTitleChange
} }
return <Editor sharedType={sharedType} id={id} />; 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 { EditorNodeType, CodeNode } from '$app/application/document/document.types';
import { createEditor, NodeEntry, BaseRange, Editor, Element } from 'slate'; 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 { withYjs, YjsEditor, withYHistory } from '@slate-yjs/core';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { CustomEditor } from '$app/components/editor/command'; import { CustomEditor } from '$app/components/editor/command';
import { proxySet, subscribeKey } from 'valtio/utils';
export function useEditor(sharedType: Y.XmlText) { export function useEditor(sharedType: Y.XmlText) {
const editor = useMemo(() => { 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 [selectedLength, setSelectedLength] = useState(0);
const blockIds = useContext(EditorSelectedBlockContext);
if (blockId === undefined) { subscribeKey(selectedBlocks, 'size', (v) => setSelectedLength(v));
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]
);
useEffect(() => { useEffect(() => {
const handleClick = () => { const { onChange } = editor;
if (selectedBlockId.length === 0) return;
setSelectedBlockId([]); 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) {
return () => { editor.onChange = (...args) => {
document.removeEventListener('click', handleClick); const isSelectionChange = editor.operations.every((arg) => arg.type === 'set_selection');
if (isSelectionChange) {
selectedBlocks.clear();
}
onChange(...args);
}; };
}, [selectedBlockId]);
document.addEventListener('keydown', onKeydown);
} else {
editor.onChange = onChange;
document.removeEventListener('keydown', onKeydown);
}
return () => {
editor.onChange = onChange;
document.removeEventListener('keydown', onKeydown);
};
}, [editor, selectedBlocks, selectedLength]);
return { return {
selectedBlockId, selectedBlocks,
onSelectedBlock,
}; };
} }
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 { import {
EditorSelectedBlockProvider, EditorSelectedBlockProvider,
useDecorate, useDecorate,
useEditor, useEditor,
useEditorSelectedBlock, useEditorState,
} from '$app/components/editor/components/editor/Editor.hooks'; } from '$app/components/editor/components/editor/Editor.hooks';
import { Slate } from 'slate-react'; import { Slate } from 'slate-react';
import { CustomEditable } from '$app/components/editor/components/editor/CustomEditable'; 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 { editor, initialValue, handleOnClickEnd, ...props } = useEditor(sharedType);
const decorate = useDecorate(editor); const decorate = useDecorate(editor);
const { onDOMBeforeInput, onKeyDown: onShortcutsKeyDown } = useShortcuts(editor); const { onDOMBeforeInput, onKeyDown: onShortcutsKeyDown } = useShortcuts(editor);
const { selectedBlocks } = useEditorState(editor);
const { onSelectedBlock, selectedBlockId } = useEditorSelectedBlock(editor);
if (editor.sharedRoot.length === 0) { if (editor.sharedRoot.length === 0) {
return <CircularProgress className='m-auto' />; return <CircularProgress className='m-auto' />;
} }
return ( return (
<EditorSelectedBlockProvider value={selectedBlockId}> <EditorSelectedBlockProvider value={selectedBlocks}>
<Slate editor={editor} initialValue={initialValue}> <Slate editor={editor} initialValue={initialValue}>
<SelectionToolbar /> <SelectionToolbar />
<BlockActionsToolbar onSelectedBlock={onSelectedBlock} /> <BlockActionsToolbar />
<CustomEditable <CustomEditable
{...props} {...props}
onDOMBeforeInput={onDOMBeforeInput} onDOMBeforeInput={onDOMBeforeInput}
onKeyDown={onShortcutsKeyDown} onKeyDown={onShortcutsKeyDown}
decorate={decorate} decorate={decorate}
className={'caret-text-title outline-none focus:outline-none'} className={'px-16 caret-text-title outline-none focus:outline-none'}
/> />
<SlashCommandPanel /> <SlashCommandPanel />
<MentionPanel /> <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 React, { FC, HTMLAttributes, useMemo } from 'react';
import { RenderElementProps } from 'slate-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 { Paragraph } from '$app/components/editor/components/blocks/paragraph';
import { Heading } from '$app/components/editor/components/blocks/heading'; import { Heading } from '$app/components/editor/components/blocks/heading';
import { TodoList } from '$app/components/editor/components/blocks/todo_list'; 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 { Mention } from '$app/components/editor/components/inline_nodes/mention';
import { GridBlock } from '$app/components/editor/components/blocks/database'; import { GridBlock } from '$app/components/editor/components/blocks/database';
import { MathEquation } from '$app/components/editor/components/blocks/math_equation'; import { MathEquation } from '$app/components/editor/components/blocks/math_equation';
import { useSelectedBlock } from '$app/components/editor/components/editor/Editor.hooks'; import { Text as TextComponent } from '../blocks/text';
import Page from '../blocks/page/Page'; import { Page } from '../blocks/page';
import { useElementState } from '$app/components/editor/components/editor/Element.hooks';
function Element({ element, attributes, children }: RenderElementProps) { function Element({ element, attributes, children }: RenderElementProps) {
const node = element; const node = element;
@ -65,13 +72,20 @@ function Element({ element, attributes, children }: RenderElementProps) {
} }
}, [node.type]) as FC<EditorElementProps & HTMLAttributes<HTMLElement>>; }, [node.type]) as FC<EditorElementProps & HTMLAttributes<HTMLElement>>;
const marginLeft = useMemo(() => { const { isSelected } = useElementState(node);
if (!node.level) return;
return (node.level - 1) * 24; const className = useMemo(() => {
}, [node.level]); 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) { if (InlineComponent) {
return ( return (
@ -81,15 +95,17 @@ function Element({ element, attributes, children }: RenderElementProps) {
); );
} }
if (node.type === EditorNodeType.Text) {
return ( return (
<div <TextComponent {...attributes} node={node as TextNode}>
{...attributes} {children}
style={{ </TextComponent>
marginLeft, );
}} }
className={`${node.isHidden ? 'hidden' : 'inline-block'} block-element leading-1 my-0.5 w-full px-16`}
> return (
<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} {children}
</Component> </Component>
</div> </div>

View File

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

View File

@ -1,7 +1,5 @@
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
import { Editor, Range } from 'slate'; 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'; import { CustomEditor } from '$app/components/editor/command';
export enum EditorCommand { export enum EditorCommand {
@ -36,7 +34,7 @@ export function withCommandShortcuts(editor: ReactEditor) {
}); });
if (endOfPanelChar !== undefined && selection && Range.isCollapsed(selection)) { if (endOfPanelChar !== undefined && selection && Range.isCollapsed(selection)) {
const block = getBlockEntry(editor); const block = CustomEditor.getBlock(editor);
const path = block ? block[1] : []; const path = block ? block[1] : [];
const { anchor } = selection; const { anchor } = selection;
const beforeText = Editor.string(editor, { anchor, focus: Editor.start(editor, path) }) + text.slice(0, -1); 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)) { if (selection && Range.isCollapsed(selection)) {
const { anchor } = selection; const { anchor } = selection;
const block = getBlockEntry(editor); const block = CustomEditor.getBlock(editor);
const path = block ? block[1] : []; const path = block ? block[1] : [];
const beforeText = Editor.string(editor, { anchor, focus: Editor.start(editor, path) }); 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 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); const slateDom = ReactEditor.toDOMNode(editor, editor);
commands.forEach((char) => { commands.forEach((char) => {

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; 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 Typography from '@mui/material/Typography';
import { MenuItem, MenuList } from '@mui/material'; import { MenuItem, MenuList } from '@mui/material';
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg'; import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';

View File

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

View File

@ -2,13 +2,13 @@ import React from 'react';
import { Element } from 'slate'; import { Element } from 'slate';
import AddBlockBelow from '$app/components/editor/components/tools/block_actions/AddBlockBelow'; 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 ( return (
<> <>
<AddBlockBelow node={node} /> <AddBlockBelow node={node} />
<DragBlock node={node} onSelectedBlock={onSelectedBlock} /> <BlockMenu node={node} />
</> </>
); );
} }

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ReactEditor, useSlate } from 'slate-react'; import { ReactEditor, useSlate } from 'slate-react';
import { getBlockActionsPosition } from '$app/components/editor/components/tools/block_actions/utils'; 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'; import { EditorNodeType } from '$app/application/document/document.types';
export function useBlockActionsToolbar(ref: React.RefObject<HTMLDivElement>) { export function useBlockActionsToolbar(ref: React.RefObject<HTMLDivElement>) {
@ -16,16 +16,34 @@ export function useBlockActionsToolbar(ref: React.RefObject<HTMLDivElement>) {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.closest('.block-actions')) return; if (target.closest(`[contenteditable="false"]`)) {
const blockElement = target ? (target.closest('.block-element') as HTMLElement) : null; 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.opacity = '0';
el.style.pointerEvents = 'none'; el.style.pointerEvents = 'none';
setNode(null); setNode(null);
return; 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 { top, left } = getBlockActionsPosition(editor, blockElement);
const slateEditorDom = ReactEditor.toDOMNode(editor, editor); const slateEditorDom = ReactEditor.toDOMNode(editor, editor);
@ -33,7 +51,7 @@ export function useBlockActionsToolbar(ref: React.RefObject<HTMLDivElement>) {
el.style.opacity = '1'; el.style.opacity = '1';
el.style.pointerEvents = 'auto'; el.style.pointerEvents = 'auto';
el.style.top = `${top + slateEditorDom.offsetTop}px`; 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; const slateNode = ReactEditor.toSlateNode(editor, blockElement) as Element;
setNode(slateNode); setNode(slateNode);
@ -49,11 +67,13 @@ export function useBlockActionsToolbar(ref: React.RefObject<HTMLDivElement>) {
setNode(null); setNode(null);
}; };
document.addEventListener('mousemove', handleMouseMove); const dom = ReactEditor.toDOMNode(editor, editor);
document.addEventListener('mouseleave', handleMouseLeave);
dom.addEventListener('mousemove', handleMouseMove);
dom.parentElement?.addEventListener('mouseleave', handleMouseLeave);
return () => { return () => {
document.removeEventListener('mousemove', handleMouseMove); dom.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseleave', handleMouseLeave); dom.parentElement?.removeEventListener('mouseleave', handleMouseLeave);
}; };
}, [editor, ref]); }, [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'; 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 ref = useRef<HTMLDivElement | null>(null);
const { node } = useBlockActionsToolbar(ref); const { node } = useBlockActionsToolbar(ref);
@ -19,6 +19,7 @@ export function BlockActionsToolbar({ onSelectedBlock }: { onSelectedBlock: (blo
onMouseDown={(e) => { onMouseDown={(e) => {
// prevent toolbar from taking focus away from editor // prevent toolbar from taking focus away from editor
e.preventDefault(); e.preventDefault();
e.stopPropagation();
}} }}
onMouseUp={(e) => { onMouseUp={(e) => {
e.stopPropagation(); e.stopPropagation();
@ -26,7 +27,7 @@ export function BlockActionsToolbar({ onSelectedBlock }: { onSelectedBlock: (blo
> >
{/* Ensure the toolbar in middle */} {/* Ensure the toolbar in middle */}
<div className={'invisible'}>0</div> <div className={'invisible'}>0</div>
{node && <BlockActions node={node} onSelectedBlock={onSelectedBlock} />} {<BlockActions node={node || undefined} />}
</div> </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 { IconButton, Tooltip } from '@mui/material';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ReactComponent as DragSvg } from '$app/assets/drag.svg'; import { ReactComponent as DragSvg } from '$app/assets/drag.svg';
import BlockOperationMenu from '$app/components/editor/components/tools/block_actions/BlockOperationMenu'; import BlockOperationMenu from '$app/components/editor/components/tools/block_actions/BlockOperationMenu';
import { Element } from 'slate'; 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 dragBtnRef = useRef<HTMLButtonElement>(null);
const [openMenu, setOpenMenu] = useState(false); const [openMenu, setOpenMenu] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const [selectedNode, setSelectedNode] = useState<Element>();
const selectedBlockContext = useContext(EditorSelectedBlockContext);
const handleClick = useCallback( const handleClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
setOpenMenu(true); setOpenMenu(true);
if (!node || !node.blockId) return; if (!node || !node.blockId) return;
setSelectedNode(node);
onSelectedBlock(node.blockId); selectedBlockContext.clear();
selectedBlockContext.add(node.blockId);
}, },
[node, onSelectedBlock] [node, selectedBlockContext]
); );
return ( return (
@ -28,27 +32,34 @@ function DragBlock({ node, onSelectedBlock }: { node: Element; onSelectedBlock:
<DragSvg /> <DragSvg />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
{openMenu && node && ( {openMenu && selectedNode && (
<BlockOperationMenu <BlockOperationMenu
onMouseMove={(e) => { onMouseMove={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
anchorOrigin={{ anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'center', vertical: 'center',
horizontal: 'left', horizontal: 'left',
}} }}
node={node} transformOrigin={{
vertical: 'center',
horizontal: 'right',
}}
PaperProps={{
onClick: (e) => {
e.stopPropagation();
},
}}
node={selectedNode}
open={openMenu} open={openMenu}
anchorEl={dragBtnRef.current} 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 Popover, { PopoverProps } from '@mui/material/Popover';
import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg'; import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg';
import { ReactComponent as CopySvg } from '$app/assets/copy.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 { 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 { PopoverCommonProps } from '$app/components/editor/components/tools/popover';
import { Element } from 'slate'; import { Element } from 'slate';
import { useSlateStatic } from 'slate-react'; import { ReactEditor, useSlateStatic } from 'slate-react';
import { CustomEditor } from '$app/components/editor/command'; 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({ export function BlockOperationMenu({
node, node,
...props ...props
}: { }: {
node: Element; node: Element;
} & PopoverProps) { } & PopoverProps) {
const optionsRef = React.useRef<HTMLDivElement>(null);
const editor = useSlateStatic(); const editor = useSlateStatic();
const { t } = useTranslation(); 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 />, icon: <DeleteSvg />,
text: t('button.delete'), text: t('button.delete'),
onClick: () => { onClick: () => {
CustomEditor.deleteNode(editor, node); CustomEditor.deleteNode(editor, node);
props.onClose?.({}, 'backdropClick'); handleClose();
}, },
}, },
{ {
@ -32,17 +81,69 @@ export function BlockOperationMenu({
text: t('button.duplicate'), text: t('button.duplicate'),
onClick: () => { onClick: () => {
CustomEditor.duplicateNode(editor, node); 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 ( return (
<Popover {...PopoverCommonProps} {...props}> <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}
disableAutoFocus={false}
onKeyDown={onKeyDown}
onMouseDown={(e) => e.stopPropagation()}
{...props}
onClose={handleClose}
>
<div className={'flex flex-col p-2'}> <div className={'flex flex-col p-2'}>
{options.map((option, index) => ( {operationOptions.map((option, index) => (
<Button <Button
color={'inherit'} color={'inherit'}
onClick={option.onClick} onClick={option.onClick}
@ -55,6 +156,35 @@ export function BlockOperationMenu({
</Button> </Button>
))} ))}
</div> </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> </Popover>
); );
} }

View File

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

View File

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

View File

@ -30,8 +30,8 @@ import Functions from '@mui/icons-material/Functions';
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { getBlock, getBlockEntry } from '$app/components/editor/plugins/utils'; import { getBlock } from '$app/components/editor/plugins/utils';
import { FontColorPicker, BgColorPicker } from '$app/components/editor/components/tools/selection_toolbar/sub_menu'; import { FontColorPicker, BgColorPicker } from '$app/components/editor/components/tools/_shared';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { addMark, Editor } from 'slate'; import { addMark, Editor } from 'slate';
import { CustomEditor } from '$app/components/editor/command'; import { CustomEditor } from '$app/components/editor/command';
@ -257,7 +257,7 @@ export function useBlockFormatActionMap(editor: ReactEditor) {
}, },
Icon: TodoListSvg, Icon: TodoListSvg,
isActive: () => { isActive: () => {
const entry = getBlockEntry(editor); const entry = CustomEditor.getBlock(editor);
if (!entry) return false; if (!entry) return false;
@ -279,7 +279,7 @@ export function useBlockFormatActionMap(editor: ReactEditor) {
}, },
Icon: QuoteSvg, Icon: QuoteSvg,
isActive: () => { isActive: () => {
const entry = getBlockEntry(editor); const entry = CustomEditor.getBlock(editor);
if (!entry) return false; if (!entry) return false;
@ -302,7 +302,7 @@ export function useBlockFormatActionMap(editor: ReactEditor) {
}, },
Icon: ToggleListSvg, Icon: ToggleListSvg,
isActive: () => { isActive: () => {
const entry = getBlockEntry(editor); const entry = CustomEditor.getBlock(editor);
if (!entry) return false; if (!entry) return false;
@ -325,7 +325,7 @@ export function useBlockFormatActionMap(editor: ReactEditor) {
}, },
Icon: NumberedListSvg, Icon: NumberedListSvg,
isActive: () => { isActive: () => {
const entry = getBlockEntry(editor); const entry = CustomEditor.getBlock(editor);
if (!entry) return false; if (!entry) return false;
@ -348,7 +348,7 @@ export function useBlockFormatActionMap(editor: ReactEditor) {
}, },
Icon: BulletedListSvg, Icon: BulletedListSvg,
isActive: () => { isActive: () => {
const entry = getBlockEntry(editor); const entry = CustomEditor.getBlock(editor);
if (!entry) return false; 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 { ReactEditor, useSlate } from 'slate-react';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import { Range } from 'slate';
import { import {
SelectionAction, SelectionAction,
useBlockFormatActions, useBlockFormatActions,
@ -14,6 +14,7 @@ import Popover from '@mui/material/Popover';
import { EditorStyleFormat } from '$app/application/document/document.types'; import { EditorStyleFormat } from '$app/application/document/document.types';
import { PopoverPreventBlurProps } from '$app/components/editor/components/tools/popover'; import { PopoverPreventBlurProps } from '$app/components/editor/components/tools/popover';
import { Tooltip } from '@mui/material'; import { Tooltip } from '@mui/material';
import { CustomEditor } from '$app/components/editor/command';
function SelectionActions({ function SelectionActions({
toolbarVisible, toolbarVisible,
@ -48,7 +49,32 @@ function SelectionActions({
handleBlur(); handleBlur();
}, [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 markOptions = useSelectionMarkFormatActions(editor);
const textOptions = useSelectionTextFormatActions(editor); const textOptions = useSelectionTextFormatActions(editor);
const blockOptions = useBlockFormatActions(editor); const blockOptions = useBlockFormatActions(editor);

View File

@ -53,7 +53,22 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
el.style.opacity = '1'; el.style.opacity = '1';
el.style.pointerEvents = 'auto'; el.style.pointerEvents = 'auto';
el.style.top = `${position.top + slateEditorDom.offsetTop - el.offsetHeight}px`; 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]); }, [closeToolbar, editor, ref]);
useEffect(() => { useEffect(() => {

View File

@ -11,7 +11,7 @@ export const SelectionToolbar = memo(() => {
<div <div
ref={ref} ref={ref}
className={ 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) => { onMouseDown={(e) => {
// prevent toolbar from taking focus away from editor // prevent toolbar from taking focus away from editor

View File

@ -1,5 +1,3 @@
import { EditorNodeType } from '$app/application/document/document.types'; 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]; 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 { Element, NodeEntry } from 'slate';
import { EditorNodeType } from '$app/application/document/document.types';
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
import { CustomEditor } from '$app/components/editor/command';
export function getHeadingCssProperty(level: number) { export function getHeadingCssProperty(level: number) {
switch (level) { switch (level) {
@ -15,45 +15,16 @@ export function getHeadingCssProperty(level: number) {
} }
} }
export function isDeleteBackwardAtStartOfBlock(editor: ReactEditor, type?: EditorNodeType) { export function getBlock(editor: ReactEditor) {
const { selection } = editor; 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) { if (match) {
const [node, path] = match as NodeEntry<Element>; const [node] = match as NodeEntry<Element>;
if (type !== undefined && node.type !== type) return false; return node;
const start = Editor.start(editor, path);
if (Point.equals(selection.anchor, start)) {
return true;
}
}
} }
return false; return;
}
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];
} }
export function getEditorDomNode(editor: ReactEditor) { export function getEditorDomNode(editor: ReactEditor) {

View File

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

View File

@ -1,5 +1,4 @@
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
import { Editor, Element, NodeEntry } from 'slate';
import { EditorNodeType } from '$app/application/document/document.types'; import { EditorNodeType } from '$app/application/document/document.types';
import { CustomEditor } from '$app/components/editor/command'; import { CustomEditor } from '$app/components/editor/command';
@ -7,24 +6,17 @@ export function withBlockInsertBreak(editor: ReactEditor) {
const { insertBreak } = editor; const { insertBreak } = editor;
editor.insertBreak = (...args) => { editor.insertBreak = (...args) => {
const nodeEntry = Editor.above(editor, { const block = CustomEditor.getBlock(editor);
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && Editor.isBlock(editor, n),
});
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; const type = node.type as EditorNodeType;
if (type === EditorNodeType.Page) { const isEmpty = CustomEditor.isEmptyText(editor, node);
insertBreak(...args);
return;
}
const isEmpty = Editor.isEmpty(editor, node);
// if the node is empty, convert it to a paragraph // 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 }); CustomEditor.turnToBlock(editor, { type: EditorNodeType.Paragraph });
return; 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 { withBlockDeleteBackward } from '$app/components/editor/plugins/withBlockDeleteBackward';
import { withBlockInsertBreak } from '$app/components/editor/plugins/withBlockInsertBreak'; 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 { 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 { 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) { export function withBlockPlugins(editor: ReactEditor) {
return withMathEquationPlugin( const { isElementReadOnly, isSelectable, isEmpty } = editor;
withPasted(
withDatabaseBlockPlugin(withSplitNodes(withMergeNodes(withBlockInsertBreak(withBlockDeleteBackward(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 { ReactEditor } from 'slate-react';
import { convertBlockToJson } from '$app/application/document/document.service'; 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 { 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'; import { InputType } from '@/services/backend';
export function withPasted(editor: ReactEditor) { export function withPasted(editor: ReactEditor) {
const { insertData, insertFragment } = editor; const { insertData } = editor;
editor.insertData = (data) => { editor.insertData = (data) => {
const fragment = data.getData('application/x-slate-fragment'); const fragment = data.getData('application/x-slate-fragment');
@ -30,90 +30,113 @@ export function withPasted(editor: ReactEditor) {
insertData(data); insertData(data);
}; };
editor.insertFragment = (fragment) => { editor.insertFragment = (fragment, options = {}) => {
let rootId = (editor.children[0] as Element)?.blockId; Editor.withoutNormalizing(editor, () => {
const { at = getDefaultInsertLocation(editor) } = options;
if (!rootId) { if (!fragment.length) {
rootId = generateId(); return;
insertFragment([ }
{
type: EditorNodeType.Paragraph, if (Range.isRange(at) && !Range.isCollapsed(at)) {
editor.delete({
unit: 'character',
});
}
const mergedText = editor.above({
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.type === EditorNodeType.Text,
}) as NodeEntry<
Element & {
textId: string;
}
>;
if (!mergedText) return;
const [mergedTextNode, mergedTextNodePath] = mergedText;
const traverse = (node: Element) => {
if (node.type === EditorNodeType.Text) {
node.textId = generateId();
return;
}
node.blockId = generateId();
node.children?.forEach((child) => traverse(child as Element));
};
fragment?.forEach((node) => traverse(node as Element));
const firstNode = fragment[0] as Element;
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());
}
}
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 (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',
});
}
}
});
};
return editor;
}
function getEmptyText(): Element {
return {
type: EditorNodeType.Text,
textId: generateId(),
children: [ children: [
{ {
text: '', text: '',
}, },
], ],
data: {},
blockId: rootId,
textId: generateId(),
parentId: '',
level: 0,
},
]);
}
const [mergedMatch] = Editor.nodes(editor, {
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.type !== undefined,
});
const mergedNode = mergedMatch
? (mergedMatch[0] as Element & {
blockId: string;
parentId: string;
level: number;
})
: null;
if (!mergedNode) return insertFragment(fragment);
const isEmpty = Editor.isEmpty(editor, mergedNode);
const mergedNodeId = isEmpty ? undefined : mergedNode.blockId;
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;
}; };
}
const newBlockId = i === 0 && mergedNodeId ? mergedNodeId : generateId(); export const getDefaultInsertLocation = (editor: Editor): Location => {
if (editor.selection) {
const parentId = idMap.get(node.parentId); return editor.selection;
} else if (editor.children.length > 0) {
if (parentId) { return Editor.end(editor, []);
node.parentId = parentId;
} else { } else {
idMap.set(node.parentId, mergedNode.parentId); return [0];
node.parentId = mergedNode.parentId;
} }
const parentLevel = levelMap.get(node.parentId);
if (parentLevel !== undefined) {
node.level = parentLevel + 1;
} else {
levelMap.set(node.parentId, mergedNode.level - 1);
node.level = mergedNode.level;
}
// 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;
}

View File

@ -1,9 +1,10 @@
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
import { Editor, Element, NodeEntry, Transforms } from 'slate'; import { Transforms, Editor, Element, NodeEntry, Path } from 'slate';
import { EditorMarkFormat, EditorNodeType, markTypes, ToggleListNode } from '$app/application/document/document.types'; import { EditorNodeType, ToggleListNode } from '$app/application/document/document.types';
import { CustomEditor } from '$app/components/editor/command'; 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 { 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) { export function withSplitNodes(editor: ReactEditor) {
const { splitNodes } = editor; const { splitNodes } = editor;
@ -16,102 +17,90 @@ export function withSplitNodes(editor: ReactEditor) {
return; return;
} }
// This is a workaround for the bug that the new paragraph will inherit the marks of the previous paragraph const match = CustomEditor.getBlock(editor);
// 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,
});
if (!match) { if (!match) {
splitNodes(...args); splitNodes(...args);
return; return;
} }
const [node, path] = match as NodeEntry<Element>; const [node, path] = match;
const nodeType = node.type as EditorNodeType;
const newBlockId = generateId(); const newBlockId = generateId();
const newTextId = generateId(); const newTextId = generateId();
const nodeType = node.type as EditorNodeType; splitNodes(...args);
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;
// should be split to a new paragraph for the first child of the toggle list
if (nodeType === EditorNodeType.ToggleListBlock) { if (nodeType === EditorNodeType.ToggleListBlock) {
const collapsed = (node as ToggleListNode).data.collapsed; 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) { if (!collapsed) {
splitNodes(...args); newNode.type = EditorNodeType.Paragraph;
Transforms.setNodes(editor, { newNodePath = textNodePath;
type: EditorNodeType.Paragraph,
data: {},
level: level + 1,
blockId: newBlockId,
parentId: blockId,
textId: newTextId,
});
} else { } else {
// if the toggle list is not collapsed, split to a toggle list after the toggle list newNode.type = EditorNodeType.ToggleListBlock;
const nextNode = CustomEditor.findNextNode(editor, node, level); newNodePath = Path.next(path);
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] });
}
} }
Transforms.insertNodes(editor, newNode, {
at: newNodePath,
select: true,
});
CustomEditor.removeMarks(editor);
return; return;
} }
// should be split to another paragraph, eg: heading and quote and page newNodePath = textNodePath;
if (BREAK_TO_PARAGRAPH_TYPES.includes(nodeType)) {
const level = node.level || 1;
const parentId = (node.parentId || node.blockId) as string;
splitNodes(...args); Transforms.insertNodes(editor, newNode, {
Transforms.setNodes(editor, { at: newNodePath,
type: EditorNodeType.Paragraph,
data: {},
blockId: newBlockId,
textId: newTextId,
level,
parentId,
}); });
return;
}
splitNodes(...args); editor.select(newNodePath);
editor.collapse({
Transforms.setNodes(editor, { blockId: newBlockId, data: {}, textId: newTextId }); edge: 'start',
const children = CustomEditor.findNodeChildren(editor, node);
children.forEach((child) => {
const childPath = ReactEditor.findPath(editor, child);
Transforms.setNodes(
editor,
{
parentId: newBlockId,
},
{
at: [childPath[0] + 1],
}
);
}); });
editor.liftNodes({
at: newNodePath,
});
CustomEditor.removeMarks(editor);
}; };
return editor; return editor;

View File

@ -24,8 +24,7 @@ describe('Transform events to actions', () => {
test('should transform insert event to insert action', () => { test('should transform insert event to insert action', () => {
const sharedType = provider.sharedType; const sharedType = provider.sharedType;
const parentId = sharedType?.getAttribute('blockId') as string; const insertTextOp = generateInsertTextOp('insert text');
const insertTextOp = generateInsertTextOp('insert text', parentId, 1);
sharedType?.applyDelta([{ retain: 2 }, insertTextOp]); sharedType?.applyDelta([{ retain: 2 }, insertTextOp]);
@ -43,24 +42,6 @@ describe('Transform events to actions', () => {
expect(actions[1].payload.prev_id).toBe('2qonPRrNTO'); 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', () => { test('should transform delete event to delete action', () => {
const sharedType = provider.sharedType; const sharedType = provider.sharedType;
@ -72,7 +53,6 @@ describe('Transform events to actions', () => {
expect(actions).toHaveLength(1); expect(actions).toHaveLength(1);
expect(actions[0].action).toBe(BlockActionTypePB.Delete); expect(actions[0].action).toBe(BlockActionTypePB.Delete);
expect(actions[0].payload.block.id).toBe('Fn4KACkt1i'); expect(actions[0].payload.block.id).toBe('Fn4KACkt1i');
expect(actions[0].payload.parent_id).toBe('3EzeCrtxlh');
}); });
test('should transform update event to update action', () => { 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].action).toBe(BlockActionTypePB.Update);
expect(actions[0].payload.block.id).toBe('Fn4KACkt1i'); expect(actions[0].payload.block.id).toBe('Fn4KACkt1i');
expect(actions[0].payload.block.data).toBe('{"checked":true}'); 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)', () => { test('should transform apply delta event to apply delta action (insert text)', () => {
const sharedType = provider.sharedType; 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(() => { 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]; const actions = applyActions.mock.calls[0][1];
expect(actions).toHaveLength(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', () => { test('should transform apply delta event to apply delta action: insert mention', () => {
const sharedType = provider.sharedType; 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(() => { sharedType?.doc?.transact(() => {
yText.applyDelta([{ retain: 1 }, genersteMentionInsertTextOp()]); 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', () => { test('should transform apply delta event to apply delta action: insert formula', () => {
const sharedType = provider.sharedType; 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(() => { sharedType?.doc?.transact(() => {
yText.applyDelta([{ retain: 1 }, generateFormulaInsertTextOp()]); yText.applyDelta([{ retain: 1 }, generateFormulaInsertTextOp()]);
}); });

View File

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

View File

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

View File

@ -32,7 +32,9 @@ export class DataClient extends EventEmitter {
this.rootId = data.rootId; 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; public on(event: 'change', listener: (events: YDelta) => void): this;

View File

@ -1,7 +1,6 @@
import * as Y from 'yjs'; import * as Y from 'yjs';
import { DataClient } from '$app/components/editor/provider/data_client'; 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 { YDelta } from '$app/components/editor/provider/types/y_event';
import { YEvents2BlockActions } from '$app/components/editor/provider/utils/action'; import { YEvents2BlockActions } from '$app/components/editor/provider/utils/action';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
@ -10,34 +9,27 @@ const REMOTE_ORIGIN = 'remote';
export class Provider extends EventEmitter { export class Provider extends EventEmitter {
document: Y.Doc = new Y.Doc(); 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; sharedType: Y.XmlText | null = null;
dataClient: DataClient; dataClient: DataClient;
// get origin data after document updated
backupDoc: Y.Doc = new Y.Doc();
constructor(public id: string, includeRoot?: boolean) { constructor(public id: string, includeRoot?: boolean) {
super(); super();
this.dataClient = new DataClient(id); this.dataClient = new DataClient(id);
void this.initialDocument(includeRoot); void this.initialDocument(includeRoot);
this.document.on('update', this.documentUpdate);
} }
initialDocument = async (includeRoot = true) => { 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 // Load the initial value into the yjs document
const delta = await this.dataClient.getInsertDelta(includeRoot); const delta = await this.dataClient.getInsertDelta(includeRoot);
sharedType.applyDelta(delta); sharedType.applyDelta(delta);
this.idList.applyDelta(convertToIdList(delta)); const rootId = this.dataClient.rootId as string;
delta.forEach((op) => {
if (op.insert instanceof Y.XmlText) {
fillIdRelationMap(op.insert, this.idRelationMap);
}
});
sharedType.setAttribute('blockId', this.dataClient.rootId); sharedType.setAttribute('blockId', rootId);
this.sharedType = sharedType; this.sharedType = sharedType;
this.sharedType?.observeDeep(this.onChange); this.sharedType?.observeDeep(this.onChange);
@ -63,7 +55,7 @@ export class Provider extends EventEmitter {
if (!this.sharedType || !events.length) return; if (!this.sharedType || !events.length) return;
// transform events to actions // transform events to actions
this.dataClient.emit('update', YEvents2BlockActions(this.sharedType, events)); this.dataClient.emit('update', YEvents2BlockActions(this.backupDoc, events));
}; };
onRemoteChange = (delta: YDelta) => { onRemoteChange = (delta: YDelta) => {
@ -73,4 +65,8 @@ export class Provider extends EventEmitter {
this.sharedType?.applyDelta(delta); this.sharedType?.applyDelta(delta);
}, REMOTE_ORIGIN); }, 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 { generateId } from '$app/components/editor/provider/utils/convert';
import { YDelta2Delta } from '$app/components/editor/provider/utils/delta'; import { YDelta2Delta } from '$app/components/editor/provider/utils/delta';
import { YDelta } from '$app/components/editor/provider/types/y_event'; 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 id = yXmlText.getAttribute('blockId');
const parentId = yXmlText.getAttribute('parentId'); const parentId = yXmlText.getAttribute('parentId');
@ -16,8 +182,6 @@ export function generateUpdateDataActions(yXmlText: Y.XmlText, data: Record<stri
block: { block: {
id, id,
data: JSON.stringify(data), data: JSON.stringify(data),
parent: parentId,
children: '',
}, },
parent_id: parentId, 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 [ return ids.map((id) => ({
{
action: BlockActionTypePB.Delete, action: BlockActionTypePB.Delete,
payload: { payload: {
block: { block: {
id, id,
}, },
parent_id: parentId, parent_id: '',
},
}));
}
export function generateInsertTextActions(insertYXmlText: Y.XmlText) {
const textId = insertYXmlText.getAttribute('textId');
const delta = YDelta2Delta(insertYXmlText.toDelta());
return [
{
action: BlockActionTypePB.InsertText,
payload: {
text_id: textId,
delta: JSON.stringify(delta),
}, },
}, },
]; ];
@ -61,24 +238,29 @@ export function generateInsertBlockActions(
insertYXmlText: Y.XmlText insertYXmlText: Y.XmlText
): ReturnType<typeof BlockActionPB.prototype.toObject>[] { ): ReturnType<typeof BlockActionPB.prototype.toObject>[] {
const childrenId = generateId(); 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 prevId = prev ? prev.getAttribute('blockId') : null;
const parentId = insertYXmlText.getAttribute('parentId'); const parentId = (insertYXmlText.parent as Y.XmlText).getAttribute('blockId');
const delta = YDelta2Delta(insertYXmlText.toDelta());
const data = insertYXmlText.getAttribute('data'); const data = insertYXmlText.getAttribute('data');
const type = insertYXmlText.getAttribute('type'); const type = insertYXmlText.getAttribute('type');
const id = insertYXmlText.getAttribute('blockId'); const id = insertYXmlText.getAttribute('blockId');
const externalId = insertYXmlText.getAttribute('textId');
return [ if (!id) {
{ Log.error('generateInsertBlockActions', 'id is not exist');
action: BlockActionTypePB.InsertText, return [];
payload: { }
text_id: externalId,
delta: JSON.stringify(delta), 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, action: BlockActionTypePB.Insert,
payload: { payload: {
@ -89,180 +271,19 @@ export function generateInsertBlockActions(
parent_id: parentId, parent_id: parentId,
children_id: childrenId, children_id: childrenId,
external_id: externalId, external_id: externalId,
external_type: 'text', external_type: externalId ? 'text' : undefined,
}, },
prev_id: prevId, prev_id: prevId,
parent_id: parentId, parent_id: parentId,
}, },
}, },
]; ];
childrenInserts.forEach((insert) => {
if (insert instanceof Y.XmlText) {
actions.push(...generateInsertBlockActions(insert));
} }
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 || '',
})
);
});
}
});
idList.applyDelta(convertToIdList(ops));
return actions; return actions;
} }

View File

@ -1,5 +1,5 @@
import { nanoid } from 'nanoid'; 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 { Element, Text } from 'slate';
import { Op } from 'quill-delta'; import { Op } from 'quill-delta';
@ -45,24 +45,8 @@ export function transformToInlineElement(op: Op): Element | null {
return null; return null;
} }
export function convertToSlateValue(data: EditorData, includeRoot: boolean): Element[] { export function getInlinesWithDelta(delta?: Op[]): (Text | Element)[] {
const nodes: Element[] = []; return delta && delta.length > 0
const traverse = (id: string, level: number, isHidden?: boolean) => {
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) => { ? delta.map((op) => {
const matchInline = transformToInlineElement(op); const matchInline = transformToInlineElement(op);
@ -75,27 +59,43 @@ export function convertToSlateValue(data: EditorData, includeRoot: boolean): Ele
...op.attributes, ...op.attributes,
}; };
}) })
: []; : [
{
text: '',
},
];
}
slateNode.children.push(...inlineNodes); export function convertToSlateValue(data: EditorData, includeRoot: boolean): Element[] {
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,
children: [],
blockId: id,
};
const textNode: Element | null =
!isRoot && node.externalId
? {
type: 'text',
children: [],
textId: node.externalId,
}
: null;
const inlineNodes = getInlinesWithDelta(delta);
textNode?.children.push(...inlineNodes);
nodes.push(slateNode);
const children = data.childrenMap[id]; const children = data.childrenMap[id];
if (children) { slateNode.children = children.map((childId) => traverse(childId));
for (const childId of children) { if (textNode) {
let isHidden = false; slateNode.children.unshift(textNode);
if (node.type === EditorNodeType.ToggleListBlock) {
const collapsed = (node.data as { collapsed: boolean })?.collapsed;
if (collapsed) {
isHidden = true;
}
}
traverse(childId, level + 1, isHidden);
}
} }
return slateNode; return slateNode;
@ -103,10 +103,24 @@ export function convertToSlateValue(data: EditorData, includeRoot: boolean): Ele
const rootId = data.rootId; const rootId = data.rootId;
traverse(rootId, 0); const root = traverse(rootId, true);
if (!includeRoot) { const nodes = root.children as Element[];
nodes.shift();
if (includeRoot) {
nodes.unshift({
...root,
children: [
{
type: 'text',
children: [
{
text: '',
},
],
},
],
});
} }
return nodes; return nodes;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,7 +32,7 @@ function TrashButton() {
onDragLeave={onDragLeave} onDragLeave={onDragLeave}
data-page-id={'trash'} data-page-id={'trash'}
onClick={navigateToTrash} 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' : '' selected ? 'bg-fill-list-active' : ''
} ${isDraggingOver ? 'bg-fill-list-hover' : ''}`} } ${isDraggingOver ? 'bg-fill-list-hover' : ''}`}
> >

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