mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
parent
a49b009980
commit
29e80a0f32
@ -22,7 +22,7 @@ import { EditorData, EditorNodeType } from '$app/application/document/document.t
|
||||
import { Log } from '$app/utils/log';
|
||||
import { Op } from 'quill-delta';
|
||||
import { Element } from 'slate';
|
||||
import { generateId, transformToInlineElement } from '$app/components/editor/provider/utils/convert';
|
||||
import { getInlinesWithDelta } from '$app/components/editor/provider/utils/convert';
|
||||
|
||||
export function blockPB2Node(block: BlockPB) {
|
||||
let data = {};
|
||||
@ -33,7 +33,7 @@ export function blockPB2Node(block: BlockPB) {
|
||||
Log.error('[Document Open] json parse error', block.data);
|
||||
}
|
||||
|
||||
const node = {
|
||||
return {
|
||||
id: block.id,
|
||||
type: block.ty as EditorNodeType,
|
||||
parent: block.parent_id,
|
||||
@ -42,8 +42,6 @@ export function blockPB2Node(block: BlockPB) {
|
||||
externalId: block.external_id,
|
||||
externalType: block.external_type,
|
||||
};
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
export const BLOCK_MAP_NAME = 'blocks';
|
||||
@ -51,7 +49,6 @@ export const META_NAME = 'meta';
|
||||
export const CHILDREN_MAP_NAME = 'children_map';
|
||||
|
||||
export const TEXT_MAP_NAME = 'text_map';
|
||||
export const EQUATION_PLACEHOLDER = '$';
|
||||
export async function openDocument(docId: string): Promise<EditorData> {
|
||||
const payload = OpenDocumentPayloadPB.fromObject({
|
||||
document_id: docId,
|
||||
@ -233,62 +230,37 @@ interface BlockJSON {
|
||||
}
|
||||
|
||||
function flattenBlockJson(block: BlockJSON) {
|
||||
const nodes: Element[] = [];
|
||||
|
||||
const traverse = (block: BlockJSON, parentId: string, level: number, isHidden: boolean) => {
|
||||
const traverse = (block: BlockJSON) => {
|
||||
const { delta, ...data } = block.data;
|
||||
const blockId = generateId();
|
||||
const node: Element = {
|
||||
blockId,
|
||||
|
||||
const slateNode: Element = {
|
||||
type: block.type,
|
||||
data,
|
||||
data: data,
|
||||
children: [],
|
||||
parentId,
|
||||
level,
|
||||
textId: generateId(),
|
||||
isHidden,
|
||||
};
|
||||
|
||||
node.children = delta
|
||||
? delta.map((op) => {
|
||||
const matchInline = transformToInlineElement(op);
|
||||
const textNode: Element | null = delta
|
||||
? {
|
||||
type: 'text',
|
||||
children: [],
|
||||
}
|
||||
: null;
|
||||
|
||||
if (matchInline) {
|
||||
return matchInline;
|
||||
}
|
||||
const inlinesNodes = getInlinesWithDelta(delta);
|
||||
|
||||
textNode?.children.push(...inlinesNodes);
|
||||
|
||||
return {
|
||||
text: op.insert as string,
|
||||
...op.attributes,
|
||||
};
|
||||
})
|
||||
: [
|
||||
{
|
||||
text: '',
|
||||
},
|
||||
];
|
||||
nodes.push(node);
|
||||
const children = block.children;
|
||||
|
||||
for (const child of children) {
|
||||
let isHidden = false;
|
||||
|
||||
if (node.type === EditorNodeType.ToggleListBlock) {
|
||||
const collapsed = (node.data as { collapsed: boolean })?.collapsed;
|
||||
|
||||
if (collapsed) {
|
||||
isHidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
traverse(child, blockId, level + 1, isHidden);
|
||||
slateNode.children = children.map((child) => traverse(child));
|
||||
if (textNode) {
|
||||
slateNode.children.unshift(textNode);
|
||||
}
|
||||
|
||||
return node;
|
||||
return slateNode;
|
||||
};
|
||||
|
||||
traverse(block, '', 0, false);
|
||||
const root = traverse(block);
|
||||
|
||||
nodes.shift();
|
||||
return nodes;
|
||||
return root.children;
|
||||
}
|
||||
|
@ -8,12 +8,18 @@ export interface EditorNode {
|
||||
id: string;
|
||||
type: EditorNodeType;
|
||||
parent?: string | null;
|
||||
data?: unknown;
|
||||
data?: BlockData;
|
||||
children?: string;
|
||||
externalId?: string;
|
||||
externalType?: string;
|
||||
}
|
||||
|
||||
export interface TextNode extends Element {
|
||||
type: EditorNodeType.Text;
|
||||
textId: string;
|
||||
blockId: string;
|
||||
}
|
||||
|
||||
export interface PageNode extends Element {
|
||||
type: EditorNodeType.Page;
|
||||
}
|
||||
@ -21,32 +27,38 @@ export interface ParagraphNode extends Element {
|
||||
type: EditorNodeType.Paragraph;
|
||||
}
|
||||
|
||||
export type BlockData = {
|
||||
[key: string]: string | boolean | number | undefined;
|
||||
font_color?: string;
|
||||
bg_color?: string;
|
||||
};
|
||||
|
||||
export interface HeadingNode extends Element {
|
||||
type: EditorNodeType.HeadingBlock;
|
||||
data: {
|
||||
level: number;
|
||||
};
|
||||
} & BlockData;
|
||||
}
|
||||
|
||||
export interface GridNode extends Element {
|
||||
type: EditorNodeType.GridBlock;
|
||||
data: {
|
||||
viewId?: string;
|
||||
};
|
||||
} & BlockData;
|
||||
}
|
||||
|
||||
export interface TodoListNode extends Element {
|
||||
type: EditorNodeType.TodoListBlock;
|
||||
data: {
|
||||
checked: boolean;
|
||||
};
|
||||
} & BlockData;
|
||||
}
|
||||
|
||||
export interface CodeNode extends Element {
|
||||
type: EditorNodeType.CodeBlock;
|
||||
data: {
|
||||
language: string;
|
||||
};
|
||||
} & BlockData;
|
||||
}
|
||||
|
||||
export interface QuoteNode extends Element {
|
||||
@ -65,7 +77,7 @@ export interface ToggleListNode extends Element {
|
||||
type: EditorNodeType.ToggleListBlock;
|
||||
data: {
|
||||
collapsed: boolean;
|
||||
};
|
||||
} & BlockData;
|
||||
}
|
||||
|
||||
export interface DividerNode extends Element {
|
||||
@ -76,18 +88,19 @@ export interface CalloutNode extends Element {
|
||||
type: EditorNodeType.CalloutBlock;
|
||||
data: {
|
||||
icon: string;
|
||||
};
|
||||
} & BlockData;
|
||||
}
|
||||
|
||||
export interface MathEquationNode extends Element {
|
||||
type: EditorNodeType.EquationBlock;
|
||||
data: {
|
||||
formula?: string;
|
||||
};
|
||||
} & BlockData;
|
||||
}
|
||||
|
||||
export interface FormulaNode extends Element {
|
||||
type: EditorInlineNodeType.Formula;
|
||||
data: boolean;
|
||||
}
|
||||
|
||||
export interface MentionNode extends Element {
|
||||
@ -129,6 +142,7 @@ export interface EditorProps {
|
||||
}
|
||||
|
||||
export enum EditorNodeType {
|
||||
Text = 'text',
|
||||
Paragraph = 'paragraph',
|
||||
Page = 'page',
|
||||
HeadingBlock = 'heading',
|
||||
@ -145,8 +159,6 @@ export enum EditorNodeType {
|
||||
GridBlock = 'grid',
|
||||
}
|
||||
|
||||
export const blockTypes: string[] = Object.values(EditorNodeType);
|
||||
|
||||
export enum EditorInlineNodeType {
|
||||
Mention = 'mention',
|
||||
Formula = 'formula',
|
||||
@ -196,18 +208,6 @@ export enum EditorStyleFormat {
|
||||
Href = 'href',
|
||||
}
|
||||
|
||||
export const markTypes: string[] = [
|
||||
EditorMarkFormat.Bold,
|
||||
EditorMarkFormat.Italic,
|
||||
EditorMarkFormat.Underline,
|
||||
EditorMarkFormat.StrikeThrough,
|
||||
EditorMarkFormat.Code,
|
||||
EditorMarkFormat.Formula,
|
||||
EditorStyleFormat.Href,
|
||||
EditorStyleFormat.FontColor,
|
||||
EditorStyleFormat.BackgroundColor,
|
||||
];
|
||||
|
||||
export enum EditorTurnFormat {
|
||||
Paragraph = 'paragraph',
|
||||
Heading1 = 'heading1', // 'heading1' is a special format, it's not a slate node type, but a slate node type's data
|
||||
|
@ -113,6 +113,7 @@ export const EditFieldPopup = ({
|
||||
await save();
|
||||
onOutsideClick();
|
||||
}}
|
||||
disableRestoreFocus={true}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
|
@ -1,47 +1,28 @@
|
||||
import { FormEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FormEventHandler, useCallback } from 'react';
|
||||
import { t } from 'i18next';
|
||||
import { PageController } from '$app/stores/effects/workspace/page/page_controller';
|
||||
import { useViewId } from '$app/hooks';
|
||||
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
||||
import { updatePageName } from '$app_reducers/pages/async_actions';
|
||||
|
||||
export const DatabaseTitle = () => {
|
||||
const viewId = useViewId();
|
||||
const [title, setTitle] = useState('');
|
||||
|
||||
const controller = useMemo(() => new PageController(viewId), [viewId]);
|
||||
|
||||
useEffect(() => {
|
||||
void controller.getPage().then((page) => {
|
||||
setTitle(page.name);
|
||||
});
|
||||
|
||||
void controller.subscribe({
|
||||
onPageChanged: (page) => {
|
||||
setTitle(page.name);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
void controller.unsubscribe();
|
||||
};
|
||||
}, [controller]);
|
||||
const pageName = useAppSelector((state) => state.pages.pageMap[viewId].name);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleInput = useCallback<FormEventHandler>(
|
||||
(event) => {
|
||||
const newTitle = (event.target as HTMLInputElement).value;
|
||||
|
||||
void controller.updatePage({
|
||||
id: viewId,
|
||||
name: newTitle,
|
||||
});
|
||||
void dispatch(updatePageName({ id: viewId, name: newTitle }));
|
||||
},
|
||||
[viewId, controller]
|
||||
[viewId, dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='mb-6 h-[70px] px-16 pt-8'>
|
||||
<input
|
||||
className='text-3xl font-semibold'
|
||||
value={title}
|
||||
value={pageName}
|
||||
placeholder={t('grid.title.placeholder')}
|
||||
onInput={handleInput}
|
||||
/>
|
||||
|
@ -61,6 +61,7 @@ export const SelectCell: FC<{
|
||||
{open ? (
|
||||
<Menu
|
||||
keepMounted={false}
|
||||
disableRestoreFocus={true}
|
||||
className='h-full w-full'
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
|
@ -38,7 +38,7 @@ function SettingsMenu(props: SettingsMenuProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu {...props}>
|
||||
<Menu {...props} disableRestoreFocus={true}>
|
||||
<MenuItem
|
||||
onClick={(event) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
@ -54,6 +54,7 @@ function SettingsMenu(props: SettingsMenuProps) {
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<Popover
|
||||
disableRestoreFocus={true}
|
||||
open={openProperties}
|
||||
onClose={() => {
|
||||
setPropertiesAnchorElPosition(undefined);
|
||||
|
@ -39,7 +39,7 @@ function RecordActions({ anchorEl, open, onClose, rowId }: Props) {
|
||||
];
|
||||
|
||||
return (
|
||||
<Menu anchorEl={anchorEl} open={open} onClose={onClose}>
|
||||
<Menu anchorEl={anchorEl} disableRestoreFocus={true} open={open} onClose={onClose}>
|
||||
{menuOptions.map((option) => (
|
||||
<MenuItem
|
||||
key={option.label}
|
||||
|
@ -16,7 +16,7 @@ function ChecklistCellActions({
|
||||
const { percentage, selectedOptions = [], options } = cell.data;
|
||||
|
||||
return (
|
||||
<Popover {...props}>
|
||||
<Popover disableRestoreFocus={true} {...props}>
|
||||
<LinearProgressWithLabel className={'m-4'} value={percentage || 0} />
|
||||
<div className={'p-1'}>
|
||||
{options?.map((option) => {
|
||||
|
@ -42,6 +42,7 @@ function DateFormat({ value, onChange }: Props) {
|
||||
<MoreSvg className={`transform text-base ${open ? '' : 'rotate-90'}`} />
|
||||
</MenuItem>
|
||||
<Menu
|
||||
disableRestoreFocus={true}
|
||||
anchorOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
|
@ -75,6 +75,7 @@ function DateTimeCellActions({
|
||||
|
||||
return (
|
||||
<Popover
|
||||
disableRestoreFocus={true}
|
||||
keepMounted={false}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
|
@ -23,6 +23,7 @@ function DateTimeFormatSelect({ field }: Props) {
|
||||
<MoreSvg className={`transform text-base ${open ? '' : 'rotate-90'}`} />
|
||||
</MenuItem>
|
||||
<Menu
|
||||
disableRestoreFocus={true}
|
||||
anchorOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
|
@ -37,6 +37,7 @@ function TimeFormat({ value, onChange }: Props) {
|
||||
<MoreSvg className={`transform text-base ${open ? '' : 'rotate-90'}`} />
|
||||
</MenuItem>
|
||||
<Menu
|
||||
disableRestoreFocus={true}
|
||||
anchorOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
|
@ -34,6 +34,7 @@ function EditNumberCellInput({
|
||||
|
||||
return (
|
||||
<Popover
|
||||
disableRestoreFocus={true}
|
||||
keepMounted={false}
|
||||
open={editing}
|
||||
anchorEl={anchorEl}
|
||||
|
@ -13,7 +13,7 @@ function NumberFormatMenu({
|
||||
onChangeFormat: (value: NumberFormatPB) => void;
|
||||
}) {
|
||||
return (
|
||||
<Menu {...props}>
|
||||
<Menu {...props} disableRestoreFocus={true}>
|
||||
{formats.map((format) => (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
|
@ -79,6 +79,7 @@ export const SelectOptionMenu: FC<SelectOptionMenuProps> = ({ fieldId, option, M
|
||||
}}
|
||||
{...menuProps}
|
||||
onClose={onClose}
|
||||
disableRestoreFocus={true}
|
||||
>
|
||||
<ListSubheader className='my-2 leading-tight'>
|
||||
<OutlinedInput
|
||||
|
@ -22,6 +22,7 @@ function EditTextCellInput({ editing, anchorEl, onClose, text, onInput }: Props)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
disableRestoreFocus={true}
|
||||
open={editing}
|
||||
anchorEl={anchorEl}
|
||||
PaperProps={{
|
||||
|
@ -119,6 +119,7 @@ function Filter({ filter, field }: Props) {
|
||||
/>
|
||||
{condition !== undefined && open && (
|
||||
<Popover
|
||||
disableRestoreFocus={true}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center',
|
||||
|
@ -33,7 +33,7 @@ function FilterActions({ filter }: { filter: Filter }) {
|
||||
>
|
||||
<MoreSvg />
|
||||
</IconButton>
|
||||
<Menu keepMounted={false} open={open} anchorEl={anchorEl} onClose={onClose}>
|
||||
<Menu disableRestoreFocus={true} keepMounted={false} open={open} anchorEl={anchorEl} onClose={onClose}>
|
||||
<MenuItem onClick={onDelete}>{t('grid.settings.deleteFilter')}</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
|
@ -34,7 +34,7 @@ function FilterFieldsMenu({
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover {...props}>
|
||||
<Popover disableRestoreFocus={true} {...props}>
|
||||
<PropertiesList showSearch searchPlaceholder={t('grid.settings.filterBy')} onItemClick={addFilter} />
|
||||
</Popover>
|
||||
);
|
||||
|
@ -35,6 +35,7 @@ export const PropertyMenu: FC<GridFieldMenuProps> = ({ field, ...props }) => {
|
||||
|
||||
return (
|
||||
<Popover
|
||||
disableRestoreFocus={true}
|
||||
transformOrigin={{
|
||||
vertical: -10,
|
||||
horizontal: 'left',
|
||||
|
@ -40,7 +40,7 @@ export const PropertyTypeMenu: FC<
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu {...props} PopoverClasses={PopoverClasses}>
|
||||
<Menu {...props} disableRestoreFocus={true} PopoverClasses={PopoverClasses}>
|
||||
{FieldTypeGroup.map((group, index) => [
|
||||
<MenuItem key={group.name} dense disabled>
|
||||
{group.name}
|
||||
|
@ -28,7 +28,7 @@ const SortFieldsMenu: FC<
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover keepMounted={false} {...props}>
|
||||
<Popover disableRestoreFocus={true} keepMounted={false} {...props}>
|
||||
<PropertiesList showSearch={true} onItemClick={addSort} searchPlaceholder={t('grid.settings.sortBy')} />
|
||||
</Popover>
|
||||
);
|
||||
|
@ -30,6 +30,7 @@ export const SortMenu: FC<MenuProps> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
disableRestoreFocus={true}
|
||||
keepMounted={false}
|
||||
MenuListProps={{
|
||||
className: 'py-1',
|
||||
|
@ -43,7 +43,7 @@ function ViewActions({ view, ...props }: { view: Page } & MenuProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu keepMounted={false} {...props}>
|
||||
<Menu keepMounted={false} disableRestoreFocus={true} {...props}>
|
||||
{options.map((option) => (
|
||||
<MenuItem key={option.id} onClick={option.action}>
|
||||
<div className={'mr-1.5'}>{option.icon}</div>
|
||||
|
@ -15,6 +15,7 @@ function GridFieldMenu({ field, onOpenPropertyMenu, onOpenMenu, ...props }: Prop
|
||||
return (
|
||||
<Portal>
|
||||
<Popover
|
||||
disableRestoreFocus={true}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
|
@ -75,6 +75,7 @@ function GridRowMenu({ rowId, ...props }: Props) {
|
||||
|
||||
return (
|
||||
<Popover
|
||||
disableRestoreFocus={true}
|
||||
keepMounted={false}
|
||||
anchorReference={'anchorPosition'}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import { Page, PageIcon } from '$app_reducers/pages/slice';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import ViewTitle from '$app/components/_shared/ViewTitle';
|
||||
@ -32,4 +32,4 @@ export function DocumentHeader({ page }: DocumentHeaderProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentHeader;
|
||||
export default memo(DocumentHeader);
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { BasePoint, Editor, Element, Node, NodeEntry, Transforms } from 'slate';
|
||||
import { Editor, Element, Node, NodeEntry, Point, Range, Transforms, Location } from 'slate';
|
||||
import { LIST_TYPES, tabBackward, tabForward } from '$app/components/editor/command/tab';
|
||||
import { isMarkActive, toggleMark } from '$app/components/editor/command/mark';
|
||||
import { isMarkActive, removeMarks, toggleMark } from '$app/components/editor/command/mark';
|
||||
import { insertFormula, isFormulaActive, unwrapFormula, updateFormula } from '$app/components/editor/command/formula';
|
||||
import {
|
||||
EditorInlineNodeType,
|
||||
@ -16,60 +16,69 @@ import { generateId } from '$app/components/editor/provider/utils/convert';
|
||||
import { YjsEditor } from '@slate-yjs/core';
|
||||
|
||||
export const CustomEditor = {
|
||||
getBlock: (editor: ReactEditor, at?: Location): NodeEntry<Element> | undefined => {
|
||||
return Editor.above(editor, {
|
||||
at,
|
||||
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* turn the current block to a new block
|
||||
* 1. clone the current block to a new block
|
||||
* 2. remove the current block
|
||||
* 3. insert the new block
|
||||
* 4. lift the children of the new block if the new block doesn't allow has children
|
||||
* @param editor
|
||||
* @param newProperties
|
||||
*/
|
||||
turnToBlock: (editor: ReactEditor, newProperties: Partial<Element>) => {
|
||||
const selection = editor.selection;
|
||||
|
||||
if (!selection) return;
|
||||
const [match] = Editor.nodes(editor, {
|
||||
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined && n.type !== undefined,
|
||||
});
|
||||
const match = CustomEditor.getBlock(editor);
|
||||
|
||||
if (!match) return;
|
||||
|
||||
const [node, path] = match as NodeEntry<Element>;
|
||||
|
||||
const parentId = node.parentId;
|
||||
const cloneNode = {
|
||||
...cloneDeep(node),
|
||||
blockId: generateId(),
|
||||
textId: generateId(),
|
||||
type: newProperties.type || EditorNodeType.Paragraph,
|
||||
data: newProperties.data || {},
|
||||
};
|
||||
const isListType = LIST_TYPES.includes(cloneNode.type as EditorNodeType);
|
||||
const extendId = isListType ? cloneNode.blockId : parentId;
|
||||
const subordinates = CustomEditor.findNodeSubordinate(editor, node);
|
||||
|
||||
Transforms.insertNodes(editor, cloneNode, { at: [path[0] + 1] });
|
||||
|
||||
subordinates.forEach((subordinate) => {
|
||||
const subordinatePath = ReactEditor.findPath(editor, subordinate);
|
||||
const level = subordinate.level ?? 2;
|
||||
|
||||
const newProperties = {
|
||||
level: isListType ? level : level - 1,
|
||||
};
|
||||
|
||||
if (subordinate.parentId === node.blockId) {
|
||||
Object.assign(newProperties, {
|
||||
parentId: extendId,
|
||||
});
|
||||
}
|
||||
|
||||
Transforms.setNodes(editor, newProperties, {
|
||||
at: [subordinatePath[0] + 1],
|
||||
});
|
||||
});
|
||||
const cloneNode = CustomEditor.cloneBlock(editor, node);
|
||||
|
||||
Transforms.removeNodes(editor, {
|
||||
at: path,
|
||||
});
|
||||
|
||||
Transforms.select(editor, selection);
|
||||
Object.assign(cloneNode, newProperties);
|
||||
|
||||
const [, ...children] = cloneNode.children;
|
||||
|
||||
Transforms.insertNodes(editor, cloneNode, { at: path });
|
||||
|
||||
const isListType = LIST_TYPES.includes(cloneNode.type as EditorNodeType);
|
||||
|
||||
// if node doesn't allow has children, the children should be lifted
|
||||
if (!isListType) {
|
||||
const length = children.length;
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
editor.liftNodes({
|
||||
at: [...path, length - i],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isSelectable = editor.isSelectable(cloneNode);
|
||||
|
||||
if (isSelectable) {
|
||||
Transforms.select(editor, selection);
|
||||
} else {
|
||||
Transforms.select(editor, path);
|
||||
}
|
||||
},
|
||||
tabForward,
|
||||
tabBackward,
|
||||
toggleMark,
|
||||
removeMarks,
|
||||
isMarkActive,
|
||||
isFormulaActive,
|
||||
updateFormula,
|
||||
@ -82,17 +91,17 @@ export const CustomEditor = {
|
||||
}
|
||||
}
|
||||
},
|
||||
isBlockActive(editor: ReactEditor, format?: string) {
|
||||
const [match] = Editor.nodes(editor, {
|
||||
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined && n.type !== undefined,
|
||||
});
|
||||
|
||||
if (format !== undefined) {
|
||||
return match && (match[0] as Element).type === format;
|
||||
isBlockActive(editor: ReactEditor, format?: string) {
|
||||
const match = CustomEditor.getBlock(editor);
|
||||
|
||||
if (match && format !== undefined) {
|
||||
return match[0].type === format;
|
||||
}
|
||||
|
||||
return !!match;
|
||||
},
|
||||
|
||||
insertMention(editor: ReactEditor, mention: Mention) {
|
||||
const mentionElement = {
|
||||
type: EditorInlineNodeType.Mention,
|
||||
@ -106,62 +115,6 @@ export const CustomEditor = {
|
||||
Transforms.move(editor);
|
||||
},
|
||||
|
||||
splitToParagraph(editor: ReactEditor) {
|
||||
Transforms.splitNodes(editor, { always: true });
|
||||
Transforms.setNodes(editor, { type: EditorNodeType.Paragraph });
|
||||
},
|
||||
|
||||
findParentNode(editor: ReactEditor, node: Element) {
|
||||
const parentId = node.parentId;
|
||||
|
||||
if (!parentId) return null;
|
||||
|
||||
return editor.children.find((child) => (child as Element).blockId === parentId) as Element;
|
||||
},
|
||||
|
||||
findNodeSubordinate(editor: ReactEditor, node: Element) {
|
||||
const index = editor.children.findIndex((child) => (child as Element).blockId === node.blockId);
|
||||
|
||||
const level = node.level ?? 1;
|
||||
const subordinateNodes: (Element & { level: number })[] = [];
|
||||
|
||||
if (index === editor.children.length - 1) return subordinateNodes;
|
||||
|
||||
for (let i = index + 1; i < editor.children.length; i++) {
|
||||
const nextNode = editor.children[i] as Element & { level: number };
|
||||
|
||||
if (nextNode.level > level) {
|
||||
subordinateNodes.push(nextNode);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return subordinateNodes;
|
||||
},
|
||||
|
||||
findNextNode(editor: ReactEditor, node: Element, level: number) {
|
||||
const index = editor.children.findIndex((child) => (child as Element).blockId === node.blockId);
|
||||
let nextIndex = -1;
|
||||
|
||||
if (index === editor.children.length - 1) return null;
|
||||
|
||||
for (let i = index + 1; i < editor.children.length; i++) {
|
||||
const nextNode = editor.children[i] as Element & { level: number };
|
||||
|
||||
if (nextNode.level === level) {
|
||||
nextIndex = i;
|
||||
break;
|
||||
}
|
||||
|
||||
if (nextNode.level < level) break;
|
||||
}
|
||||
|
||||
const nextNode = editor.children[nextIndex] as Element & { level: number };
|
||||
|
||||
return nextNode;
|
||||
},
|
||||
|
||||
toggleTodo(editor: ReactEditor, node: TodoListNode) {
|
||||
const checked = node.data.checked;
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
@ -175,38 +128,19 @@ export const CustomEditor = {
|
||||
},
|
||||
|
||||
toggleToggleList(editor: ReactEditor, node: ToggleListNode) {
|
||||
if (!node.level) return;
|
||||
const collapsed = !node.data.collapsed;
|
||||
|
||||
const collapsed = node.data.collapsed;
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
const newProperties = {
|
||||
data: {
|
||||
collapsed,
|
||||
collapsed: !collapsed,
|
||||
},
|
||||
} as Partial<Element>;
|
||||
|
||||
Transforms.select(editor, path);
|
||||
Transforms.collapse(editor, { edge: 'end' });
|
||||
Transforms.setNodes(editor, newProperties, { at: path });
|
||||
|
||||
// hide or show the children
|
||||
const index = path[0];
|
||||
|
||||
if (index === editor.children.length - 1) return;
|
||||
|
||||
for (let i = index + 1; i < editor.children.length; i++) {
|
||||
const nextNode = editor.children[i] as Element & { level: number };
|
||||
|
||||
if (nextNode.level === node.level) break;
|
||||
if (nextNode.level > node.level) {
|
||||
const nextPath = ReactEditor.findPath(editor, nextNode);
|
||||
const nextProperties = {
|
||||
isHidden: collapsed,
|
||||
} as Partial<Element>;
|
||||
|
||||
Transforms.setNodes(editor, nextProperties, { at: nextPath });
|
||||
}
|
||||
}
|
||||
editor.select(path);
|
||||
editor.collapse({
|
||||
edge: 'start',
|
||||
});
|
||||
},
|
||||
|
||||
setCalloutIcon(editor: ReactEditor, node: CalloutNode, newIcon: string) {
|
||||
@ -242,60 +176,55 @@ export const CustomEditor = {
|
||||
Transforms.setNodes(editor, newProperties, { at: path });
|
||||
},
|
||||
|
||||
findNodeChildren(editor: ReactEditor, node: Node) {
|
||||
const nodeId = (node as Element).blockId;
|
||||
|
||||
return editor.children.filter((child) => (child as Element).parentId === nodeId) as Element[];
|
||||
},
|
||||
|
||||
duplicateNode(editor: ReactEditor, node: Node) {
|
||||
const children = CustomEditor.findNodeChildren(editor, node);
|
||||
const newBlockId = generateId();
|
||||
const newTextId = generateId();
|
||||
const cloneNode = {
|
||||
...cloneDeep(node),
|
||||
blockId: newBlockId,
|
||||
textId: newTextId,
|
||||
cloneBlock(editor: ReactEditor, block: Element): Element {
|
||||
const cloneNode: Element = {
|
||||
...cloneDeep(block),
|
||||
blockId: generateId(),
|
||||
children: [],
|
||||
};
|
||||
const [firstTextNode, ...children] = block.children as Element[];
|
||||
const isSelectable = editor.isSelectable(cloneNode);
|
||||
|
||||
const textNode =
|
||||
firstTextNode && firstTextNode.type === EditorNodeType.Text && isSelectable
|
||||
? {
|
||||
textId: generateId(),
|
||||
type: EditorNodeType.Text,
|
||||
children: cloneDeep(firstTextNode.children),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (textNode) {
|
||||
cloneNode.children.push(textNode);
|
||||
}
|
||||
|
||||
const cloneChildren = children.map((child) => {
|
||||
const childBlockId = generateId();
|
||||
const childTextId = generateId();
|
||||
|
||||
return {
|
||||
...cloneDeep(child),
|
||||
blockId: childBlockId,
|
||||
textId: childTextId,
|
||||
parentId: newBlockId,
|
||||
};
|
||||
return CustomEditor.cloneBlock(editor, child);
|
||||
});
|
||||
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
const endPath = children.length ? ReactEditor.findPath(editor, children[children.length - 1]) : null;
|
||||
cloneNode.children.push(...cloneChildren);
|
||||
|
||||
Transforms.insertNodes(editor, [cloneNode, ...cloneChildren], { at: [endPath ? endPath[0] + 1 : path[0] + 1] });
|
||||
Transforms.move(editor);
|
||||
return cloneNode;
|
||||
},
|
||||
|
||||
duplicateNode(editor: ReactEditor, node: Element) {
|
||||
const cloneNode = CustomEditor.cloneBlock(editor, node);
|
||||
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
|
||||
Transforms.insertNodes(editor, cloneNode, { at: path });
|
||||
},
|
||||
|
||||
deleteNode(editor: ReactEditor, node: Node) {
|
||||
const children = CustomEditor.findNodeChildren(editor, node);
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
const endPath = children.length ? ReactEditor.findPath(editor, children[children.length - 1]) : null;
|
||||
|
||||
Transforms.removeNodes(editor, {
|
||||
at: {
|
||||
anchor: { path, offset: 0 },
|
||||
focus: { path: endPath ?? path, offset: 0 },
|
||||
},
|
||||
at: path,
|
||||
});
|
||||
|
||||
Transforms.move(editor);
|
||||
},
|
||||
|
||||
getBlockType: (editor: ReactEditor) => {
|
||||
const [match] = Editor.nodes(editor, {
|
||||
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined && n.type !== undefined,
|
||||
});
|
||||
const match = CustomEditor.getBlock(editor);
|
||||
|
||||
if (!match) return null;
|
||||
|
||||
@ -304,10 +233,6 @@ export const CustomEditor = {
|
||||
return node.type as EditorNodeType;
|
||||
},
|
||||
|
||||
isGridBlock: (editor: ReactEditor) => {
|
||||
return CustomEditor.getBlockType(editor) === EditorNodeType.GridBlock;
|
||||
},
|
||||
|
||||
selectionIncludeRoot: (editor: ReactEditor) => {
|
||||
const [match] = Editor.nodes(editor, {
|
||||
match: (n) => Element.isElement(n) && n.blockId !== undefined && n.type === EditorNodeType.Page,
|
||||
@ -324,12 +249,19 @@ export const CustomEditor = {
|
||||
editor.insertNode(
|
||||
{
|
||||
type: EditorNodeType.Paragraph,
|
||||
level: 1,
|
||||
data: {},
|
||||
blockId: generateId(),
|
||||
textId: generateId(),
|
||||
parentId: editor.sharedRoot.getAttribute('blockId'),
|
||||
children: [{ text: '' }],
|
||||
children: [
|
||||
{
|
||||
type: EditorNodeType.Text,
|
||||
textId: generateId(),
|
||||
children: [
|
||||
{
|
||||
text: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
select: true,
|
||||
@ -340,40 +272,68 @@ export const CustomEditor = {
|
||||
Transforms.move(editor);
|
||||
},
|
||||
|
||||
basePointToIndexLength(editor: ReactEditor, point: BasePoint, toStart = false) {
|
||||
const { path, offset } = point;
|
||||
focusAtStartOfBlock(editor: ReactEditor) {
|
||||
const { selection } = editor;
|
||||
|
||||
const node = editor.children[path[0]] as Element;
|
||||
const blockId = node.blockId;
|
||||
if (selection && Range.isCollapsed(selection)) {
|
||||
const match = CustomEditor.getBlock(editor);
|
||||
const [, path] = match as NodeEntry<Element>;
|
||||
const start = Editor.start(editor, path);
|
||||
|
||||
if (!blockId) return;
|
||||
const beforeText = Editor.string(editor, {
|
||||
anchor: {
|
||||
path: [path[0], 0],
|
||||
offset: 0,
|
||||
},
|
||||
focus: {
|
||||
path,
|
||||
offset,
|
||||
},
|
||||
});
|
||||
return match && Point.equals(selection.anchor, start);
|
||||
}
|
||||
|
||||
const index = beforeText.length;
|
||||
const fullText = Editor.string(editor, [path[0]]);
|
||||
const length = fullText.length - index;
|
||||
return false;
|
||||
},
|
||||
|
||||
if (toStart) {
|
||||
return {
|
||||
index: 0,
|
||||
length: index,
|
||||
blockId,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
index,
|
||||
length,
|
||||
blockId,
|
||||
};
|
||||
setBlockColor(
|
||||
editor: ReactEditor,
|
||||
node: Element,
|
||||
data: {
|
||||
font_color?: string;
|
||||
bg_color?: string;
|
||||
}
|
||||
) {
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
const newProperties = {
|
||||
data,
|
||||
} as Partial<Element>;
|
||||
|
||||
Transforms.setNodes(editor, newProperties, { at: path });
|
||||
},
|
||||
|
||||
deleteAllText(editor: ReactEditor, node: Element) {
|
||||
const [textNode] = (node.children || []) as Element[];
|
||||
const hasTextNode = textNode && textNode.type === EditorNodeType.Text;
|
||||
|
||||
if (!hasTextNode) return;
|
||||
const path = ReactEditor.findPath(editor, textNode);
|
||||
const textLength = editor.string(path).length;
|
||||
const start = Editor.start(editor, path);
|
||||
|
||||
for (let i = 0; i < textLength; i++) {
|
||||
editor.select(start);
|
||||
editor.deleteForward('character');
|
||||
}
|
||||
},
|
||||
|
||||
getNodeText: (editor: ReactEditor, node: Element) => {
|
||||
const [textNode] = (node.children || []) as Element[];
|
||||
const hasTextNode = textNode && textNode.type === EditorNodeType.Text;
|
||||
|
||||
if (!hasTextNode) return '';
|
||||
|
||||
const path = ReactEditor.findPath(editor, textNode);
|
||||
|
||||
return editor.string(path);
|
||||
},
|
||||
|
||||
isEmptyText: (editor: ReactEditor, node: Element) => {
|
||||
const [textNode] = (node.children || []) as Element[];
|
||||
const hasTextNode = textNode && textNode.type === EditorNodeType.Text;
|
||||
|
||||
if (!hasTextNode) return false;
|
||||
|
||||
return editor.isEmpty(textNode);
|
||||
},
|
||||
};
|
||||
|
@ -24,3 +24,13 @@ export function isMarkActive(editor: ReactEditor, format: string) {
|
||||
|
||||
return marks ? !!marks[format] : false;
|
||||
}
|
||||
|
||||
export function removeMarks(editor: ReactEditor) {
|
||||
const marks = Editor.marks(editor);
|
||||
|
||||
if (!marks) return;
|
||||
|
||||
for (const key in marks) {
|
||||
Editor.removeMark(editor, key);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Editor, Element, NodeEntry, Transforms } from 'slate';
|
||||
import { Path, Element, NodeEntry } from 'slate';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { EditorNodeType } from '$app/application/document/document.types';
|
||||
import { CustomEditor } from '$app/components/editor/command/index';
|
||||
@ -35,34 +35,21 @@ const LIST_ITEM_TYPES = [
|
||||
* @param editor
|
||||
*/
|
||||
export function tabForward(editor: ReactEditor) {
|
||||
const [match] = Editor.nodes(editor, {
|
||||
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && Editor.isBlock(editor, n),
|
||||
});
|
||||
const match = CustomEditor.getBlock(editor);
|
||||
|
||||
if (!match) return;
|
||||
|
||||
const [node, path] = match as NodeEntry<Element>;
|
||||
|
||||
if (!node.level) return;
|
||||
// the node is not a list item
|
||||
if (!LIST_ITEM_TYPES.includes(node.type as EditorNodeType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let previousNode;
|
||||
const previousPath = Path.previous(path);
|
||||
|
||||
for (let i = path[0] - 1; i >= 0; i--) {
|
||||
const ancestor = editor.children[i] as Element & { level: number };
|
||||
|
||||
if (ancestor.level === node.level) {
|
||||
previousNode = ancestor;
|
||||
break;
|
||||
}
|
||||
|
||||
if (ancestor.level < node.level) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const previous = editor.node(previousPath);
|
||||
const [previousNode] = previous as NodeEntry<Element>;
|
||||
|
||||
if (!previousNode) return;
|
||||
|
||||
@ -71,93 +58,38 @@ export function tabForward(editor: ReactEditor) {
|
||||
// the previous node is not a list
|
||||
if (!LIST_TYPES.includes(type)) return;
|
||||
|
||||
const previousNodeLevel = previousNode.level;
|
||||
const toPath = [...previousPath, previousNode.children.length];
|
||||
|
||||
if (!previousNodeLevel) return;
|
||||
|
||||
const newParentId = previousNode.blockId;
|
||||
const children = CustomEditor.findNodeChildren(editor, node);
|
||||
|
||||
children.forEach((child) => {
|
||||
const childPath = ReactEditor.findPath(editor, child);
|
||||
|
||||
Transforms.setNodes(
|
||||
editor,
|
||||
{
|
||||
parentId: newParentId,
|
||||
},
|
||||
{
|
||||
at: childPath,
|
||||
}
|
||||
);
|
||||
editor.moveNodes({
|
||||
at: path,
|
||||
to: toPath,
|
||||
});
|
||||
|
||||
const newProperties = { level: previousNodeLevel + 1, parentId: newParentId };
|
||||
node.children.forEach((child, index) => {
|
||||
if (index === 0) return;
|
||||
|
||||
Transforms.setNodes(editor, newProperties);
|
||||
editor.liftNodes({
|
||||
at: [...toPath, index],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Outdent the current list item
|
||||
* Conditions:
|
||||
* 1. The current node must be a list item
|
||||
* 2. The current node must be indented
|
||||
* Result:
|
||||
* 1. The current node will be the sibling of the parent node
|
||||
* 2. The current node will be outdented
|
||||
* 3. The children of the parent node will be moved to the children of the current node
|
||||
* @param editor
|
||||
*/
|
||||
export function tabBackward(editor: ReactEditor) {
|
||||
const [match] = Editor.nodes(editor, {
|
||||
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && Editor.isBlock(editor, n),
|
||||
});
|
||||
const match = CustomEditor.getBlock(editor);
|
||||
|
||||
if (!match) return;
|
||||
|
||||
const [node] = match as NodeEntry<Element & { level: number }>;
|
||||
const [node, path] = match as NodeEntry<Element & { level: number }>;
|
||||
|
||||
const level = node.level;
|
||||
|
||||
if (level <= 1) return;
|
||||
const parent = CustomEditor.findParentNode(editor, node);
|
||||
|
||||
if (!parent) return;
|
||||
|
||||
const newParentId = parent.parentId;
|
||||
|
||||
if (!newParentId) return;
|
||||
|
||||
const newProperties = { level: level - 1, parentId: newParentId };
|
||||
|
||||
const subordinates = CustomEditor.findNodeSubordinate(editor, node);
|
||||
|
||||
subordinates.forEach((subordinate) => {
|
||||
const subordinatePath = ReactEditor.findPath(editor, subordinate);
|
||||
|
||||
const subordinateLevel = subordinate.level;
|
||||
|
||||
Transforms.setNodes(
|
||||
editor,
|
||||
{
|
||||
level: subordinateLevel - 1,
|
||||
},
|
||||
{
|
||||
at: subordinatePath,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const parentChildren = CustomEditor.findNodeChildren(editor, parent);
|
||||
|
||||
const nodeIndex = parentChildren.findIndex((child) => child.blockId === node.blockId);
|
||||
|
||||
Transforms.setNodes(editor, newProperties);
|
||||
|
||||
for (let i = nodeIndex + 1; i < parentChildren.length; i++) {
|
||||
const child = parentChildren[i];
|
||||
const childPath = ReactEditor.findPath(editor, child);
|
||||
|
||||
Transforms.setNodes(editor, { parentId: node.blockId }, { at: childPath });
|
||||
if (node.type === EditorNodeType.Page) return;
|
||||
if (node.type !== EditorNodeType.Paragraph) {
|
||||
CustomEditor.turnToBlock(editor, {
|
||||
type: EditorNodeType.Paragraph,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.liftNodes({
|
||||
at: path,
|
||||
});
|
||||
}
|
||||
|
@ -1,17 +1,13 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { Editor, Element } from 'slate';
|
||||
import { useSlateStatic } from 'slate-react';
|
||||
import React from 'react';
|
||||
import { Element } from 'slate';
|
||||
import PlaceholderContent from '$app/components/editor/components/blocks/_shared/PlaceholderContent';
|
||||
|
||||
function Placeholder({ node, ...props }: { node: Element; className?: string; style?: CSSProperties }) {
|
||||
const editor = useSlateStatic();
|
||||
const isEmpty = Editor.isEmpty(editor, node);
|
||||
|
||||
function Placeholder({ node, isEmpty }: { node: Element; isEmpty: boolean }) {
|
||||
if (!isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <PlaceholderContent node={node} {...props} />;
|
||||
return <PlaceholderContent node={node} />;
|
||||
}
|
||||
|
||||
export default React.memo(Placeholder);
|
||||
|
@ -1,28 +1,35 @@
|
||||
import React, { CSSProperties, useMemo } from 'react';
|
||||
import { useSelected, useSlateStatic } from 'slate-react';
|
||||
import { Element } from 'slate';
|
||||
import { ReactEditor, useSelected, useSlateStatic } from 'slate-react';
|
||||
import { Editor, Element } from 'slate';
|
||||
import { EditorNodeType, HeadingNode } from '$app/application/document/document.types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function PlaceholderContent({ node, ...attributes }: { node: Element; className?: string; style?: CSSProperties }) {
|
||||
const { t } = useTranslation();
|
||||
const selected = useSelected();
|
||||
const editor = useSlateStatic();
|
||||
const selected = useSelected();
|
||||
const block = useMemo(() => {
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
const match = Editor.above(editor, {
|
||||
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined,
|
||||
at: path,
|
||||
});
|
||||
|
||||
const justOneParagraph = useMemo(() => {
|
||||
const root = editor.children[0] as Element;
|
||||
if (!match) return null;
|
||||
|
||||
if (node.type !== EditorNodeType.Paragraph) return false;
|
||||
return match[0] as Element;
|
||||
}, [editor, node]);
|
||||
|
||||
if (editor.children.length === 1) return true;
|
||||
|
||||
return root.type === EditorNodeType.Page && editor.children.length === 2;
|
||||
}, [editor, node.type]);
|
||||
const className = useMemo(() => {
|
||||
return `pointer-events-none absolute left-0.5 top-0 whitespace-nowrap text-text-placeholder ${
|
||||
attributes.className ?? ''
|
||||
}`;
|
||||
}, [attributes.className]);
|
||||
|
||||
const unSelectedPlaceholder = useMemo(() => {
|
||||
switch (node.type) {
|
||||
switch (block?.type) {
|
||||
case EditorNodeType.Paragraph: {
|
||||
if (justOneParagraph) {
|
||||
if (editor.children.length === 1) {
|
||||
return t('editor.slashPlaceHolder');
|
||||
}
|
||||
|
||||
@ -40,7 +47,7 @@ function PlaceholderContent({ node, ...attributes }: { node: Element; className?
|
||||
case EditorNodeType.BulletedListBlock:
|
||||
return t('document.plugins.bulletedList');
|
||||
case EditorNodeType.HeadingBlock: {
|
||||
const level = (node as HeadingNode).data.level;
|
||||
const level = (block as HeadingNode).data.level;
|
||||
|
||||
switch (level) {
|
||||
case 1:
|
||||
@ -56,27 +63,29 @@ function PlaceholderContent({ node, ...attributes }: { node: Element; className?
|
||||
|
||||
case EditorNodeType.Page:
|
||||
return t('document.title.placeholder');
|
||||
case EditorNodeType.CalloutBlock:
|
||||
case EditorNodeType.CodeBlock:
|
||||
return t('editor.typeSomething');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}, [justOneParagraph, node, t]);
|
||||
}, [block, t, editor.children.length]);
|
||||
|
||||
const selectedPlaceholder = useMemo(() => {
|
||||
switch (node.type) {
|
||||
switch (block?.type) {
|
||||
case EditorNodeType.HeadingBlock:
|
||||
return unSelectedPlaceholder;
|
||||
case EditorNodeType.Page:
|
||||
return t('document.title.placeholder');
|
||||
case EditorNodeType.GridBlock:
|
||||
case EditorNodeType.EquationBlock:
|
||||
case EditorNodeType.CodeBlock:
|
||||
return '';
|
||||
|
||||
default:
|
||||
return t('editor.slashPlaceHolder');
|
||||
}
|
||||
}, [node.type, t, unSelectedPlaceholder]);
|
||||
|
||||
const className = useMemo(() => {
|
||||
return `pointer-events-none absolute left-0.5 top-0 whitespace-nowrap text-text-placeholder ${
|
||||
attributes.className ?? ''
|
||||
}`;
|
||||
}, [attributes.className]);
|
||||
}, [block?.type, t, unSelectedPlaceholder]);
|
||||
|
||||
return (
|
||||
<span contentEditable={false} {...attributes} className={className}>
|
||||
|
@ -1,19 +1,19 @@
|
||||
import React, { forwardRef, memo } from 'react';
|
||||
import { EditorElementProps, BulletedListNode } from '$app/application/document/document.types';
|
||||
import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder';
|
||||
|
||||
export const BulletedList = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<BulletedListNode>>(({ node, children, ...attributes }, ref) => {
|
||||
return (
|
||||
<div {...attributes} className={`${attributes.className ?? ''} relative`} ref={ref}>
|
||||
<span contentEditable={false} className={'pr-2 font-medium'}>
|
||||
•
|
||||
</span>
|
||||
<span className={'relative'}>
|
||||
<Placeholder node={node} />
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
forwardRef<HTMLDivElement, EditorElementProps<BulletedListNode>>(
|
||||
({ node: _, children, className, ...attributes }, ref) => {
|
||||
return (
|
||||
<>
|
||||
<span contentEditable={false} className={'absolute flex w-6 select-none justify-center font-medium'}>
|
||||
•
|
||||
</span>
|
||||
<div ref={ref} {...attributes} className={`${className} ml-6`}>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
@ -5,16 +5,20 @@ import CalloutIcon from '$app/components/editor/components/blocks/callout/Callou
|
||||
export const Callout = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<CalloutNode>>(({ node, children, ...attributes }, ref) => {
|
||||
return (
|
||||
<div
|
||||
{...attributes}
|
||||
className={`${
|
||||
attributes.className ?? ''
|
||||
} relative my-2 flex w-full items-start gap-3 rounded border border-solid border-line-divider bg-content-blue-50 p-2`}
|
||||
ref={ref}
|
||||
>
|
||||
<CalloutIcon node={node} />
|
||||
<div className={'flex-1 py-1.5'}>{children}</div>
|
||||
</div>
|
||||
<>
|
||||
<div contentEditable={false} className={'absolute w-full select-none px-2 pt-3'}>
|
||||
<CalloutIcon node={node} />
|
||||
</div>
|
||||
<div
|
||||
{...attributes}
|
||||
ref={ref}
|
||||
className={`${
|
||||
attributes.className ?? ''
|
||||
} my-2 flex w-full flex-col rounded border border-solid border-line-divider bg-content-blue-50 py-2 pl-10`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
@ -9,21 +9,22 @@ export const Code = memo(
|
||||
const { language, handleChangeLanguage } = useCodeBlock(node);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...attributes}
|
||||
ref={ref}
|
||||
className={`${
|
||||
attributes.className ?? ''
|
||||
} my-2 w-full rounded border border-solid border-line-divider bg-content-blue-50 p-6`}
|
||||
>
|
||||
<div contentEditable={false} className={'mb-2 w-full'}>
|
||||
<>
|
||||
<div contentEditable={false} className={'absolute w-full select-none px-7 py-6'}>
|
||||
<LanguageSelect language={language} onChangeLanguage={handleChangeLanguage} />
|
||||
</div>
|
||||
|
||||
<pre className='code-block-element'>
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<div
|
||||
{...attributes}
|
||||
ref={ref}
|
||||
className={`${
|
||||
attributes.className ?? ''
|
||||
} my-2 flex w-full rounded border border-solid border-line-divider bg-content-blue-50 p-6 pt-14`}
|
||||
>
|
||||
<pre>
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
@ -1,22 +1,33 @@
|
||||
import React, { forwardRef, memo } from 'react';
|
||||
import React, { forwardRef, memo, useCallback, useContext } from 'react';
|
||||
import { EditorElementProps, GridNode } from '$app/application/document/document.types';
|
||||
|
||||
import GridView from '$app/components/editor/components/blocks/database/GridView';
|
||||
import DatabaseEmpty from '$app/components/editor/components/blocks/database/DatabaseEmpty';
|
||||
import { EditorSelectedBlockContext } from '$app/components/editor/components/editor/Editor.hooks';
|
||||
|
||||
export const GridBlock = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<GridNode>>(({ node, children }, ref) => {
|
||||
forwardRef<HTMLDivElement, EditorElementProps<GridNode>>(({ node, children, className = '', ...attributes }, ref) => {
|
||||
const viewId = node.data.viewId;
|
||||
|
||||
return (
|
||||
<div
|
||||
contentEditable={false}
|
||||
className='relative flex h-[400px] overflow-hidden border-b border-t border-line-divider caret-text-title'
|
||||
ref={ref}
|
||||
>
|
||||
{viewId ? <GridView viewId={viewId} /> : <DatabaseEmpty node={node} />}
|
||||
const blockId = node.blockId;
|
||||
const selectedBlockContext = useContext(EditorSelectedBlockContext);
|
||||
const onClick = useCallback(() => {
|
||||
if (!blockId) return;
|
||||
selectedBlockContext.clear();
|
||||
selectedBlockContext.add(blockId);
|
||||
}, [blockId, selectedBlockContext]);
|
||||
|
||||
<div className={'invisible absolute'}>{children}</div>
|
||||
return (
|
||||
<div {...attributes} onClick={onClick} className={`${className} relative my-2`}>
|
||||
<div ref={ref} className={'absolute left-0 top-0 h-full w-full caret-transparent'}>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
contentEditable={false}
|
||||
className='flex h-[400px] overflow-hidden border-b border-t border-line-divider bg-bg-body py-3 caret-text-title'
|
||||
>
|
||||
{viewId ? <GridView viewId={viewId} /> : <DatabaseEmpty node={node} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
@ -1,2 +1 @@
|
||||
export * from './GridBlock';
|
||||
export * from './withDatabaseBlockPlugin';
|
||||
|
@ -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;
|
||||
}
|
@ -3,18 +3,15 @@ import { EditorElementProps, DividerNode as DividerNodeType } from '$app/applica
|
||||
|
||||
export const DividerNode = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<DividerNodeType>>(
|
||||
({ node: _node, children: children, ...attributes }, ref) => {
|
||||
({ node: _node, children: children, className, ...attributes }, ref) => {
|
||||
return (
|
||||
<div
|
||||
{...attributes}
|
||||
ref={ref}
|
||||
contentEditable={false}
|
||||
className={`${attributes.className ?? ''} relative w-full`}
|
||||
>
|
||||
<div className={'w-full py-2.5 text-line-divider'}>
|
||||
<div {...attributes} className={`${className} relative`}>
|
||||
<div contentEditable={false} className={'w-full py-2.5 text-line-divider'}>
|
||||
<hr />
|
||||
</div>
|
||||
<span className={'absolute left-0 top-0 h-0 w-0 opacity-0'}>{children}</span>
|
||||
<div ref={ref} className={`absolute h-full w-full caret-transparent`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import React, { forwardRef, memo } from 'react';
|
||||
import { EditorElementProps, HeadingNode } from '$app/application/document/document.types';
|
||||
import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder';
|
||||
import { getHeadingCssProperty } from '$app/components/editor/plugins/utils';
|
||||
|
||||
export const Heading = memo(
|
||||
@ -8,13 +7,10 @@ export const Heading = memo(
|
||||
const level = node.data.level;
|
||||
const fontSizeCssProperty = getHeadingCssProperty(level);
|
||||
|
||||
const className = `${attributes.className ?? ''} font-bold ${fontSizeCssProperty}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...attributes}
|
||||
ref={ref}
|
||||
className={`${attributes.className ?? ''} leading-1 relative font-bold ${fontSizeCssProperty}`}
|
||||
>
|
||||
<Placeholder node={node} className={fontSizeCssProperty} />
|
||||
<div {...attributes} ref={ref} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import { PopoverCommonProps } from '$app/components/editor/components/tools/popover';
|
||||
import { TextareaAutosize } from '@mui/material';
|
||||
@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { Element } from 'slate';
|
||||
import { KeyboardReturnOutlined } from '@mui/icons-material';
|
||||
import { useSlateStatic } from 'slate-react';
|
||||
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||
|
||||
function EditPopover({
|
||||
open,
|
||||
@ -28,10 +28,19 @@ function EditPopover({
|
||||
setValue(event.currentTarget.value);
|
||||
};
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose();
|
||||
if (!node) return;
|
||||
ReactEditor.focus(editor);
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
|
||||
editor.select(path);
|
||||
}, [onClose, editor, node]);
|
||||
|
||||
const handleDone = () => {
|
||||
if (!node) return;
|
||||
CustomEditor.setMathEquationBlockFormula(editor, node, value);
|
||||
onClose();
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
@ -58,7 +67,7 @@ function EditPopover({
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
onClose={onClose}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div className={'flex flex-col gap-3 p-4'}>
|
||||
<TextareaAutosize
|
||||
|
@ -16,25 +16,28 @@ export const MathEquation = memo(
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
contentEditable={false}
|
||||
ref={ref}
|
||||
{...attributes}
|
||||
onClick={(e) => {
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
className={`${
|
||||
className ?? ''
|
||||
} relative cursor-pointer rounded border border-line-divider bg-content-blue-50 px-3 `}
|
||||
className={`${className} relative my-2 cursor-pointer`}
|
||||
>
|
||||
{formula ? (
|
||||
<KatexMath latex={formula} />
|
||||
) : (
|
||||
<div className={'relative flex h-[48px] w-full items-center gap-[10px] text-text-caption'}>
|
||||
<FunctionsOutlined />
|
||||
{t('document.plugins.mathEquation.addMathEquation')}
|
||||
</div>
|
||||
)}
|
||||
<div className={'invisible absolute'}>{children}</div>
|
||||
<div
|
||||
contentEditable={false}
|
||||
className={`w-full select-none rounded border border-line-divider bg-content-blue-50 px-3`}
|
||||
>
|
||||
{formula ? (
|
||||
<KatexMath latex={formula} />
|
||||
) : (
|
||||
<div className={'flex h-[48px] w-full items-center gap-[10px] text-text-caption'}>
|
||||
<FunctionsOutlined />
|
||||
{t('document.plugins.mathEquation.addMathEquation')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div ref={ref} className={'absolute left-0 top-0 h-full w-full caret-transparent'}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{open && (
|
||||
<EditPopover
|
||||
|
@ -1,2 +1 @@
|
||||
export * from './withMathEquationPlugin';
|
||||
export * from './MathEquation';
|
||||
|
@ -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;
|
||||
}
|
@ -1,51 +1,47 @@
|
||||
import React, { forwardRef, memo, useMemo } from 'react';
|
||||
import { EditorElementProps, NumberedListNode } from '$app/application/document/document.types';
|
||||
|
||||
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||
import { Editor, Element } from 'slate';
|
||||
import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder';
|
||||
import { ReactEditor, useSlate } from 'slate-react';
|
||||
import { Element, Path } from 'slate';
|
||||
|
||||
export const NumberedList = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<NumberedListNode>>(({ node, children, ...attributes }, ref) => {
|
||||
const editor = useSlateStatic();
|
||||
forwardRef<HTMLDivElement, EditorElementProps<NumberedListNode>>(
|
||||
({ node, children, className, ...attributes }, ref) => {
|
||||
const editor = useSlate();
|
||||
|
||||
const index = useMemo(() => {
|
||||
let index = 1;
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
const index = useMemo(() => {
|
||||
let index = 1;
|
||||
|
||||
let prevEntry = Editor.previous(editor, {
|
||||
at: path,
|
||||
});
|
||||
let prevPath = Path.previous(path);
|
||||
|
||||
while (prevEntry) {
|
||||
const prevNode = prevEntry[0];
|
||||
while (prevPath) {
|
||||
const prev = editor.node(prevPath);
|
||||
|
||||
if (Element.isElement(prevNode) && !Editor.isEditor(prevNode)) {
|
||||
if (prevNode.type === node.type && prevNode.level === node.level) {
|
||||
const prevNode = prev[0] as Element;
|
||||
|
||||
if (prevNode.type === node.type) {
|
||||
index += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
prevPath = Path.previous(prevPath);
|
||||
}
|
||||
|
||||
prevEntry = Editor.previous(editor, {
|
||||
at: prevEntry[1],
|
||||
});
|
||||
}
|
||||
return index;
|
||||
}, [editor, node, path]);
|
||||
|
||||
return index;
|
||||
}, [editor, node]);
|
||||
|
||||
return (
|
||||
<div {...attributes} className={`${attributes.className ?? ''} relative`} ref={ref}>
|
||||
<span contentEditable={false} className={'pr-2 font-medium'}>
|
||||
{index}.
|
||||
</span>
|
||||
<span className={'relative'}>
|
||||
<Placeholder node={node} />
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<span contentEditable={false} className={'absolute flex w-6 select-none justify-center font-medium'}>
|
||||
{index}.
|
||||
</span>
|
||||
<div ref={ref} {...attributes} className={`${className} ml-6`}>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
@ -1,19 +1,15 @@
|
||||
import React, { forwardRef, memo, useMemo } from 'react';
|
||||
import { EditorElementProps, PageNode } from '$app/application/document/document.types';
|
||||
import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder';
|
||||
|
||||
export const Page = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<PageNode>>(({ node, children, ...attributes }, ref) => {
|
||||
forwardRef<HTMLDivElement, EditorElementProps<PageNode>>(({ node: _, children, ...attributes }, ref) => {
|
||||
const className = useMemo(() => {
|
||||
return `${attributes.className ?? ''} mb-2 text-4xl font-bold`;
|
||||
return `${attributes.className ?? ''} text-4xl font-bold`;
|
||||
}, [attributes.className]);
|
||||
|
||||
return (
|
||||
<div ref={ref} {...attributes} className={className}>
|
||||
<span className={'relative'}>
|
||||
<Placeholder className={'top-1.5'} node={node} />
|
||||
{children}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
@ -1,16 +1,12 @@
|
||||
import React, { forwardRef, memo } from 'react';
|
||||
import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder';
|
||||
import { EditorElementProps, ParagraphNode } from '$app/application/document/document.types';
|
||||
|
||||
export const Paragraph = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<ParagraphNode>>(({ node, children, ...attributes }, ref) => {
|
||||
forwardRef<HTMLDivElement, EditorElementProps<ParagraphNode>>(({ node: _, children, ...attributes }, ref) => {
|
||||
{
|
||||
return (
|
||||
<div ref={ref} {...attributes} className={`${attributes.className ?? ''}`}>
|
||||
<span className={'relative'}>
|
||||
<Placeholder node={node} />
|
||||
{children}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,19 +1,15 @@
|
||||
import React, { forwardRef, memo, useMemo } from 'react';
|
||||
import { EditorElementProps, QuoteNode } from '$app/application/document/document.types';
|
||||
import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder';
|
||||
|
||||
export const QuoteList = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<QuoteNode>>(({ node, children, ...attributes }, ref) => {
|
||||
forwardRef<HTMLDivElement, EditorElementProps<QuoteNode>>(({ node: _, children, ...attributes }, ref) => {
|
||||
const className = useMemo(() => {
|
||||
return `${attributes.className ?? ''} relative border-l-4 border-fill-default`;
|
||||
return `flex w-full flex-col ml-2.5 border-l-[4px] border-fill-default pl-2.5 ${attributes.className ?? ''}`;
|
||||
}, [attributes.className]);
|
||||
|
||||
return (
|
||||
<div {...attributes} ref={ref} className={className}>
|
||||
<span className={'relative left-2'}>
|
||||
<Placeholder node={node} />
|
||||
{children}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
@ -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;
|
@ -0,0 +1 @@
|
||||
export * from './Text';
|
@ -3,7 +3,6 @@ import { EditorElementProps, TodoListNode } from '$app/application/document/docu
|
||||
import { ReactComponent as CheckboxCheckSvg } from '$app/assets/database/checkbox-check.svg';
|
||||
import { ReactComponent as CheckboxUncheckSvg } from '$app/assets/database/checkbox-uncheck.svg';
|
||||
import { useSlateStatic } from 'slate-react';
|
||||
import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
|
||||
export const TodoList = memo(
|
||||
@ -11,28 +10,26 @@ export const TodoList = memo(
|
||||
const { checked } = node.data;
|
||||
const editor = useSlateStatic();
|
||||
const className = useMemo(() => {
|
||||
return `relative ${attributes.className ?? ''}`;
|
||||
}, [attributes.className]);
|
||||
return `flex w-full flex-col pl-6 ${checked ? 'checked' : ''} ${attributes.className ?? ''}`;
|
||||
}, [attributes.className, checked]);
|
||||
const toggleTodo = useCallback(() => {
|
||||
CustomEditor.toggleTodo(editor, node);
|
||||
}, [editor, node]);
|
||||
|
||||
return (
|
||||
<div {...attributes} ref={ref} className={className}>
|
||||
<>
|
||||
<span
|
||||
data-playwright-selected={false}
|
||||
contentEditable={false}
|
||||
onClick={toggleTodo}
|
||||
className='absolute left-0 top-0 inline-flex cursor-pointer text-xl text-fill-default'
|
||||
className='absolute cursor-pointer select-none text-xl text-fill-default'
|
||||
>
|
||||
{checked ? <CheckboxCheckSvg /> : <CheckboxUncheckSvg />}
|
||||
</span>
|
||||
|
||||
<span className={`relative ml-6 ${checked ? 'text-text-caption line-through' : ''}`}>
|
||||
<Placeholder node={node} />
|
||||
<div {...attributes} ref={ref} className={className}>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
@ -2,7 +2,6 @@ import React, { forwardRef, memo, useCallback, useMemo } from 'react';
|
||||
import { EditorElementProps, ToggleListNode } from '$app/application/document/document.types';
|
||||
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||
import { ReactComponent as RightSvg } from '$app/assets/more.svg';
|
||||
import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
|
||||
export const ToggleList = memo(
|
||||
@ -10,27 +9,26 @@ export const ToggleList = memo(
|
||||
const { collapsed } = node.data;
|
||||
const editor = useSlateStatic() as ReactEditor;
|
||||
const className = useMemo(() => {
|
||||
return `relative ${attributes.className ?? ''}`;
|
||||
}, [attributes.className]);
|
||||
return `pl-6 ${attributes.className ?? ''} ${collapsed ? 'collapsed' : ''}`;
|
||||
}, [attributes.className, collapsed]);
|
||||
const toggleToggleList = useCallback(() => {
|
||||
CustomEditor.toggleToggleList(editor, node);
|
||||
}, [editor, node]);
|
||||
|
||||
return (
|
||||
<div {...attributes} ref={ref} className={className}>
|
||||
<>
|
||||
<span
|
||||
data-playwright-selected={false}
|
||||
contentEditable={false}
|
||||
onClick={toggleToggleList}
|
||||
className='absolute left-0 top-0 inline-block cursor-pointer rounded text-xl text-text-title hover:bg-fill-list-hover'
|
||||
className='absolute cursor-pointer select-none text-xl hover:text-fill-default'
|
||||
>
|
||||
{collapsed ? <RightSvg /> : <RightSvg className={'rotate-90 transform'} />}
|
||||
</span>
|
||||
<span className={'z-1 relative ml-6'}>
|
||||
<Placeholder node={node} />
|
||||
|
||||
<div {...attributes} ref={ref} className={className}>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import Editor from '$app/components/editor/components/editor/Editor';
|
||||
import { EditorProps } from '$app/application/document/document.types';
|
||||
import { Provider } from '$app/components/editor/provider';
|
||||
import { YXmlText } from 'yjs/dist/src/types/YXmlText';
|
||||
import { getInsertTarget, getYTarget } from '$app/components/editor/provider/utils/relation';
|
||||
|
||||
export const CollaborativeEditor = ({ id, title, showTitle = true, onTitleChange }: EditorProps) => {
|
||||
export const CollaborativeEditor = memo(({ id, title, showTitle = true, onTitleChange }: EditorProps) => {
|
||||
const [sharedType, setSharedType] = useState<YXmlText | null>(null);
|
||||
const provider = useMemo(() => {
|
||||
setSharedType(null);
|
||||
@ -13,18 +14,25 @@ export const CollaborativeEditor = ({ id, title, showTitle = true, onTitleChange
|
||||
}, [id, showTitle]);
|
||||
|
||||
const root = useMemo(() => {
|
||||
return showTitle ? (sharedType?.toDelta()[0].insert as YXmlText | null) : null;
|
||||
if (!showTitle || !sharedType || !sharedType.doc) return null;
|
||||
|
||||
return getYTarget(sharedType?.doc, [0]);
|
||||
}, [sharedType, showTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!root || root.toString() === title) return;
|
||||
const rootText = useMemo(() => {
|
||||
if (!root) return null;
|
||||
return getInsertTarget(root, [0]);
|
||||
}, [root]);
|
||||
|
||||
if (root.length > 0) {
|
||||
root.delete(0, root.length);
|
||||
useEffect(() => {
|
||||
if (!rootText || rootText.toString() === title) return;
|
||||
|
||||
if (rootText.length > 0) {
|
||||
rootText.delete(0, rootText.length);
|
||||
}
|
||||
|
||||
root.insert(0, title || '');
|
||||
}, [title, root]);
|
||||
rootText.insert(0, title || '');
|
||||
}, [title, rootText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!root) return;
|
||||
@ -32,8 +40,8 @@ export const CollaborativeEditor = ({ id, title, showTitle = true, onTitleChange
|
||||
onTitleChange?.(root.toString());
|
||||
};
|
||||
|
||||
root.observe(onChange);
|
||||
return () => root.unobserve(onChange);
|
||||
root.observeDeep(onChange);
|
||||
return () => root.unobserveDeep(onChange);
|
||||
}, [onTitleChange, root]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -55,4 +63,4 @@ export const CollaborativeEditor = ({ id, title, showTitle = true, onTitleChange
|
||||
}
|
||||
|
||||
return <Editor sharedType={sharedType} id={id} />;
|
||||
};
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { createContext, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { EditorNodeType, CodeNode } from '$app/application/document/document.types';
|
||||
|
||||
import { createEditor, NodeEntry, BaseRange, Editor, Element } from 'slate';
|
||||
@ -10,6 +10,7 @@ import { withInlines } from '$app/components/editor/components/inline_nodes';
|
||||
import { withYjs, YjsEditor, withYHistory } from '@slate-yjs/core';
|
||||
import * as Y from 'yjs';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { proxySet, subscribeKey } from 'valtio/utils';
|
||||
|
||||
export function useEditor(sharedType: Y.XmlText) {
|
||||
const editor = useMemo(() => {
|
||||
@ -77,56 +78,60 @@ export function useDecorate(editor: ReactEditor) {
|
||||
);
|
||||
}
|
||||
|
||||
export const EditorSelectedBlockContext = createContext<string[]>([]);
|
||||
export function useEditorState(editor: ReactEditor) {
|
||||
const selectedBlocks = useMemo(() => proxySet([]), []);
|
||||
|
||||
export function useSelectedBlock(blockId?: string) {
|
||||
const blockIds = useContext(EditorSelectedBlockContext);
|
||||
const [selectedLength, setSelectedLength] = useState(0);
|
||||
|
||||
if (blockId === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return blockIds.includes(blockId);
|
||||
}
|
||||
|
||||
export const EditorSelectedBlockProvider = EditorSelectedBlockContext.Provider;
|
||||
|
||||
export function useEditorSelectedBlock(editor: ReactEditor) {
|
||||
const [selectedBlockId, setSelectedBlockId] = useState<string[]>([]);
|
||||
const onSelectedBlock = useCallback(
|
||||
(blockId: string) => {
|
||||
const children = editor.children.filter((node) => (node as Element).parentId === blockId);
|
||||
const blockIds = [blockId, ...children.map((node) => (node as Element).blockId as string)];
|
||||
const node = editor.children.find((node) => (node as Element).blockId === blockId);
|
||||
|
||||
if (node) {
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
|
||||
ReactEditor.focus(editor);
|
||||
editor.select(path);
|
||||
editor.collapse({
|
||||
edge: 'start',
|
||||
});
|
||||
}
|
||||
|
||||
setSelectedBlockId(blockIds);
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
subscribeKey(selectedBlocks, 'size', (v) => setSelectedLength(v));
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = () => {
|
||||
if (selectedBlockId.length === 0) return;
|
||||
setSelectedBlockId([]);
|
||||
const { onChange } = editor;
|
||||
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (!ReactEditor.isFocused(editor) && selectedLength > 0) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const selectedBlockId = selectedBlocks.values().next().value;
|
||||
const [selectedBlock] = editor.nodes({
|
||||
at: [],
|
||||
match: (n) => Element.isElement(n) && n.blockId === selectedBlockId,
|
||||
});
|
||||
const [, path] = selectedBlock;
|
||||
|
||||
editor.select(path);
|
||||
ReactEditor.focus(editor);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleClick);
|
||||
if (selectedLength > 0) {
|
||||
editor.onChange = (...args) => {
|
||||
const isSelectionChange = editor.operations.every((arg) => arg.type === 'set_selection');
|
||||
|
||||
if (isSelectionChange) {
|
||||
selectedBlocks.clear();
|
||||
}
|
||||
|
||||
onChange(...args);
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
} else {
|
||||
editor.onChange = onChange;
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick);
|
||||
editor.onChange = onChange;
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
};
|
||||
}, [selectedBlockId]);
|
||||
}, [editor, selectedBlocks, selectedLength]);
|
||||
|
||||
return {
|
||||
selectedBlockId,
|
||||
onSelectedBlock,
|
||||
selectedBlocks,
|
||||
};
|
||||
}
|
||||
|
||||
export const EditorSelectedBlockContext = createContext<Set<string>>(new Set());
|
||||
|
||||
export const EditorSelectedBlockProvider = EditorSelectedBlockContext.Provider;
|
||||
|
@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
EditorSelectedBlockProvider,
|
||||
useDecorate,
|
||||
useEditor,
|
||||
useEditorSelectedBlock,
|
||||
useEditorState,
|
||||
} from '$app/components/editor/components/editor/Editor.hooks';
|
||||
import { Slate } from 'slate-react';
|
||||
import { CustomEditable } from '$app/components/editor/components/editor/CustomEditable';
|
||||
@ -19,24 +19,23 @@ function Editor({ sharedType }: { sharedType: Y.XmlText; id: string }) {
|
||||
const { editor, initialValue, handleOnClickEnd, ...props } = useEditor(sharedType);
|
||||
const decorate = useDecorate(editor);
|
||||
const { onDOMBeforeInput, onKeyDown: onShortcutsKeyDown } = useShortcuts(editor);
|
||||
|
||||
const { onSelectedBlock, selectedBlockId } = useEditorSelectedBlock(editor);
|
||||
const { selectedBlocks } = useEditorState(editor);
|
||||
|
||||
if (editor.sharedRoot.length === 0) {
|
||||
return <CircularProgress className='m-auto' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<EditorSelectedBlockProvider value={selectedBlockId}>
|
||||
<EditorSelectedBlockProvider value={selectedBlocks}>
|
||||
<Slate editor={editor} initialValue={initialValue}>
|
||||
<SelectionToolbar />
|
||||
<BlockActionsToolbar onSelectedBlock={onSelectedBlock} />
|
||||
<BlockActionsToolbar />
|
||||
<CustomEditable
|
||||
{...props}
|
||||
onDOMBeforeInput={onDOMBeforeInput}
|
||||
onKeyDown={onShortcutsKeyDown}
|
||||
decorate={decorate}
|
||||
className={'caret-text-title outline-none focus:outline-none'}
|
||||
className={'px-16 caret-text-title outline-none focus:outline-none'}
|
||||
/>
|
||||
<SlashCommandPanel />
|
||||
<MentionPanel />
|
||||
@ -46,4 +45,4 @@ function Editor({ sharedType }: { sharedType: Y.XmlText; id: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default Editor;
|
||||
export default memo(Editor);
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
@ -1,6 +1,12 @@
|
||||
import React, { FC, HTMLAttributes, useMemo } from 'react';
|
||||
import { RenderElementProps } from 'slate-react';
|
||||
import { EditorElementProps, EditorInlineNodeType, EditorNodeType } from '$app/application/document/document.types';
|
||||
import {
|
||||
BlockData,
|
||||
EditorElementProps,
|
||||
EditorInlineNodeType,
|
||||
EditorNodeType,
|
||||
TextNode,
|
||||
} from '$app/application/document/document.types';
|
||||
import { Paragraph } from '$app/components/editor/components/blocks/paragraph';
|
||||
import { Heading } from '$app/components/editor/components/blocks/heading';
|
||||
import { TodoList } from '$app/components/editor/components/blocks/todo_list';
|
||||
@ -15,8 +21,9 @@ import { Callout } from '$app/components/editor/components/blocks/callout';
|
||||
import { Mention } from '$app/components/editor/components/inline_nodes/mention';
|
||||
import { GridBlock } from '$app/components/editor/components/blocks/database';
|
||||
import { MathEquation } from '$app/components/editor/components/blocks/math_equation';
|
||||
import { useSelectedBlock } from '$app/components/editor/components/editor/Editor.hooks';
|
||||
import Page from '../blocks/page/Page';
|
||||
import { Text as TextComponent } from '../blocks/text';
|
||||
import { Page } from '../blocks/page';
|
||||
import { useElementState } from '$app/components/editor/components/editor/Element.hooks';
|
||||
|
||||
function Element({ element, attributes, children }: RenderElementProps) {
|
||||
const node = element;
|
||||
@ -65,13 +72,20 @@ function Element({ element, attributes, children }: RenderElementProps) {
|
||||
}
|
||||
}, [node.type]) as FC<EditorElementProps & HTMLAttributes<HTMLElement>>;
|
||||
|
||||
const marginLeft = useMemo(() => {
|
||||
if (!node.level) return;
|
||||
const { isSelected } = useElementState(node);
|
||||
|
||||
return (node.level - 1) * 24;
|
||||
}, [node.level]);
|
||||
const className = useMemo(() => {
|
||||
return `block-element my-1 flex rounded ${isSelected ? 'bg-content-blue-100' : ''}`;
|
||||
}, [isSelected]);
|
||||
|
||||
const isSelected = useSelectedBlock(node.blockId);
|
||||
const style = useMemo(() => {
|
||||
const data = (node.data as BlockData) || {};
|
||||
|
||||
return {
|
||||
backgroundColor: data.bg_color,
|
||||
color: data.font_color,
|
||||
};
|
||||
}, [node.data]);
|
||||
|
||||
if (InlineComponent) {
|
||||
return (
|
||||
@ -81,15 +95,17 @@ function Element({ element, attributes, children }: RenderElementProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (node.type === EditorNodeType.Text) {
|
||||
return (
|
||||
<TextComponent {...attributes} node={node as TextNode}>
|
||||
{children}
|
||||
</TextComponent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...attributes}
|
||||
style={{
|
||||
marginLeft,
|
||||
}}
|
||||
className={`${node.isHidden ? 'hidden' : 'inline-block'} block-element leading-1 my-0.5 w-full px-16`}
|
||||
>
|
||||
<Component className={`${isSelected ? 'bg-content-blue-100' : ''}`} node={node}>
|
||||
<div {...attributes} data-block-type={node.type} className={className}>
|
||||
<Component style={style} className={`flex w-full flex-col`} node={node}>
|
||||
{children}
|
||||
</Component>
|
||||
</div>
|
||||
|
@ -86,7 +86,9 @@ export function useShortcuts(editor: ReactEditor) {
|
||||
if (isHotkey('shift+Enter', e) && node) {
|
||||
e.preventDefault();
|
||||
if (SOFT_BREAK_TYPES.includes(node.type as EditorNodeType)) {
|
||||
CustomEditor.splitToParagraph(editor);
|
||||
editor.splitNodes({
|
||||
always: true,
|
||||
});
|
||||
} else {
|
||||
editor.insertText('\n');
|
||||
}
|
||||
|
@ -1,7 +1,5 @@
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { Editor, Range } from 'slate';
|
||||
import { getBlockEntry, isDeleteBackwardAtStartOfBlock } from '$app/components/editor/plugins/utils';
|
||||
import { EditorNodeType } from '$app/application/document/document.types';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
|
||||
export enum EditorCommand {
|
||||
@ -36,7 +34,7 @@ export function withCommandShortcuts(editor: ReactEditor) {
|
||||
});
|
||||
|
||||
if (endOfPanelChar !== undefined && selection && Range.isCollapsed(selection)) {
|
||||
const block = getBlockEntry(editor);
|
||||
const block = CustomEditor.getBlock(editor);
|
||||
const path = block ? block[1] : [];
|
||||
const { anchor } = selection;
|
||||
const beforeText = Editor.string(editor, { anchor, focus: Editor.start(editor, path) }) + text.slice(0, -1);
|
||||
@ -67,7 +65,7 @@ export function withCommandShortcuts(editor: ReactEditor) {
|
||||
|
||||
if (selection && Range.isCollapsed(selection)) {
|
||||
const { anchor } = selection;
|
||||
const block = getBlockEntry(editor);
|
||||
const block = CustomEditor.getBlock(editor);
|
||||
const path = block ? block[1] : [];
|
||||
const beforeText = Editor.string(editor, { anchor, focus: Editor.start(editor, path) });
|
||||
|
||||
@ -81,7 +79,7 @@ export function withCommandShortcuts(editor: ReactEditor) {
|
||||
}
|
||||
|
||||
// if delete backward at start of paragraph, and then it will be deleted, so we should close the panel if it is open
|
||||
if (isDeleteBackwardAtStartOfBlock(editor, EditorNodeType.Paragraph)) {
|
||||
if (CustomEditor.focusAtStartOfBlock(editor)) {
|
||||
const slateDom = ReactEditor.toDOMNode(editor, editor);
|
||||
|
||||
commands.forEach((char) => {
|
||||
|
@ -25,7 +25,13 @@ const regexMap: Record<
|
||||
],
|
||||
[EditorNodeType.QuoteBlock]: [
|
||||
{
|
||||
pattern: /^("|“|”)$/,
|
||||
pattern: /^”$/,
|
||||
},
|
||||
{
|
||||
pattern: /^“$/,
|
||||
},
|
||||
{
|
||||
pattern: /^"$/,
|
||||
},
|
||||
],
|
||||
[EditorNodeType.TodoListBlock]: [
|
||||
@ -218,7 +224,7 @@ export const withMarkdownShortcuts = (editor: ReactEditor) => {
|
||||
if (text.endsWith(' ') || text.endsWith('-')) {
|
||||
const endChar = text.slice(-1);
|
||||
const [match] = Editor.nodes(editor, {
|
||||
match: (n) => !Editor.isEditor(n) && SlateElement.isElement(n) && n.type !== undefined,
|
||||
match: (n) => !Editor.isEditor(n) && SlateElement.isElement(n) && n.type === EditorNodeType.Text,
|
||||
});
|
||||
|
||||
if (!match) {
|
||||
|
@ -1,9 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ColorPicker,
|
||||
ColorPickerProps,
|
||||
} from '$app/components/editor/components/tools/selection_toolbar/sub_menu/ColorPicker';
|
||||
import { ColorPicker, ColorPickerProps } from '$app/components/editor/components/tools/_shared/ColorPicker';
|
||||
|
||||
export function BgColorPicker(props: ColorPickerProps) {
|
||||
const { t } = useTranslation();
|
@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CustomColorPicker } from '$app/components/editor/components/tools/selection_toolbar/sub_menu/CustomColorPicker';
|
||||
import { CustomColorPicker } from '$app/components/editor/components/tools/_shared/CustomColorPicker';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { MenuItem, MenuList } from '@mui/material';
|
||||
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
|
@ -1,9 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ColorPicker,
|
||||
ColorPickerProps,
|
||||
} from '$app/components/editor/components/tools/selection_toolbar/sub_menu/ColorPicker';
|
||||
import { ColorPicker, ColorPickerProps } from '$app/components/editor/components/tools/_shared/ColorPicker';
|
||||
|
||||
export function FontColorPicker(props: ColorPickerProps) {
|
||||
const { t } = useTranslation();
|
@ -6,11 +6,11 @@ import { PopoverPreventBlurProps } from '$app/components/editor/components/tools
|
||||
import SlashCommandPanelContent from '$app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanelContent';
|
||||
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Editor, Element, Transforms } from 'slate';
|
||||
import { EditorNodeType } from '$app/application/document/document.types';
|
||||
import { Element } from 'slate';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { EditorNodeType } from '$app/application/document/document.types';
|
||||
|
||||
function AddBlockBelow({ node }: { node: Element }) {
|
||||
function AddBlockBelow({ node }: { node?: Element }) {
|
||||
const { t } = useTranslation();
|
||||
const [nodeEl, setNodeEl] = useState<HTMLElement | null>(null);
|
||||
const editor = useSlate();
|
||||
@ -19,23 +19,12 @@ function AddBlockBelow({ node }: { node: Element }) {
|
||||
const handleSlashCommandPanelClose = useCallback(
|
||||
(deleteText?: boolean) => {
|
||||
if (!nodeEl) return;
|
||||
const node = ReactEditor.toSlateNode(editor, nodeEl);
|
||||
const node = ReactEditor.toSlateNode(editor, nodeEl) as Element;
|
||||
|
||||
if (!node) return;
|
||||
|
||||
if (deleteText) {
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
|
||||
Transforms.select(editor, path);
|
||||
Transforms.insertNodes(
|
||||
editor,
|
||||
[
|
||||
{
|
||||
text: '',
|
||||
},
|
||||
],
|
||||
{
|
||||
select: true,
|
||||
}
|
||||
);
|
||||
CustomEditor.deleteAllText(editor, node);
|
||||
}
|
||||
|
||||
setNodeEl(null);
|
||||
@ -47,44 +36,53 @@ function AddBlockBelow({ node }: { node: Element }) {
|
||||
if (!node) return;
|
||||
ReactEditor.focus(editor);
|
||||
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
const [textNode] = node.children as Element[];
|
||||
const hasTextNode = textNode && textNode.type === EditorNodeType.Text;
|
||||
|
||||
editor.select(path);
|
||||
const nodePath = ReactEditor.findPath(editor, node);
|
||||
const textPath = ReactEditor.findPath(editor, textNode);
|
||||
|
||||
const focusPath = hasTextNode ? textPath : nodePath;
|
||||
|
||||
editor.select(focusPath);
|
||||
editor.collapse({
|
||||
edge: 'end',
|
||||
});
|
||||
|
||||
const isEmptyNode = editor.isEmpty(node);
|
||||
const isEmptyNode = CustomEditor.isEmptyText(editor, node);
|
||||
|
||||
if (isEmptyNode) {
|
||||
const nodeDom = ReactEditor.toDOMNode(editor, node);
|
||||
|
||||
setNodeEl(nodeDom);
|
||||
} else {
|
||||
CustomEditor.splitToParagraph(editor);
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const nextNodeEntry = Editor.next(editor, {
|
||||
at: path,
|
||||
match: (n) => Element.isElement(n) && Editor.isBlock(editor, n) && n.type === EditorNodeType.Paragraph,
|
||||
});
|
||||
editor.insertBreak();
|
||||
CustomEditor.turnToBlock(editor, {
|
||||
type: EditorNodeType.Paragraph,
|
||||
});
|
||||
|
||||
if (!nextNodeEntry) return;
|
||||
const nextNode = nextNodeEntry[0] as Element;
|
||||
requestAnimationFrame(() => {
|
||||
const block = CustomEditor.getBlock(editor);
|
||||
|
||||
const nodeDom = ReactEditor.toDOMNode(editor, nextNode);
|
||||
if (block) {
|
||||
const [node] = block;
|
||||
|
||||
const nodeDom = ReactEditor.toDOMNode(editor, node);
|
||||
|
||||
setNodeEl(nodeDom);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const searchText = useMemo(() => {
|
||||
if (!nodeEl) return '';
|
||||
const node = ReactEditor.toSlateNode(editor, nodeEl);
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
const node = ReactEditor.toSlateNode(editor, nodeEl) as Element;
|
||||
|
||||
return Editor.string(editor, path);
|
||||
if (!node) return '';
|
||||
|
||||
return CustomEditor.getNodeText(editor, node);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editor, nodeEl, editor.selection]);
|
||||
|
||||
@ -100,13 +98,12 @@ function AddBlockBelow({ node }: { node: Element }) {
|
||||
{...PopoverPreventBlurProps}
|
||||
anchorOrigin={{
|
||||
vertical: 30,
|
||||
horizontal: 64,
|
||||
horizontal: 'left',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
onMouseMove={(e) => e.stopPropagation()}
|
||||
open={openSlashCommandPanel}
|
||||
anchorEl={nodeEl}
|
||||
onClose={() => handleSlashCommandPanelClose(false)}
|
||||
|
@ -2,13 +2,13 @@ import React from 'react';
|
||||
|
||||
import { Element } from 'slate';
|
||||
import AddBlockBelow from '$app/components/editor/components/tools/block_actions/AddBlockBelow';
|
||||
import DragBlock from '$app/components/editor/components/tools/block_actions/DragBlock';
|
||||
import BlockMenu from '$app/components/editor/components/tools/block_actions/BlockMenu';
|
||||
|
||||
export function BlockActions({ node, onSelectedBlock }: { node: Element; onSelectedBlock: (blockId: string) => void }) {
|
||||
export function BlockActions({ node }: { node?: Element }) {
|
||||
return (
|
||||
<>
|
||||
<AddBlockBelow node={node} />
|
||||
<DragBlock node={node} onSelectedBlock={onSelectedBlock} />
|
||||
<BlockMenu node={node} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ReactEditor, useSlate } from 'slate-react';
|
||||
import { getBlockActionsPosition } from '$app/components/editor/components/tools/block_actions/utils';
|
||||
import { Element } from 'slate';
|
||||
import { Element, Editor } from 'slate';
|
||||
import { EditorNodeType } from '$app/application/document/document.types';
|
||||
|
||||
export function useBlockActionsToolbar(ref: React.RefObject<HTMLDivElement>) {
|
||||
@ -16,16 +16,34 @@ export function useBlockActionsToolbar(ref: React.RefObject<HTMLDivElement>) {
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
if (target.closest('.block-actions')) return;
|
||||
const blockElement = target ? (target.closest('.block-element') as HTMLElement) : null;
|
||||
if (target.closest(`[contenteditable="false"]`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!blockElement) {
|
||||
const range = ReactEditor.findEventRange(editor, e);
|
||||
|
||||
if (!range) return;
|
||||
const match = editor.above({
|
||||
match: (n) => {
|
||||
return !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined;
|
||||
},
|
||||
at: range,
|
||||
});
|
||||
|
||||
if (!match) {
|
||||
el.style.opacity = '0';
|
||||
el.style.pointerEvents = 'none';
|
||||
setNode(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const node = match[0] as Element;
|
||||
|
||||
if (node.type === EditorNodeType.Page) return;
|
||||
const blockElement = ReactEditor.toDOMNode(editor, node);
|
||||
|
||||
if (!blockElement) return;
|
||||
|
||||
const { top, left } = getBlockActionsPosition(editor, blockElement);
|
||||
|
||||
const slateEditorDom = ReactEditor.toDOMNode(editor, editor);
|
||||
@ -33,7 +51,7 @@ export function useBlockActionsToolbar(ref: React.RefObject<HTMLDivElement>) {
|
||||
el.style.opacity = '1';
|
||||
el.style.pointerEvents = 'auto';
|
||||
el.style.top = `${top + slateEditorDom.offsetTop}px`;
|
||||
el.style.left = `${left + slateEditorDom.offsetLeft}px`;
|
||||
el.style.left = `${left + slateEditorDom.offsetLeft - 64}px`;
|
||||
const slateNode = ReactEditor.toSlateNode(editor, blockElement) as Element;
|
||||
|
||||
setNode(slateNode);
|
||||
@ -49,11 +67,13 @@ export function useBlockActionsToolbar(ref: React.RefObject<HTMLDivElement>) {
|
||||
setNode(null);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseleave', handleMouseLeave);
|
||||
const dom = ReactEditor.toDOMNode(editor, editor);
|
||||
|
||||
dom.addEventListener('mousemove', handleMouseMove);
|
||||
dom.parentElement?.addEventListener('mouseleave', handleMouseLeave);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseleave', handleMouseLeave);
|
||||
dom.removeEventListener('mousemove', handleMouseMove);
|
||||
dom.parentElement?.removeEventListener('mouseleave', handleMouseLeave);
|
||||
};
|
||||
}, [editor, ref]);
|
||||
|
||||
|
@ -4,7 +4,7 @@ import BlockActions from '$app/components/editor/components/tools/block_actions/
|
||||
|
||||
import { getBlockCssProperty } from '$app/components/editor/components/tools/block_actions/utils';
|
||||
|
||||
export function BlockActionsToolbar({ onSelectedBlock }: { onSelectedBlock: (blockId: string) => void }) {
|
||||
export function BlockActionsToolbar() {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { node } = useBlockActionsToolbar(ref);
|
||||
@ -19,6 +19,7 @@ export function BlockActionsToolbar({ onSelectedBlock }: { onSelectedBlock: (blo
|
||||
onMouseDown={(e) => {
|
||||
// prevent toolbar from taking focus away from editor
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
e.stopPropagation();
|
||||
@ -26,7 +27,7 @@ export function BlockActionsToolbar({ onSelectedBlock }: { onSelectedBlock: (blo
|
||||
>
|
||||
{/* Ensure the toolbar in middle */}
|
||||
<div className={'invisible'}>0</div>
|
||||
{node && <BlockActions node={node} onSelectedBlock={onSelectedBlock} />}
|
||||
{<BlockActions node={node || undefined} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
@ -1,24 +1,28 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import React, { useCallback, useContext, useRef, useState } from 'react';
|
||||
import { IconButton, Tooltip } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ReactComponent as DragSvg } from '$app/assets/drag.svg';
|
||||
import BlockOperationMenu from '$app/components/editor/components/tools/block_actions/BlockOperationMenu';
|
||||
import { Element } from 'slate';
|
||||
import { EditorSelectedBlockContext } from '$app/components/editor/components/editor/Editor.hooks';
|
||||
|
||||
function DragBlock({ node, onSelectedBlock }: { node: Element; onSelectedBlock: (blockId: string) => void }) {
|
||||
function BlockMenu({ node }: { node?: Element }) {
|
||||
const dragBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const [openMenu, setOpenMenu] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const [selectedNode, setSelectedNode] = useState<Element>();
|
||||
const selectedBlockContext = useContext(EditorSelectedBlockContext);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setOpenMenu(true);
|
||||
if (!node || !node.blockId) return;
|
||||
|
||||
onSelectedBlock(node.blockId);
|
||||
setSelectedNode(node);
|
||||
selectedBlockContext.clear();
|
||||
selectedBlockContext.add(node.blockId);
|
||||
},
|
||||
[node, onSelectedBlock]
|
||||
[node, selectedBlockContext]
|
||||
);
|
||||
|
||||
return (
|
||||
@ -28,27 +32,34 @@ function DragBlock({ node, onSelectedBlock }: { node: Element; onSelectedBlock:
|
||||
<DragSvg />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{openMenu && node && (
|
||||
{openMenu && selectedNode && (
|
||||
<BlockOperationMenu
|
||||
onMouseMove={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'center',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
node={node}
|
||||
transformOrigin={{
|
||||
vertical: 'center',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
PaperProps={{
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
},
|
||||
}}
|
||||
node={selectedNode}
|
||||
open={openMenu}
|
||||
anchorEl={dragBtnRef.current}
|
||||
onClose={() => setOpenMenu(false)}
|
||||
onClose={() => {
|
||||
setOpenMenu(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DragBlock;
|
||||
export default BlockMenu;
|
@ -1,30 +1,79 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import Popover, { PopoverProps } from '@mui/material/Popover';
|
||||
import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg';
|
||||
import { ReactComponent as CopySvg } from '$app/assets/copy.svg';
|
||||
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@mui/material';
|
||||
import { Button, Divider, MenuProps, Menu } from '@mui/material';
|
||||
import { PopoverCommonProps } from '$app/components/editor/components/tools/popover';
|
||||
import { Element } from 'slate';
|
||||
import { useSlateStatic } from 'slate-react';
|
||||
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
|
||||
import { FontColorPicker, BgColorPicker } from '$app/components/editor/components/tools/_shared';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useBlockMenuKeyDown } from '$app/components/editor/components/tools/block_actions/BlockMenu.hooks';
|
||||
|
||||
enum SubMenuType {
|
||||
TextColor = 'textColor',
|
||||
BackgroundColor = 'backgroundColor',
|
||||
}
|
||||
|
||||
const subMenuProps: Partial<MenuProps> = {
|
||||
anchorOrigin: {
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
},
|
||||
transformOrigin: {
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
},
|
||||
};
|
||||
|
||||
export function BlockOperationMenu({
|
||||
node,
|
||||
...props
|
||||
}: {
|
||||
node: Element;
|
||||
} & PopoverProps) {
|
||||
const optionsRef = React.useRef<HTMLDivElement>(null);
|
||||
const editor = useSlateStatic();
|
||||
const { t } = useTranslation();
|
||||
const options = useMemo(
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
props.onClose?.({}, 'backdropClick');
|
||||
ReactEditor.focus(editor);
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
|
||||
editor.select(path);
|
||||
if (editor.isSelectable(node)) {
|
||||
editor.collapse({
|
||||
edge: 'start',
|
||||
});
|
||||
}
|
||||
}, [editor, node, props]);
|
||||
|
||||
const { onKeyDown } = useBlockMenuKeyDown({
|
||||
onClose: handleClose,
|
||||
});
|
||||
const [subMenuType, setSubMenuType] = useState<null | SubMenuType>(null);
|
||||
|
||||
const subMenuAnchorEl = useMemo(() => {
|
||||
if (!subMenuType) return null;
|
||||
return optionsRef.current?.querySelector(`[data-submenu-type="${subMenuType}"]`);
|
||||
}, [subMenuType]);
|
||||
|
||||
const subMenuOpen = Boolean(subMenuAnchorEl);
|
||||
|
||||
const operationOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: <DeleteSvg />,
|
||||
text: t('button.delete'),
|
||||
onClick: () => {
|
||||
CustomEditor.deleteNode(editor, node);
|
||||
props.onClose?.({}, 'backdropClick');
|
||||
handleClose();
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -32,17 +81,69 @@ export function BlockOperationMenu({
|
||||
text: t('button.duplicate'),
|
||||
onClick: () => {
|
||||
CustomEditor.duplicateNode(editor, node);
|
||||
props.onClose?.({}, 'backdropClick');
|
||||
handleClose();
|
||||
},
|
||||
},
|
||||
],
|
||||
[editor, node, props, t]
|
||||
[editor, node, handleClose, t]
|
||||
);
|
||||
|
||||
const colorOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
type: SubMenuType.TextColor,
|
||||
text: t('editor.textColor'),
|
||||
onClick: () => {
|
||||
setSubMenuType(SubMenuType.TextColor);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: SubMenuType.BackgroundColor,
|
||||
text: t('editor.backgroundColor'),
|
||||
onClick: () => {
|
||||
setSubMenuType(SubMenuType.BackgroundColor);
|
||||
},
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const subMenuContent = useMemo(() => {
|
||||
switch (subMenuType) {
|
||||
case SubMenuType.TextColor:
|
||||
return (
|
||||
<FontColorPicker
|
||||
onChange={(color) => {
|
||||
CustomEditor.setBlockColor(editor, node, { font_color: color });
|
||||
handleClose();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case SubMenuType.BackgroundColor:
|
||||
return (
|
||||
<BgColorPicker
|
||||
onChange={(color) => {
|
||||
CustomEditor.setBlockColor(editor, node, { bg_color: color });
|
||||
handleClose();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [editor, node, handleClose, subMenuType]);
|
||||
|
||||
return (
|
||||
<Popover {...PopoverCommonProps} {...props}>
|
||||
<Popover
|
||||
{...PopoverCommonProps}
|
||||
disableAutoFocus={false}
|
||||
onKeyDown={onKeyDown}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
{...props}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div className={'flex flex-col p-2'}>
|
||||
{options.map((option, index) => (
|
||||
{operationOptions.map((option, index) => (
|
||||
<Button
|
||||
color={'inherit'}
|
||||
onClick={option.onClick}
|
||||
@ -55,6 +156,35 @@ export function BlockOperationMenu({
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Divider className={'my-1'} />
|
||||
<div ref={optionsRef} className={'flex flex-col p-2'}>
|
||||
<Typography variant={'body2'} className={'mb-1 text-text-caption'}>
|
||||
{t('editor.color')}
|
||||
</Typography>
|
||||
{colorOptions.map((option, index) => (
|
||||
<Button
|
||||
data-submenu-type={option.type}
|
||||
color={'inherit'}
|
||||
onClick={option.onClick}
|
||||
size={'small'}
|
||||
endIcon={<MoreSvg />}
|
||||
className={'w-full justify-between'}
|
||||
key={index}
|
||||
>
|
||||
<div className={'flex-1 text-left'}>{option.text}</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Menu
|
||||
container={optionsRef.current}
|
||||
{...PopoverCommonProps}
|
||||
{...subMenuProps}
|
||||
open={subMenuOpen}
|
||||
anchorEl={subMenuAnchorEl}
|
||||
onClose={() => setSubMenuType(null)}
|
||||
>
|
||||
{subMenuContent}
|
||||
</Menu>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ import { EditorNodeType } from '$app/application/document/document.types';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSlate } from 'slate-react';
|
||||
import { Editor, Transforms } from 'slate';
|
||||
import { Transforms } from 'slate';
|
||||
import { getBlock } from '$app/components/editor/plugins/utils';
|
||||
import { ReactComponent as TextIcon } from '$app/assets/text.svg';
|
||||
import { ReactComponent as TodoListIcon } from '$app/assets/todo-list.svg';
|
||||
@ -134,18 +134,16 @@ export function useSlashCommandPanel({
|
||||
|
||||
if (!newNode) return;
|
||||
|
||||
const isEmpty = Editor.isEmpty(editor, newNode);
|
||||
const isEmpty = CustomEditor.isEmptyText(editor, newNode);
|
||||
|
||||
if (isEmpty) {
|
||||
CustomEditor.turnToBlock(editor, {
|
||||
type: nodeType,
|
||||
data,
|
||||
});
|
||||
return;
|
||||
if (!isEmpty) {
|
||||
Transforms.splitNodes(editor, { always: true });
|
||||
}
|
||||
|
||||
Transforms.splitNodes(editor, { always: true });
|
||||
Transforms.setNodes(editor, { type: nodeType, data });
|
||||
CustomEditor.turnToBlock(editor, {
|
||||
type: nodeType,
|
||||
data,
|
||||
});
|
||||
},
|
||||
[editor, closePanel]
|
||||
);
|
||||
|
@ -5,7 +5,7 @@ import { PopoverPreventBlurProps } from '$app/components/editor/components/tools
|
||||
import { PopoverProps } from '@mui/material/Popover';
|
||||
import { commandPanelShowProperty } from '$app/components/editor/components/editor/shortcuts/withCommandShortcuts';
|
||||
import { Editor, Point, Transforms } from 'slate';
|
||||
import { getBlockEntry } from '$app/components/editor/plugins/utils';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
|
||||
export const PanelPopoverProps: Partial<PopoverProps> = {
|
||||
...PopoverPreventBlurProps,
|
||||
@ -66,7 +66,7 @@ export function usePanel(ref: RefObject<HTMLDivElement | null>) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeEntry = getBlockEntry(editor);
|
||||
const nodeEntry = CustomEditor.getBlock(editor);
|
||||
|
||||
if (!nodeEntry) return;
|
||||
|
||||
@ -128,10 +128,8 @@ export function usePanel(ref: RefObject<HTMLDivElement | null>) {
|
||||
const isSelectionChange = editor.operations.every((op) => op.type === 'set_selection');
|
||||
const currentPoint = Editor.end(editor, editor.selection);
|
||||
const isBackward = currentPoint.offset < startPoint.current.offset;
|
||||
const isAnotherBlock =
|
||||
currentPoint.path[0] !== startPoint.current.path[0] || currentPoint.path[1] !== startPoint.current.path[1];
|
||||
|
||||
if (isAnotherBlock || isBackward) {
|
||||
if (isBackward) {
|
||||
closePanel(false);
|
||||
return;
|
||||
}
|
||||
|
@ -30,8 +30,8 @@ import Functions from '@mui/icons-material/Functions';
|
||||
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { getBlock, getBlockEntry } from '$app/components/editor/plugins/utils';
|
||||
import { FontColorPicker, BgColorPicker } from '$app/components/editor/components/tools/selection_toolbar/sub_menu';
|
||||
import { getBlock } from '$app/components/editor/plugins/utils';
|
||||
import { FontColorPicker, BgColorPicker } from '$app/components/editor/components/tools/_shared';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { addMark, Editor } from 'slate';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
@ -257,7 +257,7 @@ export function useBlockFormatActionMap(editor: ReactEditor) {
|
||||
},
|
||||
Icon: TodoListSvg,
|
||||
isActive: () => {
|
||||
const entry = getBlockEntry(editor);
|
||||
const entry = CustomEditor.getBlock(editor);
|
||||
|
||||
if (!entry) return false;
|
||||
|
||||
@ -279,7 +279,7 @@ export function useBlockFormatActionMap(editor: ReactEditor) {
|
||||
},
|
||||
Icon: QuoteSvg,
|
||||
isActive: () => {
|
||||
const entry = getBlockEntry(editor);
|
||||
const entry = CustomEditor.getBlock(editor);
|
||||
|
||||
if (!entry) return false;
|
||||
|
||||
@ -302,7 +302,7 @@ export function useBlockFormatActionMap(editor: ReactEditor) {
|
||||
},
|
||||
Icon: ToggleListSvg,
|
||||
isActive: () => {
|
||||
const entry = getBlockEntry(editor);
|
||||
const entry = CustomEditor.getBlock(editor);
|
||||
|
||||
if (!entry) return false;
|
||||
|
||||
@ -325,7 +325,7 @@ export function useBlockFormatActionMap(editor: ReactEditor) {
|
||||
},
|
||||
Icon: NumberedListSvg,
|
||||
isActive: () => {
|
||||
const entry = getBlockEntry(editor);
|
||||
const entry = CustomEditor.getBlock(editor);
|
||||
|
||||
if (!entry) return false;
|
||||
|
||||
@ -348,7 +348,7 @@ export function useBlockFormatActionMap(editor: ReactEditor) {
|
||||
},
|
||||
Icon: BulletedListSvg,
|
||||
isActive: () => {
|
||||
const entry = getBlockEntry(editor);
|
||||
const entry = CustomEditor.getBlock(editor);
|
||||
|
||||
if (!entry) return false;
|
||||
|
||||
|
@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { ReactEditor, useSlate } from 'slate-react';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
|
||||
import { Range } from 'slate';
|
||||
import {
|
||||
SelectionAction,
|
||||
useBlockFormatActions,
|
||||
@ -14,6 +14,7 @@ import Popover from '@mui/material/Popover';
|
||||
import { EditorStyleFormat } from '$app/application/document/document.types';
|
||||
import { PopoverPreventBlurProps } from '$app/components/editor/components/tools/popover';
|
||||
import { Tooltip } from '@mui/material';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
|
||||
function SelectionActions({
|
||||
toolbarVisible,
|
||||
@ -48,7 +49,32 @@ function SelectionActions({
|
||||
handleBlur();
|
||||
}, [handleBlur]);
|
||||
|
||||
const isMultiple = editor.getFragment().length > 1;
|
||||
const [isMultiple, setIsMultiple] = useState(false);
|
||||
const getIsMultiple = useCallback(() => {
|
||||
if (!editor.selection) return false;
|
||||
const selection = editor.selection;
|
||||
const start = selection.anchor;
|
||||
const end = selection.focus;
|
||||
|
||||
if (!start || !end) return false;
|
||||
|
||||
if (!Range.isExpanded(selection)) return false;
|
||||
|
||||
const startNode = CustomEditor.getBlock(editor, start);
|
||||
|
||||
const endNode = CustomEditor.getBlock(editor, end);
|
||||
|
||||
return Boolean(startNode && endNode && startNode[0].blockId !== endNode[0].blockId);
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (toolbarVisible) {
|
||||
setIsMultiple(getIsMultiple());
|
||||
} else {
|
||||
setIsMultiple(false);
|
||||
}
|
||||
}, [editor, getIsMultiple, toolbarVisible]);
|
||||
|
||||
const markOptions = useSelectionMarkFormatActions(editor);
|
||||
const textOptions = useSelectionTextFormatActions(editor);
|
||||
const blockOptions = useBlockFormatActions(editor);
|
||||
|
@ -53,7 +53,22 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
|
||||
el.style.opacity = '1';
|
||||
el.style.pointerEvents = 'auto';
|
||||
el.style.top = `${position.top + slateEditorDom.offsetTop - el.offsetHeight}px`;
|
||||
el.style.left = `${position.left + slateEditorDom.offsetLeft - el.offsetWidth / 2 + position.width / 2}px`;
|
||||
|
||||
const left = position.left + slateEditorDom.offsetLeft - el.offsetWidth / 2 + position.width / 2;
|
||||
|
||||
if (left < 0) {
|
||||
el.style.left = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
const right = left + el.offsetWidth;
|
||||
|
||||
if (right > slateEditorDom.offsetWidth) {
|
||||
el.style.left = `${slateEditorDom.offsetWidth - el.offsetWidth}px`;
|
||||
return;
|
||||
}
|
||||
|
||||
el.style.left = `${left}px`;
|
||||
}, [closeToolbar, editor, ref]);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -11,7 +11,7 @@ export const SelectionToolbar = memo(() => {
|
||||
<div
|
||||
ref={ref}
|
||||
className={
|
||||
'selection-toolbar pointer-events-none absolute z-10 flex w-fit flex-grow transform items-center rounded-lg bg-[var(--fill-toolbar)] p-2 opacity-0 shadow-lg transition-opacity'
|
||||
'selection-toolbar pointer-events-none absolute z-[100] flex w-fit flex-grow transform items-center rounded-lg bg-[var(--fill-toolbar)] p-2 opacity-0 shadow-lg transition-opacity'
|
||||
}
|
||||
onMouseDown={(e) => {
|
||||
// prevent toolbar from taking focus away from editor
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { EditorNodeType } from '$app/application/document/document.types';
|
||||
|
||||
export const BREAK_TO_PARAGRAPH_TYPES = [EditorNodeType.HeadingBlock, EditorNodeType.QuoteBlock, EditorNodeType.Page];
|
||||
|
||||
export const SOFT_BREAK_TYPES = [EditorNodeType.CalloutBlock, EditorNodeType.CodeBlock];
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Editor, Element, Location, NodeEntry, Point, Range } from 'slate';
|
||||
import { EditorNodeType } from '$app/application/document/document.types';
|
||||
import { Element, NodeEntry } from 'slate';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
|
||||
export function getHeadingCssProperty(level: number) {
|
||||
switch (level) {
|
||||
@ -15,45 +15,16 @@ export function getHeadingCssProperty(level: number) {
|
||||
}
|
||||
}
|
||||
|
||||
export function isDeleteBackwardAtStartOfBlock(editor: ReactEditor, type?: EditorNodeType) {
|
||||
const { selection } = editor;
|
||||
export function getBlock(editor: ReactEditor) {
|
||||
const match = CustomEditor.getBlock(editor);
|
||||
|
||||
if (selection && Range.isCollapsed(selection)) {
|
||||
const [match] = Editor.nodes(editor, {
|
||||
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && Editor.isBlock(editor, n),
|
||||
});
|
||||
if (match) {
|
||||
const [node] = match as NodeEntry<Element>;
|
||||
|
||||
if (match) {
|
||||
const [node, path] = match as NodeEntry<Element>;
|
||||
|
||||
if (type !== undefined && node.type !== type) return false;
|
||||
|
||||
const start = Editor.start(editor, path);
|
||||
|
||||
if (Point.equals(selection.anchor, start)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getBlockEntry(editor: ReactEditor, at?: Location) {
|
||||
if (!editor.selection) return null;
|
||||
|
||||
const entry = Editor.above(editor, {
|
||||
at,
|
||||
match: (n) => !Editor.isEditor(n) && Element.isElement(n),
|
||||
});
|
||||
|
||||
return entry as NodeEntry<Element>;
|
||||
}
|
||||
|
||||
export function getBlock(editor: ReactEditor, at?: Location) {
|
||||
const entry = getBlockEntry(editor, at);
|
||||
|
||||
return entry?.[0];
|
||||
return;
|
||||
}
|
||||
|
||||
export function getEditorDomNode(editor: ReactEditor) {
|
||||
|
@ -1,24 +1,24 @@
|
||||
import { ReactEditor } from 'slate-react';
|
||||
|
||||
import { isDeleteBackwardAtStartOfBlock } from '$app/components/editor/plugins/utils';
|
||||
import { EditorNodeType } from '$app/application/document/document.types';
|
||||
import { Editor, Element, NodeEntry } from 'slate';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
|
||||
export function withBlockDeleteBackward(editor: ReactEditor) {
|
||||
const { deleteBackward } = editor;
|
||||
const { deleteBackward, removeNodes } = editor;
|
||||
|
||||
editor.deleteBackward = (...args) => {
|
||||
if (!isDeleteBackwardAtStartOfBlock(editor)) {
|
||||
deleteBackward(...args);
|
||||
editor.removeNodes = (...args) => {
|
||||
removeNodes(...args);
|
||||
};
|
||||
|
||||
editor.deleteBackward = (unit) => {
|
||||
const match = CustomEditor.getBlock(editor);
|
||||
|
||||
if (!match || !CustomEditor.focusAtStartOfBlock(editor)) {
|
||||
deleteBackward(unit);
|
||||
return;
|
||||
}
|
||||
|
||||
const [match] = Editor.nodes(editor, {
|
||||
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && Editor.isBlock(editor, n),
|
||||
});
|
||||
|
||||
const [node] = match as NodeEntry<Element>;
|
||||
const [node, path] = match;
|
||||
|
||||
// if the current node is not a paragraph, convert it to a paragraph
|
||||
if (node.type !== EditorNodeType.Paragraph && node.type !== EditorNodeType.Page) {
|
||||
@ -26,26 +26,24 @@ export function withBlockDeleteBackward(editor: ReactEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const level = node.level;
|
||||
const next = editor.next({
|
||||
at: path,
|
||||
});
|
||||
|
||||
if (!level) {
|
||||
deleteBackward(...args);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextNode = CustomEditor.findNextNode(editor, node, level);
|
||||
|
||||
if (nextNode) {
|
||||
deleteBackward(...args);
|
||||
return;
|
||||
}
|
||||
|
||||
if (level > 1) {
|
||||
if (!next && path.length > 1) {
|
||||
CustomEditor.tabBackward(editor);
|
||||
return;
|
||||
}
|
||||
|
||||
deleteBackward(...args);
|
||||
const [, ...children] = node.children;
|
||||
|
||||
deleteBackward(unit);
|
||||
|
||||
children.forEach((child, index) => {
|
||||
editor.liftNodes({
|
||||
at: [...path, index],
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return editor;
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { Editor, Element, NodeEntry } from 'slate';
|
||||
import { EditorNodeType } from '$app/application/document/document.types';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
|
||||
@ -7,24 +6,17 @@ export function withBlockInsertBreak(editor: ReactEditor) {
|
||||
const { insertBreak } = editor;
|
||||
|
||||
editor.insertBreak = (...args) => {
|
||||
const nodeEntry = Editor.above(editor, {
|
||||
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && Editor.isBlock(editor, n),
|
||||
});
|
||||
const block = CustomEditor.getBlock(editor);
|
||||
|
||||
if (!nodeEntry) return insertBreak(...args);
|
||||
if (!block) return insertBreak(...args);
|
||||
|
||||
const [node] = nodeEntry as NodeEntry<Element>;
|
||||
const [node] = block;
|
||||
const type = node.type as EditorNodeType;
|
||||
|
||||
if (type === EditorNodeType.Page) {
|
||||
insertBreak(...args);
|
||||
return;
|
||||
}
|
||||
|
||||
const isEmpty = Editor.isEmpty(editor, node);
|
||||
const isEmpty = CustomEditor.isEmptyText(editor, node);
|
||||
|
||||
// if the node is empty, convert it to a paragraph
|
||||
if (isEmpty && type !== EditorNodeType.Paragraph) {
|
||||
if (isEmpty && type !== EditorNodeType.Paragraph && type !== EditorNodeType.Page) {
|
||||
CustomEditor.turnToBlock(editor, { type: EditorNodeType.Paragraph });
|
||||
return;
|
||||
}
|
||||
|
@ -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]);
|
||||
});
|
||||
}
|
@ -2,16 +2,27 @@ import { ReactEditor } from 'slate-react';
|
||||
|
||||
import { withBlockDeleteBackward } from '$app/components/editor/plugins/withBlockDeleteBackward';
|
||||
import { withBlockInsertBreak } from '$app/components/editor/plugins/withBlockInsertBreak';
|
||||
import { withMergeNodes } from '$app/components/editor/plugins/withMergeNodes';
|
||||
import { withSplitNodes } from '$app/components/editor/plugins/withSplitNodes';
|
||||
import { withDatabaseBlockPlugin } from '$app/components/editor/components/blocks/database';
|
||||
import { withMathEquationPlugin } from '$app/components/editor/components/blocks/math_equation';
|
||||
import { withPasted } from '$app/components/editor/plugins/withPasted';
|
||||
import { withBlockMove } from '$app/components/editor/plugins/withBlockMove';
|
||||
import { EditorNodeType } from '$app/application/document/document.types';
|
||||
|
||||
const EmbedTypes: string[] = [EditorNodeType.DividerBlock, EditorNodeType.EquationBlock, EditorNodeType.GridBlock];
|
||||
|
||||
export function withBlockPlugins(editor: ReactEditor) {
|
||||
return withMathEquationPlugin(
|
||||
withPasted(
|
||||
withDatabaseBlockPlugin(withSplitNodes(withMergeNodes(withBlockInsertBreak(withBlockDeleteBackward(editor)))))
|
||||
)
|
||||
);
|
||||
const { isElementReadOnly, isSelectable, isEmpty } = editor;
|
||||
|
||||
editor.isElementReadOnly = (element) => {
|
||||
return EmbedTypes.includes(element.type) || isElementReadOnly(element);
|
||||
};
|
||||
|
||||
editor.isSelectable = (element) => {
|
||||
return !EmbedTypes.includes(element.type) && isSelectable(element);
|
||||
};
|
||||
|
||||
editor.isEmpty = (element) => {
|
||||
return !EmbedTypes.includes(element.type) && isEmpty(element);
|
||||
};
|
||||
|
||||
return withPasted(withBlockMove(withSplitNodes(withBlockInsertBreak(withBlockDeleteBackward(editor)))));
|
||||
}
|
||||
|
@ -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] });
|
||||
}
|
||||
});
|
||||
}
|
@ -1,12 +1,12 @@
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { convertBlockToJson } from '$app/application/document/document.service';
|
||||
import { Editor, Element } from 'slate';
|
||||
import { Editor, Element, NodeEntry, Path, Location, Range } from 'slate';
|
||||
import { generateId } from '$app/components/editor/provider/utils/convert';
|
||||
import { blockTypes, EditorNodeType } from '$app/application/document/document.types';
|
||||
import { EditorNodeType } from '$app/application/document/document.types';
|
||||
import { InputType } from '@/services/backend';
|
||||
|
||||
export function withPasted(editor: ReactEditor) {
|
||||
const { insertData, insertFragment } = editor;
|
||||
const { insertData } = editor;
|
||||
|
||||
editor.insertData = (data) => {
|
||||
const fragment = data.getData('application/x-slate-fragment');
|
||||
@ -30,90 +30,113 @@ export function withPasted(editor: ReactEditor) {
|
||||
insertData(data);
|
||||
};
|
||||
|
||||
editor.insertFragment = (fragment) => {
|
||||
let rootId = (editor.children[0] as Element)?.blockId;
|
||||
editor.insertFragment = (fragment, options = {}) => {
|
||||
Editor.withoutNormalizing(editor, () => {
|
||||
const { at = getDefaultInsertLocation(editor) } = options;
|
||||
|
||||
if (!rootId) {
|
||||
rootId = generateId();
|
||||
insertFragment([
|
||||
{
|
||||
type: EditorNodeType.Paragraph,
|
||||
children: [
|
||||
{
|
||||
text: '',
|
||||
},
|
||||
],
|
||||
data: {},
|
||||
blockId: rootId,
|
||||
textId: generateId(),
|
||||
parentId: '',
|
||||
level: 0,
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (!fragment.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [mergedMatch] = Editor.nodes(editor, {
|
||||
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.type !== undefined,
|
||||
});
|
||||
if (Range.isRange(at) && !Range.isCollapsed(at)) {
|
||||
editor.delete({
|
||||
unit: 'character',
|
||||
});
|
||||
}
|
||||
|
||||
const mergedNode = mergedMatch
|
||||
? (mergedMatch[0] as Element & {
|
||||
blockId: string;
|
||||
parentId: string;
|
||||
level: number;
|
||||
})
|
||||
: null;
|
||||
const mergedText = editor.above({
|
||||
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.type === EditorNodeType.Text,
|
||||
}) as NodeEntry<
|
||||
Element & {
|
||||
textId: string;
|
||||
}
|
||||
>;
|
||||
|
||||
if (!mergedNode) return insertFragment(fragment);
|
||||
if (!mergedText) return;
|
||||
|
||||
const isEmpty = Editor.isEmpty(editor, mergedNode);
|
||||
const [mergedTextNode, mergedTextNodePath] = mergedText;
|
||||
|
||||
const mergedNodeId = isEmpty ? undefined : mergedNode.blockId;
|
||||
const traverse = (node: Element) => {
|
||||
if (node.type === EditorNodeType.Text) {
|
||||
node.textId = generateId();
|
||||
return;
|
||||
}
|
||||
|
||||
const idMap = new Map<string, string>();
|
||||
const levelMap = new Map<string, number>();
|
||||
|
||||
for (let i = 0; i < fragment.length; i++) {
|
||||
const node = fragment[i] as Element & {
|
||||
blockId: string;
|
||||
parentId: string;
|
||||
level: number;
|
||||
node.blockId = generateId();
|
||||
node.children?.forEach((child) => traverse(child as Element));
|
||||
};
|
||||
|
||||
const newBlockId = i === 0 && mergedNodeId ? mergedNodeId : generateId();
|
||||
fragment?.forEach((node) => traverse(node as Element));
|
||||
|
||||
const parentId = idMap.get(node.parentId);
|
||||
const firstNode = fragment[0] as Element;
|
||||
|
||||
if (parentId) {
|
||||
node.parentId = parentId;
|
||||
} else {
|
||||
idMap.set(node.parentId, mergedNode.parentId);
|
||||
node.parentId = mergedNode.parentId;
|
||||
if (firstNode && firstNode.type !== 'text') {
|
||||
if (firstNode.children && firstNode.children.length > 0) {
|
||||
const [textNode, ...children] = firstNode.children;
|
||||
|
||||
fragment[0] = textNode;
|
||||
fragment.splice(1, 0, ...children);
|
||||
} else {
|
||||
fragment.unshift(getEmptyText());
|
||||
}
|
||||
}
|
||||
|
||||
const parentLevel = levelMap.get(node.parentId);
|
||||
editor.insertNodes((fragment[0] as Element).children, {
|
||||
at: [...mergedTextNodePath, mergedTextNode.children.length],
|
||||
});
|
||||
editor.select(mergedTextNodePath);
|
||||
editor.collapse({
|
||||
edge: 'end',
|
||||
});
|
||||
const otherNodes = fragment.slice(1);
|
||||
|
||||
if (parentLevel !== undefined) {
|
||||
node.level = parentLevel + 1;
|
||||
} else {
|
||||
levelMap.set(node.parentId, mergedNode.level - 1);
|
||||
node.level = mergedNode.level;
|
||||
if (otherNodes.length > 0) {
|
||||
const parentPath = Path.parent(mergedTextNodePath);
|
||||
|
||||
const nextPath = Path.next(parentPath);
|
||||
const lastNodeText = (otherNodes[otherNodes.length - 1] as Element).children?.[0] as Element;
|
||||
|
||||
let canSelect = true;
|
||||
|
||||
if (!lastNodeText || lastNodeText.type !== EditorNodeType.Text) {
|
||||
canSelect = false;
|
||||
}
|
||||
|
||||
editor.insertNodes(otherNodes, {
|
||||
at: nextPath,
|
||||
select: canSelect,
|
||||
});
|
||||
|
||||
if (canSelect) {
|
||||
editor.collapse({
|
||||
edge: 'end',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// if the pasted fragment is not matched with the block type, we need to convert it to paragraph
|
||||
// and if the pasted fragment is a page, we need to convert it to paragraph
|
||||
if (!blockTypes.includes(node.type as EditorNodeType) || node.type === EditorNodeType.Page) {
|
||||
node.type = EditorNodeType.Paragraph;
|
||||
}
|
||||
|
||||
idMap.set(node.blockId, newBlockId);
|
||||
levelMap.set(newBlockId, node.level);
|
||||
node.blockId = newBlockId;
|
||||
node.textId = generateId();
|
||||
}
|
||||
|
||||
return insertFragment(fragment);
|
||||
});
|
||||
};
|
||||
|
||||
return editor;
|
||||
}
|
||||
|
||||
function getEmptyText(): Element {
|
||||
return {
|
||||
type: EditorNodeType.Text,
|
||||
textId: generateId(),
|
||||
children: [
|
||||
{
|
||||
text: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export const getDefaultInsertLocation = (editor: Editor): Location => {
|
||||
if (editor.selection) {
|
||||
return editor.selection;
|
||||
} else if (editor.children.length > 0) {
|
||||
return Editor.end(editor, []);
|
||||
} else {
|
||||
return [0];
|
||||
}
|
||||
};
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { Editor, Element, NodeEntry, Transforms } from 'slate';
|
||||
import { EditorMarkFormat, EditorNodeType, markTypes, ToggleListNode } from '$app/application/document/document.types';
|
||||
import { Transforms, Editor, Element, NodeEntry, Path } from 'slate';
|
||||
import { EditorNodeType, ToggleListNode } from '$app/application/document/document.types';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { BREAK_TO_PARAGRAPH_TYPES } from '$app/components/editor/plugins/constants';
|
||||
import { generateId } from '$app/components/editor/provider/utils/convert';
|
||||
import cloneDeep from 'lodash-es/cloneDeep';
|
||||
import { SOFT_BREAK_TYPES } from '$app/components/editor/plugins/constants';
|
||||
|
||||
export function withSplitNodes(editor: ReactEditor) {
|
||||
const { splitNodes } = editor;
|
||||
@ -16,102 +17,90 @@ export function withSplitNodes(editor: ReactEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This is a workaround for the bug that the new paragraph will inherit the marks of the previous paragraph
|
||||
// remove all marks in current selection, otherwise the new paragraph will inherit the marks
|
||||
markTypes.forEach((markType) => {
|
||||
const isActive = CustomEditor.isMarkActive(editor, markType as EditorMarkFormat);
|
||||
|
||||
if (isActive) {
|
||||
editor.removeMark(markType as EditorMarkFormat);
|
||||
}
|
||||
});
|
||||
|
||||
const [match] = Editor.nodes(editor, {
|
||||
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined && n.type !== undefined,
|
||||
});
|
||||
const match = CustomEditor.getBlock(editor);
|
||||
|
||||
if (!match) {
|
||||
splitNodes(...args);
|
||||
return;
|
||||
}
|
||||
|
||||
const [node, path] = match as NodeEntry<Element>;
|
||||
const [node, path] = match;
|
||||
const nodeType = node.type as EditorNodeType;
|
||||
|
||||
const newBlockId = generateId();
|
||||
const newTextId = generateId();
|
||||
|
||||
const nodeType = node.type as EditorNodeType;
|
||||
|
||||
// should be split to a new paragraph for the first child of the toggle list
|
||||
if (nodeType === EditorNodeType.ToggleListBlock) {
|
||||
const collapsed = (node as ToggleListNode).data.collapsed;
|
||||
const level = node.level ?? 1;
|
||||
const blockId = node.blockId as string;
|
||||
const parentId = node.parentId as string;
|
||||
|
||||
// if the toggle list is collapsed, split to a new paragraph append to the children of the toggle list
|
||||
if (!collapsed) {
|
||||
splitNodes(...args);
|
||||
Transforms.setNodes(editor, {
|
||||
type: EditorNodeType.Paragraph,
|
||||
data: {},
|
||||
level: level + 1,
|
||||
blockId: newBlockId,
|
||||
parentId: blockId,
|
||||
textId: newTextId,
|
||||
});
|
||||
} else {
|
||||
// if the toggle list is not collapsed, split to a toggle list after the toggle list
|
||||
const nextNode = CustomEditor.findNextNode(editor, node, level);
|
||||
const nextIndex = nextNode ? ReactEditor.findPath(editor, nextNode)[0] : null;
|
||||
const index = path[0];
|
||||
|
||||
splitNodes(...args);
|
||||
Transforms.setNodes(editor, { level, data: {}, blockId: newBlockId, parentId, textId: newTextId });
|
||||
if (nextIndex) {
|
||||
Transforms.moveNodes(editor, { at: [index + 1], to: [nextIndex] });
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// should be split to another paragraph, eg: heading and quote and page
|
||||
if (BREAK_TO_PARAGRAPH_TYPES.includes(nodeType)) {
|
||||
const level = node.level || 1;
|
||||
const parentId = (node.parentId || node.blockId) as string;
|
||||
|
||||
splitNodes(...args);
|
||||
Transforms.setNodes(editor, {
|
||||
type: EditorNodeType.Paragraph,
|
||||
data: {},
|
||||
blockId: newBlockId,
|
||||
textId: newTextId,
|
||||
level,
|
||||
parentId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
splitNodes(...args);
|
||||
|
||||
Transforms.setNodes(editor, { blockId: newBlockId, data: {}, textId: newTextId });
|
||||
|
||||
const children = CustomEditor.findNodeChildren(editor, node);
|
||||
|
||||
children.forEach((child) => {
|
||||
const childPath = ReactEditor.findPath(editor, child);
|
||||
|
||||
Transforms.setNodes(
|
||||
editor,
|
||||
{
|
||||
parentId: newBlockId,
|
||||
},
|
||||
{
|
||||
at: [childPath[0] + 1],
|
||||
}
|
||||
);
|
||||
const matchTextNode = editor.above({
|
||||
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.type === EditorNodeType.Text,
|
||||
});
|
||||
|
||||
if (!matchTextNode) return;
|
||||
const [textNode, textNodePath] = matchTextNode as NodeEntry<Element>;
|
||||
|
||||
editor.removeNodes({
|
||||
at: textNodePath,
|
||||
});
|
||||
|
||||
const newNodeType = [
|
||||
EditorNodeType.HeadingBlock,
|
||||
EditorNodeType.QuoteBlock,
|
||||
EditorNodeType.Page,
|
||||
...SOFT_BREAK_TYPES,
|
||||
].includes(node.type as EditorNodeType)
|
||||
? EditorNodeType.Paragraph
|
||||
: node.type;
|
||||
|
||||
const newNode: Element = {
|
||||
type: newNodeType,
|
||||
data: {},
|
||||
blockId: newBlockId,
|
||||
children: [
|
||||
{
|
||||
...cloneDeep(textNode),
|
||||
textId: newTextId,
|
||||
},
|
||||
],
|
||||
};
|
||||
let newNodePath;
|
||||
|
||||
if (nodeType === EditorNodeType.ToggleListBlock) {
|
||||
const collapsed = (node as ToggleListNode).data.collapsed;
|
||||
|
||||
if (!collapsed) {
|
||||
newNode.type = EditorNodeType.Paragraph;
|
||||
newNodePath = textNodePath;
|
||||
} else {
|
||||
newNode.type = EditorNodeType.ToggleListBlock;
|
||||
newNodePath = Path.next(path);
|
||||
}
|
||||
|
||||
Transforms.insertNodes(editor, newNode, {
|
||||
at: newNodePath,
|
||||
select: true,
|
||||
});
|
||||
|
||||
CustomEditor.removeMarks(editor);
|
||||
return;
|
||||
}
|
||||
|
||||
newNodePath = textNodePath;
|
||||
|
||||
Transforms.insertNodes(editor, newNode, {
|
||||
at: newNodePath,
|
||||
});
|
||||
|
||||
editor.select(newNodePath);
|
||||
editor.collapse({
|
||||
edge: 'start',
|
||||
});
|
||||
|
||||
editor.liftNodes({
|
||||
at: newNodePath,
|
||||
});
|
||||
|
||||
CustomEditor.removeMarks(editor);
|
||||
};
|
||||
|
||||
return editor;
|
||||
|
@ -24,8 +24,7 @@ describe('Transform events to actions', () => {
|
||||
test('should transform insert event to insert action', () => {
|
||||
const sharedType = provider.sharedType;
|
||||
|
||||
const parentId = sharedType?.getAttribute('blockId') as string;
|
||||
const insertTextOp = generateInsertTextOp('insert text', parentId, 1);
|
||||
const insertTextOp = generateInsertTextOp('insert text');
|
||||
|
||||
sharedType?.applyDelta([{ retain: 2 }, insertTextOp]);
|
||||
|
||||
@ -43,24 +42,6 @@ describe('Transform events to actions', () => {
|
||||
expect(actions[1].payload.prev_id).toBe('2qonPRrNTO');
|
||||
});
|
||||
|
||||
test('should transform move event to move action', () => {
|
||||
const sharedType = provider.sharedType;
|
||||
|
||||
const parentId = 'CxPil0324P';
|
||||
const yText = sharedType?.toDelta()[4].insert as Y.XmlText;
|
||||
sharedType?.doc?.transact(() => {
|
||||
yText.setAttribute('level', 2);
|
||||
yText.setAttribute('parentId', parentId);
|
||||
});
|
||||
|
||||
const actions = applyActions.mock.calls[0][1];
|
||||
expect(actions).toHaveLength(1);
|
||||
expect(actions[0].action).toBe(BlockActionTypePB.Move);
|
||||
expect(actions[0].payload.block.id).toBe('Fn4KACkt1i');
|
||||
expect(actions[0].payload.parent_id).toBe('CxPil0324P');
|
||||
expect(actions[0].payload.prev_id).toBe('');
|
||||
});
|
||||
|
||||
test('should transform delete event to delete action', () => {
|
||||
const sharedType = provider.sharedType;
|
||||
|
||||
@ -72,7 +53,6 @@ describe('Transform events to actions', () => {
|
||||
expect(actions).toHaveLength(1);
|
||||
expect(actions[0].action).toBe(BlockActionTypePB.Delete);
|
||||
expect(actions[0].payload.block.id).toBe('Fn4KACkt1i');
|
||||
expect(actions[0].payload.parent_id).toBe('3EzeCrtxlh');
|
||||
});
|
||||
|
||||
test('should transform update event to update action', () => {
|
||||
@ -90,17 +70,17 @@ describe('Transform events to actions', () => {
|
||||
expect(actions[0].action).toBe(BlockActionTypePB.Update);
|
||||
expect(actions[0].payload.block.id).toBe('Fn4KACkt1i');
|
||||
expect(actions[0].payload.block.data).toBe('{"checked":true}');
|
||||
expect(actions[0].payload.parent_id).toBe('3EzeCrtxlh');
|
||||
});
|
||||
|
||||
test('should transform apply delta event to apply delta action (insert text)', () => {
|
||||
const sharedType = provider.sharedType;
|
||||
|
||||
const yText = sharedType?.toDelta()[4].insert as Y.XmlText;
|
||||
const blockYText = sharedType?.toDelta()[4].insert as Y.XmlText;
|
||||
const textYText = blockYText.toDelta()[0].insert as Y.XmlText;
|
||||
sharedType?.doc?.transact(() => {
|
||||
yText.applyDelta([{ retain: 1 }, { insert: 'apply delta' }]);
|
||||
textYText.applyDelta([{ retain: 1 }, { insert: 'apply delta' }]);
|
||||
});
|
||||
const textId = yText.getAttribute('textId');
|
||||
const textId = textYText.getAttribute('textId');
|
||||
|
||||
const actions = applyActions.mock.calls[0][1];
|
||||
expect(actions).toHaveLength(1);
|
||||
@ -112,7 +92,8 @@ describe('Transform events to actions', () => {
|
||||
test('should transform apply delta event to apply delta action: insert mention', () => {
|
||||
const sharedType = provider.sharedType;
|
||||
|
||||
const yText = sharedType?.toDelta()[4].insert as Y.XmlText;
|
||||
const blockYText = sharedType?.toDelta()[4].insert as Y.XmlText;
|
||||
const yText = blockYText.toDelta()[0].insert as Y.XmlText;
|
||||
sharedType?.doc?.transact(() => {
|
||||
yText.applyDelta([{ retain: 1 }, genersteMentionInsertTextOp()]);
|
||||
});
|
||||
@ -126,7 +107,8 @@ describe('Transform events to actions', () => {
|
||||
test('should transform apply delta event to apply delta action: insert formula', () => {
|
||||
const sharedType = provider.sharedType;
|
||||
|
||||
const yText = sharedType?.toDelta()[4].insert as Y.XmlText;
|
||||
const blockYText = sharedType?.toDelta()[4].insert as Y.XmlText;
|
||||
const yText = blockYText.toDelta()[0].insert as Y.XmlText;
|
||||
sharedType?.doc?.transact(() => {
|
||||
yText.applyDelta([{ retain: 1 }, generateFormulaInsertTextOp()]);
|
||||
});
|
||||
|
@ -30,7 +30,7 @@ describe('Provider connected', () => {
|
||||
const sharedType = provider.sharedType;
|
||||
|
||||
const parentId = sharedType?.getAttribute('blockId') as string;
|
||||
const insertTextOp = generateInsertTextOp('', parentId, 1);
|
||||
const insertTextOp = generateInsertTextOp('');
|
||||
|
||||
sharedType?.applyDelta([{ retain: 2 }, insertTextOp]);
|
||||
|
||||
|
@ -26,20 +26,26 @@ export function slateElementToYText({
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function generateInsertTextOp(text: string, parentId: string, level: number, attributes?: Record<string, any>) {
|
||||
export function generateInsertTextOp(text: string) {
|
||||
const insertYText = slateElementToYText({
|
||||
children: [{ text: text }],
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
textId: generateId(),
|
||||
children: [
|
||||
{
|
||||
text,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
type: 'paragraph',
|
||||
data: {},
|
||||
blockId: generateId(),
|
||||
parentId,
|
||||
textId: generateId(),
|
||||
level,
|
||||
});
|
||||
|
||||
return {
|
||||
insert: insertYText,
|
||||
attributes,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -32,7 +32,9 @@ export class DataClient extends EventEmitter {
|
||||
|
||||
this.rootId = data.rootId;
|
||||
|
||||
return slateNodesToInsertDelta(convertToSlateValue(data, includeRoot));
|
||||
const slateValue = convertToSlateValue(data, includeRoot);
|
||||
|
||||
return slateNodesToInsertDelta(slateValue);
|
||||
}
|
||||
|
||||
public on(event: 'change', listener: (events: YDelta) => void): this;
|
||||
|
@ -1,7 +1,6 @@
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { DataClient } from '$app/components/editor/provider/data_client';
|
||||
import { convertToIdList, fillIdRelationMap } from '$app/components/editor/provider/utils/relation';
|
||||
import { YDelta } from '$app/components/editor/provider/types/y_event';
|
||||
import { YEvents2BlockActions } from '$app/components/editor/provider/utils/action';
|
||||
import { EventEmitter } from 'events';
|
||||
@ -10,34 +9,27 @@ const REMOTE_ORIGIN = 'remote';
|
||||
|
||||
export class Provider extends EventEmitter {
|
||||
document: Y.Doc = new Y.Doc();
|
||||
// id order
|
||||
idList: Y.XmlText = this.document.get('idList', Y.XmlText) as Y.XmlText;
|
||||
// id -> parentId
|
||||
idRelationMap: Y.Map<string> = this.document.getMap('idRelationMap');
|
||||
sharedType: Y.XmlText | null = null;
|
||||
dataClient: DataClient;
|
||||
// get origin data after document updated
|
||||
backupDoc: Y.Doc = new Y.Doc();
|
||||
constructor(public id: string, includeRoot?: boolean) {
|
||||
super();
|
||||
this.dataClient = new DataClient(id);
|
||||
void this.initialDocument(includeRoot);
|
||||
this.document.on('update', this.documentUpdate);
|
||||
}
|
||||
|
||||
initialDocument = async (includeRoot = true) => {
|
||||
const sharedType = this.document.get('local', Y.XmlText) as Y.XmlText;
|
||||
|
||||
const sharedType = this.document.get('sharedType', Y.XmlText) as Y.XmlText;
|
||||
// Load the initial value into the yjs document
|
||||
const delta = await this.dataClient.getInsertDelta(includeRoot);
|
||||
|
||||
sharedType.applyDelta(delta);
|
||||
|
||||
this.idList.applyDelta(convertToIdList(delta));
|
||||
delta.forEach((op) => {
|
||||
if (op.insert instanceof Y.XmlText) {
|
||||
fillIdRelationMap(op.insert, this.idRelationMap);
|
||||
}
|
||||
});
|
||||
const rootId = this.dataClient.rootId as string;
|
||||
|
||||
sharedType.setAttribute('blockId', this.dataClient.rootId);
|
||||
sharedType.setAttribute('blockId', rootId);
|
||||
|
||||
this.sharedType = sharedType;
|
||||
this.sharedType?.observeDeep(this.onChange);
|
||||
@ -63,7 +55,7 @@ export class Provider extends EventEmitter {
|
||||
|
||||
if (!this.sharedType || !events.length) return;
|
||||
// transform events to actions
|
||||
this.dataClient.emit('update', YEvents2BlockActions(this.sharedType, events));
|
||||
this.dataClient.emit('update', YEvents2BlockActions(this.backupDoc, events));
|
||||
};
|
||||
|
||||
onRemoteChange = (delta: YDelta) => {
|
||||
@ -73,4 +65,8 @@ export class Provider extends EventEmitter {
|
||||
this.sharedType?.applyDelta(delta);
|
||||
}, REMOTE_ORIGIN);
|
||||
};
|
||||
|
||||
documentUpdate = (update: Uint8Array) => {
|
||||
Y.applyUpdate(this.backupDoc, update);
|
||||
};
|
||||
}
|
||||
|
@ -3,9 +3,175 @@ import { BlockActionPB, BlockActionTypePB } from '@/services/backend';
|
||||
import { generateId } from '$app/components/editor/provider/utils/convert';
|
||||
import { YDelta2Delta } from '$app/components/editor/provider/utils/delta';
|
||||
import { YDelta } from '$app/components/editor/provider/types/y_event';
|
||||
import { convertToIdList, fillIdRelationMap, findPreviousSibling } from '$app/components/editor/provider/utils/relation';
|
||||
import { getInsertTarget, getYTarget } from '$app/components/editor/provider/utils/relation';
|
||||
import { EditorNodeType } from '$app/application/document/document.types';
|
||||
import { Log } from '$app/utils/log';
|
||||
|
||||
export function generateUpdateDataActions(yXmlText: Y.XmlText, data: Record<string, string | boolean>) {
|
||||
export function YEvents2BlockActions(
|
||||
backupDoc: Readonly<Y.Doc>,
|
||||
events: Y.YEvent<Y.XmlText>[]
|
||||
): ReturnType<typeof BlockActionPB.prototype.toObject>[] {
|
||||
const actions: ReturnType<typeof BlockActionPB.prototype.toObject>[] = [];
|
||||
|
||||
events.forEach((event) => {
|
||||
const eventActions = YEvent2BlockActions(backupDoc, event);
|
||||
|
||||
if (eventActions.length === 0) return;
|
||||
|
||||
actions.push(...eventActions);
|
||||
});
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export function YEvent2BlockActions(
|
||||
backupDoc: Readonly<Y.Doc>,
|
||||
event: Y.YEvent<Y.XmlText>
|
||||
): ReturnType<typeof BlockActionPB.prototype.toObject>[] {
|
||||
const { target: yXmlText, keys, delta, path } = event;
|
||||
const isBlockEvent = !!yXmlText.getAttribute('blockId');
|
||||
const sharedType = backupDoc.get('sharedType', Y.XmlText) as Readonly<Y.XmlText>;
|
||||
const rootId = sharedType.getAttribute('blockId');
|
||||
|
||||
const backupTarget = getYTarget(backupDoc, path) as Readonly<Y.XmlText>;
|
||||
const actions = [];
|
||||
|
||||
if (yXmlText.getAttribute('type') === 'text') {
|
||||
actions.push(...textOps2BlockActions(rootId, yXmlText, delta));
|
||||
}
|
||||
|
||||
if (keys.size > 0) {
|
||||
actions.push(...dataOps2BlockActions(yXmlText, keys));
|
||||
}
|
||||
|
||||
if (isBlockEvent) {
|
||||
actions.push(...blockOps2BlockActions(backupTarget, delta));
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
function textOps2BlockActions(
|
||||
rootId: string,
|
||||
yXmlText: Y.XmlText,
|
||||
ops: YDelta
|
||||
): ReturnType<typeof BlockActionPB.prototype.toObject>[] {
|
||||
if (ops.length === 0) return [];
|
||||
const blockYXmlText = yXmlText.parent as Y.XmlText;
|
||||
const blockId = blockYXmlText.getAttribute('blockId');
|
||||
|
||||
if (blockId === rootId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return generateApplyTextActions(yXmlText, ops);
|
||||
}
|
||||
|
||||
function dataOps2BlockActions(
|
||||
yXmlText: Y.XmlText,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
keys: Map<string, { action: 'update' | 'add' | 'delete'; oldValue: any; newValue: any }>
|
||||
) {
|
||||
const dataUpdated = keys.has('data');
|
||||
|
||||
if (!dataUpdated) return [];
|
||||
const data = yXmlText.getAttribute('data');
|
||||
|
||||
return generateUpdateActions(yXmlText, {
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
function blockOps2BlockActions(
|
||||
blockYXmlText: Readonly<Y.XmlText>,
|
||||
ops: YDelta
|
||||
): ReturnType<typeof BlockActionPB.prototype.toObject>[] {
|
||||
const actions: ReturnType<typeof BlockActionPB.prototype.toObject>[] = [];
|
||||
|
||||
let index = 0;
|
||||
|
||||
let newOps = ops;
|
||||
|
||||
if (ops.length > 1) {
|
||||
const [deleteOp, insertOp, ...otherOps] = newOps;
|
||||
|
||||
const insert = insertOp.insert;
|
||||
|
||||
if (deleteOp.delete === 1 && insert && insert instanceof Y.XmlText) {
|
||||
const textNode = getInsertTarget(blockYXmlText, [0]);
|
||||
const textId = textNode.getAttribute('textId');
|
||||
|
||||
if (textId) {
|
||||
const length = textNode.length;
|
||||
|
||||
insert.setAttribute('textId', textId);
|
||||
|
||||
actions.push(
|
||||
...generateApplyTextActions(insert, [
|
||||
{
|
||||
delete: length,
|
||||
},
|
||||
...insert.toDelta(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
newOps = [
|
||||
{
|
||||
retain: 1,
|
||||
},
|
||||
...otherOps,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
newOps.forEach((op) => {
|
||||
if (op.insert) {
|
||||
if (op.insert instanceof Y.XmlText) {
|
||||
const insertYXmlText = op.insert;
|
||||
const blockId = insertYXmlText.getAttribute('blockId');
|
||||
const textId = insertYXmlText.getAttribute('textId');
|
||||
|
||||
if (!blockId && !textId) {
|
||||
throw new Error('blockId and textId is not exist');
|
||||
}
|
||||
|
||||
actions.push(...generateInsertBlockActions(insertYXmlText));
|
||||
index += 1;
|
||||
}
|
||||
} else if (op.retain) {
|
||||
index += op.retain;
|
||||
} else if (op.delete) {
|
||||
for (let i = index; i < op.delete + index; i++) {
|
||||
const target = getInsertTarget(blockYXmlText, [i]);
|
||||
|
||||
if (target) {
|
||||
const deletedId = target.getAttribute('blockId') as string;
|
||||
|
||||
if (deletedId) {
|
||||
actions.push(
|
||||
...generateDeleteBlockActions({
|
||||
ids: [deletedId],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export function generateUpdateActions(
|
||||
yXmlText: Y.XmlText,
|
||||
{
|
||||
data,
|
||||
}: {
|
||||
data?: Record<string, string | boolean>;
|
||||
external_id?: string;
|
||||
}
|
||||
) {
|
||||
const id = yXmlText.getAttribute('blockId');
|
||||
const parentId = yXmlText.getAttribute('parentId');
|
||||
|
||||
@ -16,8 +182,6 @@ export function generateUpdateDataActions(yXmlText: Y.XmlText, data: Record<stri
|
||||
block: {
|
||||
id,
|
||||
data: JSON.stringify(data),
|
||||
parent: parentId,
|
||||
children: '',
|
||||
},
|
||||
parent_id: parentId,
|
||||
},
|
||||
@ -43,15 +207,28 @@ export function generateApplyTextActions(yXmlText: Y.XmlText, delta: YDelta) {
|
||||
];
|
||||
}
|
||||
|
||||
export function generateDeleteBlockActions({ id, parentId }: { id: string; parentId: string }) {
|
||||
export function generateDeleteBlockActions({ ids }: { ids: string[] }) {
|
||||
return ids.map((id) => ({
|
||||
action: BlockActionTypePB.Delete,
|
||||
payload: {
|
||||
block: {
|
||||
id,
|
||||
},
|
||||
parent_id: '',
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export function generateInsertTextActions(insertYXmlText: Y.XmlText) {
|
||||
const textId = insertYXmlText.getAttribute('textId');
|
||||
const delta = YDelta2Delta(insertYXmlText.toDelta());
|
||||
|
||||
return [
|
||||
{
|
||||
action: BlockActionTypePB.Delete,
|
||||
action: BlockActionTypePB.InsertText,
|
||||
payload: {
|
||||
block: {
|
||||
id,
|
||||
},
|
||||
parent_id: parentId,
|
||||
text_id: textId,
|
||||
delta: JSON.stringify(delta),
|
||||
},
|
||||
},
|
||||
];
|
||||
@ -61,24 +238,29 @@ export function generateInsertBlockActions(
|
||||
insertYXmlText: Y.XmlText
|
||||
): ReturnType<typeof BlockActionPB.prototype.toObject>[] {
|
||||
const childrenId = generateId();
|
||||
const prev = findPreviousSibling(insertYXmlText);
|
||||
|
||||
const [textInsert, ...childrenInserts] = (insertYXmlText.toDelta() as YDelta).map((op) => op.insert);
|
||||
const textInsertActions = textInsert instanceof Y.XmlText ? generateInsertTextActions(textInsert) : [];
|
||||
const externalId = textInsertActions[0]?.payload.text_id;
|
||||
const prev = insertYXmlText.prevSibling;
|
||||
const prevId = prev ? prev.getAttribute('blockId') : null;
|
||||
const parentId = insertYXmlText.getAttribute('parentId');
|
||||
const delta = YDelta2Delta(insertYXmlText.toDelta());
|
||||
const parentId = (insertYXmlText.parent as Y.XmlText).getAttribute('blockId');
|
||||
|
||||
const data = insertYXmlText.getAttribute('data');
|
||||
const type = insertYXmlText.getAttribute('type');
|
||||
const id = insertYXmlText.getAttribute('blockId');
|
||||
const externalId = insertYXmlText.getAttribute('textId');
|
||||
|
||||
return [
|
||||
{
|
||||
action: BlockActionTypePB.InsertText,
|
||||
payload: {
|
||||
text_id: externalId,
|
||||
delta: JSON.stringify(delta),
|
||||
},
|
||||
},
|
||||
if (!id) {
|
||||
Log.error('generateInsertBlockActions', 'id is not exist');
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!type || type === 'text' || Object.values(EditorNodeType).indexOf(type) === -1) {
|
||||
Log.error('generateInsertBlockActions', 'type is error: ' + type);
|
||||
return [];
|
||||
}
|
||||
|
||||
const actions: ReturnType<typeof BlockActionPB.prototype.toObject>[] = [
|
||||
...textInsertActions,
|
||||
{
|
||||
action: BlockActionTypePB.Insert,
|
||||
payload: {
|
||||
@ -89,180 +271,19 @@ export function generateInsertBlockActions(
|
||||
parent_id: parentId,
|
||||
children_id: childrenId,
|
||||
external_id: externalId,
|
||||
external_type: 'text',
|
||||
external_type: externalId ? 'text' : undefined,
|
||||
},
|
||||
prev_id: prevId,
|
||||
parent_id: parentId,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function generateMoveBlockActions(yXmlText: Y.XmlText, parentId: string, prevId: string | null) {
|
||||
const id = yXmlText.getAttribute('blockId');
|
||||
const blockParentId = yXmlText.getAttribute('parentId');
|
||||
|
||||
return [
|
||||
{
|
||||
action: BlockActionTypePB.Move,
|
||||
payload: {
|
||||
block: {
|
||||
id,
|
||||
parent_id: blockParentId,
|
||||
},
|
||||
parent_id: parentId,
|
||||
prev_id: prevId || '',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function YEvents2BlockActions(
|
||||
sharedType: Y.XmlText,
|
||||
events: Y.YEvent<Y.XmlText>[]
|
||||
): ReturnType<typeof BlockActionPB.prototype.toObject>[] {
|
||||
const actions: ReturnType<typeof BlockActionPB.prototype.toObject>[] = [];
|
||||
|
||||
events.forEach((event) => {
|
||||
const eventActions = YEvent2BlockActions(sharedType, event);
|
||||
|
||||
if (eventActions.length === 0) return;
|
||||
|
||||
actions.push(...eventActions);
|
||||
});
|
||||
|
||||
const deleteActions = actions.filter((action) => action.action === BlockActionTypePB.Delete);
|
||||
const otherActions = actions.filter((action) => action.action !== BlockActionTypePB.Delete);
|
||||
|
||||
const filteredDeleteActions = filterDeleteActions(deleteActions);
|
||||
|
||||
return [...otherActions, ...filteredDeleteActions];
|
||||
}
|
||||
|
||||
function filterDeleteActions(actions: ReturnType<typeof BlockActionPB.prototype.toObject>[]) {
|
||||
return actions.filter((deleteAction) => {
|
||||
const { payload } = deleteAction;
|
||||
|
||||
if (payload === undefined) return true;
|
||||
|
||||
const { parent_id } = payload;
|
||||
|
||||
return !actions.some((action) => action.payload?.block?.id === parent_id);
|
||||
});
|
||||
}
|
||||
|
||||
export function YEvent2BlockActions(
|
||||
sharedType: Y.XmlText,
|
||||
event: Y.YEvent<Y.XmlText>
|
||||
): ReturnType<typeof BlockActionPB.prototype.toObject>[] {
|
||||
const { target: yXmlText, keys, delta } = event;
|
||||
// when the target is equal to the sharedType, it means that the change type is insert/delete block
|
||||
const isBlockEvent = yXmlText === sharedType;
|
||||
|
||||
if (isBlockEvent) {
|
||||
return blockOps2BlockActions(sharedType, delta);
|
||||
}
|
||||
|
||||
const actions = textOps2BlockActions(sharedType, yXmlText, delta);
|
||||
|
||||
if (keys.size > 0) {
|
||||
actions.push(...parentUpdatedOps2BlockActions(yXmlText, keys));
|
||||
|
||||
actions.push(...dataOps2BlockActions(yXmlText, keys));
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
function textOps2BlockActions(
|
||||
sharedType: Y.XmlText,
|
||||
yXmlText: Y.XmlText,
|
||||
ops: YDelta
|
||||
): ReturnType<typeof BlockActionPB.prototype.toObject>[] {
|
||||
if (ops.length === 0) return [];
|
||||
const blockId = yXmlText.getAttribute('blockId');
|
||||
const rootId = sharedType.getAttribute('rootId');
|
||||
|
||||
if (blockId === rootId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return generateApplyTextActions(yXmlText, ops);
|
||||
}
|
||||
|
||||
function parentUpdatedOps2BlockActions(
|
||||
yXmlText: Y.XmlText,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
keys: Map<string, { action: 'update' | 'add' | 'delete'; oldValue: any; newValue: any }>
|
||||
) {
|
||||
const parentUpdated = keys.has('parentId');
|
||||
|
||||
if (!parentUpdated) return [];
|
||||
const parentId = yXmlText.getAttribute('parentId');
|
||||
const prev = findPreviousSibling(yXmlText) as Y.XmlText;
|
||||
|
||||
const prevId = prev?.getAttribute('blockId');
|
||||
|
||||
fillIdRelationMap(yXmlText, yXmlText.doc?.getMap('idRelationMap') as Y.Map<string>);
|
||||
|
||||
return generateMoveBlockActions(yXmlText, parentId, prevId);
|
||||
}
|
||||
|
||||
function dataOps2BlockActions(
|
||||
yXmlText: Y.XmlText,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
keys: Map<string, { action: 'update' | 'add' | 'delete'; oldValue: any; newValue: any }>
|
||||
) {
|
||||
const dataUpdated = keys.has('data');
|
||||
|
||||
if (!dataUpdated) return [];
|
||||
const data = yXmlText.getAttribute('data');
|
||||
|
||||
return generateUpdateDataActions(yXmlText, data);
|
||||
}
|
||||
|
||||
function blockOps2BlockActions(
|
||||
sharedType: Y.XmlText,
|
||||
ops: YDelta
|
||||
): ReturnType<typeof BlockActionPB.prototype.toObject>[] {
|
||||
const actions: ReturnType<typeof BlockActionPB.prototype.toObject>[] = [];
|
||||
|
||||
const idList = sharedType.doc?.get('idList') as Y.XmlText;
|
||||
const idRelationMap = sharedType.doc?.getMap('idRelationMap') as Y.Map<string>;
|
||||
let index = 0;
|
||||
|
||||
ops.forEach((op) => {
|
||||
if (op.insert) {
|
||||
if (op.insert instanceof Y.XmlText) {
|
||||
const insertYXmlText = op.insert;
|
||||
|
||||
actions.push(...generateInsertBlockActions(insertYXmlText));
|
||||
}
|
||||
|
||||
index++;
|
||||
} else if (op.retain) {
|
||||
index += op.retain;
|
||||
} else if (op.delete) {
|
||||
const deletedDelta = idList.toDelta().slice(index, index + op.delete) as {
|
||||
insert: {
|
||||
id: string;
|
||||
};
|
||||
}[];
|
||||
|
||||
deletedDelta.forEach((delta) => {
|
||||
const parentId = idRelationMap.get(delta.insert.id);
|
||||
|
||||
actions.push(
|
||||
...generateDeleteBlockActions({
|
||||
id: delta.insert.id,
|
||||
parentId: parentId || '',
|
||||
})
|
||||
);
|
||||
});
|
||||
childrenInserts.forEach((insert) => {
|
||||
if (insert instanceof Y.XmlText) {
|
||||
actions.push(...generateInsertBlockActions(insert));
|
||||
}
|
||||
});
|
||||
|
||||
idList.applyDelta(convertToIdList(ops));
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { EditorData, EditorInlineNodeType, EditorNodeType, Mention } from '$app/application/document/document.types';
|
||||
import { EditorData, EditorInlineNodeType, Mention } from '$app/application/document/document.types';
|
||||
import { Element, Text } from 'slate';
|
||||
import { Op } from 'quill-delta';
|
||||
|
||||
@ -45,57 +45,57 @@ export function transformToInlineElement(op: Op): Element | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getInlinesWithDelta(delta?: Op[]): (Text | Element)[] {
|
||||
return delta && delta.length > 0
|
||||
? delta.map((op) => {
|
||||
const matchInline = transformToInlineElement(op);
|
||||
|
||||
if (matchInline) {
|
||||
return matchInline;
|
||||
}
|
||||
|
||||
return {
|
||||
text: op.insert as string,
|
||||
...op.attributes,
|
||||
};
|
||||
})
|
||||
: [
|
||||
{
|
||||
text: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function convertToSlateValue(data: EditorData, includeRoot: boolean): Element[] {
|
||||
const nodes: Element[] = [];
|
||||
const traverse = (id: string, level: number, isHidden?: boolean) => {
|
||||
const traverse = (id: string, isRoot = false) => {
|
||||
const node = data.nodeMap[id];
|
||||
const delta = data.deltaMap[id];
|
||||
|
||||
const slateNode: Element = {
|
||||
type: node.type,
|
||||
data: node.data,
|
||||
level,
|
||||
children: [],
|
||||
isHidden,
|
||||
blockId: id,
|
||||
parentId: node.parent || '',
|
||||
textId: node.externalId || '',
|
||||
};
|
||||
|
||||
const inlineNodes: (Text | Element)[] = delta
|
||||
? delta.map((op) => {
|
||||
const matchInline = transformToInlineElement(op);
|
||||
|
||||
if (matchInline) {
|
||||
return matchInline;
|
||||
const textNode: Element | null =
|
||||
!isRoot && node.externalId
|
||||
? {
|
||||
type: 'text',
|
||||
children: [],
|
||||
textId: node.externalId,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
text: op.insert as string,
|
||||
...op.attributes,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
const inlineNodes = getInlinesWithDelta(delta);
|
||||
|
||||
slateNode.children.push(...inlineNodes);
|
||||
textNode?.children.push(...inlineNodes);
|
||||
|
||||
nodes.push(slateNode);
|
||||
const children = data.childrenMap[id];
|
||||
|
||||
if (children) {
|
||||
for (const childId of children) {
|
||||
let isHidden = false;
|
||||
|
||||
if (node.type === EditorNodeType.ToggleListBlock) {
|
||||
const collapsed = (node.data as { collapsed: boolean })?.collapsed;
|
||||
|
||||
if (collapsed) {
|
||||
isHidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
traverse(childId, level + 1, isHidden);
|
||||
}
|
||||
slateNode.children = children.map((childId) => traverse(childId));
|
||||
if (textNode) {
|
||||
slateNode.children.unshift(textNode);
|
||||
}
|
||||
|
||||
return slateNode;
|
||||
@ -103,10 +103,24 @@ export function convertToSlateValue(data: EditorData, includeRoot: boolean): Ele
|
||||
|
||||
const rootId = data.rootId;
|
||||
|
||||
traverse(rootId, 0);
|
||||
const root = traverse(rootId, true);
|
||||
|
||||
if (!includeRoot) {
|
||||
nodes.shift();
|
||||
const nodes = root.children as Element[];
|
||||
|
||||
if (includeRoot) {
|
||||
nodes.unshift({
|
||||
...root,
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
children: [
|
||||
{
|
||||
text: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return nodes;
|
||||
|
@ -5,31 +5,46 @@ import { inlineNodeTypes } from '$app/application/document/document.types';
|
||||
import { DocEventPB } from '@/services/backend';
|
||||
|
||||
export function YDelta2Delta(yDelta: YDelta): Op[] {
|
||||
return yDelta.map((op) => {
|
||||
const ops: Op[] = [];
|
||||
|
||||
yDelta.forEach((op) => {
|
||||
if (op.insert instanceof Y.XmlText) {
|
||||
const type = op.insert.getAttribute('type');
|
||||
|
||||
if (inlineNodeTypes.includes(type)) {
|
||||
return YInlineOp2Op(op);
|
||||
ops.push(...YInlineOp2Op(op));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return op as Op;
|
||||
ops.push(op as Op);
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
export function YInlineOp2Op(yOp: YOp): Op {
|
||||
if (!(yOp.insert instanceof Y.XmlText)) return yOp as Op;
|
||||
export function YInlineOp2Op(yOp: YOp): Op[] {
|
||||
if (!(yOp.insert instanceof Y.XmlText)) {
|
||||
return [
|
||||
{
|
||||
insert: yOp.insert as string,
|
||||
attributes: yOp.attributes,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const type = yOp.insert.getAttribute('type');
|
||||
const data = yOp.insert.getAttribute('data');
|
||||
|
||||
return {
|
||||
insert: yOp.insert.toJSON(),
|
||||
const delta = yOp.insert.toDelta() as Op[];
|
||||
|
||||
return delta.map((op) => ({
|
||||
insert: op.insert,
|
||||
|
||||
attributes: {
|
||||
[type]: data,
|
||||
...op.attributes,
|
||||
},
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
export function DocEvent2YDelta(events: DocEventPB): YDelta {
|
||||
|
@ -1,48 +1,24 @@
|
||||
import * as Y from 'yjs';
|
||||
import { YDelta } from '$app/components/editor/provider/types/y_event';
|
||||
|
||||
export function findPreviousSibling(yXmlText: Y.XmlText) {
|
||||
let prev = yXmlText.prevSibling;
|
||||
export function getInsertTarget(root: Y.XmlText, path: (string | number)[]): Y.XmlText {
|
||||
const delta = root.toDelta();
|
||||
const index = path[0];
|
||||
|
||||
if (!prev) return null;
|
||||
const current = delta[index];
|
||||
|
||||
const level = yXmlText.getAttribute('level');
|
||||
|
||||
if (!level) return null;
|
||||
|
||||
while (prev) {
|
||||
const prevLevel = prev.getAttribute('level');
|
||||
|
||||
if (prevLevel === level) return prev;
|
||||
if (prevLevel < level) return null;
|
||||
|
||||
prev = prev.prevSibling;
|
||||
}
|
||||
|
||||
return prev;
|
||||
}
|
||||
|
||||
export function fillIdRelationMap(yXmlText: Y.XmlText, idRelationMap: Y.Map<string>) {
|
||||
const id = yXmlText.getAttribute('blockId');
|
||||
const parentId = yXmlText.getAttribute('parentId');
|
||||
|
||||
if (id && parentId) {
|
||||
idRelationMap.set(id, parentId);
|
||||
}
|
||||
}
|
||||
|
||||
export function convertToIdList(ops: YDelta) {
|
||||
return ops.map((op) => {
|
||||
if (op.insert instanceof Y.XmlText) {
|
||||
const id = op.insert.getAttribute('blockId');
|
||||
|
||||
return {
|
||||
insert: {
|
||||
id,
|
||||
},
|
||||
};
|
||||
if (current && current.insert instanceof Y.XmlText) {
|
||||
if (path.length === 1) {
|
||||
return current.insert;
|
||||
}
|
||||
|
||||
return op;
|
||||
});
|
||||
return getInsertTarget(current.insert, path.slice(1));
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
export function getYTarget(doc: Y.Doc, path: (string | number)[]) {
|
||||
const sharedType = doc.get('sharedType', Y.XmlText) as Y.XmlText;
|
||||
|
||||
return getInsertTarget(sharedType, path);
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ function NestedPage({ pageId }: { pageId: string }) {
|
||||
});
|
||||
|
||||
const className = useMemo(() => {
|
||||
const defaultClassName = 'relative flex flex-col w-full';
|
||||
const defaultClassName = 'relative flex-1 flex flex-col w-full';
|
||||
|
||||
if (isDragging) {
|
||||
return `${defaultClassName} opacity-40`;
|
||||
|
@ -29,7 +29,7 @@ function SideBar() {
|
||||
<div className={'flex h-[36px] items-center'}>
|
||||
<UserInfo />
|
||||
</div>
|
||||
<div className={'flex-1'}>
|
||||
<div className={'flex-1 overflow-hidden'}>
|
||||
<WorkspaceManager />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -8,7 +8,7 @@ function WorkspaceNestedPages({ workspaceId }: { workspaceId: string }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={'h-[100%] overflow-y-auto overflow-x-hidden'}>
|
||||
<div className={'h-full'}>
|
||||
{pageIds?.map((pageId) => (
|
||||
<NestedPage key={pageId} pageId={pageId} />
|
||||
))}
|
||||
|
@ -32,7 +32,7 @@ function TrashButton() {
|
||||
onDragLeave={onDragLeave}
|
||||
data-page-id={'trash'}
|
||||
onClick={navigateToTrash}
|
||||
className={`mx-1 flex w-[100%] items-center rounded-lg p-2 hover:bg-fill-list-hover ${
|
||||
className={`mx-1 my-3 flex h-[32px] w-[100%] items-center rounded-lg p-2 hover:bg-fill-list-hover ${
|
||||
selected ? 'bg-fill-list-active' : ''
|
||||
} ${isDraggingOver ? 'bg-fill-list-hover' : ''}`}
|
||||
>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user