mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-14 08:36:22 +08:00
merge branch develop into branch feat/doublelink220820
This commit is contained in:
@@ -1,22 +1,34 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { BlockEditor, AsyncBlock } from './editor';
|
||||
import { genErrorObj } from '@toeverything/utils';
|
||||
import { createContext, PropsWithChildren, useContext } from 'react';
|
||||
import type { AsyncBlock, BlockEditor } from './editor';
|
||||
|
||||
const RootContext = createContext<{
|
||||
type EditorProps = {
|
||||
editor: BlockEditor;
|
||||
// TODO: Temporary fix, dependencies in the new architecture are bottom-up, editors do not need to be passed down from the top
|
||||
editorElement: () => JSX.Element;
|
||||
}>(
|
||||
};
|
||||
|
||||
const EditorContext = createContext<EditorProps>(
|
||||
genErrorObj(
|
||||
'Failed to get context! The context only can use under the "render-root"'
|
||||
'Failed to get EditorContext! The context only can use under the "render-root"'
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as any
|
||||
);
|
||||
|
||||
export const EditorProvider = RootContext.Provider;
|
||||
|
||||
export const useEditor = () => {
|
||||
return useContext(RootContext);
|
||||
return useContext(EditorContext);
|
||||
};
|
||||
|
||||
export const EditorProvider = ({
|
||||
editor,
|
||||
editorElement,
|
||||
children,
|
||||
}: PropsWithChildren<EditorProps>) => {
|
||||
return (
|
||||
<EditorContext.Provider value={{ editor, editorElement }}>
|
||||
{children}
|
||||
</EditorContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,12 +4,12 @@ import {
|
||||
services,
|
||||
type ReturnUnobserve,
|
||||
} from '@toeverything/datasource/db-service';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { EditorProvider } from './Contexts';
|
||||
import type { BlockEditor } from './editor';
|
||||
import { useIsOnDrag } from './hooks';
|
||||
import { addNewGroup, appendNewGroup } from './recast-block';
|
||||
import { BlockRenderProvider, RenderBlock } from './render-block';
|
||||
import { SelectionRect, SelectionRef } from './Selection';
|
||||
|
||||
interface RenderRootProps {
|
||||
@@ -24,11 +24,7 @@ interface RenderRootProps {
|
||||
const MAX_PAGE_WIDTH = 5000;
|
||||
export const MIN_PAGE_WIDTH = 1480;
|
||||
|
||||
export const RenderRoot = ({
|
||||
editor,
|
||||
editorElement,
|
||||
children,
|
||||
}: PropsWithChildren<RenderRootProps>) => {
|
||||
export const RenderRoot = ({ editor, editorElement }: RenderRootProps) => {
|
||||
const selectionRef = useRef<SelectionRef>(null);
|
||||
const triggeredBySelect = useRef(false);
|
||||
const [pageWidth, setPageWidth] = useState<number>(MIN_PAGE_WIDTH);
|
||||
@@ -158,39 +154,43 @@ export const RenderRoot = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<EditorProvider value={{ editor, editorElement }}>
|
||||
<Container
|
||||
isEdgeless={editor.isEdgeless}
|
||||
ref={ref => {
|
||||
if (ref != null && ref !== editor.container) {
|
||||
editor.container = ref;
|
||||
editor.getHooks().render();
|
||||
}
|
||||
}}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseOut={onMouseOut}
|
||||
onContextMenu={onContextmenu}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyDownCapture={onKeyDownCapture}
|
||||
onKeyUp={onKeyUp}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDragOverCapture={onDragOverCapture}
|
||||
onDragEnd={onDragEnd}
|
||||
onDrop={onDrop}
|
||||
isOnDrag={isOnDrag}
|
||||
>
|
||||
<Content style={{ maxWidth: pageWidth + 'px' }}>
|
||||
{children}
|
||||
</Content>
|
||||
{/** TODO: remove selectionManager insert */}
|
||||
{editor && <SelectionRect ref={selectionRef} editor={editor} />}
|
||||
{editor.isEdgeless ? null : <ScrollBlank editor={editor} />}
|
||||
{patchedNodes}
|
||||
</Container>
|
||||
<EditorProvider editor={editor} editorElement={editorElement}>
|
||||
<BlockRenderProvider blockRender={RenderBlock}>
|
||||
<Container
|
||||
isEdgeless={editor.isEdgeless}
|
||||
ref={ref => {
|
||||
if (ref != null && ref !== editor.container) {
|
||||
editor.container = ref;
|
||||
editor.getHooks().render();
|
||||
}
|
||||
}}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseOut={onMouseOut}
|
||||
onContextMenu={onContextmenu}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyDownCapture={onKeyDownCapture}
|
||||
onKeyUp={onKeyUp}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDragOverCapture={onDragOverCapture}
|
||||
onDragEnd={onDragEnd}
|
||||
onDrop={onDrop}
|
||||
isOnDrag={isOnDrag}
|
||||
>
|
||||
<Content style={{ maxWidth: pageWidth + 'px' }}>
|
||||
<RenderBlock blockId={editor.getRootBlockId()} />
|
||||
</Content>
|
||||
{/** TODO: remove selectionManager insert */}
|
||||
{editor && (
|
||||
<SelectionRect ref={selectionRef} editor={editor} />
|
||||
)}
|
||||
{editor.isEdgeless ? null : <ScrollBlank editor={editor} />}
|
||||
{patchedNodes}
|
||||
</Container>
|
||||
</BlockRenderProvider>
|
||||
</EditorProvider>
|
||||
);
|
||||
};
|
||||
@@ -251,7 +251,7 @@ function ScrollBlank({ editor }: { editor: BlockEditor }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollBlankContainter
|
||||
<ScrollBlankContainer
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onClick={onClick}
|
||||
@@ -283,7 +283,7 @@ const Content = styled('div')({
|
||||
transitionTimingFunction: 'ease-in',
|
||||
});
|
||||
|
||||
const ScrollBlankContainter = styled('div')({
|
||||
const ScrollBlankContainer = styled('div')({
|
||||
paddingBottom: '30vh',
|
||||
margin: `0 -${PADDING_X}px`,
|
||||
});
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import type { AsyncBlock } from '../editor';
|
||||
import { containerFlavor } from '@toeverything/datasource/db-service';
|
||||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
type MouseEvent,
|
||||
type PropsWithChildren,
|
||||
} from 'react';
|
||||
import type { AsyncBlock, BlockEditor } from '../editor';
|
||||
import { getRecastItemValue, useRecastBlockMeta } from '../recast-block';
|
||||
import { PendantPopover } from './pendant-popover';
|
||||
import { PendantRender } from './pendant-render';
|
||||
import { useRef } from 'react';
|
||||
import { getRecastItemValue, useRecastBlockMeta } from '../recast-block';
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
interface BlockTagProps {
|
||||
editor: BlockEditor;
|
||||
block: AsyncBlock;
|
||||
}
|
||||
|
||||
export const BlockPendantProvider = ({
|
||||
editor,
|
||||
block,
|
||||
children,
|
||||
}: PropsWithChildren<BlockTagProps>) => {
|
||||
@@ -23,8 +30,23 @@ export const BlockPendantProvider = ({
|
||||
const showTriggerLine =
|
||||
properties.filter(property => getValue(property.id)).length === 0;
|
||||
|
||||
const onClick = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>) => {
|
||||
if (containerFlavor.includes(block.type)) {
|
||||
return;
|
||||
}
|
||||
if (e.target === e.currentTarget) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const middle = (rect.left + rect.right) / 2;
|
||||
const position = e.clientX < middle ? 'start' : 'end';
|
||||
editor.selectionManager.activeNodeByNodeId(block.id, position);
|
||||
}
|
||||
},
|
||||
[editor, block]
|
||||
);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Container onClick={onClick}>
|
||||
{children}
|
||||
|
||||
{showTriggerLine ? (
|
||||
|
||||
@@ -180,9 +180,49 @@ export class AsyncBlock {
|
||||
return this.event_emitter.emit(eventName, eventData);
|
||||
}
|
||||
|
||||
onUpdate(callback: (event: EventData) => void) {
|
||||
this.on('update', callback);
|
||||
/**
|
||||
* @param deep observe deep
|
||||
*
|
||||
* NOTICE: the observe of children is async,
|
||||
* so there maybe have some delay before observe done.
|
||||
*/
|
||||
onUpdate(callback: (event: EventData) => void, deep = 0) {
|
||||
let expired = false;
|
||||
const unobserveMap: Record<string, () => void> = {};
|
||||
const observeChildren = () => {
|
||||
if (deep <= 0) {
|
||||
return;
|
||||
}
|
||||
this.children().then(children => {
|
||||
// Check current event listeners is not expired
|
||||
if (expired) {
|
||||
return;
|
||||
}
|
||||
children.forEach(child => {
|
||||
if (unobserveMap[child.id]) return;
|
||||
const unobserve = child.onUpdate(callback, deep - 1);
|
||||
unobserveMap[child.id] = unobserve;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const unobserveChildren = () => {
|
||||
Object.values(unobserveMap).forEach(unobserve => {
|
||||
unobserve();
|
||||
});
|
||||
};
|
||||
|
||||
this.on('update', e => {
|
||||
callback(e);
|
||||
// Update children observe
|
||||
observeChildren();
|
||||
});
|
||||
|
||||
observeChildren();
|
||||
|
||||
return () => {
|
||||
expired = true;
|
||||
unobserveChildren();
|
||||
this.off('update', callback);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
Point,
|
||||
Selection as SlateSelection,
|
||||
} from 'slate';
|
||||
import { AsyncBlock } from '../block';
|
||||
import { Editor } from '../editor';
|
||||
import { SelectBlock } from '../selection';
|
||||
|
||||
type TextUtilsFunctions =
|
||||
| 'getString'
|
||||
@@ -43,7 +45,9 @@ type TextUtilsFunctions =
|
||||
| 'setSelection'
|
||||
| 'insertNodes'
|
||||
| 'getNodeByPath'
|
||||
| 'wrapLink';
|
||||
| 'wrapLink'
|
||||
| 'getNodeByRange'
|
||||
| 'convertLeaf2Html';
|
||||
|
||||
type ExtendedTextUtils = SlateUtils & {
|
||||
setLinkModalVisible: (visible: boolean) => void;
|
||||
@@ -104,15 +108,116 @@ export class BlockHelper {
|
||||
return '';
|
||||
}
|
||||
|
||||
public getBlockTextBetweenSelection(blockId: string) {
|
||||
public async isBlockEditable(blockOrBlockId: AsyncBlock | string) {
|
||||
const block =
|
||||
typeof blockOrBlockId === 'string'
|
||||
? await this._editor.getBlockById(blockOrBlockId)
|
||||
: blockOrBlockId;
|
||||
const blockView = this._editor.getView(block.type);
|
||||
|
||||
return blockView.editable;
|
||||
}
|
||||
|
||||
public async getFlatBlocksUnderParent(
|
||||
parentBlockId: string,
|
||||
includeParent = false
|
||||
): Promise<AsyncBlock[]> {
|
||||
const blocks = [];
|
||||
const block = await this._editor.getBlockById(parentBlockId);
|
||||
if (includeParent) {
|
||||
blocks.push(block);
|
||||
}
|
||||
const children = await block.children();
|
||||
(
|
||||
await Promise.all(
|
||||
children.map(child => {
|
||||
return this.getFlatBlocksUnderParent(child.id, true);
|
||||
})
|
||||
)
|
||||
).forEach(editableChildren => {
|
||||
blocks.push(...editableChildren);
|
||||
});
|
||||
return blocks;
|
||||
}
|
||||
|
||||
public getBlockTextBetweenSelection(
|
||||
blockId: string,
|
||||
shouldUsePreviousSelection = true
|
||||
) {
|
||||
const text_utils = this._blockTextUtilsMap[blockId];
|
||||
if (text_utils) {
|
||||
return text_utils.getStringBetweenSelection(true);
|
||||
return text_utils.getStringBetweenSelection(
|
||||
shouldUsePreviousSelection
|
||||
);
|
||||
}
|
||||
console.warn('Could find the block text utils');
|
||||
return '';
|
||||
}
|
||||
|
||||
public async getEditableBlockPropertiesBySelectInfo(
|
||||
block: AsyncBlock,
|
||||
selectInfo?: SelectBlock
|
||||
) {
|
||||
const properties = block.getProperties();
|
||||
if (properties.text.value.length === 0) {
|
||||
return properties;
|
||||
}
|
||||
const text_value = properties.text.value;
|
||||
|
||||
const {
|
||||
text: { value: originTextValue, ...otherTextProperties },
|
||||
...otherProperties
|
||||
} = properties;
|
||||
|
||||
// Use deepClone method will throw incomprehensible error
|
||||
let textValue = JSON.parse(JSON.stringify(originTextValue));
|
||||
|
||||
if (selectInfo?.endInfo) {
|
||||
textValue = textValue.slice(0, selectInfo.endInfo.arrayIndex + 1);
|
||||
textValue[textValue.length - 1].text = text_value[
|
||||
textValue.length - 1
|
||||
].text.substring(0, selectInfo.endInfo.offset);
|
||||
}
|
||||
if (selectInfo?.startInfo) {
|
||||
textValue = textValue.slice(selectInfo.startInfo.arrayIndex);
|
||||
textValue[0].text = textValue[0].text.substring(
|
||||
selectInfo.startInfo.offset
|
||||
);
|
||||
}
|
||||
return {
|
||||
...otherProperties,
|
||||
text: {
|
||||
...otherTextProperties,
|
||||
value: textValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// For editable blocks, the properties containing the selected text will be returned with the selection information
|
||||
public async getBlockPropertiesBySelectInfo(selectBlockInfo: SelectBlock) {
|
||||
const block = await this._editor.getBlockById(selectBlockInfo.blockId);
|
||||
const blockView = this._editor.getView(block.type);
|
||||
if (blockView.editable) {
|
||||
return this.getEditableBlockPropertiesBySelectInfo(
|
||||
block,
|
||||
selectBlockInfo
|
||||
);
|
||||
} else {
|
||||
return block?.getProperties();
|
||||
}
|
||||
}
|
||||
|
||||
public convertTextValue2Html(blockId: string, textValue: any) {
|
||||
const text_utils = this._blockTextUtilsMap[blockId];
|
||||
if (!text_utils) {
|
||||
return '';
|
||||
}
|
||||
return textValue.reduce((html: string, textValueItem: any) => {
|
||||
const fragment = text_utils.convertLeaf2Html(textValueItem);
|
||||
return `${html}${fragment}`;
|
||||
}, '');
|
||||
}
|
||||
|
||||
public setBlockBlur(blockId: string) {
|
||||
const text_utils = this._blockTextUtilsMap[blockId];
|
||||
if (text_utils) {
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { HooksRunner } from '../types';
|
||||
import { Editor } from '../editor';
|
||||
import ClipboardParse from './clipboard-parse';
|
||||
import { MarkdownParser } from './markdown-parse';
|
||||
import { shouldHandlerContinue } from './utils';
|
||||
import { Paste } from './paste';
|
||||
// todo needs to be a switch
|
||||
|
||||
enum ClipboardAction {
|
||||
COPY = 'copy',
|
||||
CUT = 'cut',
|
||||
PASTE = 'paste',
|
||||
}
|
||||
|
||||
//TODO: need to consider the cursor position after inserting the children
|
||||
class BrowserClipboard {
|
||||
private _eventTarget: Element;
|
||||
private _hooks: HooksRunner;
|
||||
private _editor: Editor;
|
||||
private _clipboardParse: ClipboardParse;
|
||||
private _markdownParse: MarkdownParser;
|
||||
private _paste: Paste;
|
||||
|
||||
constructor(eventTarget: Element, hooks: HooksRunner, editor: Editor) {
|
||||
this._eventTarget = eventTarget;
|
||||
this._hooks = hooks;
|
||||
this._editor = editor;
|
||||
this._clipboardParse = new ClipboardParse(editor);
|
||||
this._markdownParse = new MarkdownParser();
|
||||
this._paste = new Paste(
|
||||
editor,
|
||||
this._clipboardParse,
|
||||
this._markdownParse
|
||||
);
|
||||
this._initialize();
|
||||
}
|
||||
|
||||
public getClipboardParse() {
|
||||
return this._clipboardParse;
|
||||
}
|
||||
|
||||
private _initialize() {
|
||||
this._handleCopy = this._handleCopy.bind(this);
|
||||
this._handleCut = this._handleCut.bind(this);
|
||||
|
||||
document.addEventListener(ClipboardAction.COPY, this._handleCopy);
|
||||
document.addEventListener(ClipboardAction.CUT, this._handleCut);
|
||||
document.addEventListener(
|
||||
ClipboardAction.PASTE,
|
||||
this._paste.handlePaste
|
||||
);
|
||||
this._eventTarget.addEventListener(
|
||||
ClipboardAction.COPY,
|
||||
this._handleCopy
|
||||
);
|
||||
this._eventTarget.addEventListener(
|
||||
ClipboardAction.CUT,
|
||||
this._handleCut
|
||||
);
|
||||
this._eventTarget.addEventListener(
|
||||
ClipboardAction.PASTE,
|
||||
this._paste.handlePaste
|
||||
);
|
||||
}
|
||||
|
||||
private _handleCopy(e: Event) {
|
||||
if (!shouldHandlerContinue(e, this._editor)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._dispatchClipboardEvent(ClipboardAction.COPY, e as ClipboardEvent);
|
||||
}
|
||||
|
||||
private _handleCut(e: Event) {
|
||||
if (!shouldHandlerContinue(e, this._editor)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._dispatchClipboardEvent(ClipboardAction.CUT, e as ClipboardEvent);
|
||||
}
|
||||
|
||||
private _preCopyCut(action: ClipboardAction, e: ClipboardEvent) {
|
||||
switch (action) {
|
||||
case ClipboardAction.COPY:
|
||||
this._hooks.beforeCopy(e);
|
||||
break;
|
||||
|
||||
case ClipboardAction.CUT:
|
||||
this._hooks.beforeCut(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private _dispatchClipboardEvent(
|
||||
action: ClipboardAction,
|
||||
e: ClipboardEvent
|
||||
) {
|
||||
this._preCopyCut(action, e);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
document.removeEventListener(ClipboardAction.COPY, this._handleCopy);
|
||||
document.removeEventListener(ClipboardAction.CUT, this._handleCut);
|
||||
document.removeEventListener(
|
||||
ClipboardAction.PASTE,
|
||||
this._paste.handlePaste
|
||||
);
|
||||
this._eventTarget.removeEventListener(
|
||||
ClipboardAction.COPY,
|
||||
this._handleCopy
|
||||
);
|
||||
this._eventTarget.removeEventListener(
|
||||
ClipboardAction.CUT,
|
||||
this._handleCut
|
||||
);
|
||||
this._eventTarget.removeEventListener(
|
||||
ClipboardAction.PASTE,
|
||||
this._paste.handlePaste
|
||||
);
|
||||
this._clipboardParse.dispose();
|
||||
this._clipboardParse = null;
|
||||
this._eventTarget = null;
|
||||
this._hooks = null;
|
||||
this._editor = null;
|
||||
}
|
||||
}
|
||||
|
||||
export { BrowserClipboard };
|
||||
@@ -1,207 +0,0 @@
|
||||
import { Protocol, BlockFlavorKeys } from '@toeverything/datasource/db-service';
|
||||
import { escape } from '@toeverything/utils';
|
||||
import { Editor } from '../editor';
|
||||
import { SelectBlock } from '../selection';
|
||||
import { ClipBlockInfo } from './types';
|
||||
|
||||
class DefaultBlockParse {
|
||||
public static html2block(el: Element): ClipBlockInfo[] | undefined | null {
|
||||
const tag_name = el.tagName;
|
||||
if (tag_name === 'DIV' || el instanceof Text) {
|
||||
return el.textContent?.split('\n').map(str => {
|
||||
const data = {
|
||||
text: escape(str),
|
||||
};
|
||||
return {
|
||||
type: 'text',
|
||||
properties: {
|
||||
text: { value: [data] },
|
||||
},
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default class ClipboardParse {
|
||||
private editor: Editor;
|
||||
private static block_types: BlockFlavorKeys[] = [
|
||||
Protocol.Block.Type.page,
|
||||
Protocol.Block.Type.reference,
|
||||
Protocol.Block.Type.heading1,
|
||||
Protocol.Block.Type.heading2,
|
||||
Protocol.Block.Type.heading3,
|
||||
Protocol.Block.Type.quote,
|
||||
Protocol.Block.Type.todo,
|
||||
Protocol.Block.Type.code,
|
||||
Protocol.Block.Type.text,
|
||||
Protocol.Block.Type.toc,
|
||||
Protocol.Block.Type.file,
|
||||
Protocol.Block.Type.image,
|
||||
Protocol.Block.Type.divider,
|
||||
Protocol.Block.Type.callout,
|
||||
Protocol.Block.Type.youtube,
|
||||
Protocol.Block.Type.figma,
|
||||
Protocol.Block.Type.group,
|
||||
Protocol.Block.Type.embedLink,
|
||||
Protocol.Block.Type.numbered,
|
||||
Protocol.Block.Type.bullet,
|
||||
];
|
||||
private static break_flags: Set<string> = new Set([
|
||||
'BLOCKQUOTE',
|
||||
'BODY',
|
||||
'CENTER',
|
||||
'DD',
|
||||
'DIR',
|
||||
'DIV',
|
||||
'DL',
|
||||
'DT',
|
||||
'FORM',
|
||||
'H1',
|
||||
'H2',
|
||||
'H3',
|
||||
'H4',
|
||||
'H5',
|
||||
'H6',
|
||||
'HEAD',
|
||||
'HTML',
|
||||
'ISINDEX',
|
||||
'MENU',
|
||||
'NOFRAMES',
|
||||
'P',
|
||||
'PRE',
|
||||
'TABLE',
|
||||
'TD',
|
||||
'TH',
|
||||
'TITLE',
|
||||
'TR',
|
||||
]);
|
||||
|
||||
constructor(editor: Editor) {
|
||||
this.editor = editor;
|
||||
this.generate_html = this.generate_html.bind(this);
|
||||
this.parse_dom = this.parse_dom.bind(this);
|
||||
}
|
||||
// TODO: escape
|
||||
public text2blocks(clipData: string): ClipBlockInfo[] {
|
||||
return (clipData || '').split('\n').map((str: string) => {
|
||||
const block_info: ClipBlockInfo = {
|
||||
type: 'text',
|
||||
properties: {
|
||||
text: { value: [{ text: str }] },
|
||||
},
|
||||
children: [] as ClipBlockInfo[],
|
||||
};
|
||||
return block_info;
|
||||
});
|
||||
}
|
||||
|
||||
public html2blocks(clipData: string): ClipBlockInfo[] {
|
||||
return this.common_html2blocks(clipData);
|
||||
}
|
||||
|
||||
private common_html2blocks(clipData: string): ClipBlockInfo[] {
|
||||
const html_el = document.createElement('html');
|
||||
html_el.innerHTML = clipData;
|
||||
return this.parse_dom(html_el);
|
||||
}
|
||||
|
||||
// tTODO:odo escape
|
||||
private parse_dom(el: Element): ClipBlockInfo[] {
|
||||
for (let i = 0; i < ClipboardParse.block_types.length; i++) {
|
||||
const block_utils = this.editor.getView(
|
||||
ClipboardParse.block_types[i]
|
||||
);
|
||||
const blocks = block_utils?.html2block?.(el, this.parse_dom);
|
||||
|
||||
if (blocks) {
|
||||
return blocks;
|
||||
}
|
||||
}
|
||||
const blocks: ClipBlockInfo[] = [];
|
||||
// blocks = DefaultBlockParse.html2block(el);
|
||||
for (let i = 0; i < el.childNodes.length; i++) {
|
||||
const child = el.childNodes[i];
|
||||
const last_block_info =
|
||||
blocks.length === 0 ? null : blocks[blocks.length - 1];
|
||||
let blocks_info = this.parse_dom(child as Element);
|
||||
if (
|
||||
last_block_info &&
|
||||
last_block_info.type === 'text' &&
|
||||
!ClipboardParse.break_flags.has((child as Element).tagName)
|
||||
) {
|
||||
const texts = last_block_info.properties?.text?.value || [];
|
||||
let j = 0;
|
||||
for (; j < blocks_info.length; j++) {
|
||||
const block = blocks_info[j];
|
||||
if (block.type === 'text') {
|
||||
const block_texts = block.properties.text.value;
|
||||
texts.push(...block_texts);
|
||||
}
|
||||
}
|
||||
last_block_info.properties = {
|
||||
text: { value: texts },
|
||||
};
|
||||
blocks_info = blocks_info.slice(j);
|
||||
}
|
||||
blocks.push(...blocks_info);
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
public async generateHtml(): Promise<string> {
|
||||
const select_info = await this.editor.selectionManager.getSelectInfo();
|
||||
return await this.generate_html(select_info.blocks);
|
||||
}
|
||||
|
||||
public async page2html(): Promise<string> {
|
||||
const root_block_id = this.editor.getRootBlockId();
|
||||
if (!root_block_id) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const block_info = await this.get_select_info(root_block_id);
|
||||
return await this.generate_html([block_info]);
|
||||
}
|
||||
|
||||
private async get_select_info(blockId: string) {
|
||||
const block = await this.editor.getBlockById(blockId);
|
||||
if (!block) return null;
|
||||
const block_info: SelectBlock = {
|
||||
blockId: block.id,
|
||||
children: [],
|
||||
};
|
||||
const children_ids = block.childrenIds;
|
||||
for (let i = 0; i < children_ids.length; i++) {
|
||||
block_info.children.push(
|
||||
await this.get_select_info(children_ids[i])
|
||||
);
|
||||
}
|
||||
return block_info;
|
||||
}
|
||||
|
||||
private async generate_html(selectBlocks: SelectBlock[]): Promise<string> {
|
||||
let result = '';
|
||||
for (let i = 0; i < selectBlocks.length; i++) {
|
||||
const sel_block = selectBlocks[i];
|
||||
if (!sel_block || !sel_block.blockId) continue;
|
||||
const block = await this.editor.getBlockById(sel_block.blockId);
|
||||
if (!block) continue;
|
||||
const block_utils = this.editor.getView(block.type);
|
||||
const html = await block_utils.block2html(
|
||||
block,
|
||||
sel_block.children,
|
||||
this.generate_html
|
||||
);
|
||||
result += html;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.editor = null;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Editor } from '../editor';
|
||||
import { SelectionManager } from '../selection';
|
||||
import { HookType, PluginHooks } from '../types';
|
||||
import ClipboardParse from './clipboard-parse';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { Copy } from './copy';
|
||||
class ClipboardPopulator {
|
||||
private _editor: Editor;
|
||||
private _hooks: PluginHooks;
|
||||
private _selectionManager: SelectionManager;
|
||||
private _clipboardParse: ClipboardParse;
|
||||
private _sub = new Subscription();
|
||||
private _copy: Copy;
|
||||
constructor(
|
||||
editor: Editor,
|
||||
hooks: PluginHooks,
|
||||
selectionManager: SelectionManager
|
||||
) {
|
||||
this._editor = editor;
|
||||
this._hooks = hooks;
|
||||
this._selectionManager = selectionManager;
|
||||
this._clipboardParse = new ClipboardParse(editor);
|
||||
this._copy = new Copy(editor);
|
||||
this._sub.add(
|
||||
hooks.get(HookType.BEFORE_COPY).subscribe(this._copy.handleCopy)
|
||||
);
|
||||
this._sub.add(
|
||||
hooks.get(HookType.BEFORE_CUT).subscribe(this._copy.handleCopy)
|
||||
);
|
||||
}
|
||||
|
||||
disposeInternal() {
|
||||
this._sub.unsubscribe();
|
||||
this._hooks = null;
|
||||
}
|
||||
}
|
||||
|
||||
export { ClipboardPopulator };
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ClipboardEventDispatcher } from './clipboardEventDispatcher';
|
||||
import { HookType } from '../types';
|
||||
import { Editor } from '../editor';
|
||||
import { Copy } from './copy';
|
||||
import { Paste } from './paste';
|
||||
|
||||
import { ClipboardUtils } from './clipboardUtils';
|
||||
|
||||
export class Clipboard {
|
||||
private _clipboardEventDispatcher: ClipboardEventDispatcher;
|
||||
private _copy: Copy;
|
||||
private _paste: Paste;
|
||||
public clipboardUtils: ClipboardUtils;
|
||||
private _clipboardTarget: HTMLElement;
|
||||
|
||||
constructor(editor: Editor, clipboardTarget: HTMLElement) {
|
||||
this.clipboardUtils = new ClipboardUtils(editor);
|
||||
this._clipboardTarget = clipboardTarget;
|
||||
this._copy = new Copy(editor);
|
||||
|
||||
this._paste = new Paste(editor);
|
||||
|
||||
this._clipboardEventDispatcher = new ClipboardEventDispatcher(
|
||||
editor,
|
||||
this._clipboardTarget
|
||||
);
|
||||
|
||||
editor
|
||||
.getHooks()
|
||||
.get(HookType.ON_COPY)
|
||||
.subscribe(this._copy.handleCopy);
|
||||
|
||||
editor.getHooks().get(HookType.ON_CUT).subscribe(this._copy.handleCopy);
|
||||
|
||||
editor
|
||||
.getHooks()
|
||||
.get(HookType.ON_PASTE)
|
||||
.subscribe(this._paste.handlePaste);
|
||||
}
|
||||
|
||||
set clipboardTarget(clipboardTarget: HTMLElement) {
|
||||
this._clipboardTarget = clipboardTarget;
|
||||
this._clipboardEventDispatcher.initialClipboardTargetEvent(
|
||||
this._clipboardTarget
|
||||
);
|
||||
}
|
||||
get clipboardTarget() {
|
||||
return this._clipboardTarget;
|
||||
}
|
||||
|
||||
public clipboardEvent2Blocks(e: ClipboardEvent) {
|
||||
return this._paste.clipboardEvent2Blocks(e);
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this._clipboardEventDispatcher.dispose(this.clipboardTarget);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Editor } from '../editor';
|
||||
import { ClipboardUtils } from './clipboardUtils';
|
||||
|
||||
enum ClipboardAction {
|
||||
copy = 'copy',
|
||||
cut = 'cut',
|
||||
paste = 'paste',
|
||||
}
|
||||
|
||||
export class ClipboardEventDispatcher {
|
||||
private _editor: Editor;
|
||||
private _utils: ClipboardUtils;
|
||||
|
||||
constructor(editor: Editor, clipboardTarget: HTMLElement) {
|
||||
this._editor = editor;
|
||||
this._utils = new ClipboardUtils(editor);
|
||||
|
||||
this._copyHandler = this._copyHandler.bind(this);
|
||||
this._cutHandler = this._cutHandler.bind(this);
|
||||
this._pasteHandler = this._pasteHandler.bind(this);
|
||||
|
||||
this.initialDocumentEvent();
|
||||
if (clipboardTarget) {
|
||||
this.initialClipboardTargetEvent(clipboardTarget);
|
||||
}
|
||||
}
|
||||
initialDocumentEvent() {
|
||||
this.disposeDocumentEvent();
|
||||
|
||||
document.addEventListener(ClipboardAction.copy, this._copyHandler);
|
||||
document.addEventListener(ClipboardAction.cut, this._cutHandler);
|
||||
document.addEventListener(ClipboardAction.paste, this._pasteHandler);
|
||||
}
|
||||
initialClipboardTargetEvent(clipboardTarget: HTMLElement) {
|
||||
if (!clipboardTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.disposeClipboardTargetEvent(clipboardTarget);
|
||||
|
||||
clipboardTarget.addEventListener(
|
||||
ClipboardAction.copy,
|
||||
this._copyHandler
|
||||
);
|
||||
clipboardTarget.addEventListener(ClipboardAction.cut, this._cutHandler);
|
||||
clipboardTarget.addEventListener(
|
||||
ClipboardAction.paste,
|
||||
this._pasteHandler
|
||||
);
|
||||
}
|
||||
disposeDocumentEvent() {
|
||||
document.removeEventListener(ClipboardAction.copy, this._copyHandler);
|
||||
document.removeEventListener(ClipboardAction.cut, this._cutHandler);
|
||||
document.removeEventListener(ClipboardAction.paste, this._pasteHandler);
|
||||
}
|
||||
disposeClipboardTargetEvent(clipboardTarget: HTMLElement) {
|
||||
if (!clipboardTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
clipboardTarget.removeEventListener(
|
||||
ClipboardAction.copy,
|
||||
this._copyHandler
|
||||
);
|
||||
clipboardTarget.removeEventListener(
|
||||
ClipboardAction.cut,
|
||||
this._cutHandler
|
||||
);
|
||||
clipboardTarget.removeEventListener(
|
||||
ClipboardAction.paste,
|
||||
this._pasteHandler
|
||||
);
|
||||
}
|
||||
|
||||
private async _copyHandler(e: ClipboardEvent) {
|
||||
if (!(await this._utils.shouldHandlerContinue(e))) {
|
||||
return;
|
||||
}
|
||||
this._editor.getHooks().onCopy(e);
|
||||
}
|
||||
|
||||
private async _cutHandler(e: ClipboardEvent) {
|
||||
if (!(await this._utils.shouldHandlerContinue(e))) {
|
||||
return;
|
||||
}
|
||||
this._editor.getHooks().onCut(e);
|
||||
}
|
||||
private async _pasteHandler(e: ClipboardEvent) {
|
||||
if (!(await this._utils.shouldHandlerContinue(e))) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._editor.getHooks().onPaste(e);
|
||||
}
|
||||
|
||||
dispose(clipboardTarget: HTMLElement) {
|
||||
this.disposeDocumentEvent();
|
||||
this.disposeClipboardTargetEvent(clipboardTarget);
|
||||
this._editor = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { Editor } from '../editor';
|
||||
import { SelectBlock, SelectInfo } from '../selection';
|
||||
import { AsyncBlock } from '../block';
|
||||
import { ClipBlockInfo, OFFICE_CLIPBOARD_MIMETYPE } from './types';
|
||||
import { Clip } from './clip';
|
||||
import { commonHTML2Block, commonHTML2Text } from './utils';
|
||||
|
||||
export class ClipboardUtils {
|
||||
private _editor: Editor;
|
||||
constructor(editor: Editor) {
|
||||
this._editor = editor;
|
||||
}
|
||||
|
||||
async shouldHandlerContinue(event: ClipboardEvent) {
|
||||
const filterNodes = ['INPUT', 'SELECT', 'TEXTAREA'];
|
||||
|
||||
if (event.defaultPrevented) {
|
||||
return false;
|
||||
}
|
||||
if (filterNodes.includes((event.target as HTMLElement)?.tagName)) {
|
||||
return false;
|
||||
}
|
||||
const selectInfo = await this._editor.selectionManager.getSelectInfo();
|
||||
|
||||
return (
|
||||
selectInfo.blocks.length &&
|
||||
this._editor.selectionManager.currentSelectInfo.type !== 'None'
|
||||
);
|
||||
}
|
||||
|
||||
async getClipInfoOfBlockById(blockId: string) {
|
||||
const block = await this._editor.getBlockById(blockId);
|
||||
const blockInfo: ClipBlockInfo = {
|
||||
type: block.type,
|
||||
properties: block?.getProperties(),
|
||||
children: [] as ClipBlockInfo[],
|
||||
};
|
||||
const children = (await block?.children()) ?? [];
|
||||
|
||||
await Promise.all(
|
||||
children.map(async childBlock => {
|
||||
const childInfo = await this.getClipInfoOfBlockById(
|
||||
childBlock.id
|
||||
);
|
||||
blockInfo.children.push(childInfo);
|
||||
})
|
||||
);
|
||||
|
||||
return blockInfo;
|
||||
}
|
||||
|
||||
async getClipDataOfBlocksById(blockIds: string[]) {
|
||||
const clipInfos = await Promise.all(
|
||||
blockIds.map(blockId => this.getClipInfoOfBlockById(blockId))
|
||||
);
|
||||
|
||||
return new Clip(
|
||||
OFFICE_CLIPBOARD_MIMETYPE.DOCS_DOCUMENT_SLICE_CLIP_WRAPPED,
|
||||
JSON.stringify({
|
||||
data: clipInfos,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async getClipInfoOfBlockBySelectInfo(selectBlockInfo: SelectBlock) {
|
||||
const block = await this._editor.getBlockById(selectBlockInfo.blockId);
|
||||
const blockInfo: ClipBlockInfo = {
|
||||
type: block?.type,
|
||||
properties:
|
||||
await this._editor.blockHelper.getBlockPropertiesBySelectInfo(
|
||||
selectBlockInfo
|
||||
),
|
||||
// Editable has no children
|
||||
children: [],
|
||||
};
|
||||
return blockInfo;
|
||||
}
|
||||
|
||||
async getClipDataOfBlocksBySelectInfo(selectInfo: SelectInfo) {
|
||||
const clipInfos = await Promise.all(
|
||||
selectInfo.blocks.map(selectBlockInfo =>
|
||||
this.getClipInfoOfBlockBySelectInfo(selectBlockInfo)
|
||||
)
|
||||
);
|
||||
return new Clip(
|
||||
OFFICE_CLIPBOARD_MIMETYPE.DOCS_DOCUMENT_SLICE_CLIP_WRAPPED,
|
||||
JSON.stringify({
|
||||
data: clipInfos,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async convertTextValue2HtmlBySelectInfo(
|
||||
blockOrBlockId: AsyncBlock | string,
|
||||
selectBlockInfo?: SelectBlock
|
||||
) {
|
||||
const block =
|
||||
typeof blockOrBlockId === 'string'
|
||||
? await this._editor.getBlockById(blockOrBlockId)
|
||||
: blockOrBlockId;
|
||||
const selectedProperties =
|
||||
await this._editor.blockHelper.getEditableBlockPropertiesBySelectInfo(
|
||||
block,
|
||||
selectBlockInfo
|
||||
);
|
||||
return this._editor.blockHelper.convertTextValue2Html(
|
||||
block.id,
|
||||
selectedProperties.text.value
|
||||
);
|
||||
}
|
||||
async convertBlock2HtmlBySelectInfos(
|
||||
blockOrBlockId: AsyncBlock | string,
|
||||
selectBlockInfos?: SelectBlock[]
|
||||
) {
|
||||
if (!selectBlockInfos) {
|
||||
const block =
|
||||
typeof blockOrBlockId === 'string'
|
||||
? await this._editor.getBlockById(blockOrBlockId)
|
||||
: blockOrBlockId;
|
||||
const children = await block?.children();
|
||||
return (
|
||||
await Promise.all(
|
||||
children.map(async childBlock => {
|
||||
const blockView = this._editor.getView(childBlock.type);
|
||||
return await blockView.block2html({
|
||||
editor: this._editor,
|
||||
block: childBlock,
|
||||
});
|
||||
})
|
||||
)
|
||||
).join('');
|
||||
}
|
||||
|
||||
return (
|
||||
await Promise.all(
|
||||
selectBlockInfos.map(async selectBlockInfo => {
|
||||
const block = await this._editor.getBlockById(
|
||||
selectBlockInfo.blockId
|
||||
);
|
||||
const blockView = this._editor.getView(block.type);
|
||||
return await blockView.block2html({
|
||||
editor: this._editor,
|
||||
block,
|
||||
selectInfo: selectBlockInfo,
|
||||
});
|
||||
})
|
||||
)
|
||||
).join('');
|
||||
}
|
||||
|
||||
async convertHTMLString2Blocks(html: string): Promise<ClipBlockInfo[]> {
|
||||
const htmlEl = document.createElement('html');
|
||||
htmlEl.innerHTML = html;
|
||||
htmlEl.querySelector('head')?.remove();
|
||||
|
||||
return this.convertHtml2Blocks(htmlEl);
|
||||
}
|
||||
async convertHtml2Blocks(element: Element): Promise<ClipBlockInfo[]> {
|
||||
const editableViews = this._editor.getEditableViews();
|
||||
// 如果block能够捕捉htmlElement则返回block的html2block
|
||||
const [clipBlockInfos] = (
|
||||
await Promise.all(
|
||||
editableViews.map(editableView => {
|
||||
return editableView?.html2block?.({
|
||||
editor: this._editor,
|
||||
element: element,
|
||||
});
|
||||
})
|
||||
)
|
||||
).filter(v => v && v.length);
|
||||
|
||||
if (clipBlockInfos) {
|
||||
return clipBlockInfos;
|
||||
}
|
||||
return (
|
||||
await Promise.all(
|
||||
Array.from(element.children).map(async childElement => {
|
||||
const clipBlockInfos = await this.convertHtml2Blocks(
|
||||
childElement
|
||||
);
|
||||
|
||||
if (clipBlockInfos && clipBlockInfos.length) {
|
||||
return clipBlockInfos;
|
||||
}
|
||||
|
||||
return this.commonHTML2Block(childElement);
|
||||
})
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
.filter(v => v);
|
||||
}
|
||||
|
||||
commonHTML2Block = commonHTML2Block;
|
||||
|
||||
commonHTML2Text = commonHTML2Text;
|
||||
|
||||
textToBlock(clipData = ''): ClipBlockInfo[] {
|
||||
return clipData.split('\n').map((str: string) => {
|
||||
return {
|
||||
type: 'text',
|
||||
properties: {
|
||||
text: { value: [{ text: str }] },
|
||||
},
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async page2html() {
|
||||
const rootBlockId = this._editor.getRootBlockId();
|
||||
if (!rootBlockId) {
|
||||
return '';
|
||||
}
|
||||
const rootBlock = await this._editor.getBlockById(rootBlockId);
|
||||
const blockView = this._editor.getView(rootBlock.type);
|
||||
|
||||
return await blockView.block2html({
|
||||
editor: this._editor,
|
||||
block: rootBlock,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,13 @@ import { Editor } from '../editor';
|
||||
import { SelectInfo } from '../selection';
|
||||
import { OFFICE_CLIPBOARD_MIMETYPE } from './types';
|
||||
import { Clip } from './clip';
|
||||
import ClipboardParse from './clipboard-parse';
|
||||
import { getClipDataOfBlocksById } from './utils';
|
||||
import { copyToClipboard } from '@toeverything/utils';
|
||||
import { ClipboardUtils } from './clipboardUtils';
|
||||
class Copy {
|
||||
private _editor: Editor;
|
||||
private _clipboardParse: ClipboardParse;
|
||||
|
||||
private _utils: ClipboardUtils;
|
||||
constructor(editor: Editor) {
|
||||
this._editor = editor;
|
||||
this._clipboardParse = new ClipboardParse(editor);
|
||||
|
||||
this._utils = new ClipboardUtils(editor);
|
||||
this.handleCopy = this.handleCopy.bind(this);
|
||||
}
|
||||
public async handleCopy(e: ClipboardEvent) {
|
||||
@@ -22,7 +18,6 @@ class Copy {
|
||||
if (!clips.length) {
|
||||
return;
|
||||
}
|
||||
// TODO: is not compatible with safari
|
||||
const success = this._copyToClipboardFromPc(clips);
|
||||
if (!success) {
|
||||
// This way, not compatible with firefox
|
||||
@@ -49,24 +44,113 @@ class Copy {
|
||||
const affineClip = await this._getAffineClip();
|
||||
clips.push(affineClip);
|
||||
|
||||
// get common html clip
|
||||
const htmlClip = await this._clipboardParse.generateHtml();
|
||||
htmlClip &&
|
||||
clips.push(new Clip(OFFICE_CLIPBOARD_MIMETYPE.HTML, htmlClip));
|
||||
const textClip = await this._getTextClip();
|
||||
clips.push(textClip);
|
||||
|
||||
const htmlClip = await this._getHtmlClip();
|
||||
|
||||
clips.push(htmlClip);
|
||||
|
||||
return clips;
|
||||
}
|
||||
|
||||
private async _getHtmlClip(): Promise<Clip> {
|
||||
const selectInfo: SelectInfo =
|
||||
await this._editor.selectionManager.getSelectInfo();
|
||||
|
||||
const htmlStr = (
|
||||
await Promise.all(
|
||||
selectInfo.blocks.map(async selectBlockInfo => {
|
||||
const block = await this._editor.getBlockById(
|
||||
selectBlockInfo.blockId
|
||||
);
|
||||
const blockView = this._editor.getView(block.type);
|
||||
return await blockView.block2html({
|
||||
editor: this._editor,
|
||||
block,
|
||||
selectInfo: selectBlockInfo,
|
||||
});
|
||||
})
|
||||
)
|
||||
).join('');
|
||||
|
||||
return new Clip(OFFICE_CLIPBOARD_MIMETYPE.HTML, htmlStr);
|
||||
}
|
||||
|
||||
private async _getAffineClip(): Promise<Clip> {
|
||||
const selectInfo: SelectInfo =
|
||||
await this._editor.selectionManager.getSelectInfo();
|
||||
|
||||
return getClipDataOfBlocksById(
|
||||
this._editor,
|
||||
if (selectInfo.type === 'Range') {
|
||||
return this._utils.getClipDataOfBlocksBySelectInfo(selectInfo);
|
||||
}
|
||||
|
||||
// The only remaining case is that selectInfo.type === 'Block'
|
||||
return this._utils.getClipDataOfBlocksById(
|
||||
selectInfo.blocks.map(block => block.blockId)
|
||||
);
|
||||
}
|
||||
|
||||
private async _getTextClip(): Promise<Clip> {
|
||||
const selectInfo: SelectInfo =
|
||||
await this._editor.selectionManager.getSelectInfo();
|
||||
|
||||
if (selectInfo.type === 'Range') {
|
||||
const text = (
|
||||
await Promise.all(
|
||||
selectInfo.blocks.map(async selectBlockInfo => {
|
||||
const block = await this._editor.getBlockById(
|
||||
selectBlockInfo.blockId
|
||||
);
|
||||
const blockView = this._editor.getView(block.type);
|
||||
const block2Text = await blockView.block2Text(
|
||||
block,
|
||||
selectBlockInfo
|
||||
);
|
||||
|
||||
return (
|
||||
block2Text ||
|
||||
this._editor.blockHelper.getBlockTextBetweenSelection(
|
||||
selectBlockInfo.blockId,
|
||||
false
|
||||
)
|
||||
);
|
||||
})
|
||||
)
|
||||
).join('\n');
|
||||
return new Clip(OFFICE_CLIPBOARD_MIMETYPE.TEXT, text);
|
||||
}
|
||||
|
||||
// The only remaining case is that selectInfo.type === 'Block'
|
||||
const selectedBlocks = (
|
||||
await Promise.all(
|
||||
selectInfo.blocks.map(selectBlockInfo =>
|
||||
this._editor.blockHelper.getFlatBlocksUnderParent(
|
||||
selectBlockInfo.blockId,
|
||||
true
|
||||
)
|
||||
)
|
||||
)
|
||||
).flat();
|
||||
|
||||
const blockText = (
|
||||
await Promise.all(
|
||||
selectedBlocks.map(async block => {
|
||||
const blockView = this._editor.getView(block.type);
|
||||
const block2Text = await blockView.block2Text(block);
|
||||
return (
|
||||
block2Text ||
|
||||
this._editor.blockHelper.getBlockText(block.id)
|
||||
);
|
||||
})
|
||||
)
|
||||
).join('\n');
|
||||
|
||||
return new Clip(OFFICE_CLIPBOARD_MIMETYPE.TEXT, blockText);
|
||||
}
|
||||
|
||||
// TODO: Optimization
|
||||
// TODO: is not compatible with safari
|
||||
private _copyToClipboardFromPc(clips: any[]) {
|
||||
let success = false;
|
||||
const tempElem = document.createElement('textarea');
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Clipboard } from './clipboard';
|
||||
export type { ClipBlockInfo, HTML2BlockResult } from './types';
|
||||
@@ -6,17 +6,11 @@ import {
|
||||
} from './types';
|
||||
import { Editor } from '../editor';
|
||||
import { AsyncBlock } from '../block';
|
||||
import ClipboardParse from './clipboard-parse';
|
||||
import { SelectInfo } from '../selection';
|
||||
import {
|
||||
Protocol,
|
||||
BlockFlavorKeys,
|
||||
services,
|
||||
} from '@toeverything/datasource/db-service';
|
||||
import { services } from '@toeverything/datasource/db-service';
|
||||
import { MarkdownParser } from './markdown-parse';
|
||||
import { shouldHandlerContinue } from './utils';
|
||||
const SUPPORT_MARKDOWN_PASTE = true;
|
||||
|
||||
import { escape } from 'html-escaper';
|
||||
import { marked } from 'marked';
|
||||
import { ClipboardUtils } from './clipboardUtils';
|
||||
type TextValueItem = {
|
||||
text: string;
|
||||
[key: string]: any;
|
||||
@@ -25,93 +19,89 @@ type TextValueItem = {
|
||||
export class Paste {
|
||||
private _editor: Editor;
|
||||
private _markdownParse: MarkdownParser;
|
||||
private _clipboardParse: ClipboardParse;
|
||||
|
||||
constructor(
|
||||
editor: Editor,
|
||||
clipboardParse: ClipboardParse,
|
||||
markdownParse: MarkdownParser
|
||||
) {
|
||||
this._markdownParse = markdownParse;
|
||||
this._clipboardParse = clipboardParse;
|
||||
private _utils: ClipboardUtils;
|
||||
constructor(editor: Editor) {
|
||||
this._markdownParse = new MarkdownParser();
|
||||
this._editor = editor;
|
||||
|
||||
this._utils = new ClipboardUtils(editor);
|
||||
this.handlePaste = this.handlePaste.bind(this);
|
||||
}
|
||||
private static _optimalMimeType: string[] = [
|
||||
// The event handler will get the most needed clipboard data based on this array order
|
||||
private static _optimalMimeTypes: string[] = [
|
||||
OFFICE_CLIPBOARD_MIMETYPE.DOCS_DOCUMENT_SLICE_CLIP_WRAPPED,
|
||||
OFFICE_CLIPBOARD_MIMETYPE.HTML,
|
||||
OFFICE_CLIPBOARD_MIMETYPE.TEXT,
|
||||
];
|
||||
public handlePaste(e: Event) {
|
||||
if (!shouldHandlerContinue(e, this._editor)) {
|
||||
return;
|
||||
}
|
||||
|
||||
public async handlePaste(e: ClipboardEvent) {
|
||||
e.stopPropagation();
|
||||
|
||||
const clipboardData = (e as ClipboardEvent).clipboardData;
|
||||
|
||||
const isPureFile = Paste._isPureFileInClipboard(clipboardData);
|
||||
if (isPureFile) {
|
||||
this._pasteFile(clipboardData);
|
||||
} else {
|
||||
this._pasteContent(clipboardData);
|
||||
}
|
||||
const blocks = await this.clipboardEvent2Blocks(e);
|
||||
await this._insertBlocks(blocks);
|
||||
}
|
||||
public getOptimalClip(clipboardData: any) {
|
||||
const mimeTypeArr = Paste._optimalMimeType;
|
||||
|
||||
for (let i = 0; i < mimeTypeArr.length; i++) {
|
||||
const data =
|
||||
clipboardData[mimeTypeArr[i]] ||
|
||||
clipboardData.getData(mimeTypeArr[i]);
|
||||
public async clipboardEvent2Blocks(e: ClipboardEvent) {
|
||||
const clipboardData = e.clipboardData;
|
||||
const isPureFile = Paste._isPureFileInClipboard(clipboardData);
|
||||
|
||||
if (isPureFile) {
|
||||
return this._file2Blocks(clipboardData);
|
||||
}
|
||||
return this._clipboardData2Blocks(clipboardData);
|
||||
}
|
||||
// Get the most needed clipboard data based on `_optimalMimeTypes` order
|
||||
public getOptimalClip(clipboardData: ClipboardEvent['clipboardData']) {
|
||||
for (let i = 0; i < Paste._optimalMimeTypes.length; i++) {
|
||||
const mimeType = Paste._optimalMimeTypes[i];
|
||||
const data = clipboardData.getData(mimeType);
|
||||
|
||||
if (data) {
|
||||
return {
|
||||
type: mimeTypeArr[i],
|
||||
type: mimeType,
|
||||
data: data,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
return null;
|
||||
}
|
||||
|
||||
private _pasteContent(clipboardData: any) {
|
||||
const originClip: { data: any; type: any } = this.getOptimalClip(
|
||||
clipboardData
|
||||
) as { data: any; type: any };
|
||||
private async _clipboardData2Blocks(
|
||||
clipboardData: ClipboardEvent['clipboardData']
|
||||
): Promise<ClipBlockInfo[]> {
|
||||
const optimalClip = this.getOptimalClip(clipboardData);
|
||||
if (
|
||||
optimalClip?.type ===
|
||||
OFFICE_CLIPBOARD_MIMETYPE.DOCS_DOCUMENT_SLICE_CLIP_WRAPPED
|
||||
) {
|
||||
const clipInfo: InnerClipInfo = JSON.parse(optimalClip.data);
|
||||
return clipInfo.data;
|
||||
}
|
||||
|
||||
const originTextClipData = clipboardData.getData(
|
||||
OFFICE_CLIPBOARD_MIMETYPE.TEXT
|
||||
const textClipData = escape(
|
||||
clipboardData.getData(OFFICE_CLIPBOARD_MIMETYPE.TEXT)
|
||||
);
|
||||
|
||||
let clipData = originClip['data'];
|
||||
const shouldConvertMarkdown =
|
||||
this._markdownParse.checkIfTextContainsMd(textClipData);
|
||||
|
||||
if (originClip['type'] === OFFICE_CLIPBOARD_MIMETYPE.TEXT) {
|
||||
clipData = Paste._excapeHtml(clipData);
|
||||
if (
|
||||
optimalClip?.type === OFFICE_CLIPBOARD_MIMETYPE.HTML &&
|
||||
!shouldConvertMarkdown
|
||||
) {
|
||||
return this._utils.convertHTMLString2Blocks(optimalClip.data);
|
||||
}
|
||||
|
||||
switch (originClip['type']) {
|
||||
/** Protocol paste */
|
||||
case OFFICE_CLIPBOARD_MIMETYPE.DOCS_DOCUMENT_SLICE_CLIP_WRAPPED:
|
||||
this._firePasteEditAction(clipData);
|
||||
break;
|
||||
case OFFICE_CLIPBOARD_MIMETYPE.HTML:
|
||||
this._pasteHtml(clipData, originTextClipData);
|
||||
break;
|
||||
case OFFICE_CLIPBOARD_MIMETYPE.TEXT:
|
||||
this._pasteText(clipData, originTextClipData);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
if (shouldConvertMarkdown) {
|
||||
const md2html = marked.parse(textClipData);
|
||||
return this._utils.convertHTMLString2Blocks(md2html);
|
||||
}
|
||||
|
||||
return this._utils.textToBlock(textClipData);
|
||||
}
|
||||
private async _firePasteEditAction(clipboardData: any) {
|
||||
const clipInfo: InnerClipInfo = JSON.parse(clipboardData);
|
||||
clipInfo && this._insertBlocks(clipInfo.data, clipInfo.select);
|
||||
}
|
||||
private async _pasteFile(clipboardData: any) {
|
||||
|
||||
private async _file2Blocks(clipboardData: any): Promise<ClipBlockInfo[]> {
|
||||
const file = Paste._getImageFile(clipboardData);
|
||||
if (file) {
|
||||
const result = await services.api.file.create({
|
||||
@@ -130,8 +120,9 @@ export class Paste {
|
||||
},
|
||||
children: [] as ClipBlockInfo[],
|
||||
};
|
||||
await this._insertBlocks([blockInfo]);
|
||||
return [blockInfo];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
private static _isPureFileInClipboard(clipboardData: DataTransfer) {
|
||||
const types = clipboardData.types;
|
||||
@@ -144,32 +135,14 @@ export class Paste {
|
||||
);
|
||||
}
|
||||
|
||||
private static _isTextEditBlock(type: BlockFlavorKeys) {
|
||||
return (
|
||||
type === Protocol.Block.Type.page ||
|
||||
type === Protocol.Block.Type.text ||
|
||||
type === Protocol.Block.Type.heading1 ||
|
||||
type === Protocol.Block.Type.heading2 ||
|
||||
type === Protocol.Block.Type.heading3 ||
|
||||
type === Protocol.Block.Type.quote ||
|
||||
type === Protocol.Block.Type.todo ||
|
||||
type === Protocol.Block.Type.code ||
|
||||
type === Protocol.Block.Type.callout ||
|
||||
type === Protocol.Block.Type.numbered ||
|
||||
type === Protocol.Block.Type.bullet
|
||||
);
|
||||
}
|
||||
|
||||
private async _insertBlocks(
|
||||
blocks: ClipBlockInfo[],
|
||||
pasteSelect?: SelectInfo
|
||||
) {
|
||||
private async _insertBlocks(blocks: ClipBlockInfo[]) {
|
||||
if (blocks.length === 0) {
|
||||
return;
|
||||
}
|
||||
const currentSelectInfo =
|
||||
await this._editor.selectionManager.getSelectInfo();
|
||||
|
||||
// TODO: Logic of insert blocks maybe should declare in blockHelper
|
||||
// When the selection is in one of the blocks, select?.type === 'Range'
|
||||
// Currently the selection does not support cross-blocking, so this case is not considered
|
||||
if (currentSelectInfo.type === 'Range') {
|
||||
@@ -177,15 +150,16 @@ export class Paste {
|
||||
const selectedBlock = await this._editor.getBlockById(
|
||||
currentSelectInfo.blocks[0].blockId
|
||||
);
|
||||
const isSelectedBlockCanEdit = Paste._isTextEditBlock(
|
||||
selectedBlock.type
|
||||
const isSelectedBlockCanEdit = this._editor.isEditableView(
|
||||
selectedBlock?.type
|
||||
);
|
||||
|
||||
const blockView = this._editor.getView(selectedBlock.type);
|
||||
const isSelectedBlockEmpty = blockView.isEmpty(selectedBlock);
|
||||
if (isSelectedBlockCanEdit && !isSelectedBlockEmpty) {
|
||||
const shouldSplitBlock =
|
||||
blocks.length > 1 ||
|
||||
!Paste._isTextEditBlock(blocks[0].type);
|
||||
!this._editor.isEditableView(blocks[0].type);
|
||||
const pureText = !shouldSplitBlock
|
||||
? blocks[0].properties.text.value
|
||||
: [{ text: '' }];
|
||||
@@ -405,7 +379,7 @@ export class Paste {
|
||||
}
|
||||
private async _setEndSelectToBlock(blockId: string) {
|
||||
const block = await this._editor.getBlockById(blockId);
|
||||
const isBlockCanEdit = Paste._isTextEditBlock(block.type);
|
||||
const isBlockCanEdit = this._editor.isEditableView(block.type);
|
||||
if (!isBlockCanEdit) {
|
||||
return;
|
||||
}
|
||||
@@ -450,34 +424,6 @@ export class Paste {
|
||||
);
|
||||
}
|
||||
|
||||
private async _pasteHtml(clipData: any, originTextClipData: any) {
|
||||
if (SUPPORT_MARKDOWN_PASTE) {
|
||||
const hasMarkdown =
|
||||
this._markdownParse.checkIfTextContainsMd(originTextClipData);
|
||||
if (hasMarkdown) {
|
||||
try {
|
||||
const convertedDataObj =
|
||||
this._markdownParse.md2Html(originTextClipData);
|
||||
if (convertedDataObj.isConverted) {
|
||||
clipData = convertedDataObj.text;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
clipData = originTextClipData;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blocks = this._clipboardParse.html2blocks(clipData);
|
||||
|
||||
await this._insertBlocks(blocks);
|
||||
}
|
||||
|
||||
private async _pasteText(clipData: any, originTextClipData: any) {
|
||||
const blocks = this._clipboardParse.text2blocks(clipData);
|
||||
await this._insertBlocks(blocks);
|
||||
}
|
||||
|
||||
private static _getImageFile(clipboardData: any) {
|
||||
const files = clipboardData.files;
|
||||
if (files && files[0] && files[0].type.indexOf('image') > -1) {
|
||||
@@ -485,16 +431,4 @@ export class Paste {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
private static _excapeHtml(data: any, onlySpace?: any) {
|
||||
if (!onlySpace) {
|
||||
// TODO:
|
||||
// data = string.htmlEscape(data);
|
||||
// data = data.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
// data = data.replace(/ /g, ' ');
|
||||
// data = data.replace(/\t/g, ' ');
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
} from '@toeverything/datasource/db-service';
|
||||
import { SelectInfo } from '../selection';
|
||||
|
||||
// export type ClipBlockInfo = Partial<ReturnEditorBlock> & {
|
||||
// children?: ClipBlockInfo[];
|
||||
// };
|
||||
export const OFFICE_CLIPBOARD_MIMETYPE = {
|
||||
DOCS_DOCUMENT_SLICE_CLIP_WRAPPED: 'affine/x-c+w',
|
||||
HTML: 'text/html',
|
||||
@@ -20,8 +23,9 @@ export const OFFICE_CLIPBOARD_MIMETYPE = {
|
||||
export interface ClipBlockInfo {
|
||||
type: BlockFlavorKeys;
|
||||
properties?: Partial<DefaultColumnsValue>;
|
||||
children: ClipBlockInfo[];
|
||||
children?: ClipBlockInfo[];
|
||||
}
|
||||
export type HTML2BlockResult = ClipBlockInfo[] | null;
|
||||
|
||||
export interface InnerClipInfo {
|
||||
select: SelectInfo;
|
||||
|
||||
@@ -1,52 +1,150 @@
|
||||
import { Editor } from '../editor';
|
||||
import { ClipBlockInfo, OFFICE_CLIPBOARD_MIMETYPE } from './types';
|
||||
import { Clip } from './clip';
|
||||
import { getRandomString } from '@toeverything/components/common';
|
||||
import { BlockFlavorKeys, Protocol } from '@toeverything/datasource/db-service';
|
||||
import { ClipBlockInfo } from './types';
|
||||
|
||||
export const shouldHandlerContinue = (event: Event, editor: Editor) => {
|
||||
const filterNodes = ['INPUT', 'SELECT', 'TEXTAREA'];
|
||||
const getIsLink = (htmlElement: HTMLElement) => {
|
||||
return ['A', 'IMG'].includes(htmlElement.tagName);
|
||||
};
|
||||
const getTextStyle = (htmlElement: HTMLElement) => {
|
||||
const tagName = htmlElement.tagName;
|
||||
const textStyle: { [key: string]: any } = {};
|
||||
|
||||
if (event.defaultPrevented) {
|
||||
return false;
|
||||
const style = (htmlElement.getAttribute('style') || '')
|
||||
.split(';')
|
||||
.reduce((style: { [key: string]: any }, styleString) => {
|
||||
const [key, value] = styleString.split(':');
|
||||
if (key && value) {
|
||||
style[key] = value;
|
||||
}
|
||||
return style;
|
||||
}, {});
|
||||
|
||||
if (
|
||||
style['font-weight'] === 'bold' ||
|
||||
Number(style['font-weight']) > 400 ||
|
||||
['STRONG', 'B', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(
|
||||
htmlElement.tagName
|
||||
)
|
||||
) {
|
||||
textStyle['bold'] = true;
|
||||
}
|
||||
if (filterNodes.includes((event.target as HTMLElement)?.tagName)) {
|
||||
return false;
|
||||
if (getIsLink(htmlElement)) {
|
||||
textStyle['type'] = 'link';
|
||||
textStyle['url'] =
|
||||
htmlElement.getAttribute('href') || htmlElement.getAttribute('src');
|
||||
textStyle['id'] = getRandomString('link');
|
||||
}
|
||||
|
||||
return editor.selectionManager.currentSelectInfo.type !== 'None';
|
||||
if (tagName === 'EM' || style['fontStyle'] === 'italic') {
|
||||
textStyle['italic'] = true;
|
||||
}
|
||||
if (
|
||||
tagName === 'U' ||
|
||||
(style['text-decoration'] &&
|
||||
style['text-decoration'].indexOf('underline') !== -1) ||
|
||||
style['border-bottom']
|
||||
) {
|
||||
textStyle['underline'] = true;
|
||||
}
|
||||
if (tagName === 'CODE') {
|
||||
textStyle['inlinecode'] = true;
|
||||
}
|
||||
if (
|
||||
tagName === 'S' ||
|
||||
tagName === 'DEL' ||
|
||||
(style['text-decoration'] &&
|
||||
style['text-decoration'].indexOf('line-through') !== -1)
|
||||
) {
|
||||
textStyle['strikethrough'] = true;
|
||||
}
|
||||
|
||||
return textStyle;
|
||||
};
|
||||
|
||||
export const getClipInfoOfBlockById = async (
|
||||
editor: Editor,
|
||||
blockId: string
|
||||
) => {
|
||||
const block = await editor.getBlockById(blockId);
|
||||
const blockView = editor.getView(block.type);
|
||||
const blockInfo: ClipBlockInfo = {
|
||||
type: block.type,
|
||||
properties: blockView.getSelProperties(block, {}),
|
||||
children: [] as ClipBlockInfo[],
|
||||
export const commonHTML2Block = (
|
||||
element: HTMLElement | Node,
|
||||
type: BlockFlavorKeys = Protocol.Block.Type.text,
|
||||
ignoreEmptyElement = true
|
||||
): ClipBlockInfo => {
|
||||
const textValue = commonHTML2Text(element, {}, ignoreEmptyElement);
|
||||
if (!textValue.length && ignoreEmptyElement) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type,
|
||||
properties: {
|
||||
text: { value: textValue },
|
||||
},
|
||||
children: [],
|
||||
};
|
||||
const children = (await block?.children()) ?? [];
|
||||
};
|
||||
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const childInfo = await getClipInfoOfBlockById(editor, children[i].id);
|
||||
blockInfo.children.push(childInfo);
|
||||
const getSingleLabelHTMLElementContent = (htmlElement: HTMLElement) => {
|
||||
if (htmlElement.tagName === 'IMG') {
|
||||
return (
|
||||
htmlElement.getAttribute('alt') || htmlElement.getAttribute('src')
|
||||
);
|
||||
}
|
||||
return blockInfo;
|
||||
return '';
|
||||
};
|
||||
|
||||
export const getClipDataOfBlocksById = async (
|
||||
editor: Editor,
|
||||
blockIds: string[]
|
||||
export const commonHTML2Text = (
|
||||
element: HTMLElement | Node,
|
||||
textStyle: { [key: string]: any } = {},
|
||||
ignoreEmptyText = true
|
||||
) => {
|
||||
const clipInfos = await Promise.all(
|
||||
blockIds.map(blockId => getClipInfoOfBlockById(editor, blockId))
|
||||
);
|
||||
if (element instanceof Text) {
|
||||
return element.textContent.split('\n').map(text => {
|
||||
return { text: text, ...textStyle };
|
||||
});
|
||||
}
|
||||
const htmlElement = element as HTMLElement;
|
||||
const childNodes = Array.from(htmlElement.childNodes);
|
||||
|
||||
return new Clip(
|
||||
OFFICE_CLIPBOARD_MIMETYPE.DOCS_DOCUMENT_SLICE_CLIP_WRAPPED,
|
||||
JSON.stringify({
|
||||
data: clipInfos,
|
||||
})
|
||||
);
|
||||
const isLink = getIsLink(htmlElement);
|
||||
const currentTextStyle = getTextStyle(htmlElement);
|
||||
|
||||
if (!childNodes.length) {
|
||||
const singleLabelContent =
|
||||
getSingleLabelHTMLElementContent(htmlElement);
|
||||
if (isLink && singleLabelContent) {
|
||||
return [
|
||||
{
|
||||
children: [
|
||||
{
|
||||
text: singleLabelContent,
|
||||
},
|
||||
],
|
||||
...currentTextStyle,
|
||||
},
|
||||
];
|
||||
}
|
||||
return ignoreEmptyText ? [] : [{ text: '', ...currentTextStyle }];
|
||||
}
|
||||
|
||||
const childTexts = childNodes
|
||||
.reduce((result, childNode) => {
|
||||
const textBlocks = commonHTML2Text(
|
||||
childNode,
|
||||
isLink
|
||||
? textStyle
|
||||
: {
|
||||
...textStyle,
|
||||
...currentTextStyle,
|
||||
},
|
||||
ignoreEmptyText
|
||||
);
|
||||
result.push(...textBlocks);
|
||||
return result;
|
||||
}, [])
|
||||
.filter(v => v);
|
||||
|
||||
if (isLink && childTexts.length) {
|
||||
return [
|
||||
{
|
||||
children: childTexts,
|
||||
...currentTextStyle,
|
||||
},
|
||||
];
|
||||
}
|
||||
return childTexts;
|
||||
};
|
||||
|
||||
@@ -13,8 +13,7 @@ import HotKeys from 'hotkeys-js';
|
||||
import type { WorkspaceAndBlockId } from './block';
|
||||
import { AsyncBlock } from './block';
|
||||
import { BlockHelper } from './block/block-helper';
|
||||
import { BrowserClipboard } from './clipboard/browser-clipboard';
|
||||
import { ClipboardPopulator } from './clipboard/clipboard-populator';
|
||||
import { Clipboard } from './clipboard';
|
||||
import { EditorCommands } from './commands';
|
||||
import { EditorConfig } from './config';
|
||||
import { DragDropManager } from './drag-drop';
|
||||
@@ -46,7 +45,7 @@ export class Editor implements Virgo {
|
||||
private _cacheManager = new Map<string, Promise<AsyncBlock | null>>();
|
||||
public mouseManager = new MouseManager(this);
|
||||
public selectionManager = new SelectionManager(this);
|
||||
public keyboardManager = new KeyboardManager(this);
|
||||
public keyboardManager: KeyboardManager;
|
||||
public scrollManager = new ScrollManager(this);
|
||||
public dragDropManager = new DragDropManager(this);
|
||||
public commands = new EditorCommands(this);
|
||||
@@ -63,8 +62,9 @@ export class Editor implements Virgo {
|
||||
readonly = false;
|
||||
private _rootBlockId: string;
|
||||
private storage_manager?: StorageManager;
|
||||
private clipboard?: BrowserClipboard;
|
||||
private clipboard_populator?: ClipboardPopulator;
|
||||
private _clipboard: Clipboard;
|
||||
// private clipboardActionDispacher?: ClipboardEventDispatcher;
|
||||
// private clipboard_populator?: ClipboardPopulator;
|
||||
public reactRenderRoot: {
|
||||
render: PatchNode;
|
||||
has: (key: string) => boolean;
|
||||
@@ -78,10 +78,13 @@ export class Editor implements Virgo {
|
||||
this._rootBlockId = props.rootBlockId;
|
||||
this.hooks = new Hooks();
|
||||
this.plugin_manager = new PluginManager(this, this.hooks);
|
||||
this._clipboard = new Clipboard(this, this.ui_container);
|
||||
this.plugin_manager.registerAll(props.plugins);
|
||||
if (props.isEdgeless) {
|
||||
this.isEdgeless = true;
|
||||
}
|
||||
// Wait for rootId/isEdgeless set
|
||||
this.keyboardManager = new KeyboardManager(this);
|
||||
for (const [name, block] of Object.entries(props.views)) {
|
||||
services.api.editorBlock.registerContentExporter(
|
||||
this.workspace,
|
||||
@@ -136,13 +139,17 @@ export class Editor implements Virgo {
|
||||
|
||||
public set container(v: HTMLDivElement) {
|
||||
this.ui_container = v;
|
||||
this._initClipboard();
|
||||
this._clipboard.clipboardTarget = v;
|
||||
}
|
||||
|
||||
public get container() {
|
||||
return this.ui_container;
|
||||
}
|
||||
|
||||
public get clipboard() {
|
||||
return this._clipboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use it discreetly.
|
||||
* Preference to use {@link withBatch}
|
||||
@@ -189,26 +196,26 @@ export class Editor implements Virgo {
|
||||
};
|
||||
}
|
||||
|
||||
private _disposeClipboard() {
|
||||
this.clipboard?.dispose();
|
||||
this.clipboard_populator?.disposeInternal();
|
||||
}
|
||||
// private _disposeClipboard() {
|
||||
// this.clipboard?.dispose();
|
||||
// this.clipboard_populator?.disposeInternal();
|
||||
// }
|
||||
|
||||
private _initClipboard() {
|
||||
this._disposeClipboard();
|
||||
if (this.ui_container && !this._isDisposed) {
|
||||
this.clipboard = new BrowserClipboard(
|
||||
this.ui_container,
|
||||
this.hooks,
|
||||
this
|
||||
);
|
||||
this.clipboard_populator = new ClipboardPopulator(
|
||||
this,
|
||||
this.hooks,
|
||||
this.selectionManager
|
||||
);
|
||||
}
|
||||
}
|
||||
// private _initClipboard() {
|
||||
// this._disposeClipboard();
|
||||
// if (this.ui_container && !this._isDisposed) {
|
||||
// this.clipboardActionDispacher = new ClipboardEventDispatcher({
|
||||
// clipboardTarget: this.ui_container,
|
||||
// hooks: this.hooks,
|
||||
// editor: this,
|
||||
// });
|
||||
// this.clipboard_populator = new ClipboardPopulator(
|
||||
// this,
|
||||
// this.hooks,
|
||||
// this.selectionManager
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
/** Root Block Id */
|
||||
getRootBlockId() {
|
||||
@@ -249,6 +256,14 @@ export class Editor implements Virgo {
|
||||
getView(type: string) {
|
||||
return this.views[type];
|
||||
}
|
||||
getEditableViews() {
|
||||
return Object.values(this.views)
|
||||
.map(view => (view.editable ? view : null))
|
||||
.filter(v => v);
|
||||
}
|
||||
isEditableView(type: string) {
|
||||
return this.views[type].editable;
|
||||
}
|
||||
|
||||
private async _initBlock(
|
||||
blockData: ReturnEditorBlock
|
||||
@@ -333,6 +348,12 @@ export class Editor implements Virgo {
|
||||
return await this.getBlock({ workspace: this.workspace, id: blockId });
|
||||
}
|
||||
|
||||
async getBlockByIds(ids: string[]): Promise<Awaited<AsyncBlock | null>[]> {
|
||||
return await Promise.all(
|
||||
ids.map(id => this.getBlock({ workspace: this.workspace, id }))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: to be optimized
|
||||
* get block`s dom by block`s id
|
||||
@@ -477,6 +498,13 @@ export class Editor implements Virgo {
|
||||
return await services.api.editorBlock.query(this.workspace, query);
|
||||
}
|
||||
|
||||
async queryByPageId(pageId: string) {
|
||||
return await services.api.editorBlock.get({
|
||||
workspace: this.workspace,
|
||||
ids: [pageId],
|
||||
});
|
||||
}
|
||||
|
||||
/** Hooks */
|
||||
|
||||
public getHooks(): HooksRunner & PluginHooks {
|
||||
@@ -496,12 +524,7 @@ export class Editor implements Virgo {
|
||||
}
|
||||
|
||||
public async page2html(): Promise<string> {
|
||||
const parse = this.clipboard?.getClipboardParse();
|
||||
if (!parse) {
|
||||
return '';
|
||||
}
|
||||
const html_str = await parse.page2html();
|
||||
return html_str;
|
||||
return this.clipboard?.clipboardUtils?.page2html?.();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
@@ -517,6 +540,6 @@ export class Editor implements Virgo {
|
||||
this.plugin_manager.dispose();
|
||||
this.selectionManager.dispose();
|
||||
this.dragDropManager.dispose();
|
||||
this._disposeClipboard();
|
||||
this._clipboard?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import ClipboardParseInner from './clipboard/clipboard-parse';
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export const ClipboardParse = ClipboardParseInner;
|
||||
|
||||
export { AsyncBlock } from './block';
|
||||
export * from './commands/types';
|
||||
export { Editor as BlockEditor } from './editor';
|
||||
export * from './selection';
|
||||
export { BlockDropPlacement, HookType, GroupDirection } from './types';
|
||||
export type { Plugin, PluginCreator, PluginHooks, Virgo } from './types';
|
||||
export { BaseView, getTextHtml, getTextProperties } from './views/base-view';
|
||||
export { BaseView } from './views/base-view';
|
||||
export type { ChildrenView, CreateView } from './views/base-view';
|
||||
export { getClipDataOfBlocksById } from './clipboard/utils';
|
||||
export type { HTML2BlockResult, ClipBlockInfo } from './clipboard';
|
||||
|
||||
@@ -2,19 +2,19 @@ import HotKeys from 'hotkeys-js';
|
||||
|
||||
import { uaHelper } from '@toeverything/utils';
|
||||
|
||||
import { Protocol } from '@toeverything/datasource/db-service';
|
||||
import { AsyncBlock, BlockEditor } from '../..';
|
||||
import { supportChildren } from '../../utils';
|
||||
import { SelectionManager } from '../selection';
|
||||
import {
|
||||
HotKeyTypes,
|
||||
HotkeyMap,
|
||||
MacHotkeyMap,
|
||||
WinHotkeyMap,
|
||||
GlobalHotkeyMap,
|
||||
GlobalMacHotkeyMap,
|
||||
GlobalWinHotkeyMap,
|
||||
HotkeyMap,
|
||||
HotKeyTypes,
|
||||
MacHotkeyMap,
|
||||
WinHotkeyMap,
|
||||
} from './hotkey-map';
|
||||
import { supportChildren } from '../../utils';
|
||||
import { Protocol } from '@toeverything/datasource/db-service';
|
||||
type KeyboardEventHandler = (event: KeyboardEvent) => void;
|
||||
export class KeyboardManager {
|
||||
private _editor: BlockEditor;
|
||||
@@ -22,8 +22,20 @@ export class KeyboardManager {
|
||||
private hotkeys: HotkeyMap;
|
||||
private global_hotkeys: GlobalHotkeyMap;
|
||||
private handler_map: { [k: string]: Array<KeyboardEventHandler> };
|
||||
/**
|
||||
* Every editor should have its own hotkey scope,
|
||||
* but edgeless had multiple editor instances,
|
||||
* all edgeless editors should have shared scope
|
||||
*/
|
||||
private hotkeyScope: string;
|
||||
|
||||
constructor(editor: BlockEditor) {
|
||||
if (editor.isEdgeless) {
|
||||
// edgeless editors should have shared scope
|
||||
this.hotkeyScope = 'whiteboard';
|
||||
} else {
|
||||
this.hotkeyScope = 'editor_' + editor.getRootBlockId();
|
||||
}
|
||||
this._editor = editor;
|
||||
this.selection_manager = this._editor.selectionManager;
|
||||
if (uaHelper.isMacOs) {
|
||||
@@ -35,7 +47,7 @@ export class KeyboardManager {
|
||||
}
|
||||
this.handler_map = {};
|
||||
|
||||
HotKeys.setScope('editor');
|
||||
HotKeys.setScope(this.hotkeyScope);
|
||||
|
||||
// this.init_common_shortcut_cb();
|
||||
this.bind_hot_key_handlers();
|
||||
@@ -44,43 +56,63 @@ export class KeyboardManager {
|
||||
private bind_hot_key_handlers() {
|
||||
this.bind_hotkey(
|
||||
this.hotkeys.selectAll,
|
||||
'editor',
|
||||
this.hotkeyScope,
|
||||
this.handle_select_all
|
||||
);
|
||||
|
||||
this.bind_hotkey(this.hotkeys.undo, 'editor', this.handle_undo);
|
||||
this.bind_hotkey(this.hotkeys.redo, 'editor', this.handle_redo);
|
||||
this.bind_hotkey(this.hotkeys.remove, 'editor', this.handle_remove);
|
||||
this.bind_hotkey(this.hotkeys.undo, this.hotkeyScope, this.handle_undo);
|
||||
this.bind_hotkey(this.hotkeys.redo, this.hotkeyScope, this.handle_redo);
|
||||
this.bind_hotkey(this.hotkeys.remove, 'all', this.handle_remove);
|
||||
this.bind_hotkey(
|
||||
this.hotkeys.checkUncheck,
|
||||
'editor',
|
||||
this.hotkeyScope,
|
||||
this.handle_check_uncheck
|
||||
);
|
||||
this.bind_hotkey(
|
||||
this.hotkeys.preExpendSelect,
|
||||
'editor',
|
||||
this.hotkeyScope,
|
||||
this.handle_pre_expend_select
|
||||
);
|
||||
this.bind_hotkey(
|
||||
this.hotkeys.nextExpendSelect,
|
||||
'editor',
|
||||
this.hotkeyScope,
|
||||
this.handle_next_expend_select
|
||||
);
|
||||
this.bind_hotkey(this.hotkeys.up, 'editor', this.handle_click_up);
|
||||
this.bind_hotkey(this.hotkeys.down, 'editor', this.handleClickDown);
|
||||
this.bind_hotkey(this.hotkeys.left, 'editor', this.handle_click_up);
|
||||
this.bind_hotkey(this.hotkeys.right, 'editor', this.handleClickDown);
|
||||
this.bind_hotkey(this.hotkeys.mergeGroup, 'editor', this.mergeGroup);
|
||||
this.bind_hotkey(
|
||||
this.hotkeys.up,
|
||||
this.hotkeyScope,
|
||||
this.handle_click_up
|
||||
);
|
||||
this.bind_hotkey(
|
||||
this.hotkeys.down,
|
||||
this.hotkeyScope,
|
||||
this.handleClickDown
|
||||
);
|
||||
this.bind_hotkey(
|
||||
this.hotkeys.left,
|
||||
this.hotkeyScope,
|
||||
this.handle_click_up
|
||||
);
|
||||
this.bind_hotkey(
|
||||
this.hotkeys.right,
|
||||
this.hotkeyScope,
|
||||
this.handleClickDown
|
||||
);
|
||||
this.bind_hotkey(
|
||||
this.hotkeys.mergeGroup,
|
||||
this.hotkeyScope,
|
||||
this.mergeGroup
|
||||
);
|
||||
this.bind_hotkey(this.hotkeys.enter, 'all', this.handleEnter);
|
||||
this.bind_hotkey(this.global_hotkeys.search, 'all', this.handle_search);
|
||||
this.bind_hotkey(
|
||||
this.hotkeys.mergeGroupDown,
|
||||
'editor',
|
||||
this.hotkeyScope,
|
||||
this.mergeGroupDown
|
||||
);
|
||||
this.bind_hotkey(
|
||||
this.hotkeys.mergeGroupUp,
|
||||
'editor',
|
||||
this.hotkeyScope,
|
||||
this.mergeGroupUp
|
||||
);
|
||||
}
|
||||
@@ -93,24 +125,12 @@ export class KeyboardManager {
|
||||
handler.forEach(h => {
|
||||
HotKeys(key, scope, h);
|
||||
if (!this.handler_map[key]) {
|
||||
this.handler_map[key] = [h];
|
||||
} else {
|
||||
this.handler_map[key].push(h);
|
||||
this.handler_map[key] = [];
|
||||
}
|
||||
this.handler_map[key].push(h);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* bind global shortcut event
|
||||
* @param {HotkeyMapKeys} type
|
||||
* @param {KeyboardEventHandler} handler
|
||||
* @memberof KeyboardManager
|
||||
*/
|
||||
public bind(type: HotKeyTypes, handler: KeyboardEventHandler) {
|
||||
this.bind_hotkey(this.hotkeys[type], 'editor', handler);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* emit a shortcut event,
|
||||
@@ -127,21 +147,12 @@ export class KeyboardManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* unbind keyboard event
|
||||
* @param {HotKeyTypes} key
|
||||
* @param {KeyboardEventHandler} cb
|
||||
* @memberof KeyboardManager
|
||||
*/
|
||||
public unbind(key: HotKeyTypes, cb: KeyboardEventHandler) {
|
||||
HotKeys.unbind(key, 'editor', cb);
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
Object.keys(this.handler_map).map(key => HotKeys.unbind(key, 'editor'));
|
||||
|
||||
Object.entries(this.handler_map).forEach(([key, fns]) =>
|
||||
fns.forEach(fn => HotKeys.unbind(key, fn))
|
||||
);
|
||||
this.handler_map = {};
|
||||
HotKeys.deleteScope(this.hotkeyScope);
|
||||
}
|
||||
|
||||
private handle_select_all = (event: KeyboardEvent) => {
|
||||
@@ -228,20 +239,20 @@ export class KeyboardManager {
|
||||
);
|
||||
} else {
|
||||
// suspend(true)
|
||||
let textBlock = await this._editor.createBlock('text');
|
||||
const textBlock = await this._editor.createBlock('text');
|
||||
await selectedNode.after(textBlock);
|
||||
this._editor.selectionManager.setActivatedNodeId(textBlock.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
private mergeGroup = async (event: Event) => {
|
||||
let selectedGroup = await this.getSelectedGroups();
|
||||
const selectedGroup = await this.getSelectedGroups();
|
||||
this._editor.commands.blockCommands.mergeGroup(...selectedGroup);
|
||||
};
|
||||
private mergeGroupDown = async (event: Event) => {
|
||||
let selectedGroup = await this.getSelectedGroups();
|
||||
const selectedGroup = await this.getSelectedGroups();
|
||||
if (selectedGroup.length) {
|
||||
let nextGroup = await selectedGroup[
|
||||
const nextGroup = await selectedGroup[
|
||||
selectedGroup.length - 1
|
||||
].nextSibling();
|
||||
if (nextGroup?.type === Protocol.Block.Type.group) {
|
||||
@@ -253,9 +264,9 @@ export class KeyboardManager {
|
||||
}
|
||||
};
|
||||
private mergeGroupUp = async (event: Event) => {
|
||||
let selectedGroup = await this.getSelectedGroups();
|
||||
const selectedGroup = await this.getSelectedGroups();
|
||||
if (selectedGroup.length) {
|
||||
let preGroup = await selectedGroup[0].previousSibling();
|
||||
const preGroup = await selectedGroup[0].previousSibling();
|
||||
if (preGroup?.type === Protocol.Block.Type.group) {
|
||||
this._editor.commands.blockCommands.mergeGroup(
|
||||
preGroup,
|
||||
|
||||
@@ -116,12 +116,15 @@ export class Hooks implements HooksRunner, PluginHooks {
|
||||
this._runHook(HookType.ON_SEARCH);
|
||||
}
|
||||
|
||||
public beforeCopy(e: ClipboardEvent): void {
|
||||
this._runHook(HookType.BEFORE_COPY, e);
|
||||
public onCopy(e: ClipboardEvent): void {
|
||||
this._runHook(HookType.ON_COPY, e);
|
||||
}
|
||||
|
||||
public beforeCut(e: ClipboardEvent): void {
|
||||
this._runHook(HookType.BEFORE_CUT, e);
|
||||
public onCut(e: ClipboardEvent): void {
|
||||
this._runHook(HookType.ON_CUT, e);
|
||||
}
|
||||
public onPaste(e: ClipboardEvent): void {
|
||||
this._runHook(HookType.ON_PASTE, e);
|
||||
}
|
||||
|
||||
public onRootNodeScroll(e: React.UIEvent): void {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CSSProperties, type UIEvent } from 'react';
|
||||
import EventEmitter from 'eventemitter3';
|
||||
import { type UIEvent } from 'react';
|
||||
|
||||
import { domToRect, Rect } from '@toeverything/utils';
|
||||
import type { Editor as BlockEditor } from '../editor';
|
||||
@@ -164,22 +164,9 @@ export class ScrollManager {
|
||||
if (!block.dom) {
|
||||
return console.warn(`Block is not exist.`);
|
||||
}
|
||||
const containerRect = domToRect(this._scrollContainer);
|
||||
const blockRect = domToRect(block.dom);
|
||||
|
||||
const blockRelativeTopToEditor =
|
||||
blockRect.top - containerRect.top - containerRect.height / 4;
|
||||
const blockRelativeLeftToEditor = blockRect.left - containerRect.left;
|
||||
|
||||
this.scrollTo({
|
||||
left: blockRelativeLeftToEditor,
|
||||
top: blockRelativeTopToEditor,
|
||||
behavior,
|
||||
});
|
||||
this._updateScrollInfo(
|
||||
blockRelativeLeftToEditor,
|
||||
blockRelativeTopToEditor
|
||||
);
|
||||
/* use dom primary ability */
|
||||
block.dom.scrollIntoView({ block: 'start', behavior });
|
||||
}
|
||||
|
||||
public async keepBlockInView(
|
||||
@@ -209,11 +196,11 @@ export class ScrollManager {
|
||||
private _getKeepInViewParams(blockRect: Rect) {
|
||||
if (this.scrollContainer == null) return 0;
|
||||
const { top, bottom } = domToRect(this._scrollContainer);
|
||||
if (blockRect.top <= top + blockRect.height * 3) {
|
||||
if (blockRect.top <= top + blockRect.height) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (blockRect.bottom >= bottom - blockRect.height * 3) {
|
||||
if (blockRect.bottom >= bottom - blockRect.height) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
* 6. Dependencies between plugins are not supported for the time being
|
||||
*/
|
||||
import type { PatchNode } from '@toeverything/components/ui';
|
||||
import type { BlockFlavors } from '@toeverything/datasource/db-service';
|
||||
import type {
|
||||
BlockFlavors,
|
||||
ReturnEditorBlock,
|
||||
} from '@toeverything/datasource/db-service';
|
||||
import { Point } from '@toeverything/utils';
|
||||
import { Observable } from 'rxjs';
|
||||
import type { AsyncBlock } from './block';
|
||||
@@ -66,6 +69,8 @@ export interface Virgo {
|
||||
) => Promise<AsyncBlock>;
|
||||
getRootBlockId: () => string;
|
||||
getBlockById(blockId: string): Promise<AsyncBlock | null>;
|
||||
getBlockByIds(blockId: string[]): Promise<(AsyncBlock | null)[]>;
|
||||
queryByPageId(pageId: string): Promise<(ReturnEditorBlock | null)[]>;
|
||||
setHotKeysScope(scope?: string): void;
|
||||
getBlockList: () => Promise<AsyncBlock[]>;
|
||||
getBlockListByLevelOrder: () => Promise<AsyncBlock[]>;
|
||||
@@ -174,8 +179,9 @@ export enum HookType {
|
||||
ON_ROOTNODE_DRAG_END = 'onRootNodeDragEnd',
|
||||
ON_ROOTNODE_DRAG_OVER_CAPTURE = 'onRootNodeDragOverCapture',
|
||||
ON_ROOTNODE_DROP = 'onRootNodeDrop',
|
||||
BEFORE_COPY = 'beforeCopy',
|
||||
BEFORE_CUT = 'beforeCut',
|
||||
ON_COPY = 'onCopy',
|
||||
ON_CUT = 'onCut',
|
||||
ON_PASTE = 'onPaste',
|
||||
ON_ROOTNODE_SCROLL = 'onRootNodeScroll',
|
||||
}
|
||||
|
||||
@@ -207,8 +213,9 @@ export interface HooksRunner {
|
||||
onRootNodeDragEnd: (e: React.DragEvent<Element>) => void;
|
||||
onRootNodeDragLeave: (e: React.DragEvent<Element>) => void;
|
||||
onRootNodeDrop: (e: React.DragEvent<Element>) => void;
|
||||
beforeCopy: (e: ClipboardEvent) => void;
|
||||
beforeCut: (e: ClipboardEvent) => void;
|
||||
onCopy: (e: ClipboardEvent) => void;
|
||||
onCut: (e: ClipboardEvent) => void;
|
||||
onPaste: (e: ClipboardEvent) => void;
|
||||
onRootNodeScroll: (e: React.UIEvent) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import type {
|
||||
Column,
|
||||
DefaultColumnsValue,
|
||||
} from '@toeverything/datasource/db-service';
|
||||
import type { Column } from '@toeverything/datasource/db-service';
|
||||
import {
|
||||
ArrayOperation,
|
||||
BlockDecoration,
|
||||
MapOperation,
|
||||
} from '@toeverything/datasource/jwt';
|
||||
import { cloneDeep } from '@toeverything/utils';
|
||||
import { ComponentType, ReactElement } from 'react';
|
||||
import type { EventData } from '../block';
|
||||
import { AsyncBlock } from '../block';
|
||||
import { HTML2BlockResult } from '../clipboard';
|
||||
import type { Editor } from '../editor';
|
||||
import { SelectBlock } from '../selection';
|
||||
|
||||
export interface CreateView {
|
||||
block: AsyncBlock;
|
||||
editor: Editor;
|
||||
@@ -133,76 +129,27 @@ export abstract class BaseView {
|
||||
return result;
|
||||
}
|
||||
|
||||
getSelProperties(block: AsyncBlock, selectInfo: any): DefaultColumnsValue {
|
||||
return cloneDeep(block.getProperties());
|
||||
}
|
||||
|
||||
html2block(el: Element, parseEl: (el: Element) => any[]): any[] | null {
|
||||
async html2block(props: {
|
||||
element: HTMLElement | Node;
|
||||
editor: Editor;
|
||||
}): Promise<HTML2BlockResult> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async block2html(
|
||||
async block2Text(
|
||||
block: AsyncBlock,
|
||||
children: SelectBlock[],
|
||||
generateHtml: (el: any[]) => Promise<string>
|
||||
// The selectInfo parameter is not passed when the block is selected in ful, the selectInfo.type is Range
|
||||
selectInfo?: SelectBlock
|
||||
): Promise<string> {
|
||||
return '';
|
||||
}
|
||||
|
||||
async block2html(props: {
|
||||
editor: Editor;
|
||||
block: AsyncBlock;
|
||||
// The selectInfo parameter is not passed when the block is selected in ful, the selectInfo.type is Range
|
||||
selectInfo?: SelectBlock;
|
||||
}) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export const getTextProperties = (
|
||||
properties: DefaultColumnsValue,
|
||||
selectInfo: any
|
||||
) => {
|
||||
let text_value = properties.text.value;
|
||||
if (text_value.length === 0) {
|
||||
return properties;
|
||||
}
|
||||
if (selectInfo.endInfo) {
|
||||
text_value = text_value.slice(0, selectInfo.endInfo.arrayIndex + 1);
|
||||
text_value[text_value.length - 1].text = text_value[
|
||||
text_value.length - 1
|
||||
].text.substring(0, selectInfo.endInfo.offset);
|
||||
}
|
||||
if (selectInfo.startInfo) {
|
||||
text_value = text_value.slice(selectInfo.startInfo.arrayIndex);
|
||||
text_value[0].text = text_value[0].text.substring(
|
||||
selectInfo.startInfo.offset
|
||||
);
|
||||
}
|
||||
properties.text.value = text_value;
|
||||
return properties;
|
||||
};
|
||||
|
||||
export const getTextHtml = (block: AsyncBlock) => {
|
||||
const generate = (textList: any[]) => {
|
||||
let content = '';
|
||||
textList.forEach(text_obj => {
|
||||
let text = text_obj.text || '';
|
||||
if (text_obj.bold) {
|
||||
text = `<strong>${text}</strong>`;
|
||||
}
|
||||
if (text_obj.italic) {
|
||||
text = `<em>${text}</em>`;
|
||||
}
|
||||
if (text_obj.underline) {
|
||||
text = `<u>${text}</u>`;
|
||||
}
|
||||
if (text_obj.inlinecode) {
|
||||
text = `<code>${text}</code>`;
|
||||
}
|
||||
if (text_obj.strikethrough) {
|
||||
text = `<s>${text}</s>`;
|
||||
}
|
||||
if (text_obj.type === 'link') {
|
||||
text = `<a href='${text_obj.url}'>${generate(
|
||||
text_obj.children
|
||||
)}</a>`;
|
||||
}
|
||||
content += text;
|
||||
});
|
||||
return content;
|
||||
};
|
||||
const text_list: any[] = block.getProperty('text').value;
|
||||
return generate(text_list);
|
||||
};
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
export { RenderRoot, MIN_PAGE_WIDTH } from './RenderRoot';
|
||||
export * from './render-block';
|
||||
export * from './hooks';
|
||||
|
||||
export { RenderBlock } from './render-block';
|
||||
|
||||
export * from './recast-block';
|
||||
export * from './recast-block/types';
|
||||
|
||||
export * from './block-pendant';
|
||||
|
||||
export { useEditor } from './Contexts';
|
||||
export * from './editor';
|
||||
export * from './hooks';
|
||||
export * from './kanban';
|
||||
export * from './kanban/types';
|
||||
|
||||
export * from './recast-block';
|
||||
export * from './recast-block/types';
|
||||
export * from './render-block';
|
||||
export { MIN_PAGE_WIDTH, RenderRoot } from './RenderRoot';
|
||||
export * from './utils';
|
||||
|
||||
export * from './editor';
|
||||
|
||||
export { useEditor } from './Contexts';
|
||||
|
||||
@@ -358,6 +358,7 @@ export const useKanban = () => {
|
||||
}
|
||||
card.append(newBlock);
|
||||
editor.selectionManager.activeNodeByNodeId(newBlock.id);
|
||||
return newBlock;
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Protocol } from '@toeverything/datasource/db-service';
|
||||
import type { AsyncBlock, BlockEditor } from '../editor';
|
||||
import type { RecastBlock } from '.';
|
||||
import { cloneRecastMetaTo, mergeRecastMeta } from './property';
|
||||
import type { RecastBlock } from './types';
|
||||
|
||||
const mergeGroupProperties = async (...groups: RecastBlock[]) => {
|
||||
const [headGroup, ...restGroups] = groups;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { nanoid } from 'nanoid';
|
||||
import { useCallback } from 'react';
|
||||
import { AsyncBlock } from '../editor';
|
||||
import { useRecastBlock } from './Context';
|
||||
import { getHistory, removeHistory, setHistory } from './history';
|
||||
import type { RecastBlock, RecastItem, StatusProperty } from './types';
|
||||
import {
|
||||
META_PROPERTIES_KEY,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
SelectProperty,
|
||||
TABLE_VALUES_KEY,
|
||||
} from './types';
|
||||
import { getHistory, removeHistory, setHistory } from './history';
|
||||
|
||||
/**
|
||||
* Generate a unique id for a property
|
||||
@@ -275,7 +275,7 @@ const isSelectLikeProperty = (
|
||||
metaProperty?: RecastMetaProperty
|
||||
): metaProperty is SelectProperty | MultiSelectProperty | StatusProperty => {
|
||||
return (
|
||||
metaProperty &&
|
||||
!!metaProperty &&
|
||||
(metaProperty.type === PropertyType.Status ||
|
||||
metaProperty.type === PropertyType.Select ||
|
||||
metaProperty.type === PropertyType.MultiSelect)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { genErrorObj } from '@toeverything/utils';
|
||||
import { createContext, PropsWithChildren, useContext } from 'react';
|
||||
import { RenderBlockProps } from './RenderBlock';
|
||||
|
||||
type BlockRenderProps = {
|
||||
blockRender: (args: RenderBlockProps) => JSX.Element;
|
||||
};
|
||||
|
||||
export const BlockRenderContext = createContext<BlockRenderProps>(
|
||||
genErrorObj(
|
||||
'Failed to get BlockChildrenContext! The context only can use under the "render-root"'
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as any
|
||||
);
|
||||
|
||||
/**
|
||||
* CAUTION! DO NOT PROVIDE A DYNAMIC BLOCK RENDER!
|
||||
*/
|
||||
export const BlockRenderProvider = ({
|
||||
blockRender,
|
||||
children,
|
||||
}: PropsWithChildren<BlockRenderProps>) => {
|
||||
return (
|
||||
<BlockRenderContext.Provider value={{ blockRender }}>
|
||||
{children}
|
||||
</BlockRenderContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useBlockRender = () => {
|
||||
const { blockRender } = useContext(BlockRenderContext);
|
||||
return {
|
||||
BlockRender: blockRender,
|
||||
};
|
||||
};
|
||||
|
||||
export const BlockRender = (props: RenderBlockProps) => {
|
||||
const { BlockRender } = useBlockRender();
|
||||
return <BlockRender {...props} />;
|
||||
};
|
||||
@@ -4,7 +4,13 @@ import { useCallback, useMemo } from 'react';
|
||||
import { useEditor } from '../Contexts';
|
||||
import { useBlock } from '../hooks';
|
||||
|
||||
interface RenderBlockProps {
|
||||
/**
|
||||
* Render nothing
|
||||
*/
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
export const NullBlockRender = () => <></>;
|
||||
|
||||
export interface RenderBlockProps {
|
||||
blockId: string;
|
||||
hasContainer?: boolean;
|
||||
}
|
||||
@@ -17,7 +23,7 @@ export function RenderBlock({
|
||||
const { block } = useBlock(blockId);
|
||||
|
||||
const setRef = useCallback(
|
||||
(dom: HTMLElement) => {
|
||||
(dom: HTMLElement | null) => {
|
||||
if (block != null && dom != null) {
|
||||
block.dom = dom;
|
||||
}
|
||||
@@ -29,7 +35,7 @@ export function RenderBlock({
|
||||
if (block?.type) {
|
||||
return editor.getView(block.type).View;
|
||||
}
|
||||
return () => null;
|
||||
return (): null => null;
|
||||
}, [editor, block?.type]);
|
||||
|
||||
if (!block) {
|
||||
@@ -64,4 +70,5 @@ export function RenderBlock({
|
||||
|
||||
const BlockContainer = styled('div')(({ theme }) => ({
|
||||
fontSize: theme.typography.body1.fontSize,
|
||||
flex: 1,
|
||||
}));
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import type { AsyncBlock } from '../editor';
|
||||
import { RenderBlock } from './RenderBlock';
|
||||
import { BlockRender } from './Context';
|
||||
import { NullBlockRender } from './RenderBlock';
|
||||
|
||||
interface RenderChildrenProps {
|
||||
export interface RenderChildrenProps {
|
||||
block: AsyncBlock;
|
||||
indent?: boolean;
|
||||
}
|
||||
|
||||
export const RenderBlockChildren = ({ block }: RenderChildrenProps) => {
|
||||
export const RenderBlockChildren = ({
|
||||
block,
|
||||
indent = true,
|
||||
}: RenderChildrenProps) => {
|
||||
if (BlockRender === NullBlockRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return block.childrenIds.length ? (
|
||||
<>
|
||||
<StyledIdentWrapper indent={indent}>
|
||||
{block.childrenIds.map(childId => {
|
||||
return <RenderBlock key={childId} blockId={childId} />;
|
||||
return <BlockRender key={childId} blockId={childId} />;
|
||||
})}
|
||||
</>
|
||||
</StyledIdentWrapper>
|
||||
) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Indent rendering child nodes
|
||||
*/
|
||||
const StyledIdentWrapper = styled('div')<{ indent?: boolean }>(
|
||||
({ indent }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
// TODO: marginLeft should use theme provided by styled
|
||||
...(indent && { marginLeft: '30px' }),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { Protocol } from '@toeverything/datasource/db-service';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AsyncBlock } from '../editor';
|
||||
import { useBlock } from '../hooks';
|
||||
import { BlockRenderProvider } from './Context';
|
||||
import { NullBlockRender, RenderBlock, RenderBlockProps } from './RenderBlock';
|
||||
|
||||
/**
|
||||
* Render block without children.
|
||||
*/
|
||||
const BlockWithoutChildrenRender = ({ blockId }: RenderBlockProps) => {
|
||||
return (
|
||||
<BlockRenderProvider blockRender={NullBlockRender}>
|
||||
<RenderBlock blockId={blockId} />
|
||||
</BlockRenderProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Render a block, but only one level of children.
|
||||
*/
|
||||
const OneLevelBlockRender = ({ blockId }: RenderBlockProps) => {
|
||||
return (
|
||||
<BlockRenderProvider blockRender={BlockWithoutChildrenRender}>
|
||||
<RenderBlock blockId={blockId} />
|
||||
</BlockRenderProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const KanbanParentBlockRender = ({
|
||||
blockId,
|
||||
active,
|
||||
}: RenderBlockProps & { active?: boolean }) => {
|
||||
return (
|
||||
<BlockBorder active={active}>
|
||||
<BlockWithoutChildrenRender blockId={blockId} />
|
||||
</BlockBorder>
|
||||
);
|
||||
};
|
||||
|
||||
const useBlockProgress = (block?: AsyncBlock) => {
|
||||
// Progress of the progress bar. The range is between 0 and 1.
|
||||
// Default progress is 1, that is 100%.
|
||||
const [progress, setProgress] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!block) {
|
||||
return;
|
||||
}
|
||||
const updateProgress = async () => {
|
||||
const children = await block.children();
|
||||
const todoChildren = children.filter(
|
||||
child => child.type === Protocol.Block.Type.todo
|
||||
);
|
||||
const checkedTodoChildren = todoChildren.filter(
|
||||
child => child.getProperty('checked')?.value === true
|
||||
);
|
||||
setProgress(checkedTodoChildren.length / todoChildren.length);
|
||||
};
|
||||
|
||||
updateProgress();
|
||||
|
||||
const unobserve = block.onUpdate(() => {
|
||||
updateProgress();
|
||||
}, 1);
|
||||
|
||||
return unobserve;
|
||||
}, [block]);
|
||||
|
||||
return progress;
|
||||
};
|
||||
|
||||
const KanbanChildrenRender = ({
|
||||
blockId,
|
||||
activeBlock,
|
||||
}: RenderBlockProps & { activeBlock?: string | null }) => {
|
||||
const { block } = useBlock(blockId);
|
||||
const progress = useBlockProgress(block);
|
||||
|
||||
if (!block || !block?.childrenIds.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<BlockRenderProvider blockRender={NullBlockRender}>
|
||||
<ProgressBar progress={progress} />
|
||||
{block?.childrenIds.map(childId => (
|
||||
<ChildBorder key={childId} active={activeBlock === childId}>
|
||||
<RenderBlock blockId={childId} />
|
||||
</ChildBorder>
|
||||
))}
|
||||
</BlockRenderProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const KanbanBlockRender = ({
|
||||
blockId,
|
||||
activeBlock,
|
||||
}: RenderBlockProps & { activeBlock?: string | null }) => {
|
||||
return (
|
||||
<BlockRenderProvider blockRender={NullBlockRender}>
|
||||
<KanbanParentBlockRender
|
||||
blockId={blockId}
|
||||
active={activeBlock === blockId}
|
||||
/>
|
||||
<KanbanChildrenRender blockId={blockId} activeBlock={activeBlock} />
|
||||
</BlockRenderProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const BlockBorder = styled('div')<{ active?: boolean }>(
|
||||
({ theme, active }) => ({
|
||||
borderRadius: '5px',
|
||||
padding: '0 4px',
|
||||
border: `1px solid ${
|
||||
active ? theme.affine.palette.primary : 'transparent'
|
||||
}`,
|
||||
})
|
||||
);
|
||||
|
||||
const ProgressBar = styled('div')<{ progress?: number }>(
|
||||
({ progress = 1 }) => ({
|
||||
height: '3px',
|
||||
width: '100%',
|
||||
background: '#CFE5FF',
|
||||
borderRadius: '5px',
|
||||
overflow: 'hidden',
|
||||
margin: '12px 0',
|
||||
|
||||
'::after': {
|
||||
content: '""',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
background: '#60A5FA',
|
||||
height: '100%',
|
||||
width: `${(progress * 100).toFixed(2)}%`,
|
||||
transition: 'ease 0.5s all',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const ChildBorder = styled(BlockBorder)(({ active, theme }) => ({
|
||||
border: `1px solid ${active ? theme.affine.palette.primary : '#E0E6EB'}`,
|
||||
margin: '4px 0',
|
||||
}));
|
||||
@@ -0,0 +1,181 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import type {
|
||||
ComponentPropsWithoutRef,
|
||||
ComponentPropsWithRef,
|
||||
CSSProperties,
|
||||
ReactElement,
|
||||
} from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import { CreateView } from '../editor';
|
||||
import { useBlockRender } from './Context';
|
||||
import { NullBlockRender } from './RenderBlock';
|
||||
|
||||
type WithChildrenConfig = {
|
||||
indent: CSSProperties['marginLeft'];
|
||||
};
|
||||
|
||||
const defaultConfig: WithChildrenConfig = {
|
||||
indent: '30px',
|
||||
};
|
||||
|
||||
const TreeView = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ lastItem?: boolean } & ComponentPropsWithRef<'div'>
|
||||
>(({ lastItem = false, children, onClick, ...restProps }, ref) => {
|
||||
return (
|
||||
<TreeWrapper ref={ref} {...restProps}>
|
||||
<StyledTreeView>
|
||||
<VerticalLine last={lastItem} onClick={onClick} />
|
||||
<HorizontalLine last={lastItem} onClick={onClick} />
|
||||
{lastItem && <LastItemRadius />}
|
||||
</StyledTreeView>
|
||||
{/* maybe need a child wrapper */}
|
||||
{children}
|
||||
</TreeWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
const CollapsedNode = forwardRef<
|
||||
HTMLDivElement,
|
||||
ComponentPropsWithoutRef<'div'>
|
||||
>((props, ref) => {
|
||||
return (
|
||||
<TreeView ref={ref} lastItem={true} {...props}>
|
||||
<Collapsed onClick={props.onClick}>···</Collapsed>
|
||||
</TreeView>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Indent rendering child nodes
|
||||
*/
|
||||
export const withTreeViewChildren = (
|
||||
creator: (props: CreateView) => ReactElement,
|
||||
customConfig: Partial<WithChildrenConfig> = {}
|
||||
) => {
|
||||
const config = {
|
||||
...defaultConfig,
|
||||
...customConfig,
|
||||
};
|
||||
|
||||
return (props: CreateView) => {
|
||||
const { block } = props;
|
||||
const { BlockRender } = useBlockRender();
|
||||
const collapsed = block.getProperty('collapsed')?.value;
|
||||
const childrenIds = block.childrenIds;
|
||||
const showChildren =
|
||||
!collapsed &&
|
||||
childrenIds.length > 0 &&
|
||||
BlockRender !== NullBlockRender;
|
||||
|
||||
const handleCollapse = () => {
|
||||
block.setProperty('collapsed', { value: true });
|
||||
};
|
||||
|
||||
const handleExpand = () => {
|
||||
block.setProperty('collapsed', { value: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{creator(props)}
|
||||
|
||||
{collapsed && (
|
||||
<CollapsedNode
|
||||
onClick={handleExpand}
|
||||
style={{ marginLeft: config.indent }}
|
||||
/>
|
||||
)}
|
||||
{showChildren &&
|
||||
childrenIds.map((childId, idx) => {
|
||||
return (
|
||||
<TreeView
|
||||
key={childId}
|
||||
lastItem={idx === childrenIds.length - 1}
|
||||
onClick={handleCollapse}
|
||||
style={{ marginLeft: config.indent }}
|
||||
>
|
||||
<BlockRender key={childId} blockId={childId} />
|
||||
</TreeView>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const TREE_COLOR = '#D5DFE6';
|
||||
// adjust left and right margins of the the tree line
|
||||
const TREE_LINE_LEFT_OFFSET = '-16px';
|
||||
// determine the position of the horizontal line by the type of the item
|
||||
const TREE_LINE_TOP_OFFSET = '20px'; // '50%'
|
||||
const TREE_LINE_WIDTH = '12px';
|
||||
|
||||
const TreeWrapper = styled('div')({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
});
|
||||
|
||||
const StyledTreeView = styled('div')({
|
||||
position: 'absolute',
|
||||
left: TREE_LINE_LEFT_OFFSET,
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
const Line = styled('div')({
|
||||
position: 'absolute',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: TREE_COLOR,
|
||||
// somehow tldraw would override this
|
||||
boxSizing: 'content-box!important' as any,
|
||||
// See [Can I add background color only for padding?](https://stackoverflow.com/questions/14628601/can-i-add-background-color-only-for-padding)
|
||||
backgroundClip: 'content-box',
|
||||
backgroundOrigin: 'content-box',
|
||||
// Increase click hot spot
|
||||
padding: '10px',
|
||||
});
|
||||
|
||||
const VerticalLine = styled(Line)<{ last: boolean }>(({ last }) => ({
|
||||
width: '1px',
|
||||
height: last ? TREE_LINE_TOP_OFFSET : '100%',
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
transform: 'translate(-50%, 0)',
|
||||
|
||||
opacity: last ? 0 : 'unset',
|
||||
}));
|
||||
|
||||
const HorizontalLine = styled(Line)<{ last: boolean }>(({ last }) => ({
|
||||
width: TREE_LINE_WIDTH,
|
||||
height: '1px',
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
top: TREE_LINE_TOP_OFFSET,
|
||||
transform: 'translate(0, -50%)',
|
||||
opacity: last ? 0 : 'unset',
|
||||
}));
|
||||
|
||||
const Collapsed = styled('div')({
|
||||
cursor: 'pointer',
|
||||
display: 'inline-block',
|
||||
color: '#98ACBD',
|
||||
padding: '8px',
|
||||
});
|
||||
|
||||
const LastItemRadius = styled('div')({
|
||||
boxSizing: 'content-box',
|
||||
position: 'absolute',
|
||||
left: '-0.5px',
|
||||
top: 0,
|
||||
height: TREE_LINE_TOP_OFFSET,
|
||||
bottom: '50%',
|
||||
width: TREE_LINE_WIDTH,
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderLeftColor: TREE_COLOR,
|
||||
borderBottomColor: TREE_COLOR,
|
||||
borderTop: 'none',
|
||||
borderRight: 'none',
|
||||
borderRadius: '0 0 0 3px',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
@@ -1,2 +1,5 @@
|
||||
export * from './RenderBlock';
|
||||
export * from './RenderBlockChildren';
|
||||
export { BlockRender, BlockRenderProvider } from './Context';
|
||||
export { NullBlockRender, RenderBlock } from './RenderBlock';
|
||||
export { RenderBlockChildren } from './RenderBlockChildren';
|
||||
export { KanbanBlockRender } from './RenderKanbanBlock';
|
||||
export { withTreeViewChildren } from './WithTreeViewChildren';
|
||||
|
||||
Reference in New Issue
Block a user