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