fix: document title operation & copy & pasted

* fix: pasted html

* fix: document title operation

* fix: code review

* fix: jest test

* fix: copy & pasted

* fix: remove default style when pasted html

* fix: link selection

* fix: rust test
This commit is contained in:
Kilu.He 2023-12-23 21:14:32 +08:00 committed by GitHub
parent c3b183504f
commit 851296fa0e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 961 additions and 545 deletions

View File

@ -34,7 +34,7 @@ lru = "0.12.0"
[dependencies]
serde_json.workspace = true
serde.workspace = true
tauri = { version = "1.5", features = ["fs-all", "shell-open"] }
tauri = { version = "1.5", features = ["clipboard-all", "fs-all", "shell-open"] }
tauri-utils = "1.5"
bytes.workspace = true
tracing.workspace = true

View File

@ -29,6 +29,11 @@
"removeFile": true,
"renameFile": true,
"exists": true
},
"clipboard": {
"all": true,
"writeText": true,
"readText": true
}
},
"bundle": {

View File

@ -0,0 +1,294 @@
import {
ApplyActionPayloadPB,
BlockActionPB,
BlockPB,
CloseDocumentPayloadPB,
ConvertDataToJsonPayloadPB,
ConvertDocumentPayloadPB,
InputType,
OpenDocumentPayloadPB,
TextDeltaPayloadPB,
} from '@/services/backend';
import {
DocumentEventApplyAction,
DocumentEventApplyTextDeltaEvent,
DocumentEventCloseDocument,
DocumentEventConvertDataToJSON,
DocumentEventConvertDocument,
DocumentEventOpenDocument,
} from '@/services/backend/events/flowy-document2';
import get from 'lodash-es/get';
import { EditorData, EditorNodeType } from '$app/application/document/document.types';
import { Log } from '$app/utils/log';
import { Op } from 'quill-delta';
import { Element } from 'slate';
import { generateId, transformToInlineElement } from '$app/components/editor/provider/utils/convert';
export function blockPB2Node(block: BlockPB) {
let data = {};
try {
data = JSON.parse(block.data);
} catch {
Log.error('[Document Open] json parse error', block.data);
}
const node = {
id: block.id,
type: block.ty as EditorNodeType,
parent: block.parent_id,
children: block.children_id,
data,
externalId: block.external_id,
externalType: block.external_type,
};
return node;
}
export const BLOCK_MAP_NAME = 'blocks';
export const META_NAME = 'meta';
export const CHILDREN_MAP_NAME = 'children_map';
export const TEXT_MAP_NAME = 'text_map';
export const EQUATION_PLACEHOLDER = '$';
export async function openDocument(docId: string): Promise<EditorData> {
const payload = OpenDocumentPayloadPB.fromObject({
document_id: docId,
});
const result = await DocumentEventOpenDocument(payload);
if (!result.ok) {
return Promise.reject(result.val);
}
const documentDataPB = result.val;
if (!documentDataPB) {
return Promise.reject('documentDataPB is null');
}
const data: EditorData = {
viewId: docId,
rootId: documentDataPB.page_id,
nodeMap: {},
childrenMap: {},
relativeMap: {},
deltaMap: {},
externalIdMap: {},
};
get(documentDataPB, BLOCK_MAP_NAME).forEach((block) => {
Object.assign(data.nodeMap, {
[block.id]: blockPB2Node(block),
});
data.relativeMap[block.children_id] = block.id;
if (block.external_id) {
data.externalIdMap[block.external_id] = block.id;
}
});
get(documentDataPB, [META_NAME, CHILDREN_MAP_NAME]).forEach((child, key) => {
const blockId = data.relativeMap[key];
data.childrenMap[blockId] = child.children;
});
get(documentDataPB, [META_NAME, TEXT_MAP_NAME]).forEach((delta, key) => {
const blockId = data.externalIdMap[key];
data.deltaMap[blockId] = delta ? JSON.parse(delta) : [];
});
return data;
}
export async function closeDocument(docId: string) {
const payload = CloseDocumentPayloadPB.fromObject({
document_id: docId,
});
const result = await DocumentEventCloseDocument(payload);
if (!result.ok) {
return Promise.reject(result.val);
}
return result.val;
}
export async function applyActions(docId: string, actions: ReturnType<typeof BlockActionPB.prototype.toObject>[]) {
if (actions.length === 0) return;
const payload = ApplyActionPayloadPB.fromObject({
document_id: docId,
actions: actions,
});
const result = await DocumentEventApplyAction(payload);
if (!result.ok) {
return Promise.reject(result.val);
}
return result.val;
}
export async function applyText(docId: string, textId: string, delta: string) {
const payload = TextDeltaPayloadPB.fromObject({
document_id: docId,
text_id: textId,
delta: delta,
});
const res = await DocumentEventApplyTextDeltaEvent(payload);
if (!res.ok) {
return Promise.reject(res.val);
}
return res.val;
}
export async function getClipboardData(
docId: string,
range: {
start: {
blockId: string;
index: number;
length: number;
};
end?: {
blockId: string;
index: number;
length: number;
};
}
) {
const payload = ConvertDocumentPayloadPB.fromObject({
range: {
start: {
block_id: range.start.blockId,
index: range.start.index,
length: range.start.length,
},
end: range.end
? {
block_id: range.end.blockId,
index: range.end.index,
length: range.end.length,
}
: undefined,
},
document_id: docId,
parse_types: {
json: true,
html: true,
text: true,
},
});
const result = await DocumentEventConvertDocument(payload);
if (!result.ok) {
return Promise.reject(result.val);
}
return {
html: result.val.html,
text: result.val.text,
json: result.val.json,
};
}
export async function convertBlockToJson(data: string, type: InputType) {
const payload = ConvertDataToJsonPayloadPB.fromObject({
data,
input_type: type,
});
const result = await DocumentEventConvertDataToJSON(payload);
if (!result.ok) {
return Promise.reject(result.val);
}
try {
const block = JSON.parse(result.val.json);
return flattenBlockJson(block);
} catch (e) {
return Promise.reject(e);
}
}
interface BlockJSON {
type: string;
children: BlockJSON[];
data: {
[key: string]: boolean | string | number | undefined;
} & {
delta?: Op[];
};
}
function flattenBlockJson(block: BlockJSON) {
const nodes: Element[] = [];
const traverse = (block: BlockJSON, parentId: string, level: number, isHidden: boolean) => {
const { delta, ...data } = block.data;
const blockId = generateId();
const node: Element = {
blockId,
type: block.type,
data,
children: [],
parentId,
level,
textId: generateId(),
isHidden,
};
node.children = delta
? delta.map((op) => {
const matchInline = transformToInlineElement(op);
if (matchInline) {
return matchInline;
}
return {
text: op.insert as string,
...op.attributes,
};
})
: [
{
text: '',
},
];
nodes.push(node);
const children = block.children;
for (const child of children) {
let isHidden = false;
if (node.type === EditorNodeType.ToggleListBlock) {
const collapsed = (node.data as { collapsed: boolean })?.collapsed;
if (collapsed) {
isHidden = true;
}
}
traverse(child, blockId, level + 1, isHidden);
}
return node;
};
traverse(block, '', 0, false);
nodes.shift();
return nodes;
}

View File

@ -1,5 +1,5 @@
import { Op } from 'quill-delta';
import { HTMLAttributes, MutableRefObject } from 'react';
import { HTMLAttributes } from 'react';
import { Element } from 'slate';
import { ViewIconTypePB, ViewLayoutPB } from '@/services/backend';
import { YXmlText } from 'yjs/dist/src/types/YXmlText';
@ -14,6 +14,9 @@ export interface EditorNode {
externalType?: string;
}
export interface PageNode extends Element {
type: EditorNodeType.Page;
}
export interface ParagraphNode extends Element {
type: EditorNodeType.Paragraph;
}
@ -120,11 +123,14 @@ export interface MentionPage {
export interface EditorProps {
id: string;
sharedType?: YXmlText;
appendTextRef?: MutableRefObject<((text: string) => void) | null>;
title?: string;
onTitleChange?: (title: string) => void;
showTitle?: boolean;
}
export enum EditorNodeType {
Paragraph = 'paragraph',
Page = 'page',
HeadingBlock = 'heading',
TodoListBlock = 'todo_list',
BulletedListBlock = 'bulleted_list',
@ -139,6 +145,8 @@ export enum EditorNodeType {
GridBlock = 'grid',
}
export const blockTypes: string[] = Object.values(EditorNodeType);
export enum EditorInlineNodeType {
Mention = 'mention',
Formula = 'formula',

View File

@ -1,127 +0,0 @@
import {
ApplyActionPayloadPB,
BlockActionPB,
BlockPB,
OpenDocumentPayloadPB,
TextDeltaPayloadPB,
} from '@/services/backend';
import {
DocumentEventApplyAction,
DocumentEventApplyTextDeltaEvent,
DocumentEventOpenDocument,
} from '@/services/backend/events/flowy-document2';
import get from 'lodash-es/get';
import { EditorData, EditorNodeType } from '$app/application/document/document.types';
import { Log } from '$app/utils/log';
export function blockPB2Node(block: BlockPB) {
let data = {};
try {
data = JSON.parse(block.data);
} catch {
Log.error('[Document Open] json parse error', block.data);
}
const node = {
id: block.id,
type: block.ty as EditorNodeType,
parent: block.parent_id,
children: block.children_id,
data,
externalId: block.external_id,
externalType: block.external_type,
};
return node;
}
export const BLOCK_MAP_NAME = 'blocks';
export const META_NAME = 'meta';
export const CHILDREN_MAP_NAME = 'children_map';
export const TEXT_MAP_NAME = 'text_map';
export const EQUATION_PLACEHOLDER = '$';
export async function openDocument(docId: string): Promise<EditorData> {
const payload = OpenDocumentPayloadPB.fromObject({
document_id: docId,
});
const result = await DocumentEventOpenDocument(payload);
if (!result.ok) {
return Promise.reject(result.val);
}
const documentDataPB = result.val;
if (!documentDataPB) {
return Promise.reject('documentDataPB is null');
}
const data: EditorData = {
viewId: docId,
rootId: documentDataPB.page_id,
nodeMap: {},
childrenMap: {},
relativeMap: {},
deltaMap: {},
externalIdMap: {},
};
get(documentDataPB, BLOCK_MAP_NAME).forEach((block) => {
Object.assign(data.nodeMap, {
[block.id]: blockPB2Node(block),
});
data.relativeMap[block.children_id] = block.id;
if (block.external_id) {
data.externalIdMap[block.external_id] = block.id;
}
});
get(documentDataPB, [META_NAME, CHILDREN_MAP_NAME]).forEach((child, key) => {
const blockId = data.relativeMap[key];
data.childrenMap[blockId] = child.children;
});
get(documentDataPB, [META_NAME, TEXT_MAP_NAME]).forEach((delta, key) => {
const blockId = data.externalIdMap[key];
data.deltaMap[blockId] = delta ? JSON.parse(delta) : [];
});
return data;
}
export async function applyActions(docId: string, actions: ReturnType<typeof BlockActionPB.prototype.toObject>[]) {
if (actions.length === 0) return;
const payload = ApplyActionPayloadPB.fromObject({
document_id: docId,
actions: actions,
});
const result = await DocumentEventApplyAction(payload);
if (!result.ok) {
return Promise.reject(result.val);
}
return result.val;
}
export async function applyText(docId: string, textId: string, delta: string) {
const payload = TextDeltaPayloadPB.fromObject({
document_id: docId,
text_id: textId,
delta: delta,
});
const res = await DocumentEventApplyTextDeltaEvent(payload);
if (!res.ok) {
return Promise.reject(res.val);
}
return res.val;
}

View File

@ -2,52 +2,28 @@ import React, { FormEventHandler, memo, useCallback, useRef } from 'react';
import { TextareaAutosize } from '@mui/material';
import { useTranslation } from 'react-i18next';
function ViewTitleInput({
value,
onChange,
onSplitTitle,
}: {
value: string;
onChange: (value: string) => void;
onSplitTitle?: (splitText: string) => void;
}) {
function ViewTitleInput({ value, onChange }: { value: string; onChange?: (value: string) => void }) {
const { t } = useTranslation();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const onTitleChange: FormEventHandler<HTMLTextAreaElement> = (e) => {
const value = e.currentTarget.value;
const onTitleChange: FormEventHandler<HTMLTextAreaElement> = useCallback(
(e) => {
const value = e.currentTarget.value;
onChange(value);
};
const handleBreakLine = useCallback(() => {
if (!onSplitTitle) return;
const selectionStart = textareaRef.current?.selectionStart;
if (value) {
const newValue = value.slice(0, selectionStart);
onChange(newValue);
onSplitTitle(value.slice(selectionStart));
}
}, [onSplitTitle, onChange, value]);
onChange?.(value);
},
[onChange]
);
return (
<TextareaAutosize
ref={textareaRef}
placeholder={t('document.title.placeholder')}
className='min-h-[40px] resize-none text-4xl font-bold leading-[50px] caret-text-title'
autoCorrect='off'
autoFocus
value={value}
onInput={onTitleChange}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
handleBreakLine();
}
}}
className={`min-h-[40px] resize-none text-4xl font-bold leading-[50px] caret-text-title`}
/>
);
}

View File

@ -6,12 +6,12 @@ import ViewTitleInput from '$app/components/_shared/ViewTitle/ViewTitleInput';
interface Props {
view: Page;
onTitleChange: (title: string) => void;
onUpdateIcon: (icon: PageIcon) => void;
onSplitTitle?: (splitText: string) => void;
showTitle?: boolean;
onTitleChange?: (title: string) => void;
onUpdateIcon?: (icon: PageIcon) => void;
}
function ViewTitle({ view, onTitleChange, onUpdateIcon: onUpdateIconProp, onSplitTitle }: Props) {
function ViewTitle({ view, onTitleChange, showTitle = true, onUpdateIcon: onUpdateIconProp }: Props) {
const [hover, setHover] = useState(false);
const [icon, setIcon] = useState<PageIcon | undefined>(view.icon);
@ -27,7 +27,7 @@ function ViewTitle({ view, onTitleChange, onUpdateIcon: onUpdateIconProp, onSpli
};
setIcon(newIcon);
onUpdateIconProp(newIcon);
onUpdateIconProp?.(newIcon);
},
[onUpdateIconProp]
);
@ -39,9 +39,11 @@ function ViewTitle({ view, onTitleChange, onUpdateIcon: onUpdateIconProp, onSpli
onMouseLeave={() => setHover(false)}
>
<ViewBanner icon={icon} hover={hover} onUpdateIcon={onUpdateIcon} />
<div className='relative'>
<ViewTitleInput value={view.name} onChange={onTitleChange} onSplitTitle={onSplitTitle} />
</div>
{showTitle && (
<div className='relative'>
<ViewTitleInput value={view.name} onChange={onTitleChange} />
</div>
)}
</div>
);
}

View File

@ -6,7 +6,7 @@ interface Props {
}
function RecordDocument({ documentId }: Props) {
return <Editor id={documentId} />;
return <Editor id={documentId} showTitle={false} />;
}
export default React.memo(RecordDocument);

View File

@ -30,7 +30,7 @@ function RecordHeader({ page, row }: Props) {
return (
<div ref={ref} className={'px-16 pb-4'}>
<RecordTitle page={page} row={row} />
<RecordProperties documentId={page?.id} row={row} />
<RecordProperties row={row} />
<Divider />
</div>
);

View File

@ -4,7 +4,6 @@ import Property from '$app/components/database/components/edit_record/record_pro
import { Draggable } from 'react-beautiful-dnd';
interface Props extends HTMLAttributes<HTMLDivElement> {
documentId?: string;
properties: Field[];
rowId: string;
placeholderNode?: React.ReactNode;

View File

@ -10,11 +10,10 @@ import { DragDropContext, Droppable, DropResult, OnDragEndResponder } from 'reac
import SwitchPropertiesVisible from '$app/components/database/components/edit_record/record_properties/SwitchPropertiesVisible';
interface Props {
documentId?: string;
row: RowMeta;
}
function RecordProperties({ documentId, row }: Props) {
function RecordProperties({ row }: Props) {
const viewId = useViewId();
const { fields } = useDatabase();
const fieldId = useMemo(() => {
@ -73,10 +72,9 @@ function RecordProperties({ documentId, row }: Props) {
<Droppable droppableId='droppable' type='droppableItem'>
{(dropProvided) => (
<PropertyList
documentId={documentId}
{...dropProvided.droppableProps}
placeholderNode={dropProvided.placeholder}
ref={dropProvided.innerRef}
{...dropProvided.droppableProps}
rowId={rowId}
properties={state}
openMenuPropertyId={openMenuPropertyId}

View File

@ -1,31 +1,32 @@
import React, { useCallback, useEffect, useRef } from 'react';
import React, { useCallback } from 'react';
import { Editor } from 'src/appflowy_app/components/editor';
import { DocumentHeader } from 'src/appflowy_app/components/document/document_header';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { updatePageName } from '$app_reducers/pages/async_actions';
export function Document({ id }: { id: string }) {
const appendTextRef = useRef<((text: string) => void) | null>(null);
const page = useAppSelector((state) => state.pages.pageMap[id]);
const onSplitTitle = useCallback((splitText: string) => {
if (appendTextRef.current === null) {
return;
}
const dispatch = useAppDispatch();
const windowSelection = window.getSelection();
const onTitleChange = useCallback(
(newTitle: string) => {
void dispatch(
updatePageName({
id,
name: newTitle,
})
);
},
[dispatch, id]
);
windowSelection?.removeAllRanges();
appendTextRef.current(splitText);
}, []);
useEffect(() => {
return () => {
appendTextRef.current = null;
};
}, []);
if (!page) return null;
return (
<div className={'relative'}>
<DocumentHeader onSplitTitle={onSplitTitle} pageId={id} />
<Editor appendTextRef={appendTextRef} id={id} />
<DocumentHeader page={page} />
<Editor id={id} onTitleChange={onTitleChange} title={page.name} />
</div>
);
}

View File

@ -1,29 +1,17 @@
import React, { useCallback } from 'react';
import { PageIcon } from '$app_reducers/pages/slice';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { Page, PageIcon } from '$app_reducers/pages/slice';
import { useAppDispatch } from '$app/stores/store';
import ViewTitle from '$app/components/_shared/ViewTitle';
import { updatePageIcon, updatePageName } from '$app_reducers/pages/async_actions';
import { updatePageIcon } from '$app_reducers/pages/async_actions';
interface DocumentHeaderProps {
pageId: string;
onSplitTitle: (splitText: string) => void;
page: Page;
}
export function DocumentHeader({ pageId, onSplitTitle }: DocumentHeaderProps) {
const page = useAppSelector((state) => state.pages.pageMap[pageId]);
export function DocumentHeader({ page }: DocumentHeaderProps) {
const dispatch = useAppDispatch();
const onTitleChange = useCallback(
(newTitle: string) => {
void dispatch(
updatePageName({
id: pageId,
name: newTitle,
})
);
},
[dispatch, pageId]
);
const pageId = page.id;
const onUpdateIcon = useCallback(
(icon: PageIcon) => {
void dispatch(
@ -39,7 +27,7 @@ export function DocumentHeader({ pageId, onSplitTitle }: DocumentHeaderProps) {
if (!page) return null;
return (
<div className={'document-header px-16 py-4'}>
<ViewTitle onSplitTitle={onSplitTitle} onUpdateIcon={onUpdateIcon} onTitleChange={onTitleChange} view={page} />
<ViewTitle showTitle={false} onUpdateIcon={onUpdateIcon} view={page} />
</div>
);
}

View File

@ -1,5 +1,5 @@
import { ReactEditor } from 'slate-react';
import { Editor, Element, Node, NodeEntry, Transforms } from 'slate';
import { BasePoint, Editor, Element, Node, NodeEntry, Transforms } from 'slate';
import { LIST_TYPES, tabBackward, tabForward } from '$app/components/editor/command/tab';
import { isMarkActive, toggleMark } from '$app/components/editor/command/mark';
import { insertFormula, isFormulaActive, unwrapFormula, updateFormula } from '$app/components/editor/command/formula';
@ -123,7 +123,7 @@ export const CustomEditor = {
const index = editor.children.findIndex((child) => (child as Element).blockId === node.blockId);
const level = node.level ?? 1;
const subordinateNodes: Element[] = [];
const subordinateNodes: (Element & { level: number })[] = [];
if (index === editor.children.length - 1) return subordinateNodes;
@ -308,6 +308,14 @@ export const CustomEditor = {
return CustomEditor.getBlockType(editor) === EditorNodeType.GridBlock;
},
selectionIncludeRoot: (editor: ReactEditor) => {
const [match] = Editor.nodes(editor, {
match: (n) => Element.isElement(n) && n.blockId !== undefined && n.type === EditorNodeType.Page,
});
return Boolean(match);
},
isCodeBlock: (editor: ReactEditor) => {
return CustomEditor.getBlockType(editor) === EditorNodeType.CodeBlock;
},
@ -332,33 +340,40 @@ export const CustomEditor = {
Transforms.move(editor);
},
insertLineAtStart: (editor: ReactEditor & YjsEditor, node: Element) => {
const blockId = generateId();
const parentId = editor.sharedRoot.getAttribute('blockId');
basePointToIndexLength(editor: ReactEditor, point: BasePoint, toStart = false) {
const { path, offset } = point;
ReactEditor.focus(editor);
editor.insertNode(
{
...node,
blockId,
parentId,
textId: generateId(),
level: 1,
},
{
at: [0],
}
);
const node = editor.children[path[0]] as Element;
const blockId = node.blockId;
editor.select({
if (!blockId) return;
const beforeText = Editor.string(editor, {
anchor: {
path: [0, 0],
path: [path[0], 0],
offset: 0,
},
focus: {
path: [0, 0],
offset: 0,
path,
offset,
},
});
const index = beforeText.length;
const fullText = Editor.string(editor, [path[0]]);
const length = fullText.length - index;
if (toStart) {
return {
index: 0,
length: index,
blockId,
};
} else {
return {
index,
length,
blockId,
};
}
},
};

View File

@ -43,21 +43,29 @@ export function tabForward(editor: ReactEditor) {
const [node, path] = match as NodeEntry<Element>;
if (!node.level) return;
// the node is not a list item
if (!LIST_ITEM_TYPES.includes(node.type as EditorNodeType)) {
return;
}
const previous = Editor.previous(editor, {
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && Editor.isBlock(editor, n) && n.level === node.level,
at: path,
});
let previousNode;
if (!previous) return;
for (let i = path[0] - 1; i >= 0; i--) {
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;
const type = previousNode.type as EditorNodeType;
// the previous node is not a list
@ -111,7 +119,7 @@ export function tabBackward(editor: ReactEditor) {
const level = node.level;
if (level === 1) return;
if (level <= 1) return;
const parent = CustomEditor.findParentNode(editor, node);
if (!parent) return;
@ -122,6 +130,24 @@ export function tabBackward(editor: ReactEditor) {
const newProperties = { level: level - 1, parentId: newParentId };
const subordinates = CustomEditor.findNodeSubordinate(editor, node);
subordinates.forEach((subordinate) => {
const subordinatePath = ReactEditor.findPath(editor, subordinate);
const subordinateLevel = subordinate.level;
Transforms.setNodes(
editor,
{
level: subordinateLevel - 1,
},
{
at: subordinatePath,
}
);
});
const parentChildren = CustomEditor.findNodeChildren(editor, parent);
const nodeIndex = parentChildren.findIndex((child) => child.blockId === node.blockId);

View File

@ -1,65 +1,17 @@
import React, { CSSProperties, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Editor, Element, Range } from 'slate';
import { useSelected, useSlate } from 'slate-react';
import { EditorNodeType, HeadingNode } from '$app/application/document/document.types';
import React, { CSSProperties } from 'react';
import { Editor, Element } from 'slate';
import { useSlateStatic } from 'slate-react';
import PlaceholderContent from '$app/components/editor/components/blocks/_shared/PlaceholderContent';
function Placeholder({ node, className, style }: { node: Element; className?: string; style?: CSSProperties }) {
const editor = useSlate();
const { t } = useTranslation();
function Placeholder({ node, ...props }: { node: Element; className?: string; style?: CSSProperties }) {
const editor = useSlateStatic();
const isEmpty = Editor.isEmpty(editor, node);
const selected = useSelected() && editor.selection && Range.isCollapsed(editor.selection);
const unSelectedPlaceholder = useMemo(() => {
switch (node.type) {
case EditorNodeType.ToggleListBlock:
return t('document.plugins.toggleList');
case EditorNodeType.QuoteBlock:
return t('editor.quote');
case EditorNodeType.TodoListBlock:
return t('document.plugins.todoList');
case EditorNodeType.NumberedListBlock:
return t('document.plugins.numberedList');
case EditorNodeType.BulletedListBlock:
return t('document.plugins.bulletedList');
case EditorNodeType.HeadingBlock: {
const level = (node as HeadingNode).data.level;
if (!isEmpty) {
return null;
}
switch (level) {
case 1:
return t('editor.mobileHeading1');
case 2:
return t('editor.mobileHeading2');
case 3:
return t('editor.mobileHeading3');
default:
return '';
}
}
default:
return '';
}
}, [node, t]);
const selectedPlaceholder = useMemo(() => {
switch (node.type) {
case EditorNodeType.HeadingBlock:
return unSelectedPlaceholder;
default:
return t('editor.slashPlaceHolder');
}
}, [node.type, t, unSelectedPlaceholder]);
return isEmpty ? (
<span
contentEditable={false}
style={style}
className={`pointer-events-none absolute left-0.5 top-0 whitespace-nowrap text-text-placeholder ${className}`}
>
{selected ? selectedPlaceholder : unSelectedPlaceholder}
</span>
) : null;
return <PlaceholderContent node={node} {...props} />;
}
export default React.memo(Placeholder);

View File

@ -0,0 +1,88 @@
import React, { CSSProperties, useMemo } from 'react';
import { useSelected, useSlateStatic } from 'slate-react';
import { Element } from 'slate';
import { EditorNodeType, HeadingNode } from '$app/application/document/document.types';
import { useTranslation } from 'react-i18next';
function PlaceholderContent({ node, ...attributes }: { node: Element; className?: string; style?: CSSProperties }) {
const { t } = useTranslation();
const selected = useSelected();
const editor = useSlateStatic();
const justOneParagraph = useMemo(() => {
const root = editor.children[0] as Element;
if (node.type !== EditorNodeType.Paragraph) return false;
if (editor.children.length === 1) return true;
return root.type === EditorNodeType.Page && editor.children.length === 2;
}, [editor, node.type]);
const unSelectedPlaceholder = useMemo(() => {
switch (node.type) {
case EditorNodeType.Paragraph: {
if (justOneParagraph) {
return t('editor.slashPlaceHolder');
}
return '';
}
case EditorNodeType.ToggleListBlock:
return t('document.plugins.toggleList');
case EditorNodeType.QuoteBlock:
return t('editor.quote');
case EditorNodeType.TodoListBlock:
return t('document.plugins.todoList');
case EditorNodeType.NumberedListBlock:
return t('document.plugins.numberedList');
case EditorNodeType.BulletedListBlock:
return t('document.plugins.bulletedList');
case EditorNodeType.HeadingBlock: {
const level = (node as HeadingNode).data.level;
switch (level) {
case 1:
return t('editor.mobileHeading1');
case 2:
return t('editor.mobileHeading2');
case 3:
return t('editor.mobileHeading3');
default:
return '';
}
}
case EditorNodeType.Page:
return t('document.title.placeholder');
default:
return '';
}
}, [justOneParagraph, node, t]);
const selectedPlaceholder = useMemo(() => {
switch (node.type) {
case EditorNodeType.HeadingBlock:
return unSelectedPlaceholder;
case EditorNodeType.Page:
return t('document.title.placeholder');
default:
return t('editor.slashPlaceHolder');
}
}, [node.type, t, unSelectedPlaceholder]);
const className = useMemo(() => {
return `pointer-events-none absolute left-0.5 top-0 whitespace-nowrap text-text-placeholder ${
attributes.className ?? ''
}`;
}, [attributes.className]);
return (
<span contentEditable={false} {...attributes} className={className}>
{selected ? selectedPlaceholder : unSelectedPlaceholder}
</span>
);
}
export default PlaceholderContent;

View File

@ -13,7 +13,7 @@ export const Callout = memo(
ref={ref}
>
<CalloutIcon node={node} />
<div className={'flex-1'}>{children}</div>
<div className={'flex-1 py-1.5'}>{children}</div>
</div>
);
})

View File

@ -27,7 +27,7 @@ function CalloutIcon({ node }: { node: CalloutNode }) {
onClick={() => {
setOpen(true);
}}
className={`p-1`}
className={`h-8 w-8 p-1`}
>
{node.data.icon}
</IconButton>

View File

@ -5,8 +5,7 @@ import { getHeadingCssProperty } from '$app/components/editor/plugins/utils';
export const Heading = memo(
forwardRef<HTMLDivElement, EditorElementProps<HeadingNode>>(({ node, children, ...attributes }, ref) => {
const { data } = node;
const { level } = data;
const level = node.data.level;
const fontSizeCssProperty = getHeadingCssProperty(level);
return (

View File

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

View File

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

View File

@ -5,12 +5,36 @@ import { EditorProps } from '$app/application/document/document.types';
import { Provider } from '$app/components/editor/provider';
import { YXmlText } from 'yjs/dist/src/types/YXmlText';
export const CollaborativeEditor = (props: EditorProps) => {
export const CollaborativeEditor = ({ id, title, showTitle = true, onTitleChange }: EditorProps) => {
const [sharedType, setSharedType] = useState<YXmlText | null>(null);
const provider = useMemo(() => {
setSharedType(null);
return new Provider(props.id);
}, [props.id]);
return new Provider(id, showTitle);
}, [id, showTitle]);
const root = useMemo(() => {
return showTitle ? (sharedType?.toDelta()[0].insert as YXmlText | null) : null;
}, [sharedType, showTitle]);
useEffect(() => {
if (!root || root.toString() === title) return;
if (root.length > 0) {
root.delete(0, root.length);
}
root.insert(0, title || '');
}, [title, root]);
useEffect(() => {
if (!root) return;
const onChange = () => {
onTitleChange?.(root.toString());
};
root.observe(onChange);
return () => root.unobserve(onChange);
}, [onTitleChange, root]);
useEffect(() => {
provider.connect();
@ -26,9 +50,9 @@ export const CollaborativeEditor = (props: EditorProps) => {
};
}, [provider]);
if (!sharedType || props.id !== provider.id) {
if (!sharedType || id !== provider.id) {
return null;
}
return <Editor {...props} sharedType={sharedType || undefined} />;
return <Editor sharedType={sharedType} id={id} />;
};

View File

@ -1,32 +1,11 @@
import React, { ComponentProps } from 'react';
import { Editable, ReactEditor, useSlate } from 'slate-react';
import { Editable } from 'slate-react';
import Element from './Element';
import { Leaf } from './Leaf';
import { useTranslation } from 'react-i18next';
type CustomEditableProps = Omit<ComponentProps<typeof Editable>, 'renderElement' | 'renderLeaf'> &
Partial<Pick<ComponentProps<typeof Editable>, 'renderElement' | 'renderLeaf'>>;
export function CustomEditable({ renderElement = Element, renderLeaf = Leaf, ...props }: CustomEditableProps) {
const editor = useSlate();
const { t } = useTranslation();
return (
<Editable
{...props}
placeholder={t('editor.slashPlaceHolder')}
renderPlaceholder={({ attributes, children }) => {
const focused = ReactEditor.isFocused(editor);
if (focused) return <></>;
return (
<div {...attributes} className={`h-full whitespace-nowrap`}>
<div className={'flex h-full items-center pl-1'}>{children}</div>
</div>
);
}}
renderElement={renderElement}
renderLeaf={renderLeaf}
/>
);
return <Editable {...props} renderElement={renderElement} renderLeaf={renderLeaf} />;
}

View File

@ -1,7 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { EditorNodeType, CodeNode } from '$app/application/document/document.types';
import { createEditor, NodeEntry, BaseRange, Editor, Transforms, Element } from 'slate';
import { createEditor, NodeEntry, BaseRange, Editor, Element } from 'slate';
import { ReactEditor, withReact } from 'slate-react';
import { withBlockPlugins } from '$app/components/editor/plugins/withBlockPlugins';
import { decorateCode } from '$app/components/editor/components/blocks/code/utils';
@ -11,7 +11,7 @@ import { withYjs, YjsEditor, withYHistory } from '@slate-yjs/core';
import * as Y from 'yjs';
import { CustomEditor } from '$app/components/editor/command';
export function useEditor(sharedType?: Y.XmlText) {
export function useEditor(sharedType: Y.XmlText) {
const editor = useMemo(() => {
if (!sharedType) return null;
const e = withShortcuts(withBlockPlugins(withInlines(withReact(withYHistory(withYjs(createEditor(), sharedType))))));
@ -26,16 +26,8 @@ export function useEditor(sharedType?: Y.XmlText) {
return normalizeNode(entry);
}
Transforms.insertNodes(
e,
[
{
type: EditorNodeType.Paragraph,
children: [{ text: '' }],
},
],
{ at: [0] }
);
// Ensure editor always has at least 1 valid child
CustomEditor.insertEmptyLineAtEnd(e as ReactEditor & YjsEditor);
};
return e;

View File

@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React from 'react';
import {
EditorSelectedBlockProvider,
useDecorate,
@ -7,34 +7,19 @@ import {
} from '$app/components/editor/components/editor/Editor.hooks';
import { Slate } from 'slate-react';
import { CustomEditable } from '$app/components/editor/components/editor/CustomEditable';
import { EditorNodeType, EditorProps } from '$app/application/document/document.types';
import { SelectionToolbar } from '$app/components/editor/components/tools/selection_toolbar';
import { useShortcuts } from '$app/components/editor/components/editor/shortcuts';
import { BlockActionsToolbar } from '$app/components/editor/components/tools/block_actions';
import { SlashCommandPanel } from '$app/components/editor/components/tools/command_panel/slash_command_panel';
import { MentionPanel } from '$app/components/editor/components/tools/command_panel/mention_panel';
import { CircularProgress } from '@mui/material';
import { CustomEditor } from '$app/components/editor/command';
import * as Y from 'yjs';
function Editor({ sharedType, appendTextRef }: EditorProps) {
function Editor({ sharedType }: { sharedType: Y.XmlText; id: string }) {
const { editor, initialValue, handleOnClickEnd, ...props } = useEditor(sharedType);
const decorate = useDecorate(editor);
const { onDOMBeforeInput, onKeyDown: onShortcutsKeyDown } = useShortcuts(editor);
useEffect(() => {
if (!appendTextRef) return;
appendTextRef.current = (text: string) => {
CustomEditor.insertLineAtStart(editor, {
type: EditorNodeType.Paragraph,
children: [{ text }],
});
};
return () => {
appendTextRef.current = null;
};
}, [appendTextRef, editor]);
const { onSelectedBlock, selectedBlockId } = useEditorSelectedBlock(editor);
if (editor.sharedRoot.length === 0) {

View File

@ -16,6 +16,7 @@ import { Mention } from '$app/components/editor/components/inline_nodes/mention'
import { GridBlock } from '$app/components/editor/components/blocks/database';
import { MathEquation } from '$app/components/editor/components/blocks/math_equation';
import { useSelectedBlock } from '$app/components/editor/components/editor/Editor.hooks';
import Page from '../blocks/page/Page';
function Element({ element, attributes, children }: RenderElementProps) {
const node = element;
@ -33,6 +34,8 @@ function Element({ element, attributes, children }: RenderElementProps) {
const Component = useMemo(() => {
switch (node.type) {
case EditorNodeType.Page:
return Page;
case EditorNodeType.HeadingBlock:
return Heading;
case EditorNodeType.TodoListBlock:

View File

@ -58,8 +58,15 @@ export function useShortcuts(editor: ReactEditor) {
});
}
const node = getBlock(editor);
if (isHotkey('Tab', e)) {
e.preventDefault();
if (SOFT_BREAK_TYPES.includes(node?.type as EditorNodeType)) {
editor.insertText('\t');
return;
}
return CustomEditor.tabForward(editor);
}
@ -68,14 +75,19 @@ export function useShortcuts(editor: ReactEditor) {
return CustomEditor.tabBackward(editor);
}
const node = getBlock(editor);
if (isHotkey('Enter', e)) {
if (SOFT_BREAK_TYPES.includes(node?.type as EditorNodeType)) {
e.preventDefault();
editor.insertText('\n');
return;
}
}
if (isHotkey('shift+Enter', e) && node) {
e.preventDefault();
if (SOFT_BREAK_TYPES.includes(node.type as EditorNodeType)) {
e.preventDefault();
CustomEditor.splitToParagraph(editor);
} else if (node.type === EditorNodeType.Paragraph) {
e.preventDefault();
} else {
editor.insertText('\n');
}

View File

@ -24,7 +24,7 @@ export function withCommandShortcuts(editor: ReactEditor) {
const { insertText, deleteBackward } = editor;
editor.insertText = (text) => {
if (CustomEditor.isCodeBlock(editor)) {
if (CustomEditor.isCodeBlock(editor) || CustomEditor.selectionIncludeRoot(editor)) {
insertText(text);
return;
}

View File

@ -138,7 +138,7 @@ export const withMarkdownShortcuts = (editor: ReactEditor) => {
const { insertText } = editor;
editor.insertText = (text) => {
if (CustomEditor.isCodeBlock(editor)) {
if (CustomEditor.isCodeBlock(editor) || CustomEditor.selectionIncludeRoot(editor)) {
insertText(text);
return;
}

View File

@ -1,7 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { ReactEditor, useSelected, useSlate } from 'slate-react';
import { getNodePath, moveCursorToNodeEnd, moveCursorToPoint } from '$app/components/editor/components/editor/utils';
import { BasePoint, Transforms, Text, Range, Point } from 'slate';
import { BasePoint, Transforms, Text, Range, Editor } from 'slate';
import { LinkEditPopover } from '$app/components/editor/components/marks/link/LinkEditPopover';
export const Link = memo(({ leaf, children }: { leaf: Text; children: React.ReactNode }) => {
@ -9,33 +9,50 @@ export const Link = memo(({ leaf, children }: { leaf: Text; children: React.Reac
const editor = useSlate();
const [selected, setSelected] = useState(false);
const ref = useRef<HTMLSpanElement | null>(null);
const [openEditPopover, setOpenEditPopover] = useState<boolean>(false);
const selected = useMemo(() => {
if (!editor.selection || !nodeSelected || !ref.current) return false;
const getSelected = useCallback(
(el: HTMLSpanElement, selection: Range) => {
const entry = Editor.node(editor, selection);
const [node, path] = entry;
const dom = ReactEditor.toDOMNode(editor, node);
const node = ReactEditor.toSlateNode(editor, ref.current);
const path = ReactEditor.findPath(editor, node);
const range = { anchor: { path, offset: 0 }, focus: { path, offset: leaf.text.length } };
const isContained = Range.includes(range, editor.selection);
const selectionIsCollapsed = Range.isCollapsed(editor.selection);
const point = Range.start(editor.selection);
if (!dom.contains(el)) return false;
if ((selectionIsCollapsed && point && Point.equals(point, range.focus)) || Point.equals(point, range.anchor)) {
return false;
}
const offset = Editor.string(editor, path).length;
const range = {
anchor: {
path,
offset: 0,
},
focus: {
path,
offset,
},
};
return isContained;
}, [editor, nodeSelected, leaf.text.length]);
return Range.equals(range, selection);
},
[editor]
);
useEffect(() => {
if (selected) {
setOpenEditPopover(true);
} else {
if (!ref.current) return;
const selection = editor.selection;
if (!nodeSelected || !selection) {
setOpenEditPopover(false);
setSelected(false);
return;
}
}, [selected]);
const selected = getSelected(ref.current, selection);
setOpenEditPopover(selected);
setSelected(selected);
}, [getSelected, editor, nodeSelected]);
const handleClick = useCallback(() => {
if (ref.current === null) {
@ -45,6 +62,7 @@ export const Link = memo(({ leaf, children }: { leaf: Text; children: React.Reac
const path = getNodePath(editor, ref.current);
setOpenEditPopover(true);
setSelected(true);
ReactEditor.focus(editor);
Transforms.select(editor, path);
}, [editor]);
@ -52,6 +70,7 @@ export const Link = memo(({ leaf, children }: { leaf: Text; children: React.Reac
const handleEditPopoverClose = useCallback(
(at?: BasePoint) => {
setOpenEditPopover(false);
setSelected(false);
if (ref.current === null) {
return;
}

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { ReactEditor, useSlate } from 'slate-react';
import { getBlockActionsPosition } from '$app/components/editor/components/tools/block_actions/utils';
import { Element } from 'slate';
import { EditorNodeType } from '$app/application/document/document.types';
export function useBlockActionsToolbar(ref: React.RefObject<HTMLDivElement>) {
const editor = useSlate();
@ -57,6 +58,6 @@ export function useBlockActionsToolbar(ref: React.RefObject<HTMLDivElement>) {
}, [editor, ref]);
return {
node,
node: node?.type === EditorNodeType.Page ? null : node,
};
}

View File

@ -16,6 +16,7 @@ import { ReactComponent as ToggleListIcon } from '$app/assets/show-menu.svg';
import { ReactComponent as GridIcon } from '$app/assets/grid.svg';
import { DataObjectOutlined, FunctionsOutlined, HorizontalRuleOutlined, MenuBookOutlined } from '@mui/icons-material';
import { CustomEditor } from '$app/components/editor/command';
import { randomEmoji } from '$app/utils/emoji';
enum SlashCommandPanelTab {
BASIC = 'basic',
@ -107,7 +108,25 @@ export function useSlashCommandPanel({
if (!nodeType) return;
const data = headingTypes.includes(type) ? { level: headingTypeToLevelMap[type] } : {};
const data = {};
if (headingTypes.includes(type)) {
Object.assign(data, {
level: headingTypeToLevelMap[type],
});
}
if (nodeType === EditorNodeType.CalloutBlock) {
Object.assign(data, {
icon: randomEmoji(),
});
}
if (nodeType === EditorNodeType.CodeBlock) {
Object.assign(data, {
language: 'javascript',
});
}
closePanel(true);

View File

@ -2,6 +2,7 @@ import { ReactEditor, useSlate } from 'slate-react';
import { MutableRefObject, useCallback, useEffect, useRef, useState } from 'react';
import { getSelectionPosition } from '$app/components/editor/components/tools/selection_toolbar/utils';
import debounce from 'lodash-es/debounce';
import { CustomEditor } from '$app/components/editor/command';
export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>) {
const editor = useSlate() as ReactEditor;
@ -9,6 +10,19 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
const [visible, setVisible] = useState(false);
const rangeRef = useRef<Range | null>(null);
const closeToolbar = useCallback(() => {
const el = ref.current;
if (!el) {
return;
}
rangeRef.current = null;
setVisible(false);
el.style.opacity = '0';
el.style.pointerEvents = 'none';
}, [ref]);
const recalculatePosition = useCallback(() => {
const el = ref.current;
@ -16,17 +30,20 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
return;
}
if (CustomEditor.selectionIncludeRoot(editor)) {
closeToolbar();
return;
}
if (rangeRef.current) {
closeToolbar();
return;
}
const position = getSelectionPosition(editor);
if (!position) {
rangeRef.current = null;
setVisible(false);
el.style.opacity = '0';
el.style.pointerEvents = 'none';
closeToolbar();
return;
}
@ -37,7 +54,7 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
el.style.pointerEvents = 'auto';
el.style.top = `${position.top + slateEditorDom.offsetTop - el.offsetHeight}px`;
el.style.left = `${position.left + slateEditorDom.offsetLeft - el.offsetWidth / 2 + position.width / 2}px`;
}, [editor, ref]);
}, [closeToolbar, editor, ref]);
useEffect(() => {
const debounceRecalculatePosition = debounce(recalculatePosition, 100);

View File

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

View File

@ -5,11 +5,11 @@ import { ReactEditor } from 'slate-react';
export function getHeadingCssProperty(level: number) {
switch (level) {
case 1:
return 'text-3xl pt-4';
return 'text-3xl pt-4 pb-2';
case 2:
return 'text-2xl pt-3';
return 'text-2xl pt-3 pb-2';
case 3:
return 'text-xl pt-2';
return 'text-xl pt-2 pb-2';
default:
return '';
}

View File

@ -21,7 +21,7 @@ export function withBlockDeleteBackward(editor: ReactEditor) {
const [node] = match as NodeEntry<Element>;
// if the current node is not a paragraph, convert it to a paragraph
if (node.type !== EditorNodeType.Paragraph) {
if (node.type !== EditorNodeType.Paragraph && node.type !== EditorNodeType.Page) {
CustomEditor.turnToBlock(editor, { type: EditorNodeType.Paragraph });
return;
}

View File

@ -1,6 +1,5 @@
import { ReactEditor } from 'slate-react';
import { Editor, Element, NodeEntry } from 'slate';
import { SOFT_BREAK_TYPES } from '$app/components/editor/plugins/constants';
import { EditorNodeType } from '$app/application/document/document.types';
import { CustomEditor } from '$app/components/editor/command';
@ -17,9 +16,8 @@ export function withBlockInsertBreak(editor: ReactEditor) {
const [node] = nodeEntry as NodeEntry<Element>;
const type = node.type as EditorNodeType;
// should insert a soft break, eg: code block and callout
if (SOFT_BREAK_TYPES.includes(type)) {
editor.insertText('\n');
if (type === EditorNodeType.Page) {
insertBreak(...args);
return;
}

View File

@ -10,8 +10,8 @@ import { withPasted } from '$app/components/editor/plugins/withPasted';
export function withBlockPlugins(editor: ReactEditor) {
return withMathEquationPlugin(
withDatabaseBlockPlugin(
withPasted(withSplitNodes(withMergeNodes(withBlockInsertBreak(withBlockDeleteBackward(editor)))))
withPasted(
withDatabaseBlockPlugin(withSplitNodes(withMergeNodes(withBlockInsertBreak(withBlockDeleteBackward(editor)))))
)
);
}

View File

@ -1,43 +1,106 @@
import { ReactEditor } from 'slate-react';
import { Editor, Element, NodeEntry, Node, Transforms, Point } from 'slate';
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 } = editor;
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);
};
// before merging nodes, check whether the node is a block and whether the selection is at the start of the block
// if so, move the children of the node to the previous node
editor.mergeNodes = (...args) => {
const { selection } = editor;
const isBlock = (n: Node) =>
!Editor.isEditor(n) && Element.isElement(n) && n.type !== undefined && n.level !== undefined;
const [match] = Editor.nodes(editor, {
const [merged] = Editor.nodes(editor, {
match: isBlock,
});
if (match && selection) {
const [node, path] = match as NodeEntry<Element>;
const start = Editor.start(editor, path);
if (!merged) {
mergeNodes(...args);
return;
}
if (Point.equals(selection.anchor, start)) {
const previous = Editor.previous(editor, { at: path });
const [previousNode] = previous as NodeEntry<Element>;
const previousLevel = previousNode.level ?? 1;
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);
const children = CustomEditor.findNodeChildren(editor, node);
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);
children.forEach((child) => {
const childPath = ReactEditor.findPath(editor, child);
Transforms.setNodes(editor, { level: previousLevel + 1, parentId: previousNode.blockId }, { at: childPath });
editor.select([0]);
editor.insertText(text);
editor.removeNodes({ at: path });
// move children to root
moveNodes(editor, 1, root.blockId, (n) => {
return n.parentId === mergedNode.blockId;
});
return;
}
}
const nextNode = editor.children[path[0] + 1] as Element & { level: number };
mergeNodes(...args);
if (!nextNode) {
CustomEditor.insertEmptyLineAtEnd(editor as ReactEditor & YjsEditor);
return;
}
if (mergedNode.blockId === nextNode.parentId) {
// the node will be deleted when the node has no text
if (mergedNode.children.length === 1 && 'text' in mergedNode.children[0] && mergedNode.children[0].text === '') {
moveNodes(editor, root.level + 1, root.blockId, (n) => n.parentId === mergedNode.blockId);
}
return;
}
// check if the old node is removed
const oldNodeRemoved = !editor.children.some((child) => (child as Element).blockId === nextNode.parentId);
if (oldNodeRemoved) {
// if the old node is removed, we need to move the children of the old node to the new node
moveNodes(editor, mergedNode.level + 1, mergedNode.blockId, (n) => {
return n.parentId === nextNode.parentId;
});
}
};
return editor;
}
function moveNodes(editor: ReactEditor, level: number, parentId: string, match: (n: Element) => boolean) {
editor.children.forEach((child, index) => {
if (match(child as Element)) {
Transforms.setNodes(editor, { level, parentId }, { at: [index] });
}
});
}

View File

@ -1,86 +1,119 @@
import { ReactEditor } from 'slate-react';
import { Editor, Element, Node, NodeEntry, Transforms } from 'slate';
import { Log } from '$app/utils/log';
import { convertBlockToJson } from '$app/application/document/document.service';
import { Editor, Element } from 'slate';
import { generateId } from '$app/components/editor/provider/utils/convert';
import { blockTypes, EditorNodeType } from '$app/application/document/document.types';
import { InputType } from '@/services/backend';
export function withPasted(editor: ReactEditor) {
const { insertData, mergeNodes } = editor;
editor.mergeNodes = (...args) => {
const isBlock = (n: Node) =>
!Editor.isEditor(n) && Element.isElement(n) && n.type !== undefined && n.level !== undefined;
const [match] = Editor.nodes(editor, {
match: isBlock,
});
const node = match ? (match[0] as Element) : null;
if (!node) {
mergeNodes(...args);
return;
}
// This is a hack to fix the bug that the children of the node will be moved to the previous node
const previous = Editor.previous(editor, {
match: (n) => {
return !Editor.isEditor(n) && Element.isElement(n) && n.type !== undefined && n.level === (node.level ?? 1) - 1;
},
});
if (previous) {
const [previousNode] = previous as NodeEntry<Element>;
if (previousNode && previousNode.blockId !== node.parentId) {
const children = editor.children.filter((child) => (child as Element).parentId === node.parentId);
children.forEach((child) => {
const childIndex = editor.children.findIndex((c) => c === child);
const childPath = [childIndex];
Transforms.setNodes(editor, { parentId: previousNode.blockId }, { at: childPath });
});
}
}
mergeNodes(...args);
};
const { insertData, insertFragment } = editor;
editor.insertData = (data) => {
const fragment = data.getData('application/x-slate-fragment');
try {
if (fragment) {
const decoded = decodeURIComponent(window.atob(fragment));
const parsed = JSON.parse(decoded);
if (fragment) {
insertData(data);
return;
}
if (parsed instanceof Array) {
const idMap = new Map<string, string>();
const html = data.getData('text/html');
const text = data.getData('text/plain');
const inputType = html ? InputType.Html : InputType.PlainText;
for (const parsedElement of parsed as Element[]) {
if (!parsedElement.blockId) continue;
const newBlockId = generateId();
if (parsedElement.parentId) {
parsedElement.parentId = idMap.get(parsedElement.parentId) ?? parsedElement.parentId;
}
idMap.set(parsedElement.blockId, newBlockId);
parsedElement.blockId = newBlockId;
parsedElement.textId = generateId();
}
editor.insertFragment(parsed);
return;
}
}
} catch (err) {
Log.error('insertData', err);
if (html || text) {
void convertBlockToJson(html || text, inputType).then((nodes) => {
editor.insertFragment(nodes);
});
return;
}
insertData(data);
};
editor.insertFragment = (fragment) => {
let rootId = (editor.children[0] as Element)?.blockId;
if (!rootId) {
rootId = generateId();
insertFragment([
{
type: EditorNodeType.Paragraph,
children: [
{
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();
const parentId = idMap.get(node.parentId);
if (parentId) {
node.parentId = parentId;
} else {
idMap.set(node.parentId, mergedNode.parentId);
node.parentId = mergedNode.parentId;
}
const parentLevel = levelMap.get(node.parentId);
if (parentLevel !== undefined) {
node.level = parentLevel + 1;
} else {
levelMap.set(node.parentId, mergedNode.level - 1);
node.level = mergedNode.level;
}
// if the pasted fragment is not matched with the block type, we need to convert it to paragraph
// and if the pasted fragment is a page, we need to convert it to paragraph
if (!blockTypes.includes(node.type as EditorNodeType) || node.type === EditorNodeType.Page) {
node.type = EditorNodeType.Paragraph;
}
idMap.set(node.blockId, newBlockId);
levelMap.set(newBlockId, node.level);
node.blockId = newBlockId;
node.textId = generateId();
}
return insertFragment(fragment);
};
return editor;
}

View File

@ -76,14 +76,19 @@ export function withSplitNodes(editor: ReactEditor) {
return;
}
// should be split to another paragraph, eg: heading and quote
// should be split to another paragraph, eg: heading and quote and page
if (BREAK_TO_PARAGRAPH_TYPES.includes(nodeType)) {
const level = node.level || 1;
const parentId = (node.parentId || node.blockId) as string;
splitNodes(...args);
Transforms.setNodes(editor, {
type: EditorNodeType.Paragraph,
data: {},
blockId: newBlockId,
textId: newTextId,
level,
parentId,
});
return;
}

View File

@ -27,7 +27,7 @@ describe('Transform events to actions', () => {
const parentId = sharedType?.getAttribute('blockId') as string;
const insertTextOp = generateInsertTextOp('insert text', parentId, 1);
sharedType?.applyDelta([{ retain: 1 }, insertTextOp]);
sharedType?.applyDelta([{ retain: 2 }, insertTextOp]);
const actions = applyActions.mock.calls[0][1];
expect(actions).toHaveLength(2);
@ -47,7 +47,7 @@ describe('Transform events to actions', () => {
const sharedType = provider.sharedType;
const parentId = 'CxPil0324P';
const yText = sharedType?.toDelta()[3].insert as Y.XmlText;
const yText = sharedType?.toDelta()[4].insert as Y.XmlText;
sharedType?.doc?.transact(() => {
yText.setAttribute('level', 2);
yText.setAttribute('parentId', parentId);
@ -65,7 +65,7 @@ describe('Transform events to actions', () => {
const sharedType = provider.sharedType;
sharedType?.doc?.transact(() => {
sharedType?.applyDelta([{ retain: 3 }, { delete: 1 }]);
sharedType?.applyDelta([{ retain: 4 }, { delete: 1 }]);
});
const actions = applyActions.mock.calls[0][1];
@ -78,7 +78,7 @@ describe('Transform events to actions', () => {
test('should transform update event to update action', () => {
const sharedType = provider.sharedType;
const yText = sharedType?.toDelta()[3].insert as Y.XmlText;
const yText = sharedType?.toDelta()[4].insert as Y.XmlText;
sharedType?.doc?.transact(() => {
yText.setAttribute('data', {
checked: true,
@ -96,7 +96,7 @@ describe('Transform events to actions', () => {
test('should transform apply delta event to apply delta action (insert text)', () => {
const sharedType = provider.sharedType;
const yText = sharedType?.toDelta()[3].insert as Y.XmlText;
const yText = sharedType?.toDelta()[4].insert as Y.XmlText;
sharedType?.doc?.transact(() => {
yText.applyDelta([{ retain: 1 }, { insert: 'apply delta' }]);
});
@ -112,7 +112,7 @@ describe('Transform events to actions', () => {
test('should transform apply delta event to apply delta action: insert mention', () => {
const sharedType = provider.sharedType;
const yText = sharedType?.toDelta()[3].insert as Y.XmlText;
const yText = sharedType?.toDelta()[4].insert as Y.XmlText;
sharedType?.doc?.transact(() => {
yText.applyDelta([{ retain: 1 }, genersteMentionInsertTextOp()]);
});
@ -126,7 +126,7 @@ describe('Transform events to actions', () => {
test('should transform apply delta event to apply delta action: insert formula', () => {
const sharedType = provider.sharedType;
const yText = sharedType?.toDelta()[3].insert as Y.XmlText;
const yText = sharedType?.toDelta()[4].insert as Y.XmlText;
sharedType?.doc?.transact(() => {
yText.applyDelta([{ retain: 1 }, generateFormulaInsertTextOp()]);
});

View File

@ -22,7 +22,7 @@ describe('Provider connected', () => {
test('should initial document', () => {
const sharedType = provider.sharedType;
expect(sharedType).not.toBeNull();
expect(sharedType?.length).toBe(24);
expect(sharedType?.length).toBe(25);
expect(sharedType?.getAttribute('blockId')).toBe('3EzeCrtxlh');
});
@ -32,9 +32,9 @@ describe('Provider connected', () => {
const parentId = sharedType?.getAttribute('blockId') as string;
const insertTextOp = generateInsertTextOp('', parentId, 1);
sharedType?.applyDelta([{ retain: 1 }, insertTextOp]);
sharedType?.applyDelta([{ retain: 2 }, insertTextOp]);
expect(sharedType?.length).toBe(25);
expect(sharedType?.length).toBe(26);
expect(applyActions).toBeCalledTimes(1);
});
});

View File

@ -10,10 +10,11 @@ jest.mock('$app/application/notification', () => {
jest.mock('nanoid', () => ({ nanoid: jest.fn().mockReturnValue(String(Math.random())) }));
jest.mock('$app/application/document/document_service', () => {
jest.mock('$app/application/document/document.service', () => {
return {
openDocument: jest.fn().mockReturnValue(Promise.resolve(read_me)),
applyActions,
closeDocument: jest.fn().mockReturnValue(Promise.resolve()),
};
});

View File

@ -1,4 +1,4 @@
import { applyActions, openDocument } from '$app/application/document/document_service';
import { applyActions, closeDocument, openDocument } from '$app/application/document/document.service';
import { slateNodesToInsertDelta } from '@slate-yjs/core';
import { convertToSlateValue } from '$app/components/editor/provider/utils/convert';
import { EventEmitter } from 'events';
@ -23,16 +23,16 @@ export class DataClient extends EventEmitter {
public disconnect() {
this.off('update', this.handleReceiveMessage);
void closeDocument(this.id);
void this.unsubscribe.then((unsubscribe) => unsubscribe());
}
public async getInsertDelta() {
public async getInsertDelta(includeRoot = true) {
const data = await openDocument(this.id);
this.rootId = data.rootId;
return slateNodesToInsertDelta(convertToSlateValue(data));
return slateNodesToInsertDelta(convertToSlateValue(data, includeRoot));
}
public on(event: 'change', listener: (events: YDelta) => void): this;

View File

@ -16,17 +16,17 @@ export class Provider extends EventEmitter {
idRelationMap: Y.Map<string> = this.document.getMap('idRelationMap');
sharedType: Y.XmlText | null = null;
dataClient: DataClient;
constructor(public id: string) {
constructor(public id: string, includeRoot?: boolean) {
super();
this.dataClient = new DataClient(id);
void this.initialDocument();
void this.initialDocument(includeRoot);
}
initialDocument = async () => {
initialDocument = async (includeRoot = true) => {
const sharedType = this.document.get('local', Y.XmlText) as Y.XmlText;
// Load the initial value into the yjs document
const delta = await this.dataClient.getInsertDelta();
const delta = await this.dataClient.getInsertDelta(includeRoot);
sharedType.applyDelta(delta);

View File

@ -163,7 +163,7 @@ export function YEvent2BlockActions(
return blockOps2BlockActions(sharedType, delta);
}
const actions = textOps2BlockActions(yXmlText, delta);
const actions = textOps2BlockActions(sharedType, yXmlText, delta);
if (keys.size > 0) {
actions.push(...parentUpdatedOps2BlockActions(yXmlText, keys));
@ -174,8 +174,19 @@ export function YEvent2BlockActions(
return actions;
}
function textOps2BlockActions(yXmlText: Y.XmlText, ops: YDelta): ReturnType<typeof BlockActionPB.prototype.toObject>[] {
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);
}

View File

@ -45,7 +45,7 @@ export function transformToInlineElement(op: Op): Element | null {
return null;
}
export function convertToSlateValue(data: EditorData): Element[] {
export function convertToSlateValue(data: EditorData, includeRoot: boolean): Element[] {
const nodes: Element[] = [];
const traverse = (id: string, level: number, isHidden?: boolean) => {
const node = data.nodeMap[id];
@ -63,7 +63,7 @@ export function convertToSlateValue(data: EditorData): Element[] {
};
const inlineNodes: (Text | Element)[] = delta
? data.deltaMap[id].map((op) => {
? delta.map((op) => {
const matchInline = transformToInlineElement(op);
if (matchInline) {
@ -105,7 +105,9 @@ export function convertToSlateValue(data: EditorData): Element[] {
traverse(rootId, 0);
nodes.shift();
if (!includeRoot) {
nodes.shift();
}
return nodes;
}

View File

@ -8,6 +8,8 @@ export function findPreviousSibling(yXmlText: Y.XmlText) {
const level = yXmlText.getAttribute('level');
if (!level) return null;
while (prev) {
const prevLevel = prev.getAttribute('level');

View File

@ -12,6 +12,8 @@ export function useLoadExpandedPages() {
const isTrash = useMemo(() => location.pathname.includes('trash'), [location.pathname]);
const currentPageId = params.id;
const pageMap = useAppSelector((state) => state.pages.pageMap);
const currentPage = currentPageId ? pageMap[currentPageId] : null;
const [pagePath, setPagePath] = useState<
(
| Page
@ -78,5 +80,6 @@ export function useLoadExpandedPages() {
return {
pagePath,
currentPage,
};
}

View File

@ -9,10 +9,10 @@ import { useTranslation } from 'react-i18next';
function Breadcrumb() {
const { t } = useTranslation();
const { pagePath } = useLoadExpandedPages();
const { pagePath, currentPage } = useLoadExpandedPages();
const navigate = useNavigate();
const activePage = useMemo(() => pagePath[pagePath.length - 1], [pagePath]);
const parentPages = useMemo(() => pagePath.slice(1, pagePath.length - 1).filter(Boolean) as Page[], [pagePath]);
const parentPages = useMemo(() => pagePath.slice(1, -1).filter(Boolean) as Page[], [pagePath]);
const navigateToPage = useCallback(
(page: Page) => {
const pageType = pageTypeMap[page.layout];
@ -36,7 +36,7 @@ function Breadcrumb() {
{page.name || t('document.title.placeholder')}
</Link>
))}
<Typography color='text.primary'>{activePage?.name || t('menuAppHeader.defaultNewPageName')}</Typography>
<Typography color='text.primary'>{currentPage?.name || t('menuAppHeader.defaultNewPageName')}</Typography>
</Breadcrumbs>
);
}

View File

@ -66,6 +66,8 @@ export const updatePageName = createAsyncThunk(
const { id, name } = payload;
const page = pageMap[id];
if (name === page.name) return;
dispatch(
pagesActions.onPageChanged({
...page,

View File

@ -65,6 +65,6 @@ export default defineConfig({
],
},
optimizeDeps: {
include: ['@mui/material/Tooltip', '@emotion/styled', '@mui/material/Unstable_Grid2'],
include: ['@mui/material/Tooltip'],
},
});

View File

@ -105,6 +105,9 @@ pub const FONT_STYLE: &str = "font-style";
pub const TEXT_DECORATION: &str = "text-decoration";
pub const BACKGROUND_COLOR: &str = "background-color";
pub const TRANSPARENT: &str = "transparent";
pub const DEFAULT_FONT_COLOR: &str = "rgb(0, 0, 0)";
pub const COLOR: &str = "color";
pub const LINE_THROUGH: &str = "line-through";

View File

@ -61,7 +61,13 @@ impl DocumentDataParser {
let mut children = vec![];
let mut start_found = false;
let mut end_found = false;
self.block_to_nested_block(root_id, &mut children, &mut start_found, &mut end_found)
self
.block_to_nested_block(root_id, &mut children, &mut start_found, &mut end_found)
.map(|mut root| {
root.data.clear();
root
})
}
fn block_to_nested_block(

View File

@ -422,9 +422,16 @@ fn get_attributes_with_style(style: &str) -> HashMap<String, Value> {
attributes.insert(STRIKETHROUGH.to_string(), Value::Bool(true));
},
BACKGROUND_COLOR => {
if value.eq(TRANSPARENT) {
continue;
}
attributes.insert(BG_COLOR.to_string(), Value::String(value.to_string()));
},
COLOR => {
if value.eq(DEFAULT_FONT_COLOR) {
continue;
}
attributes.insert(FONT_COLOR.to_string(), Value::String(value.to_string()));
},
_ => {},

View File

@ -5,6 +5,7 @@ use collab_document::blocks::DocumentData;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
use tracing::trace;
use validator::ValidationError;
pub fn get_delta_for_block(block_id: &str, data: &DocumentData) -> Option<Vec<InsertDelta>> {

View File

@ -6,7 +6,6 @@
"delta": [
{
"attributes": {
"bg_color": "transparent",
"font_color": "#000000"
},
"insert": "The Notion Document"
@ -23,7 +22,6 @@
"delta": [
{
"attributes": {
"bg_color": "transparent",
"font_color": "#000000"
},
"insert": "Heading-1"
@ -40,7 +38,6 @@
"delta": [
{
"attributes": {
"bg_color": "transparent",
"font_color": "#000000"
},
"insert": "Heading - 2"
@ -57,7 +54,6 @@
"delta": [
{
"attributes": {
"bg_color": "transparent",
"font_color": "#000000"
},
"insert": "Heading - 3"
@ -74,7 +70,6 @@
"delta": [
{
"attributes": {
"bg_color": "transparent",
"font_color": "#000000"
},
"insert": "Heading - 4"
@ -91,7 +86,6 @@
"delta": [
{
"attributes": {
"bg_color": "transparent",
"font_color": "#000000"
},
"insert": "This is a paragraph"
@ -107,7 +101,6 @@
"delta": [
{
"attributes": {
"bg_color": "transparent",
"font_color": "#000000"
},
"insert": "paragraphs child"
@ -123,7 +116,6 @@
"delta": [
{
"attributes": {
"bg_color": "transparent",
"font_color": "#000000"
},
"insert": "This is a bulleted list - 1"
@ -138,7 +130,6 @@
"delta": [
{
"attributes": {
"bg_color": "transparent",
"font_color": "#000000"
},
"insert": "This is a bulleted list - 1 - 1"
@ -153,7 +144,6 @@
"delta": [
{
"attributes": {
"bg_color": "transparent",
"font_color": "#000000"
},
"insert": "This is a bulleted list - 2"
@ -168,7 +158,6 @@
"delta": [
{
"attributes": {
"bg_color": "transparent",
"font_color": "#000000"
},
"insert": "This is a paragraph"
@ -193,7 +182,6 @@
"delta": [
{
"attributes": {
"bg_color": "transparent",
"font_color": "#000000"
},
"insert": "This is a todo - 1"
@ -225,7 +213,6 @@
"delta": [
{
"attributes": {
"bg_color": "transparent",
"font_color": "#000000"
},
"insert": "This is a todo - 1-1"
@ -248,7 +235,6 @@
"delta": [
{
"attributes": {
"bg_color": "transparent",
"font_color": "#000000"
},
"insert": "This is a paragraph"
@ -264,7 +250,6 @@
"delta": [
{
"attributes": {
"bg_color": "transparent",
"font_color": "#000000"
},
"insert": "This is a numbered list -1"
@ -279,7 +264,6 @@
"delta": [
{
"attributes": {
"bg_color": "transparent",
"font_color": "#000000"
},
"insert": "This is a numbered list -2"
@ -294,7 +278,6 @@
"delta": [
{
"attributes": {
"bg_color": "transparent",
"font_color": "#000000"
},
"insert": "This is a numbered list-1-1"
@ -309,7 +292,6 @@
"delta": [
{
"attributes": {
"bg_color": "transparent",
"font_color": "#000000"
},
"insert": "This is a paragraph"
@ -325,7 +307,6 @@
"delta": [
{
"attributes": {
"bg_color": "transparent",
"font_color": "#000000"
},
"insert": "This is a paragraph"