mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
parent
c3b183504f
commit
851296fa0e
@ -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
|
||||
|
@ -29,6 +29,11 @@
|
||||
"removeFile": true,
|
||||
"renameFile": true,
|
||||
"exists": true
|
||||
},
|
||||
"clipboard": {
|
||||
"all": true,
|
||||
"writeText": true,
|
||||
"readText": true
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
@ -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;
|
||||
}
|
@ -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',
|
||||
|
@ -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;
|
||||
}
|
@ -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`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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;
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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;
|
@ -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>
|
||||
);
|
||||
})
|
||||
|
@ -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>
|
||||
|
@ -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 (
|
||||
|
@ -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;
|
@ -0,0 +1 @@
|
||||
export * from './Page';
|
@ -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} />;
|
||||
};
|
||||
|
@ -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} />;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
@ -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:
|
||||
|
@ -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');
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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];
|
||||
|
@ -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 '';
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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)))))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -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] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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()]);
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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()),
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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');
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -65,6 +65,6 @@ export default defineConfig({
|
||||
],
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['@mui/material/Tooltip', '@emotion/styled', '@mui/material/Unstable_Grid2'],
|
||||
include: ['@mui/material/Tooltip'],
|
||||
},
|
||||
});
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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()));
|
||||
},
|
||||
_ => {},
|
||||
|
@ -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>> {
|
||||
|
@ -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": "paragraph’s 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"
|
||||
|
Loading…
Reference in New Issue
Block a user