mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
init: the first public commit for AFFiNE
This commit is contained in:
12
libs/components/editor-core/.babelrc
Normal file
12
libs/components/editor-core/.babelrc
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nrwl/react/babel",
|
||||
{
|
||||
"runtime": "automatic",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
18
libs/components/editor-core/.eslintrc.json
Normal file
18
libs/components/editor-core/.eslintrc.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": ["plugin:@nrwl/nx/react", "../../../.eslintrc.json"],
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
7
libs/components/editor-core/README.md
Normal file
7
libs/components/editor-core/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# components-editor-core
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test components-editor-core` to execute the unit tests via [Jest](https://jestjs.io).
|
||||
9
libs/components/editor-core/jest.config.js
Normal file
9
libs/components/editor-core/jest.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
displayName: 'components-editor-core',
|
||||
preset: '../../../jest.preset.js',
|
||||
transform: {
|
||||
'^.+\\.[tj]sx?$': 'babel-jest',
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||
coverageDirectory: '../../../coverage/libs/components/editor-core',
|
||||
};
|
||||
15
libs/components/editor-core/package.json
Normal file
15
libs/components/editor-core/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@toeverything/components/editor-core",
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mui/icons-material": "^5.8.4",
|
||||
"date-fns": "^2.28.0",
|
||||
"eventemitter3": "^4.0.7",
|
||||
"hotkeys-js": "^3.9.4",
|
||||
"lru-cache": "^7.10.1",
|
||||
"nanoid": "^4.0.0",
|
||||
"slate": "^0.81.0",
|
||||
"style9": "^0.13.3"
|
||||
}
|
||||
}
|
||||
47
libs/components/editor-core/project.json
Normal file
47
libs/components/editor-core/project.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"sourceRoot": "libs/components/editor-core/src",
|
||||
"projectType": "library",
|
||||
"tags": ["components:editor-core"],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nrwl/web:rollup",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/components/editor-core",
|
||||
"tsConfig": "libs/components/editor-core/tsconfig.lib.json",
|
||||
"project": "libs/components/editor-core/package.json",
|
||||
"entryFile": "libs/components/editor-core/src/index.ts",
|
||||
"external": ["react/jsx-runtime"],
|
||||
"rollupConfig": "libs/rollup.config.cjs",
|
||||
"compiler": "babel",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "libs/components/editor-core/README.md",
|
||||
"input": ".",
|
||||
"output": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"libs/components/editor-core/**/*.{ts,tsx,js,jsx}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"check": {
|
||||
"executor": "./tools/executors/tsCheck:tsCheck"
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/libs/components/editor-core"],
|
||||
"options": {
|
||||
"jestConfig": "libs/components/editor-core/jest.config.js",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
295
libs/components/editor-core/src/RenderRoot.tsx
Normal file
295
libs/components/editor-core/src/RenderRoot.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import type { BlockEditor } from './editor';
|
||||
import { Point } from '@toeverything/utils';
|
||||
import { styled, usePatchNodes } from '@toeverything/components/ui';
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { RootContext } from './contexts';
|
||||
import { SelectionRect, SelectionRef } from './selection';
|
||||
import {
|
||||
Protocol,
|
||||
services,
|
||||
type ReturnUnobserve,
|
||||
} from '@toeverything/datasource/db-service';
|
||||
import { addNewGroup } from './recast-block';
|
||||
|
||||
interface RenderRootProps {
|
||||
editor: BlockEditor;
|
||||
editorElement: () => JSX.Element;
|
||||
/**
|
||||
* Scroll to the bottom of the article visually leave blank
|
||||
*/
|
||||
scrollBlank?: boolean;
|
||||
}
|
||||
|
||||
const MAX_PAGE_WIDTH = 5000;
|
||||
export const MIN_PAGE_WIDTH = 1480;
|
||||
|
||||
export const RenderRoot: FC<PropsWithChildren<RenderRootProps>> = ({
|
||||
editor,
|
||||
editorElement,
|
||||
children,
|
||||
}) => {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const selectionRef = useRef<SelectionRef>(null);
|
||||
const triggeredBySelect = useRef(false);
|
||||
const [container, setContainer] = useState<HTMLDivElement>();
|
||||
const [pageWidth, setPageWidth] = useState<number>(MIN_PAGE_WIDTH);
|
||||
|
||||
const { patch, has, patchedNodes } = usePatchNodes();
|
||||
|
||||
editor.setReactRenderRoot({ patch, has });
|
||||
const rootId = editor.getRootBlockId();
|
||||
const fetchPageBlockWidth = useCallback(async () => {
|
||||
const dbPageBlock = await services.api.editorBlock.getBlock(
|
||||
editor.workspace,
|
||||
rootId
|
||||
);
|
||||
if (!dbPageBlock) return;
|
||||
if (dbPageBlock.getDecoration('fullWidthChecked')) {
|
||||
setPageWidth(MAX_PAGE_WIDTH);
|
||||
} else {
|
||||
setPageWidth(MIN_PAGE_WIDTH);
|
||||
}
|
||||
}, [editor.workspace, rootId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (container) {
|
||||
editor.container = container;
|
||||
editor.getHooks().render();
|
||||
}
|
||||
}, [editor, container]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPageBlockWidth();
|
||||
|
||||
let unobserve: ReturnUnobserve | undefined = undefined;
|
||||
const observe = async () => {
|
||||
unobserve = await services.api.editorBlock.observe(
|
||||
{
|
||||
workspace: editor.workspace,
|
||||
id: rootId,
|
||||
},
|
||||
fetchPageBlockWidth
|
||||
);
|
||||
};
|
||||
observe();
|
||||
return () => {
|
||||
unobserve?.();
|
||||
};
|
||||
}, [rootId, editor, fetchPageBlockWidth]);
|
||||
|
||||
const onMouseMove = async (
|
||||
event: React.MouseEvent<HTMLDivElement, MouseEvent>
|
||||
) => {
|
||||
selectionRef.current?.onMouseMove(event);
|
||||
if (!contentRef.current) {
|
||||
return;
|
||||
}
|
||||
const rootRect: DOMRect = contentRef.current.getBoundingClientRect();
|
||||
editor.getHooks().onRootNodeMouseMove(event, rootRect);
|
||||
|
||||
const slidingBlock = await editor.getBlockByPoint(
|
||||
new Point(event.clientX, event.clientY)
|
||||
);
|
||||
|
||||
if (slidingBlock && slidingBlock.dom) {
|
||||
editor.getHooks().afterOnNodeMouseMove(event, {
|
||||
blockId: slidingBlock.id,
|
||||
dom: slidingBlock.dom,
|
||||
rect: slidingBlock.dom.getBoundingClientRect(),
|
||||
rootRect: rootRect,
|
||||
type: slidingBlock.type,
|
||||
properties: slidingBlock.getProperties(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseDown = (
|
||||
event: React.MouseEvent<HTMLDivElement, MouseEvent>
|
||||
) => {
|
||||
triggeredBySelect.current = true;
|
||||
selectionRef.current?.onMouseDown(event);
|
||||
editor.getHooks().onRootNodeMouseDown(event);
|
||||
};
|
||||
|
||||
const onMouseUp = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
selectionRef.current?.onMouseUp(event);
|
||||
editor.getHooks().onRootNodeMouseUp(event);
|
||||
};
|
||||
|
||||
const onMouseOut = (
|
||||
event: React.MouseEvent<HTMLDivElement, MouseEvent>
|
||||
) => {
|
||||
editor.getHooks().onRootNodeMouseOut(event);
|
||||
};
|
||||
|
||||
const onMouseLeave = (
|
||||
event: React.MouseEvent<HTMLDivElement, MouseEvent>
|
||||
) => {
|
||||
editor.getHooks().onRootNodeMouseLeave(event);
|
||||
};
|
||||
|
||||
const onKeyDown: React.KeyboardEventHandler<HTMLDivElement> = event => {
|
||||
// IMP move into keyboard managers?
|
||||
editor.getHooks().onRootNodeKeyDown(event);
|
||||
};
|
||||
|
||||
const onKeyUp: React.KeyboardEventHandler<HTMLDivElement> = event => {
|
||||
// IMP move into keyboard managers?
|
||||
editor.getHooks().onRootNodeKeyUp(event);
|
||||
};
|
||||
|
||||
const onKeyDownCapture: React.KeyboardEventHandler<
|
||||
HTMLDivElement
|
||||
> = event => {
|
||||
editor.getHooks().onRootNodeKeyDownCapture(event);
|
||||
};
|
||||
|
||||
const onDragOver = (event: React.DragEvent<Element>) => {
|
||||
if (!contentRef.current) {
|
||||
return;
|
||||
}
|
||||
const rootRect: DOMRect = contentRef.current.getBoundingClientRect();
|
||||
editor.dragDropManager.handlerEditorDragOver(event);
|
||||
if (editor.dragDropManager.isEnabled()) {
|
||||
editor.getHooks().onRootNodeDragOver(event, rootRect);
|
||||
}
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const onDragOverCapture = (event: React.DragEvent<Element>) => {
|
||||
if (!contentRef.current) {
|
||||
return;
|
||||
}
|
||||
const rootRect: DOMRect = contentRef.current.getBoundingClientRect();
|
||||
if (editor.dragDropManager.isEnabled()) {
|
||||
editor.getHooks().onRootNodeDragOver(event, rootRect);
|
||||
}
|
||||
};
|
||||
|
||||
const onDragEnd = (event: React.DragEvent<Element>) => {
|
||||
const rootRect: DOMRect = contentRef.current.getBoundingClientRect();
|
||||
editor.dragDropManager.handlerEditorDragEnd(event);
|
||||
editor.getHooks().onRootNodeDragEnd(event, rootRect);
|
||||
};
|
||||
|
||||
const onDrop = (event: React.DragEvent<Element>) => {
|
||||
editor.dragDropManager.handlerEditorDrop(event);
|
||||
editor.getHooks().onRootNodeDrop(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<RootContext.Provider value={{ editor, editorElement }}>
|
||||
<Container
|
||||
isWhiteboard={editor.isWhiteboard}
|
||||
ref={ref => {
|
||||
ref && setContainer(ref);
|
||||
}}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseOut={onMouseOut}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyDownCapture={onKeyDownCapture}
|
||||
onKeyUp={onKeyUp}
|
||||
onDragOver={onDragOver}
|
||||
onDragOverCapture={onDragOverCapture}
|
||||
onDragEnd={onDragEnd}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<Content
|
||||
ref={contentRef}
|
||||
style={{ maxWidth: pageWidth + 'px' }}
|
||||
>
|
||||
{children}
|
||||
{patchedNodes}
|
||||
</Content>
|
||||
{editor.isWhiteboard ? null : <ScrollBlank editor={editor} />}
|
||||
{/** TODO: remove selectionManager insert */}
|
||||
{container && editor && (
|
||||
<SelectionRect
|
||||
ref={selectionRef}
|
||||
container={container}
|
||||
editor={editor}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
</RootContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
function ScrollBlank({ editor }: { editor: BlockEditor }) {
|
||||
const mouseMoved = useRef(false);
|
||||
|
||||
const onMouseDown = useCallback(() => (mouseMoved.current = false), []);
|
||||
const onMouseMove = useCallback(() => (mouseMoved.current = true), []);
|
||||
const onClick = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
if (mouseMoved.current) {
|
||||
mouseMoved.current = false;
|
||||
return;
|
||||
}
|
||||
const lastBlock = await editor.getRootLastChildrenBlock();
|
||||
|
||||
const lastGroupBlock = await editor.getRootLastChildrenBlock();
|
||||
// If last block is not a group
|
||||
// create a group with a empty text
|
||||
if (lastGroupBlock.type !== 'group') {
|
||||
addNewGroup(editor, lastBlock, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastGroupBlock.childrenIds.length > 1) {
|
||||
addNewGroup(editor, lastBlock, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the **only** block in the group is text and is empty
|
||||
// active the text block
|
||||
const theGroupChildBlock = await lastGroupBlock.firstChild();
|
||||
|
||||
if (
|
||||
theGroupChildBlock &&
|
||||
theGroupChildBlock.type === Protocol.Block.Type.text &&
|
||||
theGroupChildBlock.blockProvider?.isEmpty()
|
||||
) {
|
||||
await editor.selectionManager.activeNodeByNodeId(
|
||||
theGroupChildBlock.id
|
||||
);
|
||||
return;
|
||||
}
|
||||
// else create a new group
|
||||
addNewGroup(editor, lastBlock, true);
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollBlankContainter
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Container = styled('div')(
|
||||
({ isWhiteboard }: { isWhiteboard: boolean }) => ({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflowY: isWhiteboard ? 'unset' : 'auto',
|
||||
padding: isWhiteboard ? 0 : '96px 150px 0 150px',
|
||||
minWidth: isWhiteboard ? 'unset' : '940px',
|
||||
})
|
||||
);
|
||||
|
||||
const Content = styled('div')({
|
||||
width: '100%',
|
||||
margin: '0 auto',
|
||||
transitionDuration: '.2s',
|
||||
transitionTimingFunction: 'ease-in',
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
const ScrollBlankContainter = styled('div')({ paddingBottom: '30vh' });
|
||||
@@ -0,0 +1,21 @@
|
||||
import { AsyncBlock, BlockEditor } from '../editor';
|
||||
import type { FC, ReactElement } from 'react';
|
||||
import { BlockPendantProvider } from '../block-pendant';
|
||||
import { DragDropWrapper } from '../drag-drop-wrapper';
|
||||
|
||||
type BlockContentWrapperProps = {
|
||||
block: AsyncBlock;
|
||||
editor: BlockEditor;
|
||||
children: ReactElement | null;
|
||||
};
|
||||
|
||||
export const WrapperWithPendantAndDragDrop: FC<BlockContentWrapperProps> =
|
||||
function ({ block, children, editor }) {
|
||||
return (
|
||||
<DragDropWrapper block={block} editor={editor}>
|
||||
<BlockPendantProvider block={block}>
|
||||
{children}
|
||||
</BlockPendantProvider>
|
||||
</DragDropWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './BlockContentWrapper';
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { CSSProperties, useRef } from 'react';
|
||||
import { Add } from '@mui/icons-material';
|
||||
import {
|
||||
Popover,
|
||||
type PopoverProps,
|
||||
PopperHandler,
|
||||
} from '@toeverything/components/ui';
|
||||
import { CreatePendantPanel } from './pendant-operation-panel';
|
||||
import { IconButton } from './StyledComponent';
|
||||
import { AsyncBlock } from '../editor';
|
||||
|
||||
type Props = {
|
||||
block: AsyncBlock;
|
||||
onSure?: () => void;
|
||||
iconStyle?: CSSProperties;
|
||||
} & Omit<PopoverProps, 'content'>;
|
||||
export const AddPendantPopover = ({
|
||||
block,
|
||||
onSure,
|
||||
iconStyle,
|
||||
...popoverProps
|
||||
}: Props) => {
|
||||
const popoverHandlerRef = useRef<PopperHandler>();
|
||||
return (
|
||||
<Popover
|
||||
ref={popoverHandlerRef}
|
||||
content={
|
||||
<CreatePendantPanel
|
||||
block={block}
|
||||
onSure={() => {
|
||||
popoverHandlerRef.current?.setVisible(false);
|
||||
onSure?.();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
placement="bottom-start"
|
||||
// visible={true}
|
||||
style={{ padding: 0 }}
|
||||
{...popoverProps}
|
||||
>
|
||||
<IconButton style={{ marginRight: 12, ...iconStyle }}>
|
||||
<Add sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { styled, Popover } from '@toeverything/components/ui';
|
||||
import type { AsyncBlock } from '../editor';
|
||||
import { PendantPopover } from './pendant-popover';
|
||||
import { PendantRender } from './pendant-render';
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
interface BlockTagProps {
|
||||
block: AsyncBlock;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Need to be refactored
|
||||
*/
|
||||
export const BlockPendantProvider: FC<PropsWithChildren<BlockTagProps>> = ({
|
||||
block,
|
||||
children,
|
||||
}) => {
|
||||
const [container, setContainer] = useState<HTMLElement>(null);
|
||||
return (
|
||||
<Container ref={(dom: HTMLElement) => setContainer(dom)}>
|
||||
{children}
|
||||
{container && (
|
||||
<PendantPopover block={block} container={container}>
|
||||
<TriggerLine className="triggerLine" />
|
||||
</PendantPopover>
|
||||
)}
|
||||
<PendantRender block={block} />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const Container = styled('div')({
|
||||
position: 'relative',
|
||||
padding: '4px',
|
||||
'&:hover .triggerLine::after': {
|
||||
display: 'flex',
|
||||
},
|
||||
});
|
||||
|
||||
const TriggerLine = styled('div')`
|
||||
padding: 4px 0;
|
||||
width: 100px;
|
||||
cursor: default;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
position: relative;
|
||||
//background: red;
|
||||
::after {
|
||||
content: '';
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: #d9d9d9;
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
}
|
||||
`;
|
||||
317
libs/components/editor-core/src/block-pendant/PendantTag.tsx
Normal file
317
libs/components/editor-core/src/block-pendant/PendantTag.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import React from 'react';
|
||||
import { Tag, type TagProps } from '@toeverything/components/ui';
|
||||
import {
|
||||
DateValue,
|
||||
InformationProperty,
|
||||
InformationValue,
|
||||
MentionValue,
|
||||
MultiSelectProperty,
|
||||
MultiSelectValue,
|
||||
RecastBlockValue,
|
||||
RecastMetaProperty,
|
||||
SelectOption,
|
||||
SelectProperty,
|
||||
SelectValue,
|
||||
StatusProperty,
|
||||
StatusValue,
|
||||
TextValue,
|
||||
} from '../recast-block';
|
||||
import { IconNames, PendantTypes } from './types';
|
||||
import format from 'date-fns/format';
|
||||
import { IconMap, pendantColors } from './config';
|
||||
|
||||
type PendantTagProps = {
|
||||
value: RecastBlockValue;
|
||||
property: RecastMetaProperty;
|
||||
} & TagProps;
|
||||
|
||||
const MultiSelectRender = ({
|
||||
options,
|
||||
tagProps,
|
||||
}: {
|
||||
options: SelectOption[];
|
||||
tagProps: TagProps;
|
||||
}) => {
|
||||
const { style, ...otherProps } = tagProps;
|
||||
return (
|
||||
<>
|
||||
{options.map((option: SelectOption) => {
|
||||
const Icon = IconMap[option.iconName as IconNames];
|
||||
return (
|
||||
<Tag
|
||||
key={option.id}
|
||||
{...otherProps}
|
||||
style={{
|
||||
background: style?.background || option.background,
|
||||
color: style?.color || option.color,
|
||||
...style,
|
||||
}}
|
||||
startElement={
|
||||
Icon && (
|
||||
<Icon
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: style?.color || option.color,
|
||||
marginRight: '4px',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{option.name}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const PendantTag = (props: PendantTagProps) => {
|
||||
const { value, property, ...tagProps } = props;
|
||||
const {
|
||||
background: customBackground,
|
||||
color: customColor,
|
||||
iconName,
|
||||
} = property;
|
||||
const { style: styleTagStyle, ...otherTagProps } = tagProps;
|
||||
const type = value.type;
|
||||
const { background: defaultBackground, color: defaultColor } =
|
||||
pendantColors[type];
|
||||
|
||||
const background = customBackground || defaultBackground;
|
||||
const color = customColor || defaultColor;
|
||||
const Icon = IconMap[iconName as IconNames];
|
||||
if (value.type === PendantTypes.Text) {
|
||||
const { value: textValue } = value as TextValue;
|
||||
return (
|
||||
<Tag
|
||||
style={{
|
||||
background,
|
||||
color,
|
||||
...styleTagStyle,
|
||||
}}
|
||||
startElement={
|
||||
Icon && (
|
||||
<Icon
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: color || '#fff',
|
||||
marginRight: '4px',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{...otherTagProps}
|
||||
>
|
||||
{textValue}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
if (value.type === PendantTypes.Date) {
|
||||
const { value: dateValue } = value as DateValue;
|
||||
if (Array.isArray(dateValue)) {
|
||||
return (
|
||||
<Tag
|
||||
style={{
|
||||
background,
|
||||
color,
|
||||
...styleTagStyle,
|
||||
}}
|
||||
startElement={
|
||||
Icon && (
|
||||
<Icon
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: color || '#fff',
|
||||
marginRight: '4px',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{...otherTagProps}
|
||||
>
|
||||
{format(dateValue[0], 'yyyy-MM-dd')} ~{' '}
|
||||
{format(dateValue[1], 'yyyy-MM-dd')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tag
|
||||
style={{
|
||||
background,
|
||||
color,
|
||||
...styleTagStyle,
|
||||
}}
|
||||
startElement={
|
||||
Icon && (
|
||||
<Icon
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: color || '#fff',
|
||||
marginRight: '4px',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{...otherTagProps}
|
||||
>
|
||||
{format(dateValue, 'yyyy-MM-dd')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
if (value.type === PendantTypes.MultiSelect) {
|
||||
const { value: multiSelectValue } = value as MultiSelectValue;
|
||||
const selectedOptions = (
|
||||
property as MultiSelectProperty
|
||||
).options.filter(o => multiSelectValue.includes(o.id));
|
||||
|
||||
if (selectedOptions.length) {
|
||||
return (
|
||||
<MultiSelectRender
|
||||
options={selectedOptions}
|
||||
tagProps={tagProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (value.type === PendantTypes.Select) {
|
||||
const { value: selectValue } = value as SelectValue;
|
||||
const { style, ...otherProps } = tagProps;
|
||||
|
||||
const option = (property as SelectProperty).options.find(
|
||||
o => o.id === selectValue
|
||||
);
|
||||
const OptionIcon = IconMap[option.iconName as IconNames];
|
||||
return (
|
||||
<Tag
|
||||
style={{
|
||||
background: option.background,
|
||||
color: option.color,
|
||||
...style,
|
||||
}}
|
||||
startElement={
|
||||
OptionIcon && (
|
||||
<OptionIcon
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: option.color || '#fff',
|
||||
marginRight: '4px',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{...otherProps}
|
||||
>
|
||||
{option.name}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
if (value.type === PendantTypes.Status) {
|
||||
const { value: statusValue } = value as StatusValue;
|
||||
const { style, ...otherProps } = tagProps;
|
||||
|
||||
const option = (property as StatusProperty).options.find(
|
||||
o => o.id === statusValue
|
||||
);
|
||||
const OptionIcon = IconMap[option.iconName as IconNames];
|
||||
|
||||
return (
|
||||
<Tag
|
||||
style={{
|
||||
background: option.background,
|
||||
color: option.color,
|
||||
...style,
|
||||
}}
|
||||
startElement={
|
||||
OptionIcon && (
|
||||
<OptionIcon
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: option.color || '#fff',
|
||||
marginRight: '4px',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{...otherProps}
|
||||
>
|
||||
{option.name}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
if (value.type === PendantTypes.Mention) {
|
||||
const { value: mentionValue } = value as MentionValue;
|
||||
|
||||
return (
|
||||
<Tag
|
||||
style={{
|
||||
background,
|
||||
color,
|
||||
...styleTagStyle,
|
||||
}}
|
||||
startElement={
|
||||
Icon && (
|
||||
<Icon
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: color || '#fff',
|
||||
marginRight: '4px',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{...otherTagProps}
|
||||
>
|
||||
@{mentionValue}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
if (value.type === PendantTypes.Information) {
|
||||
const {
|
||||
value: { email, location, phone },
|
||||
} = value as InformationValue;
|
||||
const { emailOptions, phoneOptions, locationOptions } =
|
||||
property as InformationProperty;
|
||||
|
||||
const emailSelectedOptions = emailOptions.filter(o =>
|
||||
email.includes(o.id)
|
||||
);
|
||||
const phoneSelectedOptions = phoneOptions.filter(o =>
|
||||
phone.includes(o.id)
|
||||
);
|
||||
const locationSelectedOptions = locationOptions.filter(o =>
|
||||
location.includes(o.id)
|
||||
);
|
||||
|
||||
if (
|
||||
emailSelectedOptions.length ||
|
||||
phoneSelectedOptions.length ||
|
||||
locationSelectedOptions.length
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<MultiSelectRender
|
||||
options={emailSelectedOptions}
|
||||
tagProps={tagProps}
|
||||
/>
|
||||
<MultiSelectRender
|
||||
options={phoneSelectedOptions}
|
||||
tagProps={tagProps}
|
||||
/>
|
||||
<MultiSelectRender
|
||||
options={locationSelectedOptions}
|
||||
tagProps={tagProps}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <Tag {...tagProps}>{property.name}</Tag>;
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
import React from 'react';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
export const IconButton = styled('button')`
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: #f5f7f8;
|
||||
color: #98acbd;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const commonStyle = {
|
||||
height: '24px',
|
||||
padding: '0 8px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
fontSize: '12px',
|
||||
borderRadius: '5px',
|
||||
};
|
||||
|
||||
export const StyledSureButton = styled('button')(({ theme }) => ({
|
||||
...commonStyle,
|
||||
background: theme.affine.palette.primary,
|
||||
color: '#fff',
|
||||
}));
|
||||
|
||||
export const StyledCancelButton = styled('button')(({ theme }) => ({
|
||||
...commonStyle,
|
||||
background: '#fff',
|
||||
color: theme.affine.palette.primaryText,
|
||||
border: `1px solid ${theme.affine.palette.borderColor}`,
|
||||
}));
|
||||
|
||||
export const StyledPopoverWrapper = styled('div')`
|
||||
padding: 24px;
|
||||
width: 332px;
|
||||
color: #4c6275;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
export const StyledOperationWrapper = styled('div')<{ isFocus: boolean }>(
|
||||
({ isFocus, theme }) => {
|
||||
return {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderTop: `1px solid`,
|
||||
borderBottom: `1px solid`,
|
||||
borderColor: isFocus
|
||||
? `${theme.affine.palette.primary}`
|
||||
: 'transparent',
|
||||
transition: 'border .1s',
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const StyledOperationTitle = styled('div')(({ theme }) => {
|
||||
return {
|
||||
color: theme.affine.palette.secondaryText,
|
||||
fontSize: 20,
|
||||
marginBottom: 12,
|
||||
fontWeight: 'bold',
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledOperationLabel = styled('div')(({ theme }) => {
|
||||
return {
|
||||
color: theme.affine.palette.secondaryText,
|
||||
fontSize: 12,
|
||||
marginBottom: 12,
|
||||
fontWeight: 'bold',
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledInputEndAdornment = styled('div')`
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export const StyledDivider = styled('div')`
|
||||
height: 1px;
|
||||
padding: 12px 0;
|
||||
margin: 12px 0;
|
||||
position: relative;
|
||||
&::after {
|
||||
content: '';
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: #e0e6eb;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
margin: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledPopoverContent = styled('div')(({ theme }) => {
|
||||
return {
|
||||
color: theme.affine.palette.secondaryText,
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 12px',
|
||||
marginBottom: '12px',
|
||||
};
|
||||
});
|
||||
export const StyledPopoverSubTitle = styled('div')(({ theme }) => {
|
||||
return {
|
||||
color: theme.affine.palette.secondaryText,
|
||||
fontSize: '16px',
|
||||
marginBottom: '12px',
|
||||
fontWeight: 'bold',
|
||||
padding: '0 12px',
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledHighLightWrapper = styled('div')<{
|
||||
isFocus: boolean;
|
||||
}>(({ isFocus, theme }) => {
|
||||
return {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderTop: `1px solid`,
|
||||
borderBottom: `1px solid`,
|
||||
borderColor: isFocus ? theme.affine.palette.primary : 'transparent',
|
||||
transition: 'border .1s',
|
||||
};
|
||||
});
|
||||
212
libs/components/editor-core/src/block-pendant/config.ts
Normal file
212
libs/components/editor-core/src/block-pendant/config.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import {
|
||||
PendantIconConfig,
|
||||
PendantOptions,
|
||||
PendantTypes,
|
||||
IconNames,
|
||||
} from './types';
|
||||
import {
|
||||
MultiSelectIcon,
|
||||
SingleSelectIcon,
|
||||
TextFontIcon,
|
||||
DateIcon,
|
||||
CollaboratorIcon,
|
||||
StatusIcon,
|
||||
PhoneIcon,
|
||||
InformationIcon,
|
||||
LocationIcon,
|
||||
EmailIcon,
|
||||
} from '@toeverything/components/icons';
|
||||
|
||||
// export const selectColors = [
|
||||
// { background: '#C3DBFF', color: '#253486' },
|
||||
// { background: '#C6F1F3', color: '#0C6066' },
|
||||
// { background: '#C5FBE0', color: '#05683D' },
|
||||
// { background: '#FFF5AB', color: '#896406' },
|
||||
// { background: '#FFCCA7', color: '#8F4500' },
|
||||
// { background: '#FFCECE', color: '#AF1212' },
|
||||
// { background: '#E3DEFF', color: '#511AAB' },
|
||||
// ];
|
||||
//
|
||||
// export const statusSelectColors = [
|
||||
// { background: '#C5FBE0', color: '#05683D' },
|
||||
// { background: '#FFF5AB', color: '#896406' },
|
||||
// { background: '#FFCECE', color: '#AF1212' },
|
||||
// { background: '#E3DEFF', color: '#511AAB' },
|
||||
// ];
|
||||
|
||||
export const pendantColors = {
|
||||
[PendantTypes.Text]: {
|
||||
background: '#7389FD',
|
||||
color: '#FFF',
|
||||
},
|
||||
[PendantTypes.Date]: {
|
||||
background: '#5DC6CD',
|
||||
color: '#FFF',
|
||||
},
|
||||
[PendantTypes.Select]: {
|
||||
background: '#EAAE14',
|
||||
color: '#FFF',
|
||||
},
|
||||
[PendantTypes.MultiSelect]: {
|
||||
background: '#A691FC',
|
||||
color: '#FFF',
|
||||
},
|
||||
[PendantTypes.Mention]: {
|
||||
background: '#57C696',
|
||||
color: '#FFF',
|
||||
},
|
||||
[PendantTypes.Status]: {
|
||||
background: '#57C696',
|
||||
color: '#FFF',
|
||||
},
|
||||
[PendantTypes.Information]: {
|
||||
background: '#57C696',
|
||||
color: '#FFF',
|
||||
},
|
||||
};
|
||||
|
||||
export const IconMap = {
|
||||
[IconNames.TEXT]: TextFontIcon,
|
||||
[IconNames.DATE]: DateIcon,
|
||||
[IconNames.STATUS]: StatusIcon,
|
||||
[IconNames.MULTI_SELECT]: MultiSelectIcon,
|
||||
[IconNames.SINGLE_SELECT]: SingleSelectIcon,
|
||||
[IconNames.COLLABORATOR]: CollaboratorIcon,
|
||||
[IconNames.INFORMATION]: InformationIcon,
|
||||
[IconNames.PHONE]: PhoneIcon,
|
||||
[IconNames.LOCATION]: LocationIcon,
|
||||
[IconNames.EMAIL]: EmailIcon,
|
||||
};
|
||||
|
||||
export const pendantIconConfig: { [key: string]: PendantIconConfig } = {
|
||||
[PendantTypes.Text]: {
|
||||
name: IconNames.TEXT,
|
||||
background: '#67dcaa',
|
||||
color: '#FFF',
|
||||
},
|
||||
[PendantTypes.Date]: { name: IconNames.DATE, background: '', color: '' },
|
||||
[PendantTypes.Status]: {
|
||||
name: IconNames.STATUS,
|
||||
background: ['#C5FBE0', '#FFF5AB', '#FFCECE', '#E3DEFF'],
|
||||
color: ['#05683D', '#896406', '#AF1212', '#511AAB'],
|
||||
},
|
||||
[PendantTypes.Select]: {
|
||||
name: IconNames.SINGLE_SELECT,
|
||||
background: [
|
||||
'#C3DBFF',
|
||||
'#C6F1F3',
|
||||
'#C5FBE0',
|
||||
'#FFF5AB',
|
||||
'#FFCCA7',
|
||||
'#FFCECE',
|
||||
'#E3DEFF',
|
||||
],
|
||||
color: [
|
||||
'#253486',
|
||||
'#0C6066',
|
||||
'#05683D',
|
||||
'#896406',
|
||||
'#8F4500',
|
||||
'#AF1212',
|
||||
'#511AAB',
|
||||
],
|
||||
},
|
||||
|
||||
[PendantTypes.MultiSelect]: {
|
||||
name: IconNames.MULTI_SELECT,
|
||||
background: [
|
||||
'#C3DBFF',
|
||||
'#C6F1F3',
|
||||
'#C5FBE0',
|
||||
'#FFF5AB',
|
||||
'#FFCCA7',
|
||||
'#FFCECE',
|
||||
'#E3DEFF',
|
||||
],
|
||||
color: [
|
||||
'#253486',
|
||||
'#0C6066',
|
||||
'#05683D',
|
||||
'#896406',
|
||||
'#8F4500',
|
||||
'#AF1212',
|
||||
'#511AAB',
|
||||
],
|
||||
},
|
||||
[PendantTypes.Mention]: {
|
||||
name: IconNames.COLLABORATOR,
|
||||
background: '#FFD568',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
[PendantTypes.Information]: {
|
||||
name: IconNames.INFORMATION,
|
||||
background: '#FFD568',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
Phone: {
|
||||
name: IconNames.PHONE,
|
||||
background: '#c3dbff',
|
||||
color: '#263486',
|
||||
},
|
||||
Location: {
|
||||
name: IconNames.LOCATION,
|
||||
background: '#c5f1f3',
|
||||
color: '#263486',
|
||||
},
|
||||
Email: {
|
||||
name: IconNames.EMAIL,
|
||||
background: '#ffcca7',
|
||||
color: '#8f4400',
|
||||
},
|
||||
};
|
||||
|
||||
export const pendantOptions: PendantOptions[] = [
|
||||
{
|
||||
name: 'Text',
|
||||
type: PendantTypes.Text,
|
||||
iconName: IconNames.TEXT,
|
||||
subTitle: 'Add KeyWords',
|
||||
},
|
||||
{
|
||||
name: 'Date',
|
||||
type: PendantTypes.Date,
|
||||
iconName: IconNames.DATE,
|
||||
subTitle: '',
|
||||
},
|
||||
{
|
||||
name: 'Status',
|
||||
type: PendantTypes.Status,
|
||||
iconName: IconNames.STATUS,
|
||||
subTitle: '',
|
||||
},
|
||||
{
|
||||
name: 'Single Select',
|
||||
type: PendantTypes.Select,
|
||||
iconName: IconNames.SINGLE_SELECT,
|
||||
subTitle: 'Select A Options',
|
||||
},
|
||||
{
|
||||
name: 'Multiple Select',
|
||||
type: PendantTypes.MultiSelect,
|
||||
iconName: IconNames.MULTI_SELECT,
|
||||
subTitle: 'Create A List Of Options',
|
||||
},
|
||||
{
|
||||
name: 'Collaborator',
|
||||
type: PendantTypes.Mention,
|
||||
iconName: IconNames.COLLABORATOR,
|
||||
subTitle: 'Assign People',
|
||||
},
|
||||
{
|
||||
name: 'Information',
|
||||
type: PendantTypes.Information,
|
||||
iconName: IconNames.INFORMATION,
|
||||
subTitle: '',
|
||||
},
|
||||
// {
|
||||
// name: 'Information',
|
||||
// type: [PendantTypes.Phone, PendantTypes.Location, PendantTypes.Email],
|
||||
// icon: InformationIcon,
|
||||
// subTitle: '',
|
||||
// },
|
||||
];
|
||||
1
libs/components/editor-core/src/block-pendant/index.ts
Normal file
1
libs/components/editor-core/src/block-pendant/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './BlockPendantProvider';
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { ReactNode, useRef, useState } from 'react';
|
||||
import { getLatestPropertyValue } from '../utils';
|
||||
import {
|
||||
getRecastItemValue,
|
||||
RecastMetaProperty,
|
||||
useRecastBlock,
|
||||
useRecastBlockMeta,
|
||||
RecastBlockValue,
|
||||
} from '../../recast-block';
|
||||
import { AsyncBlock } from '../../editor';
|
||||
import { Popover, PopperHandler, styled } from '@toeverything/components/ui';
|
||||
import { PendantTag } from '../PendantTag';
|
||||
import { UpdatePendantPanel } from '../pendant-operation-panel';
|
||||
|
||||
export const PendantHistoryPanel = ({
|
||||
block,
|
||||
endElement,
|
||||
}: {
|
||||
block: AsyncBlock;
|
||||
endElement?: ReactNode;
|
||||
}) => {
|
||||
const [propertyWithValue, setPropertyWithValue] = useState<{
|
||||
value: RecastBlockValue;
|
||||
property: RecastMetaProperty;
|
||||
}>();
|
||||
const popoverHandlerRef = useRef<{ [key: string]: PopperHandler }>({});
|
||||
|
||||
const { getProperty } = useRecastBlockMeta();
|
||||
|
||||
const { getAllValue } = getRecastItemValue(block);
|
||||
|
||||
const recastBlock = useRecastBlock();
|
||||
|
||||
const latestPropertyValues = getLatestPropertyValue({
|
||||
recastBlockId: recastBlock.id,
|
||||
blockId: block.id,
|
||||
});
|
||||
const blockValues = getAllValue();
|
||||
|
||||
const history = latestPropertyValues
|
||||
.filter(latest => !blockValues.find(v => v && v.id === latest.value.id))
|
||||
.map(v => v.value);
|
||||
|
||||
return (
|
||||
<StyledPendantHistoryPanel>
|
||||
{history.map(item => {
|
||||
const property = getProperty(item.id);
|
||||
return (
|
||||
<Popover
|
||||
key={item.id}
|
||||
ref={ref => {
|
||||
popoverHandlerRef.current[item.id] = ref;
|
||||
}}
|
||||
placement="bottom-start"
|
||||
content={
|
||||
propertyWithValue && (
|
||||
<UpdatePendantPanel
|
||||
block={block}
|
||||
value={propertyWithValue.value}
|
||||
property={propertyWithValue.property}
|
||||
hasDelete={false}
|
||||
onSure={() => {
|
||||
popoverHandlerRef.current[
|
||||
item.id
|
||||
].setVisible(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
popoverHandlerRef.current[
|
||||
item.id
|
||||
].setVisible(false);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
trigger="click"
|
||||
>
|
||||
<PendantTag
|
||||
style={{
|
||||
background: '#F5F7F8',
|
||||
color: '#98ACBD',
|
||||
marginRight: 12,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
property={property as RecastMetaProperty}
|
||||
value={item}
|
||||
onClick={e => {
|
||||
if (property) {
|
||||
setPropertyWithValue({
|
||||
property,
|
||||
value: item,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
})}
|
||||
{endElement}
|
||||
</StyledPendantHistoryPanel>
|
||||
);
|
||||
};
|
||||
const StyledPendantHistoryPanel = styled('div')`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './PendantHistoryPanel';
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { ModifyPanelContentProps } from './types';
|
||||
import {
|
||||
MuiSwitch as Switch,
|
||||
DateRange,
|
||||
styled,
|
||||
Calendar,
|
||||
type Range,
|
||||
} from '@toeverything/components/ui';
|
||||
|
||||
export default ({ onValueChange, initialValue }: ModifyPanelContentProps) => {
|
||||
const [dateTime, setDateTime] = useState<Date | null>(
|
||||
initialValue && !Array.isArray(initialValue.value)
|
||||
? new Date(initialValue.value as number)
|
||||
: null
|
||||
);
|
||||
|
||||
const [isRange, setIsRange] = useState<boolean>(
|
||||
Array.isArray(initialValue?.value) || false
|
||||
);
|
||||
|
||||
const [dateRange, setDateRange] = useState<Range[]>([
|
||||
{
|
||||
startDate:
|
||||
initialValue && Array.isArray(initialValue.value)
|
||||
? new Date(initialValue.value[0])
|
||||
: new Date(),
|
||||
endDate:
|
||||
initialValue && Array.isArray(initialValue.value)
|
||||
? new Date(initialValue.value[1])
|
||||
: null,
|
||||
key: 'selection',
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!initialValue && (
|
||||
<StyledSwitchContainer>
|
||||
<StyledSwitchLabel>Use Date Range</StyledSwitchLabel>
|
||||
<Switch
|
||||
size="small"
|
||||
onChange={(e, checked) => {
|
||||
setIsRange(checked);
|
||||
}}
|
||||
/>
|
||||
</StyledSwitchContainer>
|
||||
)}
|
||||
|
||||
<StyledDateContainer
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{isRange ? (
|
||||
<DateRange
|
||||
// editableDateInputs={true}
|
||||
onChange={({ selection }) => {
|
||||
const { startDate, endDate } = selection;
|
||||
setDateRange([selection]);
|
||||
|
||||
if (startDate && endDate) {
|
||||
onValueChange([
|
||||
startDate.getTime(),
|
||||
endDate.getTime(),
|
||||
]);
|
||||
}
|
||||
}}
|
||||
moveRangeOnFirstSelection={false}
|
||||
ranges={dateRange}
|
||||
/>
|
||||
) : (
|
||||
<Calendar
|
||||
date={dateTime}
|
||||
onChange={date => {
|
||||
setDateTime(date);
|
||||
date && onValueChange(date.getTime());
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</StyledDateContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledSwitchContainer = styled('div')`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
const StyledSwitchLabel = styled('span')`
|
||||
font-size: 14px;
|
||||
margin-right: 10px;
|
||||
`;
|
||||
|
||||
/**
|
||||
* DateRange & Calendar width is calc by their container width, but include container`s padding,
|
||||
* and this calc width style is just right
|
||||
* so there have to set margin negative
|
||||
* **/
|
||||
const StyledDateContainer = styled('div')`
|
||||
margin-left: -24px;
|
||||
`;
|
||||
@@ -0,0 +1,18 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Input } from '@toeverything/components/ui';
|
||||
import { ModifyPanelContentProps } from './types';
|
||||
|
||||
export default ({ onValueChange, initialValue }: ModifyPanelContentProps) => {
|
||||
const [text, setText] = useState(initialValue?.value || '');
|
||||
return (
|
||||
<Input
|
||||
placeholder="Input email"
|
||||
value={text}
|
||||
onChange={e => {
|
||||
setText(e.target.value);
|
||||
onValueChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { type CSSProperties, useState } from 'react';
|
||||
import { Input, styled, InputProps } from '@toeverything/components/ui';
|
||||
import { StyledHighLightWrapper } from '../StyledComponent';
|
||||
import { IconNames } from '../types';
|
||||
import { IconMap } from '../config';
|
||||
|
||||
type IconInputProps = PendantIconProps & InputProps;
|
||||
|
||||
type PendantIconProps = {
|
||||
iconName?: IconNames;
|
||||
background?: CSSProperties['background'];
|
||||
color?: CSSProperties['color'];
|
||||
iconStyle?: CSSProperties;
|
||||
};
|
||||
|
||||
export const PendantIcon = ({
|
||||
iconName,
|
||||
iconStyle,
|
||||
color,
|
||||
background,
|
||||
}: PendantIconProps) => {
|
||||
const Icon = IconMap[iconName];
|
||||
return (
|
||||
Icon && (
|
||||
<StyledIconWrapper style={{ ...iconStyle, color, background }}>
|
||||
<Icon style={{ fontSize: 16, color }} />
|
||||
</StyledIconWrapper>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const IconInput = ({
|
||||
iconName,
|
||||
iconStyle,
|
||||
color,
|
||||
background,
|
||||
...inputProps
|
||||
}: IconInputProps) => {
|
||||
return (
|
||||
<>
|
||||
<PendantIcon
|
||||
iconName={iconName}
|
||||
iconStyle={iconStyle}
|
||||
color={color}
|
||||
background={background}
|
||||
/>
|
||||
<Input
|
||||
style={{
|
||||
flexGrow: '1',
|
||||
border: 'none',
|
||||
}}
|
||||
{...inputProps}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type HighLightIconInputProps = {
|
||||
startElement?: React.ReactNode;
|
||||
endElement?: React.ReactNode;
|
||||
} & IconInputProps;
|
||||
|
||||
export const HighLightIconInput = (props: HighLightIconInputProps) => {
|
||||
const {
|
||||
onFocus,
|
||||
onBlur,
|
||||
startElement = null,
|
||||
endElement = null,
|
||||
...otherProps
|
||||
} = props;
|
||||
const [focus, setFocus] = useState(false);
|
||||
|
||||
return (
|
||||
<StyledHighLightWrapper isFocus={focus}>
|
||||
{startElement}
|
||||
<IconInput
|
||||
onFocus={e => {
|
||||
setFocus(true);
|
||||
onFocus?.(e);
|
||||
}}
|
||||
onBlur={e => {
|
||||
setFocus(false);
|
||||
onBlur?.(e);
|
||||
}}
|
||||
{...otherProps}
|
||||
/>
|
||||
{endElement}
|
||||
</StyledHighLightWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledIconWrapper = styled('div')`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 3px;
|
||||
`;
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
import { ModifyPanelContentProps } from './types';
|
||||
import { StyledDivider, StyledPopoverSubTitle } from '../StyledComponent';
|
||||
import { BasicSelect } from './Select';
|
||||
import { InformationProperty, InformationValue } from '../../recast-block';
|
||||
import { genInitialOptions, getPendantIconsConfigByName } from '../utils';
|
||||
|
||||
export default (props: ModifyPanelContentProps) => {
|
||||
const { onPropertyChange, onValueChange, initialValue, property } = props;
|
||||
const propProperty = property as InformationProperty;
|
||||
const propValue = initialValue as InformationValue;
|
||||
|
||||
const optionsRef = useRef(propProperty);
|
||||
const valueRef = useRef(propValue?.value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledPopoverSubTitle>Email</StyledPopoverSubTitle>
|
||||
<BasicSelect
|
||||
isMulti={true}
|
||||
iconConfig={getPendantIconsConfigByName('Email')}
|
||||
initialValue={propValue?.value?.email || []}
|
||||
onValueChange={selectedId => {
|
||||
valueRef.current = {
|
||||
...valueRef.current,
|
||||
email: selectedId,
|
||||
};
|
||||
onValueChange(valueRef.current);
|
||||
}}
|
||||
onPropertyChange={options => {
|
||||
// setEmailOptions(options);
|
||||
optionsRef.current = {
|
||||
...optionsRef.current,
|
||||
emailOptions: options,
|
||||
};
|
||||
onPropertyChange(optionsRef.current);
|
||||
}}
|
||||
initialOptions={
|
||||
propProperty?.emailOptions ||
|
||||
genInitialOptions(
|
||||
property?.type,
|
||||
getPendantIconsConfigByName('Email')
|
||||
)
|
||||
}
|
||||
/>
|
||||
<StyledDivider />
|
||||
<StyledPopoverSubTitle>Phone</StyledPopoverSubTitle>
|
||||
<BasicSelect
|
||||
isMulti={true}
|
||||
iconConfig={getPendantIconsConfigByName('Phone')}
|
||||
initialValue={propValue?.value?.phone || []}
|
||||
onValueChange={selectedId => {
|
||||
valueRef.current = {
|
||||
...valueRef.current,
|
||||
phone: selectedId,
|
||||
};
|
||||
onValueChange(valueRef.current);
|
||||
}}
|
||||
onPropertyChange={options => {
|
||||
optionsRef.current = {
|
||||
...optionsRef.current,
|
||||
phoneOptions: options,
|
||||
};
|
||||
onPropertyChange(optionsRef.current);
|
||||
}}
|
||||
initialOptions={
|
||||
propProperty?.phoneOptions ||
|
||||
genInitialOptions(
|
||||
property?.type,
|
||||
getPendantIconsConfigByName('Phone')
|
||||
)
|
||||
}
|
||||
/>
|
||||
<StyledDivider />
|
||||
<StyledPopoverSubTitle>Location</StyledPopoverSubTitle>
|
||||
<BasicSelect
|
||||
isMulti={true}
|
||||
iconConfig={getPendantIconsConfigByName('Location')}
|
||||
initialValue={propValue?.value?.location || []}
|
||||
onValueChange={selectedId => {
|
||||
valueRef.current = {
|
||||
...valueRef.current,
|
||||
location: selectedId,
|
||||
};
|
||||
onValueChange(valueRef.current);
|
||||
}}
|
||||
onPropertyChange={options => {
|
||||
optionsRef.current = {
|
||||
...optionsRef.current,
|
||||
locationOptions: options,
|
||||
};
|
||||
onPropertyChange(optionsRef.current);
|
||||
}}
|
||||
initialOptions={
|
||||
propProperty?.locationOptions ||
|
||||
genInitialOptions(
|
||||
property?.type,
|
||||
getPendantIconsConfigByName('Location')
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Input } from '@toeverything/components/ui';
|
||||
import { ModifyPanelContentProps } from './types';
|
||||
|
||||
export default ({ onValueChange, initialValue }: ModifyPanelContentProps) => {
|
||||
const [text, setText] = useState(initialValue?.value || '');
|
||||
return (
|
||||
<Input
|
||||
placeholder="Input location"
|
||||
value={text}
|
||||
onChange={e => {
|
||||
setText(e.target.value);
|
||||
onValueChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import { useUserAndSpaces } from '@toeverything/datasource/state';
|
||||
import {
|
||||
Option,
|
||||
Select,
|
||||
useTheme,
|
||||
MuiAvatar as Avatar,
|
||||
} from '@toeverything/components/ui';
|
||||
import { ModifyPanelContentProps } from './types';
|
||||
import { PendantIcon } from './IconInput';
|
||||
|
||||
export default ({
|
||||
onValueChange,
|
||||
initialValue,
|
||||
iconConfig,
|
||||
}: ModifyPanelContentProps) => {
|
||||
const {
|
||||
user: { username, nickname, photo },
|
||||
} = useUserAndSpaces();
|
||||
|
||||
const [selectedValue, setSelectedValue] = useState(initialValue?.value);
|
||||
const [focus, setFocus] = useState(false);
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Select
|
||||
width={284}
|
||||
placeholder={
|
||||
<>
|
||||
<PendantIcon
|
||||
iconName={iconConfig?.name}
|
||||
color={iconConfig?.color as CSSProperties['color']}
|
||||
background={
|
||||
iconConfig?.background as CSSProperties['background']
|
||||
}
|
||||
/>
|
||||
Select a collaborator
|
||||
</>
|
||||
}
|
||||
value={selectedValue}
|
||||
onChange={selectedValue => {
|
||||
setSelectedValue(selectedValue);
|
||||
onValueChange(selectedValue);
|
||||
}}
|
||||
style={{
|
||||
borderLeft: 'none',
|
||||
borderRight: 'none',
|
||||
borderRadius: '0',
|
||||
borderTop: focus
|
||||
? `1px solid ${theme.affine.palette.primary}`
|
||||
: 'none',
|
||||
borderBottom: focus
|
||||
? `1px solid ${theme.affine.palette.primary}`
|
||||
: 'none',
|
||||
transition: 'border-color .15s',
|
||||
}}
|
||||
onListboxOpenChange={open => {
|
||||
setFocus(open);
|
||||
}}
|
||||
>
|
||||
<Option value={nickname}>
|
||||
<Avatar
|
||||
alt={username}
|
||||
src={photo}
|
||||
sx={{ width: 20, height: 20, marginRight: '12px' }}
|
||||
/>
|
||||
{nickname}
|
||||
</Option>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { PendantTypes } from '../types';
|
||||
import { ModifyPanelContentProps, ModifyPanelProps } from './types';
|
||||
import { MuiButton as Button, styled } from '@toeverything/components/ui';
|
||||
import DatePanel from './Date';
|
||||
import TextPanel from './Text';
|
||||
import { Select } from './Select';
|
||||
import Mention from './Mention';
|
||||
import Information from './Information';
|
||||
|
||||
import { PendantSelect } from './PendantTypeSelect';
|
||||
import { StyledCancelButton, StyledSureButton } from '../StyledComponent';
|
||||
|
||||
const View = (props: ModifyPanelContentProps) => {
|
||||
const { type } = props;
|
||||
switch (type) {
|
||||
case PendantTypes.Date:
|
||||
return <DatePanel {...props} />;
|
||||
case PendantTypes.Text:
|
||||
return <TextPanel {...props} />;
|
||||
case PendantTypes.Status:
|
||||
return <Select {...props} isMulti={false} />;
|
||||
case PendantTypes.Select:
|
||||
return <Select {...props} isMulti={false} />;
|
||||
case PendantTypes.MultiSelect:
|
||||
return <Select {...props} isMulti={true} />;
|
||||
case PendantTypes.Mention:
|
||||
return <Mention {...props} />;
|
||||
case PendantTypes.Information:
|
||||
return <Information {...props} />;
|
||||
default:
|
||||
return <div className="">{type}</div>;
|
||||
}
|
||||
};
|
||||
|
||||
export const PendantModifyPanel = ({
|
||||
type: propsType,
|
||||
onSure,
|
||||
onDelete,
|
||||
initialValue,
|
||||
property,
|
||||
onCancel,
|
||||
initialOptions,
|
||||
iconConfig,
|
||||
isStatusSelect,
|
||||
}: ModifyPanelProps) => {
|
||||
const currentValue = useRef(initialValue?.value);
|
||||
// propertyValueMemo is used to memoize the property,
|
||||
// the property is not complete defined yet and it maybe not here
|
||||
// so use any type defined
|
||||
const propertyValueMemo = useRef<any>();
|
||||
useEffect(() => {
|
||||
currentValue.current = initialValue?.value;
|
||||
}, [propsType]);
|
||||
|
||||
const [type, setType] = useState<PendantTypes>();
|
||||
|
||||
useEffect(() => {
|
||||
setType(Array.isArray(propsType) ? propsType[0] : propsType);
|
||||
}, [propsType]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.isArray(propsType) && (
|
||||
<PendantSelect
|
||||
currentType={type}
|
||||
types={propsType}
|
||||
onChange={selectType => setType(selectType)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View
|
||||
type={type}
|
||||
initialValue={initialValue}
|
||||
onValueChange={newValue => {
|
||||
currentValue.current = newValue;
|
||||
}}
|
||||
onPropertyChange={newPropertyValue => {
|
||||
propertyValueMemo.current = newPropertyValue;
|
||||
}}
|
||||
property={property}
|
||||
initialOptions={initialOptions}
|
||||
iconConfig={iconConfig}
|
||||
isStatusSelect={isStatusSelect}
|
||||
/>
|
||||
<ModifyPanelBottom>
|
||||
{onDelete && (
|
||||
<StyledCancelButton
|
||||
style={{ marginRight: 10 }}
|
||||
onClick={async () => {
|
||||
onDelete?.(
|
||||
type,
|
||||
currentValue.current,
|
||||
propertyValueMemo.current
|
||||
);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</StyledCancelButton>
|
||||
)}
|
||||
|
||||
{onCancel && (
|
||||
<StyledCancelButton
|
||||
style={{ marginRight: 10 }}
|
||||
onClick={async () => {
|
||||
onCancel?.();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</StyledCancelButton>
|
||||
)}
|
||||
<StyledSureButton
|
||||
onClick={async () => {
|
||||
onSure(
|
||||
type,
|
||||
propertyValueMemo.current,
|
||||
currentValue.current
|
||||
);
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</StyledSureButton>
|
||||
</ModifyPanelBottom>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ModifyPanelBottom = styled('div')({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: '12px',
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { PendantTypes } from '../types';
|
||||
import { MuiRadio as Radio, styled } from '@toeverything/components/ui';
|
||||
import React from 'react';
|
||||
|
||||
export const PendantSelect = ({
|
||||
currentType,
|
||||
types,
|
||||
onChange,
|
||||
}: {
|
||||
currentType: PendantTypes;
|
||||
types: PendantTypes[];
|
||||
onChange: (type: PendantTypes) => void;
|
||||
}) => {
|
||||
return (
|
||||
<StyledContainer>
|
||||
{types.map(type => {
|
||||
return (
|
||||
<div key={type}>
|
||||
<Radio
|
||||
checked={type === currentType}
|
||||
onChange={e => {
|
||||
onChange(type);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
{type}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledContainer = styled('div')`
|
||||
display: flex;
|
||||
font-size: 12px;
|
||||
margin-bottom: 10px;
|
||||
margin-top: -10px;
|
||||
`;
|
||||
@@ -0,0 +1,18 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Input } from '@toeverything/components/ui';
|
||||
import { ModifyPanelContentProps } from './types';
|
||||
|
||||
export default ({ onValueChange, initialValue }: ModifyPanelContentProps) => {
|
||||
const [text, setText] = useState(initialValue?.value || '');
|
||||
return (
|
||||
<Input
|
||||
placeholder="Input phone"
|
||||
value={text}
|
||||
onChange={e => {
|
||||
setText(e.target.value);
|
||||
onValueChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,270 @@
|
||||
import React, { CSSProperties, useEffect, useState } from 'react';
|
||||
import { Add, Delete, Close } from '@mui/icons-material';
|
||||
import { ModifyPanelContentProps } from './types';
|
||||
import {
|
||||
MultiSelectProperty,
|
||||
MultiSelectValue,
|
||||
SelectOption,
|
||||
SelectProperty,
|
||||
SelectOptionId,
|
||||
RecastBlockValue,
|
||||
RecastMetaProperty,
|
||||
} from '../../recast-block';
|
||||
import { Checkbox, Radio, styled, useTheme } from '@toeverything/components/ui';
|
||||
import { HighLightIconInput } from './IconInput';
|
||||
import {
|
||||
PendantIconConfig,
|
||||
IconNames,
|
||||
OptionIdType,
|
||||
OptionType,
|
||||
} from '../types';
|
||||
import { genBasicOption } from '../utils';
|
||||
|
||||
type OptionItemType = {
|
||||
option: OptionType;
|
||||
onNameChange: (id: OptionIdType, name: string) => void;
|
||||
onStatusChange: (id: OptionIdType, status: boolean) => void;
|
||||
onDelete: (id: OptionIdType) => void;
|
||||
onInsertOption: (id: OptionIdType) => void;
|
||||
checked: boolean;
|
||||
isMulti: boolean;
|
||||
iconConfig: {
|
||||
name: IconNames;
|
||||
background: CSSProperties['background'];
|
||||
color: CSSProperties['color'];
|
||||
};
|
||||
};
|
||||
|
||||
type SelectPropsType = {
|
||||
isMulti?: boolean;
|
||||
} & ModifyPanelContentProps;
|
||||
|
||||
export const BasicSelect = ({
|
||||
isMulti = false,
|
||||
initialValue,
|
||||
onValueChange,
|
||||
onPropertyChange,
|
||||
initialOptions = [],
|
||||
iconConfig,
|
||||
}: {
|
||||
isMulti?: boolean;
|
||||
initialValue: SelectOptionId[];
|
||||
initialOptions: OptionType[];
|
||||
onValueChange: (value: any) => void;
|
||||
onPropertyChange?: (newProperty: any) => void;
|
||||
iconConfig?: PendantIconConfig;
|
||||
}) => {
|
||||
const [options, setOptions] = useState<OptionType[]>(initialOptions);
|
||||
const [selectIds, setSelectIds] = useState<OptionIdType[]>(initialValue);
|
||||
|
||||
const insertOption = (insertId: OptionIdType) => {
|
||||
const newOption = genBasicOption({
|
||||
index: options.length + 1,
|
||||
iconConfig,
|
||||
});
|
||||
const index = options.findIndex(o => o.id === insertId);
|
||||
options.splice(index + 1, 0, newOption);
|
||||
setOptions([...options]);
|
||||
};
|
||||
const deleteOption = (id: OptionIdType) => {
|
||||
if (options.length === 1) {
|
||||
return;
|
||||
}
|
||||
const deleteOptionIndex = options.findIndex(o => o.id === id);
|
||||
options.splice(deleteOptionIndex, 1);
|
||||
setOptions([...options]);
|
||||
};
|
||||
|
||||
const onNameChange = (id: OptionIdType, name: string) => {
|
||||
const changedIndex = options.findIndex(o => o.id === id);
|
||||
options[changedIndex].name = name;
|
||||
setOptions([...options]);
|
||||
};
|
||||
|
||||
const onStatusChange = (id: OptionIdType, status: boolean) => {
|
||||
if (status) {
|
||||
if (isMulti) {
|
||||
selectIds.push(id);
|
||||
setSelectIds([...selectIds]);
|
||||
} else {
|
||||
setSelectIds([id]);
|
||||
}
|
||||
} else {
|
||||
const index = selectIds.findIndex(selectId => selectId === id);
|
||||
selectIds.splice(index, 1);
|
||||
setSelectIds([...selectIds]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onValueChange(isMulti ? selectIds : selectIds[0]);
|
||||
}, [selectIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (options.every(o => !o.name)) {
|
||||
return;
|
||||
}
|
||||
onPropertyChange?.([...options.filter(o => o.name)]);
|
||||
}, [options]);
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
{options.map((option, index) => {
|
||||
const checked = selectIds.includes(option.id);
|
||||
|
||||
return (
|
||||
<OptionItem
|
||||
key={option.id}
|
||||
isMulti={isMulti}
|
||||
checked={checked}
|
||||
option={option}
|
||||
onNameChange={onNameChange}
|
||||
onStatusChange={onStatusChange}
|
||||
onDelete={deleteOption}
|
||||
onInsertOption={insertOption}
|
||||
iconConfig={{
|
||||
name: option?.iconName as IconNames,
|
||||
color: option?.color,
|
||||
background: option?.background,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<StyledAddButton
|
||||
onClick={() => {
|
||||
insertOption(options[options.length - 1].id);
|
||||
}}
|
||||
>
|
||||
<Add style={{ fontSize: 12, marginRight: 4 }} />
|
||||
Add New
|
||||
</StyledAddButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const Select = ({
|
||||
isMulti = false,
|
||||
initialValue,
|
||||
property,
|
||||
onValueChange,
|
||||
onPropertyChange,
|
||||
initialOptions = [],
|
||||
iconConfig,
|
||||
}: SelectPropsType) => {
|
||||
const propProperty = property as SelectProperty | MultiSelectProperty;
|
||||
const propInitialValueValue = initialValue?.value as
|
||||
| SelectOptionId
|
||||
| SelectOptionId[];
|
||||
|
||||
return (
|
||||
<BasicSelect
|
||||
isMulti={isMulti}
|
||||
iconConfig={iconConfig}
|
||||
initialValue={
|
||||
propInitialValueValue
|
||||
? isMulti
|
||||
? (propInitialValueValue as SelectOptionId[])
|
||||
: [propInitialValueValue as SelectOptionId]
|
||||
: []
|
||||
}
|
||||
onValueChange={onValueChange}
|
||||
onPropertyChange={onPropertyChange}
|
||||
initialOptions={propProperty?.options || initialOptions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const OptionItem = ({
|
||||
option,
|
||||
onNameChange,
|
||||
onStatusChange,
|
||||
onDelete,
|
||||
onInsertOption,
|
||||
checked,
|
||||
isMulti,
|
||||
iconConfig,
|
||||
}: OptionItemType) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<HighLightIconInput
|
||||
iconName={iconConfig?.name}
|
||||
color={iconConfig?.color}
|
||||
background={iconConfig?.background}
|
||||
value={option.name}
|
||||
placeholder="Option content"
|
||||
onChange={e => {
|
||||
onNameChange(option.id, e.target.value);
|
||||
}}
|
||||
startElement={
|
||||
<StyledOptionBox>
|
||||
{isMulti ? (
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onChange={e => {
|
||||
onStatusChange(option.id, e.target.checked);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
) : (
|
||||
<Radio
|
||||
checked={checked}
|
||||
onChange={e => {
|
||||
onStatusChange(option.id, e.target.checked);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</StyledOptionBox>
|
||||
}
|
||||
endElement={
|
||||
<StyledCloseButton
|
||||
onClick={() => {
|
||||
onDelete(option.id);
|
||||
}}
|
||||
>
|
||||
<Close
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: theme.affine.palette.icons,
|
||||
}}
|
||||
/>
|
||||
</StyledCloseButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledIconWrapper = styled('div')`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 3px;
|
||||
`;
|
||||
const StyledOptionBox = styled('div')`
|
||||
display: inline-flex;
|
||||
margin-right: 12px;
|
||||
`;
|
||||
|
||||
const StyledCloseButton = styled('button')`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
`;
|
||||
|
||||
const StyledAddButton = styled('button')(({ theme }) => {
|
||||
return {
|
||||
height: 26,
|
||||
padding: '0 8px',
|
||||
color: theme.affine.palette.primaryText,
|
||||
display: 'inline-flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
fontSize: 14,
|
||||
marginTop: 12,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
|
||||
import { ModifyPanelContentProps } from './types';
|
||||
import { HighLightIconInput } from './IconInput';
|
||||
|
||||
export default ({
|
||||
onValueChange,
|
||||
initialValue,
|
||||
iconConfig,
|
||||
}: ModifyPanelContentProps) => {
|
||||
const [text, setText] = useState(initialValue?.value || '');
|
||||
return (
|
||||
<HighLightIconInput
|
||||
iconName={iconConfig?.name}
|
||||
color={iconConfig?.color as CSSProperties['color']}
|
||||
background={iconConfig?.background as CSSProperties['background']}
|
||||
value={text}
|
||||
placeholder="Input text"
|
||||
onChange={e => {
|
||||
setText(e.target.value);
|
||||
onValueChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './PendantModifyPanel';
|
||||
@@ -0,0 +1,26 @@
|
||||
import { OptionType, PendantIconConfig, PendantTypes } from '../types';
|
||||
import type { RecastBlockValue, RecastMetaProperty } from '../../recast-block';
|
||||
import { FunctionComponent } from 'react';
|
||||
|
||||
export type ModifyPanelProps = {
|
||||
type: PendantTypes | PendantTypes[];
|
||||
onSure: (type: PendantTypes, newPropertyItem: any, newValue: any) => void;
|
||||
onDelete?: (type: PendantTypes, value: any, propertyValue: any) => void;
|
||||
onCancel?: () => void;
|
||||
initialValue?: RecastBlockValue;
|
||||
initialOptions?: OptionType[];
|
||||
iconConfig?: PendantIconConfig;
|
||||
isStatusSelect?: boolean;
|
||||
property?: RecastMetaProperty;
|
||||
};
|
||||
|
||||
export type ModifyPanelContentProps = {
|
||||
type: PendantTypes;
|
||||
onValueChange: (value: any) => void;
|
||||
onPropertyChange?: (newProperty: any) => void;
|
||||
initialValue?: RecastBlockValue;
|
||||
initialOptions?: OptionType[];
|
||||
iconConfig?: PendantIconConfig;
|
||||
isStatusSelect?: boolean;
|
||||
property?: RecastMetaProperty;
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import { Input, Option, Select, Tooltip } from '@toeverything/components/ui';
|
||||
import { HelpCenterIcon } from '@toeverything/components/icons';
|
||||
import { AsyncBlock } from '../../editor';
|
||||
|
||||
import { pendantOptions, IconMap, pendantIconConfig } from '../config';
|
||||
import { OptionType, PendantOptions, PendantTypes } from '../types';
|
||||
import { PendantModifyPanel } from '../pendant-modify-panel';
|
||||
import {
|
||||
StyledDivider,
|
||||
StyledInputEndAdornment,
|
||||
StyledOperationLabel,
|
||||
StyledOperationTitle,
|
||||
StyledPopoverSubTitle,
|
||||
StyledPopoverWrapper,
|
||||
} from '../StyledComponent';
|
||||
import {
|
||||
PropertyType,
|
||||
useRecastBlock,
|
||||
useRecastBlockMeta,
|
||||
useSelectProperty,
|
||||
genSelectOptionId,
|
||||
InformationProperty,
|
||||
} from '../../recast-block';
|
||||
import {
|
||||
genInitialOptions,
|
||||
getOfficialSelected,
|
||||
getPendantIconsConfigByType,
|
||||
} from '../utils';
|
||||
import { usePendant } from '../use-pendant';
|
||||
|
||||
export const CreatePendantPanel = ({
|
||||
block,
|
||||
onSure,
|
||||
}: {
|
||||
block: AsyncBlock;
|
||||
onSure?: () => void;
|
||||
}) => {
|
||||
const [selectedOption, setSelectedOption] = useState<PendantOptions>();
|
||||
const [fieldName, setFieldName] = useState<string>('');
|
||||
const { addProperty, removeProperty } = useRecastBlockMeta();
|
||||
// const { getValue, setValue, getAllValue } = getRecastItemValue(block);
|
||||
const { createSelect } = useSelectProperty();
|
||||
const { setPendant } = usePendant(block);
|
||||
const recastBlock = useRecastBlock();
|
||||
|
||||
return (
|
||||
<StyledPopoverWrapper>
|
||||
<StyledOperationTitle>Add Field</StyledOperationTitle>
|
||||
<StyledOperationLabel>Field Type</StyledOperationLabel>
|
||||
<Select
|
||||
width={284}
|
||||
placeholder="Search for a field type"
|
||||
value={selectedOption}
|
||||
onChange={(selectedValue: PendantOptions) => {
|
||||
setSelectedOption(selectedValue);
|
||||
}}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
{pendantOptions.map(item => {
|
||||
const Icon = IconMap[item.iconName];
|
||||
return (
|
||||
<Option key={item.name} value={item}>
|
||||
<Icon
|
||||
style={{
|
||||
fontSize: 20,
|
||||
marginRight: 12,
|
||||
color: '#98ACBD',
|
||||
}}
|
||||
/>
|
||||
{item.name}
|
||||
</Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
<StyledOperationLabel>Field Title</StyledOperationLabel>
|
||||
<Input
|
||||
value={fieldName}
|
||||
placeholder="Input your field name here"
|
||||
onChange={e => {
|
||||
setFieldName(e.target.value);
|
||||
}}
|
||||
endAdornment={
|
||||
<Tooltip content="Help info here">
|
||||
<StyledInputEndAdornment>
|
||||
<HelpCenterIcon />
|
||||
</StyledInputEndAdornment>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
{selectedOption ? (
|
||||
<>
|
||||
<StyledDivider />
|
||||
{selectedOption.subTitle && (
|
||||
<StyledPopoverSubTitle>
|
||||
{selectedOption.subTitle}
|
||||
</StyledPopoverSubTitle>
|
||||
)}
|
||||
<PendantModifyPanel
|
||||
type={selectedOption.type}
|
||||
// Select, MultiSelect, Status use this props as initial property
|
||||
initialOptions={genInitialOptions(
|
||||
selectedOption.type,
|
||||
getPendantIconsConfigByType(selectedOption.type)
|
||||
)}
|
||||
iconConfig={getPendantIconsConfigByType(
|
||||
selectedOption.type
|
||||
)}
|
||||
// isStatusSelect={selectedOption.name === 'Status'}
|
||||
onSure={async (type, newPropertyItem, newValue) => {
|
||||
if (!fieldName) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
type === PendantTypes.MultiSelect ||
|
||||
type === PendantTypes.Select ||
|
||||
type === PendantTypes.Status
|
||||
) {
|
||||
const newProperty = await createSelect({
|
||||
name: fieldName,
|
||||
options: newPropertyItem,
|
||||
type,
|
||||
});
|
||||
|
||||
const selectedId = getOfficialSelected({
|
||||
isMulti: type === PendantTypes.MultiSelect,
|
||||
options: newProperty.options,
|
||||
tempOptions: newPropertyItem,
|
||||
tempSelectedId: newValue,
|
||||
});
|
||||
|
||||
await setPendant(newProperty, selectedId);
|
||||
} else if (type === PendantTypes.Information) {
|
||||
const emailOptions = genOptionWithId(
|
||||
newPropertyItem.emailOptions
|
||||
);
|
||||
|
||||
const phoneOptions = genOptionWithId(
|
||||
newPropertyItem.phoneOptions
|
||||
);
|
||||
|
||||
const locationOptions = genOptionWithId(
|
||||
newPropertyItem.locationOptions
|
||||
);
|
||||
|
||||
const newProperty = await addProperty({
|
||||
type,
|
||||
name: fieldName,
|
||||
emailOptions,
|
||||
phoneOptions,
|
||||
locationOptions,
|
||||
} as Omit<InformationProperty, 'id'>);
|
||||
|
||||
await setPendant(newProperty, {
|
||||
email: getOfficialSelected({
|
||||
isMulti: true,
|
||||
options: emailOptions,
|
||||
tempOptions:
|
||||
newPropertyItem.emailOptions,
|
||||
tempSelectedId: newValue.email,
|
||||
}),
|
||||
phone: getOfficialSelected({
|
||||
isMulti: true,
|
||||
options: phoneOptions,
|
||||
tempOptions:
|
||||
newPropertyItem.phoneOptions,
|
||||
tempSelectedId: newValue.phone,
|
||||
}),
|
||||
location: getOfficialSelected({
|
||||
isMulti: true,
|
||||
options: locationOptions,
|
||||
tempOptions:
|
||||
newPropertyItem.locationOptions,
|
||||
tempSelectedId: newValue.location,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
// TODO: Color and background should use pendant config, but ui is not design now
|
||||
const iconConfig =
|
||||
getPendantIconsConfigByType(type);
|
||||
// TODO: Color and background should be choose by user in the future
|
||||
const newProperty = await addProperty({
|
||||
type: type,
|
||||
name: fieldName,
|
||||
background:
|
||||
iconConfig.background as CSSProperties['background'],
|
||||
color: iconConfig.color as CSSProperties['color'],
|
||||
iconName: iconConfig.name,
|
||||
});
|
||||
|
||||
await setPendant(newProperty, newValue);
|
||||
}
|
||||
|
||||
onSure?.();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</StyledPopoverWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const genOptionWithId = (options: OptionType[] = []) => {
|
||||
return options.map((option: OptionType) => ({
|
||||
...option,
|
||||
id: genSelectOptionId(),
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
import React from 'react';
|
||||
import { PendantModifyPanel } from '../pendant-modify-panel';
|
||||
import type { AsyncBlock } from '../../editor';
|
||||
import {
|
||||
genSelectOptionId,
|
||||
InformationProperty,
|
||||
type MultiSelectProperty,
|
||||
type RecastBlockValue,
|
||||
type RecastMetaProperty,
|
||||
type SelectOption,
|
||||
type SelectProperty,
|
||||
useRecastBlockMeta,
|
||||
useSelectProperty,
|
||||
} from '../../recast-block';
|
||||
import { OptionType, PendantTypes, TempInformationType } from '../types';
|
||||
import {
|
||||
getOfficialSelected,
|
||||
getPendantIconsConfigByType,
|
||||
// getPendantIconsConfigByNameOrType,
|
||||
} from '../utils';
|
||||
import { usePendant } from '../use-pendant';
|
||||
import {
|
||||
StyledPopoverWrapper,
|
||||
StyledOperationTitle,
|
||||
StyledOperationLabel,
|
||||
StyledInputEndAdornment,
|
||||
StyledDivider,
|
||||
StyledPopoverContent,
|
||||
StyledPopoverSubTitle,
|
||||
} from '../StyledComponent';
|
||||
import { IconMap, pendantOptions } from '../config';
|
||||
|
||||
type SelectPropertyType = MultiSelectProperty | SelectProperty;
|
||||
|
||||
type Props = {
|
||||
value: RecastBlockValue;
|
||||
property: RecastMetaProperty;
|
||||
block: AsyncBlock;
|
||||
hasDelete?: boolean;
|
||||
onSure?: () => void;
|
||||
onCancel?: () => void;
|
||||
};
|
||||
|
||||
export const UpdatePendantPanel = ({
|
||||
value,
|
||||
property,
|
||||
block,
|
||||
hasDelete = false,
|
||||
onSure,
|
||||
onCancel,
|
||||
}: Props) => {
|
||||
const { updateSelect } = useSelectProperty();
|
||||
const { setPendant, removePendant } = usePendant(block);
|
||||
const pendantOption = pendantOptions.find(v => v.type === property.type);
|
||||
const iconConfig = getPendantIconsConfigByType(property.type);
|
||||
const Icon = IconMap[iconConfig.name];
|
||||
const { updateProperty } = useRecastBlockMeta();
|
||||
|
||||
return (
|
||||
<StyledPopoverWrapper>
|
||||
<StyledOperationLabel>Field Type</StyledOperationLabel>
|
||||
<StyledPopoverContent>
|
||||
<Icon
|
||||
style={{
|
||||
fontSize: 20,
|
||||
marginRight: 12,
|
||||
color: '#98ACBD',
|
||||
}}
|
||||
/>
|
||||
{property.type}
|
||||
</StyledPopoverContent>
|
||||
<StyledOperationLabel>Field Title</StyledOperationLabel>
|
||||
<StyledPopoverContent>{property.name}</StyledPopoverContent>
|
||||
<StyledDivider />
|
||||
{pendantOption.subTitle && (
|
||||
<StyledPopoverSubTitle>
|
||||
{pendantOption.subTitle}
|
||||
</StyledPopoverSubTitle>
|
||||
)}
|
||||
<PendantModifyPanel
|
||||
initialValue={
|
||||
{
|
||||
type: value.type,
|
||||
value: value.value,
|
||||
} as RecastBlockValue
|
||||
}
|
||||
iconConfig={getPendantIconsConfigByType(property.type)}
|
||||
property={property}
|
||||
type={property.type}
|
||||
onSure={async (type, newPropertyItem, newValue) => {
|
||||
if (
|
||||
type === PendantTypes.MultiSelect ||
|
||||
type === PendantTypes.Select ||
|
||||
type === PendantTypes.Status
|
||||
) {
|
||||
const newOptions = newPropertyItem as OptionType[];
|
||||
let selectProperty = property as SelectPropertyType;
|
||||
const deleteOptionIds = selectProperty.options
|
||||
.filter(o => {
|
||||
return !newOptions.find(no => no.id === o.id);
|
||||
})
|
||||
.map(o => o.id);
|
||||
const addOptions = newOptions.filter(
|
||||
o => typeof o.id === 'number'
|
||||
);
|
||||
|
||||
const { addSelectOptions, removeSelectOptions } =
|
||||
updateSelect(selectProperty);
|
||||
|
||||
deleteOptionIds.length &&
|
||||
(selectProperty = (await removeSelectOptions(
|
||||
...deleteOptionIds
|
||||
)) as SelectPropertyType);
|
||||
|
||||
addOptions.length &&
|
||||
(selectProperty = (await addSelectOptions(
|
||||
...(addOptions as unknown as Omit<
|
||||
SelectOption,
|
||||
'id'
|
||||
>[])
|
||||
)) as SelectPropertyType);
|
||||
|
||||
const selectedId = getOfficialSelected({
|
||||
isMulti: type === PendantTypes.MultiSelect,
|
||||
options: selectProperty.options,
|
||||
tempOptions: newPropertyItem,
|
||||
tempSelectedId: newValue,
|
||||
});
|
||||
|
||||
await setPendant(selectProperty, selectedId);
|
||||
} else if (type === PendantTypes.Information) {
|
||||
// const { emailOptions, phoneOptions, locationOptions } =
|
||||
// property as InformationProperty;
|
||||
const optionGroup =
|
||||
newPropertyItem as TempInformationType;
|
||||
|
||||
const emailOptions = optionGroup.emailOptions.map(
|
||||
option => {
|
||||
if (typeof option.id === 'number') {
|
||||
option.id = genSelectOptionId();
|
||||
}
|
||||
return option;
|
||||
}
|
||||
);
|
||||
const phoneOptions = optionGroup.phoneOptions.map(
|
||||
option => {
|
||||
if (typeof option.id === 'number') {
|
||||
option.id = genSelectOptionId();
|
||||
}
|
||||
return option;
|
||||
}
|
||||
);
|
||||
const locationOptions = optionGroup.locationOptions.map(
|
||||
option => {
|
||||
if (typeof option.id === 'number') {
|
||||
option.id = genSelectOptionId();
|
||||
}
|
||||
return option;
|
||||
}
|
||||
);
|
||||
|
||||
const newProperty = await updateProperty({
|
||||
...property,
|
||||
emailOptions,
|
||||
phoneOptions,
|
||||
locationOptions,
|
||||
} as InformationProperty);
|
||||
|
||||
await setPendant(newProperty, {
|
||||
email: getOfficialSelected({
|
||||
isMulti: true,
|
||||
options: emailOptions as SelectOption[],
|
||||
tempOptions: newPropertyItem.emailOptions,
|
||||
tempSelectedId: newValue.email,
|
||||
}),
|
||||
phone: getOfficialSelected({
|
||||
isMulti: true,
|
||||
options: phoneOptions as SelectOption[],
|
||||
tempOptions: newPropertyItem.phoneOptions,
|
||||
tempSelectedId: newValue.phone,
|
||||
}),
|
||||
location: getOfficialSelected({
|
||||
isMulti: true,
|
||||
options: locationOptions as SelectOption[],
|
||||
tempOptions: newPropertyItem.locationOptions,
|
||||
tempSelectedId: newValue.location,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
await setPendant(property, newValue);
|
||||
}
|
||||
|
||||
onSure?.();
|
||||
}}
|
||||
onDelete={
|
||||
hasDelete
|
||||
? async () => {
|
||||
await removePendant(property);
|
||||
}
|
||||
: null
|
||||
}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</StyledPopoverWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CreatePendantPanel } from './CreatePendantPanel';
|
||||
export { UpdatePendantPanel } from './UpdatePendantPanel';
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, { FC, useRef } from 'react';
|
||||
import { AsyncBlock } from '../../editor';
|
||||
import { PendantHistoryPanel } from '../pendant-history-panel';
|
||||
import { Popover, type PopperHandler } from '@toeverything/components/ui';
|
||||
import { AddPendantPopover } from '../AddPendantPopover';
|
||||
|
||||
export const PendantPopover: FC<{
|
||||
block: AsyncBlock;
|
||||
container: HTMLElement;
|
||||
children?: React.ReactElement;
|
||||
}> = props => {
|
||||
const { block, children, container } = props;
|
||||
const popoverHandlerRef = useRef<PopperHandler>();
|
||||
return (
|
||||
<Popover
|
||||
ref={popoverHandlerRef}
|
||||
pointerEnterDelay={300}
|
||||
pointerLeaveDelay={200}
|
||||
placement="bottom-start"
|
||||
container={container}
|
||||
// visible={true}
|
||||
// trigger="click"
|
||||
content={
|
||||
<PendantHistoryPanel
|
||||
block={block}
|
||||
endElement={
|
||||
<AddPendantPopover
|
||||
block={block}
|
||||
onSure={() => {
|
||||
popoverHandlerRef.current?.setVisible(false);
|
||||
}}
|
||||
offset={[0, -30]}
|
||||
trigger="click"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
offset={[0, 0]}
|
||||
style={popoverContainerStyle}
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const popoverContainerStyle = {
|
||||
padding: '8px 0 0 12px',
|
||||
maxWidth: '700px',
|
||||
minHeight: '36px',
|
||||
BoxSizing: 'border-box',
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './PendantPopover';
|
||||
@@ -0,0 +1,108 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { AsyncBlock } from '../../editor';
|
||||
import {
|
||||
getRecastItemValue,
|
||||
PropertyType,
|
||||
RecastBlockValue,
|
||||
RecastMetaProperty,
|
||||
useRecastBlockMeta,
|
||||
} from '../../recast-block';
|
||||
import { Popover, PopperHandler, styled } from '@toeverything/components/ui';
|
||||
import { PendantTag } from '../PendantTag';
|
||||
|
||||
import { pendantColors } from '../config';
|
||||
import { UpdatePendantPanel } from '../pendant-operation-panel';
|
||||
import { AddPendantPopover } from '../AddPendantPopover';
|
||||
import { PendantTypes } from '../types';
|
||||
|
||||
export const PendantRender = ({ block }: { block: AsyncBlock }) => {
|
||||
const [propertyWithValue, setPropertyWithValue] = useState<{
|
||||
value: RecastBlockValue;
|
||||
property: RecastMetaProperty;
|
||||
}>();
|
||||
const popoverHandlerRef = useRef<{ [key: string]: PopperHandler }>({});
|
||||
|
||||
const { getProperties } = useRecastBlockMeta();
|
||||
|
||||
const { getValue, removeValue } = getRecastItemValue(block);
|
||||
|
||||
const properties = getProperties();
|
||||
|
||||
const propertyWithValues = properties
|
||||
.map(property => {
|
||||
return {
|
||||
value: getValue(property.id),
|
||||
property,
|
||||
};
|
||||
})
|
||||
.filter(v => v.value);
|
||||
|
||||
return (
|
||||
<BlockPendantContainer>
|
||||
{propertyWithValues.map(pWithV => {
|
||||
const { id, type } = pWithV.value;
|
||||
|
||||
/* (暂时关闭,HOOK中需要加入Scene的判断)Remove duplicate label(tag) */
|
||||
// if (groupBy?.id === id) {
|
||||
// return null;
|
||||
// }
|
||||
|
||||
return (
|
||||
<Popover
|
||||
ref={ref => {
|
||||
popoverHandlerRef.current[id] = ref;
|
||||
}}
|
||||
key={id}
|
||||
trigger="click"
|
||||
placement="bottom-start"
|
||||
content={
|
||||
propertyWithValue && (
|
||||
<UpdatePendantPanel
|
||||
block={block}
|
||||
value={propertyWithValue.value}
|
||||
property={propertyWithValue.property}
|
||||
hasDelete={false}
|
||||
onSure={() => {
|
||||
popoverHandlerRef.current[
|
||||
id
|
||||
].setVisible(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
popoverHandlerRef.current[
|
||||
id
|
||||
].setVisible(false);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<PendantTag
|
||||
key={id}
|
||||
style={{
|
||||
marginRight: 10,
|
||||
marginTop: 4,
|
||||
}}
|
||||
onClick={e => {
|
||||
setPropertyWithValue(pWithV);
|
||||
}}
|
||||
value={pWithV.value}
|
||||
property={pWithV.property}
|
||||
closeable
|
||||
onClose={async () => {
|
||||
await removeValue(pWithV.property.id);
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
})}
|
||||
{propertyWithValues.length ? (
|
||||
<AddPendantPopover block={block} iconStyle={{ marginTop: 5 }} />
|
||||
) : null}
|
||||
</BlockPendantContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const BlockPendantContainer = styled('div')`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './PandentRender';
|
||||
54
libs/components/editor-core/src/block-pendant/types.ts
Normal file
54
libs/components/editor-core/src/block-pendant/types.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { ReactElement, CSSProperties } from 'react';
|
||||
import {
|
||||
PropertyType as PendantTypes,
|
||||
SelectOption,
|
||||
SelectOptionId,
|
||||
} from '../recast-block';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { TextFontIcon } from '@toeverything/components/icons';
|
||||
|
||||
export { PropertyType as PendantTypes } from '../recast-block';
|
||||
|
||||
export enum IconNames {
|
||||
TEXT = 'text',
|
||||
DATE = 'date',
|
||||
STATUS = 'status',
|
||||
MULTI_SELECT = 'multiSelect',
|
||||
SINGLE_SELECT = 'singleSelect',
|
||||
COLLABORATOR = 'collaborator',
|
||||
INFORMATION = 'information',
|
||||
PHONE = 'phone',
|
||||
LOCATION = 'location',
|
||||
EMAIL = 'email',
|
||||
}
|
||||
|
||||
export type BasicPendantOption = {
|
||||
type: PendantTypes | PendantTypes[];
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type PendantOptions = {
|
||||
name: string;
|
||||
type: PendantTypes;
|
||||
iconName: IconNames;
|
||||
subTitle: string;
|
||||
};
|
||||
|
||||
export type PendantIconConfig = {
|
||||
name: IconNames;
|
||||
// background: CSSProperties['background'];
|
||||
// color: CSSProperties['color'];
|
||||
background: CSSProperties['background'] | CSSProperties['background'][];
|
||||
color: CSSProperties['color'] | CSSProperties['color'][];
|
||||
};
|
||||
|
||||
export type OptionIdType = SelectOptionId | number;
|
||||
export type OptionType = Omit<SelectOption, 'id'> & {
|
||||
id: OptionIdType;
|
||||
};
|
||||
|
||||
export type TempInformationType = {
|
||||
phoneOptions: OptionType[];
|
||||
locationOptions: OptionType[];
|
||||
emailOptions: OptionType[];
|
||||
};
|
||||
41
libs/components/editor-core/src/block-pendant/use-pendant.ts
Normal file
41
libs/components/editor-core/src/block-pendant/use-pendant.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { removePropertyValueRecord, setLatestPropertyValue } from './utils';
|
||||
import { AsyncBlock } from '../editor';
|
||||
import {
|
||||
getRecastItemValue,
|
||||
RecastMetaProperty,
|
||||
useRecastBlock,
|
||||
} from '../recast-block';
|
||||
|
||||
export const usePendant = (block: AsyncBlock) => {
|
||||
// const { getProperties, removeProperty } = useRecastBlockMeta();
|
||||
const recastBlock = useRecastBlock();
|
||||
const { getValue, setValue, removeValue } = getRecastItemValue(block);
|
||||
// const { updateSelect } = useSelectProperty();
|
||||
|
||||
const setPendant = async (property: RecastMetaProperty, newValue: any) => {
|
||||
const nv = {
|
||||
id: property.id,
|
||||
type: property.type,
|
||||
value: newValue,
|
||||
};
|
||||
await setValue(nv);
|
||||
setLatestPropertyValue({
|
||||
recastBlockId: recastBlock.id,
|
||||
blockId: block.id,
|
||||
value: nv,
|
||||
});
|
||||
};
|
||||
|
||||
const removePendant = async (property: RecastMetaProperty) => {
|
||||
await removeValue(property.id);
|
||||
removePropertyValueRecord({
|
||||
recastBlockId: block.id,
|
||||
propertyId: property.id,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
setPendant,
|
||||
removePendant,
|
||||
};
|
||||
};
|
||||
206
libs/components/editor-core/src/block-pendant/utils.ts
Normal file
206
libs/components/editor-core/src/block-pendant/utils.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
PropertyType,
|
||||
RecastBlockValue,
|
||||
RecastPropertyId,
|
||||
RecastMetaProperty,
|
||||
SelectOption,
|
||||
SelectOptionId,
|
||||
} from '../recast-block';
|
||||
import { OptionIdType, OptionType } from './types';
|
||||
import { pendantIconConfig } from './config';
|
||||
import { PendantIconConfig, PendantTypes } from './types';
|
||||
type Props = {
|
||||
recastBlockId: string;
|
||||
blockId: string;
|
||||
value: RecastBlockValue;
|
||||
};
|
||||
|
||||
type StorageMap = {
|
||||
[recastBlockId: string]: {
|
||||
[propertyId: RecastPropertyId]: {
|
||||
blockId: string;
|
||||
value: RecastBlockValue;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const LOCAL_STROAGE_NAME = 'TEMPORARY_PENDANT_DATA';
|
||||
|
||||
const ensureLocalStorage = () => {
|
||||
const data = localStorage.getItem(LOCAL_STROAGE_NAME);
|
||||
if (!data) {
|
||||
localStorage.setItem(LOCAL_STROAGE_NAME, JSON.stringify({}));
|
||||
}
|
||||
};
|
||||
|
||||
export const setLatestPropertyValue = ({
|
||||
recastBlockId,
|
||||
blockId,
|
||||
value,
|
||||
}: Props) => {
|
||||
ensureLocalStorage();
|
||||
const data: StorageMap = JSON.parse(
|
||||
localStorage.getItem(LOCAL_STROAGE_NAME) as string
|
||||
);
|
||||
|
||||
if (!data[recastBlockId]) {
|
||||
data[recastBlockId] = {};
|
||||
}
|
||||
const propertyValueRecord = data[recastBlockId];
|
||||
const propertyId = value.id;
|
||||
propertyValueRecord[propertyId] = {
|
||||
blockId: blockId,
|
||||
value,
|
||||
};
|
||||
|
||||
localStorage.setItem(LOCAL_STROAGE_NAME, JSON.stringify(data));
|
||||
};
|
||||
|
||||
export const getLatestPropertyValue = ({
|
||||
recastBlockId,
|
||||
}: {
|
||||
recastBlockId: string;
|
||||
blockId: string;
|
||||
}): Array<{
|
||||
blockId: string;
|
||||
value: RecastBlockValue;
|
||||
propertyId: RecastPropertyId;
|
||||
}> => {
|
||||
ensureLocalStorage();
|
||||
const data: StorageMap = JSON.parse(
|
||||
localStorage.getItem(LOCAL_STROAGE_NAME) as string
|
||||
);
|
||||
|
||||
if (!data[recastBlockId]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const returnData = [];
|
||||
for (const propertyId in data[recastBlockId]) {
|
||||
returnData.push({
|
||||
propertyId: propertyId as RecastPropertyId,
|
||||
...data[recastBlockId][propertyId as RecastPropertyId],
|
||||
});
|
||||
}
|
||||
|
||||
return returnData;
|
||||
};
|
||||
|
||||
export const removePropertyValueRecord = ({
|
||||
recastBlockId,
|
||||
propertyId,
|
||||
}: {
|
||||
recastBlockId: string;
|
||||
propertyId: RecastPropertyId;
|
||||
}) => {
|
||||
ensureLocalStorage();
|
||||
const data: StorageMap = JSON.parse(
|
||||
localStorage.getItem(LOCAL_STROAGE_NAME) as string
|
||||
);
|
||||
if (!data[recastBlockId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
delete data[recastBlockId][propertyId];
|
||||
|
||||
localStorage.setItem(LOCAL_STROAGE_NAME, JSON.stringify(data));
|
||||
};
|
||||
|
||||
/**
|
||||
* In select pendant panel, use mock options instead of use `createSelect` when add or delete option
|
||||
* so the option`s id is index number, not `SelectOptionId`
|
||||
* need to convert index number to id when Select is created and set selected
|
||||
*
|
||||
* 1. find index in mock options
|
||||
* 2. find generated options for index
|
||||
*
|
||||
* **/
|
||||
export const getOfficialSelected = ({
|
||||
isMulti,
|
||||
options,
|
||||
tempSelectedId,
|
||||
tempOptions,
|
||||
}: {
|
||||
isMulti: boolean;
|
||||
options: SelectOption[];
|
||||
tempSelectedId: OptionIdType | OptionIdType[];
|
||||
tempOptions: OptionType[];
|
||||
}) => {
|
||||
let selectedId: string | string[] = isMulti ? [] : '';
|
||||
|
||||
if (isMulti) {
|
||||
const selectedIndex = (tempSelectedId as OptionIdType[])
|
||||
.map(id => {
|
||||
return tempOptions.findIndex((o: OptionType) => o.id === id);
|
||||
})
|
||||
.filter(index => index != -1);
|
||||
selectedId = selectedIndex.map((index: number) => {
|
||||
return options[index].id;
|
||||
});
|
||||
} else {
|
||||
const selectedIndex = tempOptions.findIndex(
|
||||
(o: OptionType) => o.id === tempSelectedId
|
||||
);
|
||||
selectedId = options[selectedIndex]?.id || '';
|
||||
}
|
||||
return selectedId;
|
||||
};
|
||||
|
||||
export const getPendantIconsConfigByType = (
|
||||
pendantType: string
|
||||
): PendantIconConfig => {
|
||||
return pendantIconConfig[pendantType];
|
||||
};
|
||||
|
||||
export const getPendantIconsConfigByName = (
|
||||
pendantName?: string
|
||||
): PendantIconConfig => {
|
||||
return pendantIconConfig[pendantName];
|
||||
};
|
||||
|
||||
export const genBasicOption = ({
|
||||
index,
|
||||
iconConfig,
|
||||
name = '',
|
||||
}: {
|
||||
index: number;
|
||||
iconConfig: PendantIconConfig;
|
||||
name?: string;
|
||||
}): OptionType => {
|
||||
const iconName = iconConfig.name;
|
||||
const background = Array.isArray(iconConfig.background)
|
||||
? iconConfig.background[index % iconConfig.background.length]
|
||||
: iconConfig.background;
|
||||
const color = Array.isArray(iconConfig.color)
|
||||
? iconConfig.color[index % iconConfig.color.length]
|
||||
: iconConfig.color;
|
||||
|
||||
return {
|
||||
id: index,
|
||||
name,
|
||||
color,
|
||||
background,
|
||||
iconName,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Status Pendant is a Select Pendant built-in some options
|
||||
* **/
|
||||
export const genInitialOptions = (
|
||||
type: PendantTypes,
|
||||
iconConfig: PendantIconConfig
|
||||
) => {
|
||||
if (type === PendantTypes.Status) {
|
||||
return [
|
||||
genBasicOption({ index: 0, iconConfig, name: 'No Started' }),
|
||||
genBasicOption({
|
||||
index: 1,
|
||||
iconConfig,
|
||||
name: 'In Progress',
|
||||
}),
|
||||
genBasicOption({ index: 2, iconConfig, name: 'Complete' }),
|
||||
];
|
||||
}
|
||||
return [genBasicOption({ index: 0, iconConfig })];
|
||||
};
|
||||
37
libs/components/editor-core/src/contexts.tsx
Normal file
37
libs/components/editor-core/src/contexts.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { BlockEditor, AsyncBlock } from './editor';
|
||||
import type { Column } from '@toeverything/datasource/db-service';
|
||||
import { genErrorObj } from '@toeverything/utils';
|
||||
|
||||
export const RootContext = createContext<{
|
||||
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;
|
||||
}>(
|
||||
genErrorObj(
|
||||
'Failed to get context! The context only can use under the "render-root"'
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as any
|
||||
);
|
||||
|
||||
export const useEditor = () => {
|
||||
return useContext(RootContext);
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const BlockContext = createContext<AsyncBlock>(null as any);
|
||||
|
||||
/**
|
||||
* Context of column information
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const ColumnsContext = createContext<{
|
||||
fromId: string;
|
||||
columns: Column[];
|
||||
}>({
|
||||
fromId: '',
|
||||
columns: [],
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { AsyncBlock, BlockEditor } from '../editor';
|
||||
import { ReactElement } from 'react';
|
||||
|
||||
export const dragDropWrapperClass = 'drag-drop-wrapper';
|
||||
|
||||
interface DragDropWrapperProps {
|
||||
editor: BlockEditor;
|
||||
block: AsyncBlock;
|
||||
children: ReactElement | null;
|
||||
}
|
||||
|
||||
export function DragDropWrapper({
|
||||
children,
|
||||
editor,
|
||||
block,
|
||||
}: DragDropWrapperProps) {
|
||||
const handler_drag_over: React.DragEventHandler<
|
||||
HTMLDivElement
|
||||
> = async event => {
|
||||
const rootDom = await editor.getBlockDomById(editor.getRootBlockId());
|
||||
|
||||
if (block.dom && rootDom) {
|
||||
editor.getHooks().afterOnNodeDragOver(event, {
|
||||
blockId: block.id,
|
||||
dom: block.dom,
|
||||
rect: block.dom?.getBoundingClientRect(),
|
||||
rootRect: rootDom.getBoundingClientRect(),
|
||||
type: block.type,
|
||||
properties: block.getProperties(),
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div onDragOver={handler_drag_over} className={dragDropWrapperClass}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './DragDropWrapper';
|
||||
486
libs/components/editor-core/src/editor/block/async-block.ts
Normal file
486
libs/components/editor-core/src/editor/block/async-block.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
/* eslint-disable max-lines */
|
||||
import EventEmitter from 'eventemitter3';
|
||||
import {
|
||||
ReturnEditorBlock,
|
||||
UpdateEditorBlock,
|
||||
DefaultColumnsValue,
|
||||
Protocol,
|
||||
} from '@toeverything/datasource/db-service';
|
||||
import {
|
||||
isDev,
|
||||
createNoopWithMessage,
|
||||
lowerFirst,
|
||||
last,
|
||||
} from '@toeverything/utils';
|
||||
import { BlockProvider } from './block-provider';
|
||||
import { BaseView, BaseView as BlockView } from './../views/base-view';
|
||||
|
||||
type EventType = 'update';
|
||||
export interface EventData {
|
||||
block: AsyncBlock;
|
||||
oldData?: ReturnEditorBlock;
|
||||
}
|
||||
type EventCallback = (eventData: EventData) => void;
|
||||
|
||||
export interface WorkspaceAndBlockId {
|
||||
workspace: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
type Unobserve = () => void;
|
||||
type Observe = (
|
||||
workspaceAndBlockId: WorkspaceAndBlockId,
|
||||
callback: (blockData: ReturnEditorBlock) => void
|
||||
) => Promise<Unobserve>;
|
||||
|
||||
export interface BlockNodeCtorOptions {
|
||||
initData: ReturnEditorBlock;
|
||||
viewClass: BaseView;
|
||||
services: AsyncBlockServices;
|
||||
}
|
||||
interface AsyncBlockServices {
|
||||
/**
|
||||
* Used to monitor block data changes
|
||||
*/
|
||||
observe: Observe;
|
||||
/**
|
||||
* for loading child nodes
|
||||
*/
|
||||
load: (
|
||||
workspaceAndBlockId: WorkspaceAndBlockId
|
||||
) => Promise<AsyncBlock | null>;
|
||||
/**
|
||||
* Used to update block data
|
||||
*/
|
||||
update: (patches: UpdateEditorBlock) => Promise<boolean>;
|
||||
/**
|
||||
* delete block
|
||||
*/
|
||||
remove: (workspaceAndBlockId: WorkspaceAndBlockId) => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface LifeCycleHook {
|
||||
/**
|
||||
* Triggered when the block data changes
|
||||
*/
|
||||
onUpdate: (event: EventData) => Promise<void>;
|
||||
}
|
||||
function eventNameFromHookName(hookName: string) {
|
||||
return hookName.replace(/^on([A-Z].+)/, (matched, name) =>
|
||||
lowerFirst(name)
|
||||
);
|
||||
}
|
||||
|
||||
export class AsyncBlock {
|
||||
private raw_data: ReturnEditorBlock;
|
||||
private services: AsyncBlockServices = {
|
||||
observe: createNoopWithMessage({
|
||||
module: 'AsyncBlock',
|
||||
message: 'observe not set',
|
||||
}),
|
||||
load: createNoopWithMessage({
|
||||
module: 'AsyncBlock',
|
||||
message: 'load not set',
|
||||
}),
|
||||
update: createNoopWithMessage({
|
||||
module: 'AsyncBlock',
|
||||
message: 'update not set',
|
||||
}),
|
||||
remove: createNoopWithMessage({
|
||||
module: 'AsyncBlock',
|
||||
message: 'remove not set',
|
||||
}),
|
||||
};
|
||||
private life_cycle?: LifeCycleHook = {
|
||||
onUpdate: createNoopWithMessage({
|
||||
module: 'AsyncBlock',
|
||||
message: 'onUpdate not set',
|
||||
}),
|
||||
};
|
||||
|
||||
private event_emitter = new EventEmitter();
|
||||
|
||||
private viewClass: BaseView;
|
||||
|
||||
public dom?: HTMLElement;
|
||||
public renderPath: string[] = [];
|
||||
|
||||
public blockProvider?: BlockProvider;
|
||||
public firstCreateFlag?: boolean;
|
||||
private initialized = false;
|
||||
|
||||
constructor(options: BlockNodeCtorOptions) {
|
||||
const { initData, viewClass, services } = options;
|
||||
const { id } = initData;
|
||||
this.renderPath = [id];
|
||||
this.raw_data = initData;
|
||||
this.viewClass = viewClass;
|
||||
this.services = services;
|
||||
}
|
||||
|
||||
registerProvider(options: { blockView: BlockView }) {
|
||||
this.blockProvider = new BlockProvider({
|
||||
block: this as AsyncBlock,
|
||||
blockView: options.blockView,
|
||||
});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.unobserve?.();
|
||||
this.unobserve = undefined;
|
||||
}
|
||||
|
||||
setLifeCycle(lifeCycle: LifeCycleHook) {
|
||||
if (this.life_cycle) {
|
||||
Object.entries(this.life_cycle).forEach(([key, callback]) => {
|
||||
this.off(eventNameFromHookName(key) as EventType, callback);
|
||||
});
|
||||
}
|
||||
this.life_cycle = lifeCycle;
|
||||
Object.entries(this.life_cycle).forEach(([key, callback]) => {
|
||||
this.on(eventNameFromHookName(key) as EventType, callback);
|
||||
});
|
||||
}
|
||||
|
||||
// Internally monitor db block data changes
|
||||
private unobserve?: Unobserve;
|
||||
async init() {
|
||||
if (this.initialized) {
|
||||
console.error(
|
||||
'The block is already initialized! Please do not repeat call the init!',
|
||||
this
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.initialized = true;
|
||||
this.raw_data = await this.filterPageInvalidChildren(this.raw_data);
|
||||
const { workspace, id } = this.raw_data;
|
||||
this.unobserve = await this.services.observe(
|
||||
{ workspace, id },
|
||||
async blockData => {
|
||||
const oldData = this.raw_data;
|
||||
this.raw_data = blockData;
|
||||
this.raw_data = await this.filterPageInvalidChildren(blockData);
|
||||
this.emit('update', { block: this, oldData });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private on(eventName: EventType, callback: EventCallback) {
|
||||
return this.event_emitter.on(eventName, callback);
|
||||
}
|
||||
|
||||
private off(eventName: EventType, callback: EventCallback) {
|
||||
return this.event_emitter.off(eventName, callback);
|
||||
}
|
||||
|
||||
private emit(eventName: EventType, eventData: EventData) {
|
||||
return this.event_emitter.emit(eventName, eventData);
|
||||
}
|
||||
|
||||
onUpdate(callback: (event: EventData) => void) {
|
||||
this.on('update', callback);
|
||||
return () => {
|
||||
this.off('update', callback);
|
||||
};
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.raw_data.id;
|
||||
}
|
||||
|
||||
get workspace() {
|
||||
return this.raw_data.workspace;
|
||||
}
|
||||
|
||||
get type() {
|
||||
return this.raw_data.type;
|
||||
}
|
||||
|
||||
get created() {
|
||||
return this.raw_data.created;
|
||||
}
|
||||
|
||||
get createdDate() {
|
||||
if (isDev) {
|
||||
return new Date(this.created);
|
||||
}
|
||||
console.error('createDate is only allowed in development environment');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
get lastUpdated() {
|
||||
return this.raw_data.lastUpdated;
|
||||
}
|
||||
|
||||
get lastUpdatedDate() {
|
||||
if (isDev) {
|
||||
return new Date(this.lastUpdated);
|
||||
}
|
||||
console.error(
|
||||
'only allowed to use lastUpdatedDate in development environment'
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
get columns() {
|
||||
return this.raw_data.columns;
|
||||
}
|
||||
|
||||
get parentId() {
|
||||
return this.raw_data.parentId;
|
||||
}
|
||||
|
||||
async parent() {
|
||||
return this.load_node(this.raw_data.parentId || '');
|
||||
}
|
||||
|
||||
get childrenIds() {
|
||||
return this.raw_data.children;
|
||||
}
|
||||
|
||||
async children() {
|
||||
return await this.load_nodes(this.raw_data.children);
|
||||
}
|
||||
|
||||
async childAt(position: number) {
|
||||
const child_id = this.raw_data.children[position];
|
||||
if (!child_id) {
|
||||
return null;
|
||||
}
|
||||
return this.load_node(child_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the child in the group, or -1 if it is not present.
|
||||
*/
|
||||
findChildIndex(childId: string) {
|
||||
return this.raw_data.children.indexOf(childId);
|
||||
}
|
||||
|
||||
async firstChild() {
|
||||
const children = this.raw_data.children;
|
||||
if (!children?.length) {
|
||||
return null;
|
||||
}
|
||||
return this.load_node(children[0]);
|
||||
}
|
||||
|
||||
async lastChild() {
|
||||
const children = this.raw_data.children;
|
||||
if (!children?.length) {
|
||||
return null;
|
||||
}
|
||||
return this.load_node(children[children.length - 1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all children from block
|
||||
*/
|
||||
async removeChildren() {
|
||||
return this.services.update({
|
||||
id: this.id,
|
||||
workspace: this.raw_data.workspace,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
|
||||
async nextSibling() {
|
||||
const parent = await this.parent();
|
||||
if (!parent) {
|
||||
return null;
|
||||
}
|
||||
const position = parent.findChildIndex(this.id);
|
||||
if (position === -1) {
|
||||
return null;
|
||||
}
|
||||
return await parent.childAt(position + 1);
|
||||
}
|
||||
|
||||
async nextSiblings() {
|
||||
const parent = await this.parent();
|
||||
if (!parent) {
|
||||
return [];
|
||||
}
|
||||
const position = parent.findChildIndex(this.id);
|
||||
if (position === -1) {
|
||||
return [];
|
||||
}
|
||||
return await this.load_nodes(
|
||||
parent.raw_data.children.slice(position + 1)
|
||||
);
|
||||
}
|
||||
|
||||
async previousSibling() {
|
||||
const parent = await this.parent();
|
||||
if (!parent) {
|
||||
return null;
|
||||
}
|
||||
const position = parent.findChildIndex(this.id);
|
||||
if (position < 1) {
|
||||
return null;
|
||||
}
|
||||
return await parent.childAt(position - 1);
|
||||
}
|
||||
|
||||
async physicallyPerviousSibling(): Promise<null | AsyncBlock> {
|
||||
let preNode = await this.previousSibling();
|
||||
// if preNode is not parent level check if previous block has selectable children
|
||||
if (preNode) {
|
||||
let children = await preNode.children();
|
||||
// loop until find the latest deepest children block
|
||||
while (children && children.length) {
|
||||
preNode = last(children) || null;
|
||||
if (preNode) {
|
||||
children = await preNode.children();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
preNode = await this.parent();
|
||||
}
|
||||
return preNode || null;
|
||||
}
|
||||
|
||||
async previousSiblings() {
|
||||
const parent = await this.parent();
|
||||
if (!parent) {
|
||||
return [];
|
||||
}
|
||||
const position = parent.findChildIndex(this.id);
|
||||
if (position < 1) {
|
||||
return [];
|
||||
}
|
||||
return await this.load_nodes(
|
||||
parent.raw_data.children.slice(0, position)
|
||||
);
|
||||
}
|
||||
|
||||
async insert(position: number, blocks: AsyncBlock[]) {
|
||||
const children = [...this.raw_data.children];
|
||||
children.splice(position, 0, ...blocks.map(block => block.id));
|
||||
return this.services.update({
|
||||
id: this.id,
|
||||
workspace: this.raw_data.workspace,
|
||||
children,
|
||||
});
|
||||
}
|
||||
|
||||
async append(...blocks: AsyncBlock[]) {
|
||||
return await this.insert(this.raw_data.children.length, blocks);
|
||||
}
|
||||
|
||||
async prepend(...blocks: AsyncBlock[]) {
|
||||
return await this.insert(0, blocks);
|
||||
}
|
||||
|
||||
async after(...blocks: AsyncBlock[]) {
|
||||
const parent = await this.parent();
|
||||
if (!parent) {
|
||||
return false;
|
||||
}
|
||||
const position = parent.findChildIndex(this.id);
|
||||
if (position === -1) {
|
||||
return false;
|
||||
}
|
||||
return await parent.insert(position + 1, blocks);
|
||||
}
|
||||
|
||||
async before(...blocks: AsyncBlock[]) {
|
||||
const parent = await this.parent();
|
||||
if (!parent) {
|
||||
return false;
|
||||
}
|
||||
const position = parent.findChildIndex(this.id);
|
||||
if (position === -1) {
|
||||
return false;
|
||||
}
|
||||
return await parent.insert(position, blocks);
|
||||
}
|
||||
|
||||
async remove(): Promise<boolean> {
|
||||
const parentId = this.parentId;
|
||||
const ret = await this.services.remove({
|
||||
workspace: this.raw_data.workspace,
|
||||
id: this.raw_data.id,
|
||||
});
|
||||
const parent = await this.services.load({
|
||||
workspace: this.raw_data.workspace,
|
||||
id: parentId,
|
||||
});
|
||||
if (ret && parent !== null && parent.viewClass) {
|
||||
return await parent.viewClass.onDeleteChild(parent);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
async setType(type: ReturnEditorBlock['type']) {
|
||||
return this.services.update({
|
||||
id: this.id,
|
||||
workspace: this.raw_data.workspace,
|
||||
type,
|
||||
});
|
||||
}
|
||||
|
||||
getProperty<
|
||||
T extends keyof DefaultColumnsValue = keyof DefaultColumnsValue
|
||||
>(key: T): DefaultColumnsValue[T] | undefined {
|
||||
return (this.raw_data.properties as any)?.[
|
||||
key
|
||||
] as DefaultColumnsValue[T];
|
||||
}
|
||||
|
||||
getProperties(): DefaultColumnsValue {
|
||||
return this.raw_data.properties as DefaultColumnsValue;
|
||||
}
|
||||
|
||||
async setProperty<
|
||||
T extends keyof DefaultColumnsValue = keyof DefaultColumnsValue
|
||||
>(key: T, value: DefaultColumnsValue[T]) {
|
||||
return this.services.update({
|
||||
id: this.id,
|
||||
workspace: this.raw_data.workspace,
|
||||
properties: {
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async setProperties(properties: Partial<DefaultColumnsValue>) {
|
||||
return this.services.update({
|
||||
id: this.id,
|
||||
workspace: this.raw_data.workspace,
|
||||
properties: properties as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
|
||||
private async load_node(id: string): Promise<AsyncBlock | null> {
|
||||
return await this.services.load({
|
||||
workspace: this.raw_data.workspace,
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
private async load_nodes(ids: string[]): Promise<Array<AsyncBlock>> {
|
||||
return (await Promise.all(ids.map(id => this.load_node(id)))).filter(
|
||||
Boolean
|
||||
) as AsyncBlock[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the shape children from the page block
|
||||
*/
|
||||
async filterPageInvalidChildren(rawData: ReturnEditorBlock) {
|
||||
if (rawData.type !== Protocol.Block.Type.page) {
|
||||
return rawData;
|
||||
}
|
||||
// The load node method will filter invalid children automatically
|
||||
const children = await this.load_nodes(this.raw_data.children);
|
||||
rawData.children = children.map(child => child.id);
|
||||
return rawData;
|
||||
}
|
||||
|
||||
// Get block location information
|
||||
getBoundingClientRect() {
|
||||
return this.dom?.getBoundingClientRect();
|
||||
}
|
||||
}
|
||||
312
libs/components/editor-core/src/editor/block/block-helper.ts
Normal file
312
libs/components/editor-core/src/editor/block/block-helper.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import type {
|
||||
fontColorPalette,
|
||||
SlateUtils,
|
||||
TextAlignOptions,
|
||||
} from '@toeverything/components/common';
|
||||
import { Point, Selection as SlateSelection } from 'slate';
|
||||
import { Editor } from '../editor';
|
||||
|
||||
type TextUtilsFunctions =
|
||||
| 'getString'
|
||||
| 'getStringBetweenStartAndSelection'
|
||||
| 'getStringBetweenSelection'
|
||||
| 'setSearchSlash'
|
||||
| 'removeSearchSlash'
|
||||
| 'getSearchSlashText'
|
||||
| 'selectionToSlateRange'
|
||||
| 'transformPoint'
|
||||
| 'toggleTextFormatBySelection'
|
||||
| 'isTextFormatActive'
|
||||
| 'setLinkModalVisible'
|
||||
| 'setParagraphAlignBySelection'
|
||||
| 'setTextFontColorBySelection'
|
||||
| 'setTextFontBgColorBySelection'
|
||||
| 'setCommentBySelection'
|
||||
| 'resolveCommentById'
|
||||
| 'getCommentsIdsBySelection'
|
||||
| 'getCurrentSelection'
|
||||
| 'removeSelection'
|
||||
| 'insertReference'
|
||||
| 'isCollapsed'
|
||||
| 'blur';
|
||||
|
||||
type ExtendedTextUtils = SlateUtils & {
|
||||
setLinkModalVisible: (visible: boolean) => void;
|
||||
};
|
||||
|
||||
type TextUtils = { [K in TextUtilsFunctions]: ExtendedTextUtils[K] };
|
||||
|
||||
/**
|
||||
*
|
||||
* block helper aim to get block`s self infos
|
||||
* @class BlockHelper
|
||||
*/
|
||||
export class BlockHelper {
|
||||
private _editor: Editor;
|
||||
private _blockTextUtilsMap: Record<string, TextUtils> = {};
|
||||
|
||||
constructor(editor: Editor) {
|
||||
this._editor = editor;
|
||||
}
|
||||
|
||||
public registerTextUtils(blockId: string, utils: ExtendedTextUtils) {
|
||||
this._blockTextUtilsMap[blockId] = utils;
|
||||
}
|
||||
|
||||
public unRegisterTextUtils(blockId: string) {
|
||||
delete this._blockTextUtilsMap[blockId];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* get block serializer text by block id
|
||||
* @memberof BlockHelper
|
||||
*/
|
||||
public getBlockText(blockId: string) {
|
||||
const text_utils = this._blockTextUtilsMap[blockId];
|
||||
if (text_utils) {
|
||||
return text_utils.getString();
|
||||
}
|
||||
console.warn('Could find the block text utils');
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* get serializer text between start and end selection by block id
|
||||
* @memberof BlockHelper
|
||||
*/
|
||||
public getBlockTextBeforeSelection(blockId: string) {
|
||||
const text_utils = this._blockTextUtilsMap[blockId];
|
||||
if (text_utils) {
|
||||
return text_utils.getStringBetweenStartAndSelection();
|
||||
}
|
||||
console.warn('Could find the block text utils');
|
||||
return '';
|
||||
}
|
||||
|
||||
public getBlockTextBetweenSelection(blockId: string) {
|
||||
const text_utils = this._blockTextUtilsMap[blockId];
|
||||
if (text_utils) {
|
||||
return text_utils.getStringBetweenSelection(true);
|
||||
}
|
||||
console.warn('Could find the block text utils');
|
||||
return '';
|
||||
}
|
||||
|
||||
public setBlockBlur(blockId: string) {
|
||||
const text_utils = this._blockTextUtilsMap[blockId];
|
||||
if (text_utils) {
|
||||
return text_utils.blur();
|
||||
}
|
||||
console.warn('Could find the block text utils');
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* set slash character into a special node
|
||||
* @param {string} blockId
|
||||
* @param {Point} point
|
||||
* @memberof BlockHelper
|
||||
*/
|
||||
public setSearchSlash(blockId: string, point: Point) {
|
||||
const text_utils = this._blockTextUtilsMap[blockId];
|
||||
if (text_utils) {
|
||||
text_utils.setSearchSlash(point);
|
||||
} else {
|
||||
console.warn('Could find the block text utils');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* change slash node back to normal node,
|
||||
* if afferent remove flash remove slash symbol
|
||||
* @param {string} blockId
|
||||
* @param {boolean} [isRemoveSlash]
|
||||
* @memberof BlockHelper
|
||||
*/
|
||||
public removeSearchSlash(blockId: string, isRemoveSlash?: boolean) {
|
||||
const text_utils = this._blockTextUtilsMap[blockId];
|
||||
if (text_utils) {
|
||||
text_utils.removeSearchSlash(isRemoveSlash);
|
||||
} else {
|
||||
console.warn('Could find the block text utils');
|
||||
}
|
||||
}
|
||||
|
||||
public insertReference(
|
||||
reference: string,
|
||||
blockId: string,
|
||||
selection: Selection,
|
||||
offset: number
|
||||
) {
|
||||
const text_utils = this._blockTextUtilsMap[blockId];
|
||||
if (text_utils) {
|
||||
const offsetSelection = window.getSelection();
|
||||
offsetSelection.setBaseAndExtent(
|
||||
selection.anchorNode,
|
||||
selection.anchorOffset,
|
||||
selection.focusNode,
|
||||
selection.focusOffset + offset
|
||||
);
|
||||
|
||||
text_utils.removeSelection(offsetSelection);
|
||||
text_utils.insertReference(reference);
|
||||
|
||||
// range.
|
||||
// text_utils.toggleTextFormatBySelection(format, range);
|
||||
}
|
||||
console.warn('Could find the block text utils');
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* convert browser selection to slate rang
|
||||
* @memberof BlockHelper
|
||||
*/
|
||||
public selectionToSlateRange(blockId: string, selection: Selection) {
|
||||
const text_utils = this._blockTextUtilsMap[blockId];
|
||||
if (text_utils) {
|
||||
return text_utils.selectionToSlateRange(selection);
|
||||
}
|
||||
console.warn('Could find the block text utils');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* get special slash node`s text
|
||||
* @memberof BlockHelper
|
||||
*/
|
||||
public getSearchSlashText(blockId: string) {
|
||||
const text_utils = this._blockTextUtilsMap[blockId];
|
||||
if (text_utils) {
|
||||
return text_utils.getSearchSlashText();
|
||||
}
|
||||
console.warn('Could find the block text utils');
|
||||
return '';
|
||||
}
|
||||
|
||||
public transformPoint(
|
||||
blockId: string,
|
||||
...restArgs: Parameters<TextUtils['transformPoint']>
|
||||
) {
|
||||
const text_utils = this._blockTextUtilsMap[blockId];
|
||||
if (text_utils) {
|
||||
return text_utils.transformPoint(...restArgs);
|
||||
}
|
||||
console.warn('Could find the block text utils');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public toggleTextFormatBySelection(
|
||||
blockId: string,
|
||||
format: 'bold' | 'italic' | 'underline' | 'strikethrough' | 'inlinecode'
|
||||
) {
|
||||
const text_utils = this._blockTextUtilsMap[blockId];
|
||||
if (text_utils) {
|
||||
return text_utils.toggleTextFormatBySelection(format);
|
||||
}
|
||||
console.warn('Could find the block text utils');
|
||||
}
|
||||
|
||||
public setLinkModalVisible(blockId: string, visible: boolean) {
|
||||
const text_utils = this._blockTextUtilsMap[blockId];
|
||||
if (text_utils) {
|
||||
return text_utils.setLinkModalVisible(visible);
|
||||
}
|
||||
console.warn('Could find the block text utils');
|
||||
}
|
||||
|
||||
public setParagraphAlign(blockId: string, format: TextAlignOptions) {
|
||||
const text_utils = this._blockTextUtilsMap[blockId];
|
||||
if (text_utils) {
|
||||
return text_utils.setParagraphAlignBySelection(format, true);
|
||||
}
|
||||
console.warn('Could find the block text utils');
|
||||
}
|
||||
|
||||
public setTextFontColor(
|
||||
blockId: string,
|
||||
color: keyof typeof fontColorPalette
|
||||
) {
|
||||
const text_utils = this._blockTextUtilsMap[blockId];
|
||||
if (text_utils) {
|
||||
return text_utils.setTextFontColorBySelection(color, true);
|
||||
}
|
||||
console.warn('Could find the block text utils');
|
||||
}
|
||||
|
||||
public setTextFontBgColor(
|
||||
blockId: string,
|
||||
bgColor: keyof typeof fontColorPalette
|
||||
) {
|
||||
const text_utils = this._blockTextUtilsMap[blockId];
|
||||
if (text_utils) {
|
||||
return text_utils.setTextFontBgColorBySelection(bgColor, true);
|
||||
}
|
||||
console.warn('Could find the block text utils');
|
||||
}
|
||||
|
||||
public addComment(blockId: string, commentId: string) {
|
||||
const text_utils = this._blockTextUtilsMap[blockId];
|
||||
if (text_utils) {
|
||||
return text_utils.setCommentBySelection(commentId);
|
||||
}
|
||||
console.warn('Could find the block text utils');
|
||||
}
|
||||
|
||||
public resolveComment(blockId: string, commentId: string) {
|
||||
const text_utils = this._blockTextUtilsMap[blockId];
|
||||
if (text_utils) {
|
||||
return text_utils.resolveCommentById(commentId);
|
||||
}
|
||||
console.warn('Could find the block text utils');
|
||||
}
|
||||
|
||||
public async getBlockDragImg(blockId: string) {
|
||||
let blockImg;
|
||||
const block = await this._editor.getBlockById(blockId);
|
||||
if (block) {
|
||||
blockImg = block.dom;
|
||||
}
|
||||
return blockImg;
|
||||
}
|
||||
|
||||
public blur(blockId: string) {
|
||||
const textUtils = this._blockTextUtilsMap[blockId];
|
||||
if (textUtils != null) {
|
||||
textUtils.blur();
|
||||
}
|
||||
}
|
||||
|
||||
public getCurrentSelection(blockId: string): SlateSelection {
|
||||
const textUtils = this._blockTextUtilsMap[blockId];
|
||||
if (textUtils) {
|
||||
return textUtils.getCurrentSelection();
|
||||
}
|
||||
|
||||
console.warn('Could find the block text utils');
|
||||
return undefined as SlateSelection;
|
||||
}
|
||||
|
||||
public isSelectionCollapsed(blockId: string): boolean | undefined {
|
||||
const text_utils = this._blockTextUtilsMap[blockId];
|
||||
if (text_utils) {
|
||||
return text_utils.isCollapsed();
|
||||
}
|
||||
console.warn('Could find the block text utils');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public getCommentsIdsBySelection(blockId: string): string[] {
|
||||
const text_utils = this._blockTextUtilsMap[blockId];
|
||||
if (text_utils) {
|
||||
return text_utils.getCommentsIdsBySelection();
|
||||
}
|
||||
console.warn('Could find the block text utils');
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { AsyncBlock } from './async-block';
|
||||
import { BaseView as BlockView } from './../views/base-view';
|
||||
|
||||
interface BlockProviderCtorProps {
|
||||
blockView: BlockView;
|
||||
block: AsyncBlock;
|
||||
}
|
||||
|
||||
export class BlockProvider {
|
||||
private block: AsyncBlock;
|
||||
private block_view: BlockView;
|
||||
constructor(props: BlockProviderCtorProps) {
|
||||
this.block = props.block;
|
||||
this.block_view = props.blockView;
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return this.block_view.isEmpty(this.block);
|
||||
}
|
||||
}
|
||||
2
libs/components/editor-core/src/editor/block/index.ts
Normal file
2
libs/components/editor-core/src/editor/block/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AsyncBlock } from './async-block';
|
||||
export type { WorkspaceAndBlockId, EventData } from './async-block';
|
||||
@@ -0,0 +1,417 @@
|
||||
import { HooksRunner } from '../types';
|
||||
import {
|
||||
OFFICE_CLIPBOARD_MIMETYPE,
|
||||
InnerClipInfo,
|
||||
ClipBlockInfo,
|
||||
} 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 { MarkdownParser } from './markdown-parse';
|
||||
|
||||
// todo needs to be a switch
|
||||
const support_markdown_paste = true;
|
||||
|
||||
enum ClipboardAction {
|
||||
COPY = 'copy',
|
||||
CUT = 'cut',
|
||||
PASTE = 'paste',
|
||||
}
|
||||
class BrowserClipboard {
|
||||
private event_target: Element;
|
||||
private hooks: HooksRunner;
|
||||
private editor: Editor;
|
||||
private clipboard_parse: ClipboardParse;
|
||||
private markdown_parse: MarkdownParser;
|
||||
|
||||
private static optimal_mime_type: string[] = [
|
||||
OFFICE_CLIPBOARD_MIMETYPE.DOCS_DOCUMENT_SLICE_CLIP_WRAPPED,
|
||||
OFFICE_CLIPBOARD_MIMETYPE.HTML,
|
||||
OFFICE_CLIPBOARD_MIMETYPE.TEXT,
|
||||
];
|
||||
|
||||
constructor(eventTarget: Element, hooks: HooksRunner, editor: Editor) {
|
||||
this.event_target = eventTarget;
|
||||
this.hooks = hooks;
|
||||
this.editor = editor;
|
||||
this.clipboard_parse = new ClipboardParse(editor);
|
||||
this.markdown_parse = new MarkdownParser();
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
public getClipboardParse() {
|
||||
return this.clipboard_parse;
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
this.handle_copy = this.handle_copy.bind(this);
|
||||
this.handle_cut = this.handle_cut.bind(this);
|
||||
this.handle_paste = this.handle_paste.bind(this);
|
||||
|
||||
document.addEventListener(ClipboardAction.COPY, this.handle_copy);
|
||||
document.addEventListener(ClipboardAction.CUT, this.handle_cut);
|
||||
document.addEventListener(ClipboardAction.PASTE, this.handle_paste);
|
||||
this.event_target.addEventListener(
|
||||
ClipboardAction.COPY,
|
||||
this.handle_copy
|
||||
);
|
||||
this.event_target.addEventListener(
|
||||
ClipboardAction.CUT,
|
||||
this.handle_cut
|
||||
);
|
||||
this.event_target.addEventListener(
|
||||
ClipboardAction.PASTE,
|
||||
this.handle_paste
|
||||
);
|
||||
}
|
||||
|
||||
private handle_copy(e: Event) {
|
||||
//@ts-ignore
|
||||
if (e.defaultPrevented || e.target.nodeName === 'INPUT') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dispatch_clipboard_event(
|
||||
ClipboardAction.COPY,
|
||||
e as ClipboardEvent
|
||||
);
|
||||
}
|
||||
|
||||
private handle_cut(e: Event) {
|
||||
//@ts-ignore
|
||||
if (e.defaultPrevented || e.target.nodeName === 'INPUT') {
|
||||
return;
|
||||
}
|
||||
this.dispatch_clipboard_event(ClipboardAction.CUT, e as ClipboardEvent);
|
||||
}
|
||||
|
||||
private handle_paste(e: Event) {
|
||||
//@ts-ignore TODO should be handled more scientifically here, whether to trigger the paste time, also need some whitelist mechanism
|
||||
if (e.defaultPrevented || e.target.nodeName === 'INPUT') {
|
||||
return;
|
||||
}
|
||||
|
||||
const clipboardData = (e as ClipboardEvent).clipboardData;
|
||||
|
||||
const isPureFile = this.is_pure_file_in_clipboard(clipboardData);
|
||||
|
||||
if (!isPureFile) {
|
||||
this.paste_content(clipboardData);
|
||||
} else {
|
||||
this.paste_file(clipboardData);
|
||||
}
|
||||
// this.editor.selectionManager
|
||||
// .getSelectInfo()
|
||||
// .then(selectionInfo => console.log(selectionInfo));
|
||||
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
private paste_content(clipboardData: any) {
|
||||
const originClip: { data: any; type: any } = this.getOptimalClip(
|
||||
clipboardData
|
||||
) as { data: any; type: any };
|
||||
const originTextClipData = clipboardData.getData(
|
||||
OFFICE_CLIPBOARD_MIMETYPE.TEXT
|
||||
);
|
||||
|
||||
let clipData = originClip['data'];
|
||||
|
||||
if (originClip['type'] === OFFICE_CLIPBOARD_MIMETYPE.TEXT) {
|
||||
clipData = this.excape_html(clipData);
|
||||
}
|
||||
|
||||
switch (originClip['type']) {
|
||||
/** Protocol paste */
|
||||
case OFFICE_CLIPBOARD_MIMETYPE.DOCS_DOCUMENT_SLICE_CLIP_WRAPPED:
|
||||
this.fire_paste_edit_action(clipData);
|
||||
break;
|
||||
case OFFICE_CLIPBOARD_MIMETYPE.HTML:
|
||||
this.paste_html(clipData, originTextClipData);
|
||||
break;
|
||||
case OFFICE_CLIPBOARD_MIMETYPE.TEXT:
|
||||
this.paste_text(clipData, originTextClipData);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private paste_html(clipData: any, originTextClipData: any) {
|
||||
if (support_markdown_paste) {
|
||||
const has_markdown =
|
||||
this.markdown_parse.checkIfTextContainsMd(originTextClipData);
|
||||
if (has_markdown) {
|
||||
try {
|
||||
const convertedDataObj =
|
||||
this.markdown_parse.md2Html(originTextClipData);
|
||||
if (convertedDataObj.isConverted) {
|
||||
clipData = convertedDataObj.text;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
clipData = originTextClipData;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blocks = this.clipboard_parse.html2blocks(clipData);
|
||||
this.insert_blocks(blocks);
|
||||
}
|
||||
|
||||
private paste_text(clipData: any, originTextClipData: any) {
|
||||
const blocks = this.clipboard_parse.text2blocks(clipData);
|
||||
this.insert_blocks(blocks);
|
||||
}
|
||||
|
||||
private async paste_file(clipboardData: any) {
|
||||
const file = this.get_image_file(clipboardData);
|
||||
if (file) {
|
||||
const result = await services.api.file.create({
|
||||
workspace: this.editor.workspace,
|
||||
file: file,
|
||||
});
|
||||
const block_info: ClipBlockInfo = {
|
||||
type: 'image',
|
||||
properties: {
|
||||
image: {
|
||||
value: result.id,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
},
|
||||
},
|
||||
children: [] as ClipBlockInfo[],
|
||||
};
|
||||
this.insert_blocks([block_info]);
|
||||
}
|
||||
}
|
||||
|
||||
private get_image_file(clipboardData: any) {
|
||||
const files = clipboardData.files;
|
||||
if (files && files[0] && files[0].type.indexOf('image') > -1) {
|
||||
return files[0];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
private excape_html(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;
|
||||
}
|
||||
|
||||
public getOptimalClip(clipboardData: any) {
|
||||
const mimeTypeArr = BrowserClipboard.optimal_mime_type;
|
||||
|
||||
for (let i = 0; i < mimeTypeArr.length; i++) {
|
||||
const data =
|
||||
clipboardData[mimeTypeArr[i]] ||
|
||||
clipboardData.getData(mimeTypeArr[i]);
|
||||
|
||||
if (data) {
|
||||
return {
|
||||
type: mimeTypeArr[i],
|
||||
data: data,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private is_pure_file_in_clipboard(clipboardData: DataTransfer) {
|
||||
const types = clipboardData.types;
|
||||
|
||||
const res =
|
||||
(types.length === 1 && types[0] === 'Files') ||
|
||||
(types.length === 2 &&
|
||||
(types.includes('text/plain') || types.includes('text/html')) &&
|
||||
types.includes('Files'));
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
private async fire_paste_edit_action(clipboardData: any) {
|
||||
const clip_info: InnerClipInfo = JSON.parse(clipboardData);
|
||||
clip_info && this.insert_blocks(clip_info.data, clip_info.select);
|
||||
}
|
||||
|
||||
private can_edit_text(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
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: cursor positioning problem
|
||||
private async insert_blocks(blocks: ClipBlockInfo[], select?: SelectInfo) {
|
||||
if (blocks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cur_select_info =
|
||||
await this.editor.selectionManager.getSelectInfo();
|
||||
if (cur_select_info.blocks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let begin_index = 0;
|
||||
const cur_node_id =
|
||||
cur_select_info.blocks[cur_select_info.blocks.length - 1].blockId;
|
||||
let cur_block = await this.editor.getBlockById(cur_node_id);
|
||||
const block_view = this.editor.getView(cur_block.type);
|
||||
if (
|
||||
cur_select_info.type === 'Range' &&
|
||||
cur_block.type === 'text' &&
|
||||
block_view.isEmpty(cur_block)
|
||||
) {
|
||||
cur_block.setType(blocks[0].type);
|
||||
cur_block.setProperties(blocks[0].properties);
|
||||
this.paste_children(cur_block, blocks[0].children);
|
||||
begin_index = 1;
|
||||
} else if (
|
||||
select?.type === 'Range' &&
|
||||
cur_select_info.type === 'Range' &&
|
||||
this.can_edit_text(cur_block.type) &&
|
||||
this.can_edit_text(blocks[0].type)
|
||||
) {
|
||||
if (
|
||||
cur_select_info.blocks.length > 0 &&
|
||||
cur_select_info.blocks[0].startInfo
|
||||
) {
|
||||
const start_info = cur_select_info.blocks[0].startInfo;
|
||||
const end_info = cur_select_info.blocks[0].endInfo;
|
||||
const cur_text_value = cur_block.getProperty('text').value;
|
||||
const pre_cur_text_value = cur_text_value.slice(
|
||||
0,
|
||||
start_info.arrayIndex
|
||||
);
|
||||
const last_cur_text_value = cur_text_value.slice(
|
||||
end_info.arrayIndex + 1
|
||||
);
|
||||
const pre_text = cur_text_value[
|
||||
start_info.arrayIndex
|
||||
].text.substring(0, start_info.offset);
|
||||
const last_text = cur_text_value[
|
||||
end_info.arrayIndex
|
||||
].text.substring(end_info.offset);
|
||||
|
||||
let last_block: ClipBlockInfo = blocks[blocks.length - 1];
|
||||
if (!this.can_edit_text(last_block.type)) {
|
||||
last_block = { type: 'text', children: [] };
|
||||
blocks.push(last_block);
|
||||
}
|
||||
const last_values = last_block.properties?.text?.value;
|
||||
last_text && last_values.push({ text: last_text });
|
||||
last_values.push(...last_cur_text_value);
|
||||
last_block.properties = {
|
||||
text: { value: last_values },
|
||||
};
|
||||
|
||||
const insert_info = blocks[0].properties.text;
|
||||
pre_text && pre_cur_text_value.push({ text: pre_text });
|
||||
pre_cur_text_value.push(...insert_info.value);
|
||||
this.editor.blockHelper.setBlockBlur(cur_node_id);
|
||||
setTimeout(async () => {
|
||||
const cur_block = await this.editor.getBlockById(
|
||||
cur_node_id
|
||||
);
|
||||
cur_block.setProperties({
|
||||
text: { value: pre_cur_text_value },
|
||||
});
|
||||
this.paste_children(cur_block, blocks[0].children);
|
||||
}, 0);
|
||||
begin_index = 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = begin_index; i < blocks.length; i++) {
|
||||
const next_block = await this.editor.createBlock(blocks[i].type);
|
||||
next_block.setProperties(blocks[i].properties);
|
||||
if (cur_block.type === 'page') {
|
||||
cur_block.prepend(next_block);
|
||||
} else {
|
||||
cur_block.after(next_block);
|
||||
}
|
||||
|
||||
this.paste_children(next_block, blocks[i].children);
|
||||
cur_block = next_block;
|
||||
}
|
||||
}
|
||||
|
||||
private async paste_children(parent: AsyncBlock, children: any[]) {
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const next_block = await this.editor.createBlock(children[i].type);
|
||||
next_block.setProperties(children[i].properties);
|
||||
parent.append(next_block);
|
||||
await this.paste_children(next_block, children[i].children);
|
||||
}
|
||||
}
|
||||
|
||||
private pre_copy_cut(action: ClipboardAction, e: ClipboardEvent) {
|
||||
switch (action) {
|
||||
case ClipboardAction.COPY:
|
||||
this.hooks.beforeCopy(e);
|
||||
break;
|
||||
|
||||
case ClipboardAction.CUT:
|
||||
this.hooks.beforeCut(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private dispatch_clipboard_event(
|
||||
action: ClipboardAction,
|
||||
e: ClipboardEvent
|
||||
) {
|
||||
this.pre_copy_cut(action, e);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
document.removeEventListener(ClipboardAction.COPY, this.handle_copy);
|
||||
document.removeEventListener(ClipboardAction.CUT, this.handle_cut);
|
||||
document.removeEventListener(ClipboardAction.PASTE, this.handle_paste);
|
||||
this.event_target.removeEventListener(
|
||||
ClipboardAction.COPY,
|
||||
this.handle_copy
|
||||
);
|
||||
this.event_target.removeEventListener(
|
||||
ClipboardAction.CUT,
|
||||
this.handle_cut
|
||||
);
|
||||
this.event_target.removeEventListener(
|
||||
ClipboardAction.PASTE,
|
||||
this.handle_paste
|
||||
);
|
||||
this.clipboard_parse.dispose();
|
||||
this.clipboard_parse = null;
|
||||
this.event_target = null;
|
||||
this.hooks = null;
|
||||
this.editor = null;
|
||||
}
|
||||
}
|
||||
|
||||
export { BrowserClipboard };
|
||||
23
libs/components/editor-core/src/editor/clipboard/clip.ts
Normal file
23
libs/components/editor-core/src/editor/clipboard/clip.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
class Clip {
|
||||
private mime_type: any;
|
||||
private data: any;
|
||||
|
||||
constructor(mimeType: any, data: any) {
|
||||
this.mime_type = mimeType;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
getMimeType() {
|
||||
return this.mime_type;
|
||||
}
|
||||
|
||||
getData() {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
hasData() {
|
||||
return this.data !== null && this.data !== undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export { Clip };
|
||||
@@ -0,0 +1,209 @@
|
||||
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 &&
|
||||
block_utils.html2block &&
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Editor } from '../editor';
|
||||
import { SelectionManager, SelectInfo, SelectBlock } from '../selection';
|
||||
import { HookType, PluginHooks } from '../types';
|
||||
import {
|
||||
ClipBlockInfo,
|
||||
OFFICE_CLIPBOARD_MIMETYPE,
|
||||
InnerClipInfo,
|
||||
} from './types';
|
||||
import { Clip } from './clip';
|
||||
import assert from 'assert';
|
||||
import ClipboardParse from './clipboard-parse';
|
||||
|
||||
class ClipboardPopulator {
|
||||
private editor: Editor;
|
||||
private hooks: PluginHooks;
|
||||
private selection_manager: SelectionManager;
|
||||
private clipboard: any;
|
||||
private clipboard_parse: ClipboardParse;
|
||||
|
||||
constructor(
|
||||
editor: Editor,
|
||||
hooks: PluginHooks,
|
||||
selectionManager: SelectionManager,
|
||||
clipboard: any
|
||||
) {
|
||||
this.editor = editor;
|
||||
this.hooks = hooks;
|
||||
this.selection_manager = selectionManager;
|
||||
this.clipboard = clipboard;
|
||||
this.clipboard_parse = new ClipboardParse(editor);
|
||||
hooks.addHook(HookType.BEFORE_COPY, this.populate_app_clipboard, this);
|
||||
hooks.addHook(HookType.BEFORE_CUT, this.populate_app_clipboard, this);
|
||||
}
|
||||
|
||||
private async populate_app_clipboard(e: ClipboardEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const clips = await this.getClips();
|
||||
if (!clips.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: is not compatible with safari
|
||||
const success = this.copy_to_cliboard_from_pc(clips);
|
||||
if (!success) {
|
||||
// This way, not compatible with firefox
|
||||
const clipboardData = e.clipboardData;
|
||||
if (clipboardData) {
|
||||
try {
|
||||
clips.forEach(clip => {
|
||||
clipboardData.setData(
|
||||
clip.getMimeType(),
|
||||
clip.getData()
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
// TODO handle exception
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private copy_to_cliboard_from_pc(clips: any[]) {
|
||||
let success = false;
|
||||
const tempElem = document.createElement('textarea');
|
||||
tempElem.value = 'temp';
|
||||
document.body.appendChild(tempElem);
|
||||
tempElem.select();
|
||||
tempElem.setSelectionRange(0, tempElem.value.length);
|
||||
|
||||
const listener = function (e: any) {
|
||||
const clipboardData = e.clipboardData;
|
||||
if (clipboardData) {
|
||||
clips.forEach(clip => {
|
||||
clipboardData.setData(clip.getMimeType(), clip.getData());
|
||||
});
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
tempElem.removeEventListener('copy', listener);
|
||||
} as any;
|
||||
|
||||
tempElem.addEventListener('copy', listener);
|
||||
try {
|
||||
success = document.execCommand('copy');
|
||||
} finally {
|
||||
tempElem.removeEventListener('copy', listener);
|
||||
document.body.removeChild(tempElem);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
private async get_clip_block_info(selBlock: SelectBlock) {
|
||||
const block = await this.editor.getBlockById(selBlock.blockId);
|
||||
const block_view = this.editor.getView(block.type);
|
||||
assert(block_view);
|
||||
const block_info: ClipBlockInfo = {
|
||||
type: block.type,
|
||||
properties: block_view.getSelProperties(block, selBlock),
|
||||
children: [] as any[],
|
||||
};
|
||||
|
||||
for (let i = 0; i < selBlock.children.length; i++) {
|
||||
const child_info = await this.get_clip_block_info(
|
||||
selBlock.children[i]
|
||||
);
|
||||
block_info.children.push(child_info);
|
||||
}
|
||||
|
||||
return block_info;
|
||||
}
|
||||
|
||||
private async get_inner_clip(): Promise<InnerClipInfo> {
|
||||
const clips: ClipBlockInfo[] = [];
|
||||
const select_info: SelectInfo =
|
||||
await this.selection_manager.getSelectInfo();
|
||||
for (let i = 0; i < select_info.blocks.length; i++) {
|
||||
const sel_block = select_info.blocks[i];
|
||||
const clip_block_info = await this.get_clip_block_info(sel_block);
|
||||
clips.push(clip_block_info);
|
||||
}
|
||||
const clipInfo: InnerClipInfo = {
|
||||
select: select_info,
|
||||
data: clips,
|
||||
};
|
||||
return clipInfo;
|
||||
}
|
||||
|
||||
async getClips() {
|
||||
const clips: any[] = [];
|
||||
|
||||
const inner_clip = await this.get_inner_clip();
|
||||
clips.push(
|
||||
new Clip(
|
||||
OFFICE_CLIPBOARD_MIMETYPE.DOCS_DOCUMENT_SLICE_CLIP_WRAPPED,
|
||||
JSON.stringify(inner_clip)
|
||||
)
|
||||
);
|
||||
|
||||
const html_clip = await this.clipboard_parse.generateHtml();
|
||||
html_clip &&
|
||||
clips.push(new Clip(OFFICE_CLIPBOARD_MIMETYPE.HTML, html_clip));
|
||||
|
||||
return clips;
|
||||
}
|
||||
|
||||
disposeInternal() {
|
||||
this.hooks.removeHook(
|
||||
HookType.BEFORE_COPY,
|
||||
this.populate_app_clipboard
|
||||
);
|
||||
this.hooks.removeHook(HookType.BEFORE_CUT, this.populate_app_clipboard);
|
||||
this.hooks = null;
|
||||
}
|
||||
}
|
||||
|
||||
export { ClipboardPopulator };
|
||||
1418
libs/components/editor-core/src/editor/clipboard/markdown-parse.ts
Normal file
1418
libs/components/editor-core/src/editor/clipboard/markdown-parse.ts
Normal file
File diff suppressed because it is too large
Load Diff
29
libs/components/editor-core/src/editor/clipboard/types.ts
Normal file
29
libs/components/editor-core/src/editor/clipboard/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
BlockFlavorKeys,
|
||||
DefaultColumnsValue,
|
||||
} from '@toeverything/datasource/db-service';
|
||||
import { SelectInfo } from '../selection';
|
||||
|
||||
export const OFFICE_CLIPBOARD_MIMETYPE = {
|
||||
DOCS_DOCUMENT_SLICE_CLIP_WRAPPED: 'affine/x-c+w',
|
||||
HTML: 'text/html',
|
||||
TEXT: 'text/plain',
|
||||
IMAGE_BMP: 'image/bmp',
|
||||
IMAGE_GIF: 'image/gif',
|
||||
IMAGE_JPEG: 'image/jpeg',
|
||||
IMAGE_JPG: 'image/jpg',
|
||||
IMAGE_PNG: 'image/png',
|
||||
IMAGE_SVG: 'image/svg',
|
||||
IMAGE_WEBP: 'image/webp',
|
||||
};
|
||||
|
||||
export interface ClipBlockInfo {
|
||||
type: BlockFlavorKeys;
|
||||
properties?: Partial<DefaultColumnsValue>;
|
||||
children: ClipBlockInfo[];
|
||||
}
|
||||
|
||||
export interface InnerClipInfo {
|
||||
select: SelectInfo;
|
||||
data: ClipBlockInfo[];
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { BlockFlavorKeys, Protocol } from '@toeverything/datasource/db-service';
|
||||
import { sleep } from '@toeverything/utils';
|
||||
import type { AsyncBlock } from '../../editor';
|
||||
import { mergeGroup, splitGroup } from '../../recast-block';
|
||||
import { Editor as BlockEditor } from '../editor';
|
||||
import { GridDropType } from './types';
|
||||
|
||||
/**
|
||||
*
|
||||
* commands for control blocks
|
||||
* @export
|
||||
* @class BlockCommands
|
||||
*/
|
||||
export class BlockCommands {
|
||||
private _editor: BlockEditor;
|
||||
|
||||
constructor(editor: BlockEditor) {
|
||||
this._editor = editor;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* create a block after typed id
|
||||
* @param {keyof BlockFlavors} type
|
||||
* @param {string} blockId
|
||||
* @return {*}
|
||||
* @memberof BlockCommands
|
||||
*/
|
||||
public async createNextBlock(
|
||||
blockId: string,
|
||||
type: BlockFlavorKeys = Protocol.Block.Type.text
|
||||
) {
|
||||
const block = await this._editor.getBlockById(blockId);
|
||||
if (block) {
|
||||
const next_block = await this._editor.createBlock(type);
|
||||
if (next_block) {
|
||||
await block.after(next_block);
|
||||
this._editor.selectionManager.activeNodeByNodeId(next_block.id);
|
||||
return next_block;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* remove block by block id
|
||||
* @param {string} blockId
|
||||
* @memberof BlockCommands
|
||||
*/
|
||||
public async removeBlock(blockId: string) {
|
||||
const block = await this._editor.getBlockById(blockId);
|
||||
if (block) {
|
||||
block.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* convert blocks to other type
|
||||
* @param {BlockFlavorKeys} type
|
||||
* @memberof BlockCommands
|
||||
*/
|
||||
public async convertBlock(blockId: string, type: BlockFlavorKeys) {
|
||||
const block = await this._editor.getBlockById(blockId);
|
||||
if (block) {
|
||||
await block.setType(type);
|
||||
await sleep(10);
|
||||
this._editor.selectionManager.activeNodeByNodeId(block.id);
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* move block to another block`s after
|
||||
* @param {string} from
|
||||
* @param {string} to
|
||||
* @memberof BlockCommands
|
||||
*/
|
||||
public async moveBlockAfter(from: string, to: string) {
|
||||
const fromBlock = await this._editor.getBlockById(from);
|
||||
const toBlock = await this._editor.getBlockById(to);
|
||||
if (fromBlock && toBlock) {
|
||||
await fromBlock.remove();
|
||||
await toBlock.after(fromBlock);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* move block to another block`s after
|
||||
* @param {string} from
|
||||
* @param {string} to
|
||||
* @memberof BlockCommands
|
||||
*/
|
||||
public async moveBlockBefore(from: string, to: string) {
|
||||
const fromBlock = await this._editor.getBlockById(from);
|
||||
const toBlock = await this._editor.getBlockById(to);
|
||||
if (fromBlock && toBlock) {
|
||||
await fromBlock.remove();
|
||||
await toBlock.before(fromBlock);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* add a new layout block
|
||||
* @param {string} dragBlockId
|
||||
* @param {string} dropBlockId
|
||||
* @param {*} [type=GridDropType.left]
|
||||
* @memberof BlockCommands
|
||||
*/
|
||||
public async createLayoutBlock(
|
||||
dragBlockId: string,
|
||||
dropBlockId: string,
|
||||
type = GridDropType.left
|
||||
) {
|
||||
const layoutBlock = await this._editor.createBlock(
|
||||
Protocol.Block.Type.grid
|
||||
);
|
||||
const dragBlock = await this._editor.getBlockById(dragBlockId);
|
||||
const dropBlock = await this._editor.getBlockById(dropBlockId);
|
||||
const leftGridItemBlock = await this._editor.createBlock(
|
||||
Protocol.Block.Type.gridItem
|
||||
);
|
||||
const rightGridItemBlock = await this._editor.createBlock(
|
||||
Protocol.Block.Type.gridItem
|
||||
);
|
||||
let leftBlock, rightBlock;
|
||||
if (type === GridDropType.left) {
|
||||
leftBlock = dragBlock;
|
||||
rightBlock = dropBlock;
|
||||
} else {
|
||||
leftBlock = dropBlock;
|
||||
rightBlock = dragBlock;
|
||||
}
|
||||
await dropBlock.after(layoutBlock);
|
||||
await leftBlock.remove();
|
||||
await rightBlock.remove();
|
||||
await leftGridItemBlock.append(leftBlock);
|
||||
await rightGridItemBlock.append(rightBlock);
|
||||
await layoutBlock.append(leftGridItemBlock, rightGridItemBlock);
|
||||
return layoutBlock;
|
||||
}
|
||||
|
||||
public async createGridItem(blockId: string) {
|
||||
const gridItemBlock = await this.createNextBlock(
|
||||
blockId,
|
||||
Protocol.Block.Type.gridItem
|
||||
);
|
||||
if (gridItemBlock) {
|
||||
const textBlock = await this._editor.createBlock(
|
||||
Protocol.Block.Type.text
|
||||
);
|
||||
gridItemBlock.append(textBlock);
|
||||
return [gridItemBlock, textBlock];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
public async splitGroupFromBlock(blockId: string) {
|
||||
const block = await this._editor.getBlockById(blockId);
|
||||
await splitGroup(this._editor, block);
|
||||
return;
|
||||
}
|
||||
|
||||
public async mergeGroup(...blocks: AsyncBlock[]) {
|
||||
await mergeGroup(...blocks);
|
||||
return;
|
||||
}
|
||||
}
|
||||
21
libs/components/editor-core/src/editor/commands/index.ts
Normal file
21
libs/components/editor-core/src/editor/commands/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { BlockEditor } from '../..';
|
||||
import { BlockCommands } from './block-commands';
|
||||
import { TextCommands } from './text-commands';
|
||||
export * from './types';
|
||||
|
||||
/**
|
||||
*
|
||||
* commands bind with editor , use for change model data
|
||||
* if want to get some block info use block helper
|
||||
* @export
|
||||
* @class EditorCommands
|
||||
* @deprecated to move into dbCommands
|
||||
*/
|
||||
export class EditorCommands {
|
||||
public blockCommands: BlockCommands;
|
||||
public textCommands: TextCommands;
|
||||
constructor(editor: BlockEditor) {
|
||||
this.blockCommands = new BlockCommands(editor);
|
||||
this.textCommands = new TextCommands(editor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
*
|
||||
* commands for control text and text styles
|
||||
* @export
|
||||
* @class TextCommand
|
||||
*/
|
||||
import { BlockEditor } from '../..';
|
||||
|
||||
export class TextCommands {
|
||||
private _editor: BlockEditor;
|
||||
|
||||
constructor(editor: BlockEditor) {
|
||||
this._editor = editor;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* get block text by block id
|
||||
* @param {string} blockId
|
||||
* @return {*}
|
||||
* @memberof TextCommand
|
||||
*/
|
||||
public async getBlockText(blockId: string) {
|
||||
let string = '';
|
||||
const block = await this._editor.getBlockById(blockId);
|
||||
if (block) {
|
||||
string = block.getProperty('text')?.value[0]?.text || '';
|
||||
}
|
||||
return string;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* set block text by block id
|
||||
* @param {string} blockId
|
||||
* @param {string} text
|
||||
* @memberof TextCommand
|
||||
*/
|
||||
public async setBlockText(blockId: string, text: string) {
|
||||
const block = await this._editor.getBlockById(blockId);
|
||||
if (block) {
|
||||
await block.setProperty('text', { value: [{ text: text }] });
|
||||
}
|
||||
}
|
||||
|
||||
public async getBlockRichText() {
|
||||
// TODO: need implement
|
||||
}
|
||||
}
|
||||
4
libs/components/editor-core/src/editor/commands/types.ts
Normal file
4
libs/components/editor-core/src/editor/commands/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum GridDropType {
|
||||
left = 'left',
|
||||
right = 'right',
|
||||
}
|
||||
290
libs/components/editor-core/src/editor/drag-drop/drag-drop.ts
Normal file
290
libs/components/editor-core/src/editor/drag-drop/drag-drop.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { domToRect, Point, ValueOf } from '@toeverything/utils';
|
||||
import { AsyncBlock } from '../..';
|
||||
import { GridDropType } from '../commands/types';
|
||||
import { Editor } from '../editor';
|
||||
import { BlockDropPlacement, GroupDirection } from '../types';
|
||||
// TODO: Evaluate implementing custom events with Rxjs
|
||||
import EventEmitter from 'eventemitter3';
|
||||
|
||||
type DargType =
|
||||
| ValueOf<InstanceType<typeof DragDropManager>['dragActions']>
|
||||
| '';
|
||||
export class DragDropManager {
|
||||
private _editor: Editor;
|
||||
private _enabled: boolean;
|
||||
|
||||
private _blockIdKey = 'blockId';
|
||||
private _rootIdKey = 'rootId';
|
||||
private _dragType: DargType;
|
||||
private _blockDragDirection: BlockDropPlacement;
|
||||
private _blockDragTargetId = '';
|
||||
|
||||
private _dragBlockHotDistance = 20;
|
||||
|
||||
private _dragActions = {
|
||||
dragBlock: 'dragBlock',
|
||||
dragGroup: 'dragGroup',
|
||||
} as const;
|
||||
|
||||
get dragActions() {
|
||||
return this._dragActions;
|
||||
}
|
||||
|
||||
constructor(editor: Editor) {
|
||||
this._editor = editor;
|
||||
this._enabled = true;
|
||||
this._dragType = '';
|
||||
this._blockDragDirection = BlockDropPlacement.none;
|
||||
}
|
||||
|
||||
get dragType() {
|
||||
return this._dragType;
|
||||
}
|
||||
|
||||
set dragType(type: DargType) {
|
||||
this._dragType = type;
|
||||
}
|
||||
|
||||
private _setBlockDragDirection(direction: BlockDropPlacement) {
|
||||
this._blockDragDirection = direction;
|
||||
}
|
||||
|
||||
private _setBlockDragTargetId(id: string) {
|
||||
this._blockDragTargetId = id;
|
||||
}
|
||||
|
||||
private async _canBeDrop(event: React.DragEvent<Element>) {
|
||||
const blockId = event.dataTransfer.getData(this._blockIdKey);
|
||||
if (blockId === undefined || this._blockDragTargetId === undefined) {
|
||||
return false;
|
||||
}
|
||||
let curr = this._blockDragTargetId;
|
||||
while (curr !== this._editor.getRootBlockId()) {
|
||||
if (curr === blockId) return false;
|
||||
const block = await this._editor.getBlockById(curr);
|
||||
curr = block.parentId;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _handleDropBlock(event: React.DragEvent<Element>) {
|
||||
if (this._blockDragDirection !== BlockDropPlacement.none) {
|
||||
const blockId = event.dataTransfer.getData(this._blockIdKey);
|
||||
if (!(await this._canBeDrop(event))) return;
|
||||
if (this._blockDragDirection === BlockDropPlacement.bottom) {
|
||||
this._editor.commands.blockCommands.moveBlockAfter(
|
||||
blockId,
|
||||
this._blockDragTargetId
|
||||
);
|
||||
}
|
||||
if (
|
||||
[BlockDropPlacement.left, BlockDropPlacement.right].includes(
|
||||
this._blockDragDirection
|
||||
)
|
||||
) {
|
||||
await this._editor.commands.blockCommands.createLayoutBlock(
|
||||
blockId,
|
||||
this._blockDragTargetId,
|
||||
this._blockDragDirection === BlockDropPlacement.left
|
||||
? GridDropType.left
|
||||
: GridDropType.right
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async getGroupBlockByPoint(point: Point) {
|
||||
const blockList = await this._editor.getBlockList();
|
||||
return blockList.find(block => {
|
||||
if (block.type === 'group' && block.dom) {
|
||||
const rect = domToRect(block.dom);
|
||||
if (rect.fromNewLeft(rect.left - 30).isContainPoint(point)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
private async _handleDropGroup(event: React.DragEvent<Element>) {
|
||||
const blockId = event.dataTransfer.getData(this._blockIdKey);
|
||||
const toGroup = await this.getGroupBlockByPoint(
|
||||
new Point(event.clientX, event.clientY)
|
||||
);
|
||||
if (toGroup && blockId && toGroup.id !== blockId) {
|
||||
const fromGroup = await this._editor.getBlockById(blockId);
|
||||
if (fromGroup) {
|
||||
const direction = await this.checkDragGroupDirection(
|
||||
fromGroup,
|
||||
toGroup,
|
||||
new Point(event.clientX, event.clientY)
|
||||
);
|
||||
direction === GroupDirection.down
|
||||
? this._editor.commands.blockCommands.moveBlockAfter(
|
||||
blockId,
|
||||
toGroup.id
|
||||
)
|
||||
: this._editor.commands.blockCommands.moveBlockBefore(
|
||||
blockId,
|
||||
toGroup.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public isEnabled() {
|
||||
return this._enabled;
|
||||
}
|
||||
|
||||
public enableDragDrop(enabled: boolean) {
|
||||
this._enabled = enabled;
|
||||
}
|
||||
|
||||
public disableDragDrop(disable: boolean) {
|
||||
this._enabled = false;
|
||||
}
|
||||
|
||||
public setDragBlockInfo(event: React.DragEvent<Element>, blockId: string) {
|
||||
this.dragType = this.dragActions.dragBlock;
|
||||
event.dataTransfer.setData(
|
||||
this._dragActions.dragBlock,
|
||||
this.dragActions.dragBlock
|
||||
);
|
||||
event.dataTransfer.setData(this._blockIdKey, blockId);
|
||||
event.dataTransfer.setData(
|
||||
this._rootIdKey,
|
||||
this._editor.getRootBlockId()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Drag data store's dragover event is Protected mode.
|
||||
* Drag over can not get dataTransfer value by event.dataTransfer.
|
||||
* @param {React.DragEvent<Element>} [event]
|
||||
* @return {*}
|
||||
* @memberof DragDropManager
|
||||
*/
|
||||
public isDragBlock(event: React.DragEvent<Element>) {
|
||||
return event.dataTransfer.types.includes(
|
||||
this.dragActions.dragBlock.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
public setDragGroupInfo(event: React.DragEvent<Element>, blockId: string) {
|
||||
this.dragType = this.dragActions.dragGroup;
|
||||
event.dataTransfer.setData(
|
||||
this._dragActions.dragGroup,
|
||||
this.dragActions.dragGroup
|
||||
);
|
||||
event.dataTransfer.setData(this._blockIdKey, blockId);
|
||||
event.dataTransfer.setData(
|
||||
this._rootIdKey,
|
||||
this._editor.getRootBlockId()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Drag data store's dragover event is Protected mode.
|
||||
* Drag over can not get dataTransfer value by event.dataTransfer.
|
||||
* @param {React.DragEvent<Element>} [event]
|
||||
* @return {*}
|
||||
* @memberof DragDropManager
|
||||
*/
|
||||
public isDragGroup(event: React.DragEvent<Element>) {
|
||||
return event.dataTransfer.types.includes(
|
||||
this.dragActions.dragGroup.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
public async checkBlockDragTypes(
|
||||
event: React.DragEvent<Element>,
|
||||
blockDom: HTMLElement,
|
||||
blockId: string
|
||||
) {
|
||||
const { clientX, clientY } = event;
|
||||
this._setBlockDragTargetId(blockId);
|
||||
|
||||
const mousePoint = new Point(clientX, clientY);
|
||||
const rect = domToRect(blockDom);
|
||||
let direction = BlockDropPlacement.bottom;
|
||||
if (mousePoint.x - rect.left <= this._dragBlockHotDistance) {
|
||||
direction = BlockDropPlacement.left;
|
||||
}
|
||||
if (rect.right - mousePoint.x <= this._dragBlockHotDistance) {
|
||||
direction = BlockDropPlacement.right;
|
||||
}
|
||||
if (!rect.isContainPoint(mousePoint)) {
|
||||
direction = BlockDropPlacement.none;
|
||||
}
|
||||
if (!(await this._canBeDrop(event))) {
|
||||
direction = BlockDropPlacement.none;
|
||||
}
|
||||
this._setBlockDragDirection(direction);
|
||||
return direction;
|
||||
}
|
||||
|
||||
public handlerEditorDrop(event: React.DragEvent<Element>) {
|
||||
// IMP: can not use Decorators now may use decorators is right
|
||||
if (this.isEnabled()) {
|
||||
if (this.isDragBlock(event)) {
|
||||
this._handleDropBlock(event);
|
||||
}
|
||||
if (this.isDragGroup(event)) {
|
||||
this._handleDropGroup(event);
|
||||
}
|
||||
}
|
||||
this.dragType = '';
|
||||
}
|
||||
|
||||
public handlerEditorDragOver(event: React.DragEvent<Element>) {
|
||||
// IMP: can not use Decorators now
|
||||
}
|
||||
|
||||
public handlerEditorDragEnd(event: React.DragEvent<Element>) {
|
||||
this._resetDragDropData();
|
||||
if (this.isEnabled()) {
|
||||
// TODO: handle drag end event flow
|
||||
}
|
||||
}
|
||||
|
||||
private _resetDragDropData() {
|
||||
this._dragType = '';
|
||||
this._setBlockDragDirection(BlockDropPlacement.none);
|
||||
this._setBlockDragTargetId('');
|
||||
}
|
||||
|
||||
public async checkDragGroupDirection(
|
||||
groupBlock: AsyncBlock,
|
||||
dragOverGroup: AsyncBlock,
|
||||
mousePoint: Point
|
||||
) {
|
||||
let direction = GroupDirection.down;
|
||||
if (groupBlock && dragOverGroup && dragOverGroup.dom) {
|
||||
const preBlock = await dragOverGroup.previousSibling();
|
||||
const nextBlock = await dragOverGroup.nextSibling();
|
||||
let isSibling = false;
|
||||
if (preBlock?.id === groupBlock.id) {
|
||||
direction = GroupDirection.down;
|
||||
isSibling = true;
|
||||
}
|
||||
if (nextBlock?.id === groupBlock.id) {
|
||||
direction = GroupDirection.up;
|
||||
isSibling = true;
|
||||
}
|
||||
if (!isSibling) {
|
||||
const dragOverGroupRect = domToRect(dragOverGroup.dom);
|
||||
if (
|
||||
mousePoint.y <
|
||||
dragOverGroupRect.top + dragOverGroupRect.height / 2
|
||||
) {
|
||||
direction = GroupDirection.up;
|
||||
} else {
|
||||
direction = GroupDirection.down;
|
||||
}
|
||||
}
|
||||
}
|
||||
return direction;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DragDropManager } from './drag-drop';
|
||||
export * from './types';
|
||||
12
libs/components/editor-core/src/editor/drag-drop/types.ts
Normal file
12
libs/components/editor-core/src/editor/drag-drop/types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export enum BlockDropPlacement {
|
||||
left = 'left',
|
||||
right = 'right',
|
||||
top = 'top',
|
||||
bottom = 'bottom',
|
||||
none = 'none',
|
||||
}
|
||||
|
||||
export enum GroupDirection {
|
||||
up = 'up',
|
||||
down = 'down',
|
||||
}
|
||||
450
libs/components/editor-core/src/editor/editor.ts
Normal file
450
libs/components/editor-core/src/editor/editor.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
/* eslint-disable max-lines */
|
||||
import type { ReactNode } from 'react';
|
||||
import HotKeys from 'hotkeys-js';
|
||||
import LRUCache from 'lru-cache';
|
||||
|
||||
import { services } from '@toeverything/datasource/db-service';
|
||||
import type {
|
||||
BlockFlavors,
|
||||
ReturnEditorBlock,
|
||||
UpdateEditorBlock,
|
||||
} from '@toeverything/datasource/db-service';
|
||||
import type { PatchNode, UnPatchNode } from '@toeverything/components/ui';
|
||||
|
||||
import { AsyncBlock } from './block';
|
||||
import type { WorkspaceAndBlockId } from './block';
|
||||
import type { BaseView } from './views/base-view';
|
||||
import { SelectionManager } from './selection';
|
||||
import { Hooks, PluginManager } from './plugin';
|
||||
import { EditorCommands } from './commands';
|
||||
import {
|
||||
Virgo,
|
||||
HooksRunner,
|
||||
PluginHooks,
|
||||
PluginCreator,
|
||||
StorageManager,
|
||||
VirgoSelection,
|
||||
PluginManagerInterface,
|
||||
} from './types';
|
||||
import { KeyboardManager } from './keyboard';
|
||||
import { MouseManager } from './mouse';
|
||||
import { ScrollManager } from './scroll';
|
||||
import assert from 'assert';
|
||||
import { domToRect, last, Point, sleep } from '@toeverything/utils';
|
||||
import { Commands } from '@toeverything/datasource/commands';
|
||||
import { BrowserClipboard } from './clipboard/browser-clipboard';
|
||||
import { ClipboardPopulator } from './clipboard/clipboard-populator';
|
||||
import { BlockHelper } from './block/block-helper';
|
||||
import { DragDropManager } from './drag-drop';
|
||||
|
||||
export interface EditorCtorProps {
|
||||
workspace: string;
|
||||
views: Partial<Record<keyof BlockFlavors, BaseView>>;
|
||||
plugins: PluginCreator[];
|
||||
rootBlockId?: string;
|
||||
isWhiteboard?: boolean;
|
||||
}
|
||||
|
||||
export class Editor implements Virgo {
|
||||
private cache_manager = new LRUCache<string, Promise<AsyncBlock | null>>({
|
||||
max: 8192,
|
||||
ttl: 1000 * 60 * 30,
|
||||
});
|
||||
public mouseManager = new MouseManager(this);
|
||||
public selectionManager = new SelectionManager(this);
|
||||
public keyboardManager = new KeyboardManager(this);
|
||||
public scrollManager = new ScrollManager(this);
|
||||
public dragDropManager = new DragDropManager(this);
|
||||
public commands = new EditorCommands(this);
|
||||
public blockHelper = new BlockHelper(this);
|
||||
public bdCommands: Commands;
|
||||
public ui_container?: HTMLDivElement;
|
||||
public version = '0.0.1';
|
||||
public copyright = '@Affine 2019-2022';
|
||||
private plugin_manager: PluginManager;
|
||||
private hooks: Hooks;
|
||||
private views: Record<string, BaseView> = {};
|
||||
workspace: string;
|
||||
readonly = false;
|
||||
private root_block_id: string;
|
||||
private storage_manager?: StorageManager;
|
||||
private clipboard?: BrowserClipboard;
|
||||
private clipboard_populator?: ClipboardPopulator;
|
||||
public reactRenderRoot: {
|
||||
render: PatchNode;
|
||||
has: (key: string) => boolean;
|
||||
};
|
||||
public isWhiteboard = false;
|
||||
private _isDisposed = false;
|
||||
|
||||
constructor(props: EditorCtorProps) {
|
||||
this.workspace = props.workspace;
|
||||
this.views = props.views;
|
||||
this.root_block_id = props.rootBlockId || '';
|
||||
this.hooks = new Hooks();
|
||||
this.plugin_manager = new PluginManager(this, this.hooks);
|
||||
this.plugin_manager.registerAll(props.plugins);
|
||||
if (props.isWhiteboard) {
|
||||
this.isWhiteboard = true;
|
||||
}
|
||||
for (const [name, block] of Object.entries(props.views)) {
|
||||
services.api.editorBlock.registerContentExporter(
|
||||
this.workspace,
|
||||
name,
|
||||
{ flavor: name as keyof BlockFlavors },
|
||||
block.onExport.bind(block)
|
||||
);
|
||||
services.api.editorBlock.registerMetadataExporter(
|
||||
this.workspace,
|
||||
name,
|
||||
{ flavor: name as keyof BlockFlavors },
|
||||
block.onMetadata.bind(block)
|
||||
);
|
||||
services.api.editorBlock.registerTagExporter(
|
||||
this.workspace,
|
||||
name,
|
||||
{ flavor: name as keyof BlockFlavors },
|
||||
block.onTagging.bind(block)
|
||||
);
|
||||
}
|
||||
this.bdCommands = new Commands(props.workspace);
|
||||
services.api.editorBlock.onHistoryChange(
|
||||
this.workspace,
|
||||
'affine',
|
||||
map => {
|
||||
map.set('node', this.selectionManager.getActivatedNodeId());
|
||||
map.set(
|
||||
'selection',
|
||||
this.selectionManager.getLastActiveSelectionSetting()
|
||||
);
|
||||
}
|
||||
);
|
||||
services.api.editorBlock.onHistoryRevoke(
|
||||
this.workspace,
|
||||
'affine',
|
||||
async meta => {
|
||||
const node = meta.get('node') as string;
|
||||
const selection = meta.get('selection') as any;
|
||||
await this.selectionManager.activeNodeByNodeId(node);
|
||||
await this.selectionManager.setNodeActiveSelection(
|
||||
node,
|
||||
selection
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public set container(v: HTMLDivElement) {
|
||||
this.ui_container = v;
|
||||
this.init_clipboard();
|
||||
}
|
||||
|
||||
public get container() {
|
||||
return this.ui_container;
|
||||
}
|
||||
// preference to use withSuspend
|
||||
public suspend(flag: boolean) {
|
||||
services.api.editorBlock.suspend(this.workspace, flag);
|
||||
}
|
||||
|
||||
public async withSuspend<T extends (...args: any[]) => any>(
|
||||
fn: T
|
||||
): Promise<Awaited<ReturnType<T>>> {
|
||||
services.api.editorBlock.suspend(this.workspace, true);
|
||||
const result = await fn();
|
||||
services.api.editorBlock.suspend(this.workspace, false);
|
||||
return result;
|
||||
}
|
||||
|
||||
public setReactRenderRoot(props: {
|
||||
patch: PatchNode;
|
||||
has: (key: string) => boolean;
|
||||
}) {
|
||||
this.reactRenderRoot = {
|
||||
render: props.patch,
|
||||
has: props.has,
|
||||
};
|
||||
}
|
||||
|
||||
private _disposeClipboard() {
|
||||
this.clipboard?.dispose();
|
||||
this.clipboard_populator?.disposeInternal();
|
||||
}
|
||||
|
||||
private init_clipboard() {
|
||||
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,
|
||||
this.clipboard
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Root Block Id */
|
||||
getRootBlockId() {
|
||||
return this.root_block_id;
|
||||
}
|
||||
|
||||
setRootBlockId(rootBlockId: string) {
|
||||
this.root_block_id = rootBlockId;
|
||||
}
|
||||
|
||||
setHotKeysScope(scope?: string) {
|
||||
HotKeys.setScope(scope || 'all');
|
||||
}
|
||||
|
||||
/** Block CRUD */
|
||||
|
||||
options = {
|
||||
load: ({ workspace, id }: WorkspaceAndBlockId) =>
|
||||
this.getBlock({ workspace, id }),
|
||||
update: async (patches: UpdateEditorBlock) => {
|
||||
return await services.api.editorBlock.update(patches);
|
||||
},
|
||||
remove: async ({ workspace, id }: WorkspaceAndBlockId) => {
|
||||
return await services.api.editorBlock.delete({ workspace, id });
|
||||
},
|
||||
observe: async (
|
||||
{ workspace, id }: WorkspaceAndBlockId,
|
||||
callback: (blockData: ReturnEditorBlock) => void
|
||||
) => {
|
||||
return await services.api.editorBlock.observe(
|
||||
{ workspace, id },
|
||||
callback
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
getView(type: string) {
|
||||
return this.views[type];
|
||||
}
|
||||
|
||||
private async _initBlock(
|
||||
blockData: ReturnEditorBlock
|
||||
): Promise<AsyncBlock | null> {
|
||||
if (!blockData) {
|
||||
return null;
|
||||
}
|
||||
const block = new AsyncBlock({
|
||||
initData: blockData,
|
||||
viewClass: this.getView(blockData.type),
|
||||
services: {
|
||||
load: (...args) => this.getBlock(...args),
|
||||
update: (...args) => this.options.update(...args),
|
||||
remove: (...args) => this.options.remove(...args),
|
||||
observe: (...args) => this.options.observe(...args),
|
||||
},
|
||||
});
|
||||
await block.init();
|
||||
const blockView = this.getView(block.type);
|
||||
if (!blockView) {
|
||||
return null;
|
||||
}
|
||||
block.setLifeCycle({
|
||||
onUpdate: blockView.onUpdate,
|
||||
});
|
||||
block.registerProvider({
|
||||
blockView: blockView,
|
||||
});
|
||||
return await blockView.onCreate(block);
|
||||
}
|
||||
|
||||
private async getBlock({
|
||||
workspace,
|
||||
id,
|
||||
}: WorkspaceAndBlockId): Promise<AsyncBlock | null> {
|
||||
const block = this.cache_manager.get(id);
|
||||
if (block) {
|
||||
return block;
|
||||
}
|
||||
const block_promise = new Promise<AsyncBlock | null>(resolve => {
|
||||
const create = async () => {
|
||||
const blocksData = await services.api.editorBlock.get({
|
||||
workspace,
|
||||
ids: [id],
|
||||
});
|
||||
if (!blocksData.length) {
|
||||
console.warn('Failed to get blocks_data', id);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
if (blocksData && blocksData[0]) {
|
||||
const asyncBlock = await this._initBlock(blocksData[0]);
|
||||
if (!asyncBlock) {
|
||||
// console.warn('Failed to initBlock', id, blocksData);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
resolve(asyncBlock);
|
||||
}
|
||||
};
|
||||
create();
|
||||
});
|
||||
this.cache_manager.set(id, block_promise);
|
||||
return await block_promise;
|
||||
}
|
||||
|
||||
async createBlock(type: keyof BlockFlavors, parentId?: string) {
|
||||
assert(type, `The block type is missing.`);
|
||||
const blockData = await services.api.editorBlock.create({
|
||||
workspace: this.workspace,
|
||||
type,
|
||||
parentId,
|
||||
});
|
||||
const block = await this._initBlock(blockData);
|
||||
if (block) {
|
||||
this.cache_manager.set(block.id, Promise.resolve(block));
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
async getBlockById(blockId: string): Promise<AsyncBlock | null> {
|
||||
return await this.getBlock({ workspace: this.workspace, id: blockId });
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: to be optimized
|
||||
* get block`s dom by block`s id
|
||||
* @param {string} blockId
|
||||
* @param {number} [times=1]
|
||||
* @return {*} {(Promise<HTMLElement | null>)}
|
||||
* @memberof Editor
|
||||
*/
|
||||
async getBlockDomById(
|
||||
blockId: string,
|
||||
times = 1
|
||||
): Promise<HTMLElement | null> {
|
||||
const block = await this.getBlockById(blockId);
|
||||
if (times === 10) {
|
||||
console.warn('render');
|
||||
return null;
|
||||
}
|
||||
if (block) {
|
||||
if (block.dom) {
|
||||
return block.dom;
|
||||
}
|
||||
await sleep(16);
|
||||
return this.getBlockDomById(blockId, times + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async getBlockList() {
|
||||
const rootBlockId = this.getRootBlockId();
|
||||
const rootBlock = await this.getBlockById(rootBlockId);
|
||||
const blockList: Array<AsyncBlock> = rootBlock ? [rootBlock] : [];
|
||||
const children = (await rootBlock?.children()) || [];
|
||||
for (const block of children) {
|
||||
if (!block) {
|
||||
continue;
|
||||
}
|
||||
const blockChildren = await block.children();
|
||||
blockList.push(block);
|
||||
if (blockChildren) {
|
||||
children.push(...blockChildren);
|
||||
}
|
||||
}
|
||||
return blockList;
|
||||
}
|
||||
|
||||
async getRootLastChildrenBlock(rootBlockId = this.getRootBlockId()) {
|
||||
const rootBlock = await this.getBlockById(rootBlockId);
|
||||
if (!rootBlock) {
|
||||
throw new Error('root block is not found');
|
||||
}
|
||||
const lastChildren = await rootBlock.lastChild();
|
||||
return lastChildren ?? rootBlock;
|
||||
}
|
||||
|
||||
async getLastBlock(rootBlockId = this.getRootBlockId()) {
|
||||
const rootBlock = await this.getBlockById(rootBlockId);
|
||||
if (!rootBlock) {
|
||||
throw new Error('root block is not found');
|
||||
}
|
||||
let lastBlock = rootBlock;
|
||||
let children = await rootBlock.children();
|
||||
while (children.length) {
|
||||
// Safe type assert because the length of children has been checked
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
lastBlock = last(children)!;
|
||||
children = (await lastBlock?.children()) || [];
|
||||
}
|
||||
return lastBlock;
|
||||
}
|
||||
|
||||
async getBlockByPoint(point: Point) {
|
||||
const blockList = await this.getBlockList();
|
||||
|
||||
return blockList.reverse().find(block => {
|
||||
return (
|
||||
Boolean(block.dom) && domToRect(block.dom).isContainPoint(point)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async undo() {
|
||||
await services.api.editorBlock.undo(this.workspace);
|
||||
}
|
||||
|
||||
async redo() {
|
||||
await services.api.editorBlock.redo(this.workspace);
|
||||
}
|
||||
|
||||
async search(query: Parameters<typeof services.api.editorBlock.search>[1]) {
|
||||
return services.api.editorBlock.search(this.workspace, query);
|
||||
}
|
||||
|
||||
async queryBlock(query: any) {
|
||||
return await services.api.editorBlock.query(this.workspace, query);
|
||||
}
|
||||
|
||||
/** Hooks */
|
||||
|
||||
public getHooks(): HooksRunner & PluginHooks {
|
||||
return this.hooks;
|
||||
}
|
||||
|
||||
public get storageManager(): StorageManager | undefined {
|
||||
return this.storage_manager;
|
||||
}
|
||||
|
||||
public get selection(): VirgoSelection {
|
||||
return this.selectionManager;
|
||||
}
|
||||
|
||||
public get plugins(): PluginManagerInterface {
|
||||
return this.plugin_manager;
|
||||
}
|
||||
|
||||
public async page2html(): Promise<string> {
|
||||
const parse = this.clipboard?.getClipboardParse();
|
||||
if (!parse) {
|
||||
return '';
|
||||
}
|
||||
const html_str = await parse.page2html();
|
||||
return html_str;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._isDisposed = true;
|
||||
Object.keys(this.views).map(name =>
|
||||
services.api.editorBlock.unregisterContentExporter(
|
||||
this.workspace,
|
||||
name
|
||||
)
|
||||
);
|
||||
this.keyboardManager.dispose();
|
||||
this.hooks.dispose();
|
||||
this.plugin_manager.dispose();
|
||||
this.selectionManager.dispose();
|
||||
this._disposeClipboard();
|
||||
}
|
||||
}
|
||||
18
libs/components/editor-core/src/editor/index.ts
Normal file
18
libs/components/editor-core/src/editor/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
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 {
|
||||
BlockDomInfo,
|
||||
Plugin,
|
||||
PluginCreator,
|
||||
PluginHooks,
|
||||
Virgo,
|
||||
} from './types';
|
||||
export { BaseView, getTextHtml, getTextProperties } from './views/base-view';
|
||||
export type { ChildrenView, CreateView } from './views/base-view';
|
||||
@@ -0,0 +1,69 @@
|
||||
export type HotKeyTypes =
|
||||
| 'selectAll'
|
||||
| 'newPage'
|
||||
| 'undo'
|
||||
| 'redo'
|
||||
| 'remove'
|
||||
| 'checkUncheck'
|
||||
| 'preExpendSelect'
|
||||
| 'nextExpendSelect'
|
||||
| 'up'
|
||||
| 'down'
|
||||
| 'left'
|
||||
| 'right'
|
||||
| 'enter'
|
||||
| 'mergeGroup'
|
||||
| 'mergeGroupUp'
|
||||
| 'mergeGroupDown';
|
||||
export type HotkeyMap = Record<HotKeyTypes, string>;
|
||||
|
||||
/** hot key maps for mac */
|
||||
export const MacHotkeyMap: HotkeyMap = {
|
||||
selectAll: 'command+a',
|
||||
newPage: 'command+n',
|
||||
undo: 'command+z',
|
||||
redo: 'command+shift+z',
|
||||
remove: 'backspace',
|
||||
checkUncheck: 'esc',
|
||||
preExpendSelect: 'shift+up',
|
||||
nextExpendSelect: 'shift+down',
|
||||
up: 'up',
|
||||
down: 'down',
|
||||
left: 'left',
|
||||
right: 'right',
|
||||
enter: 'enter',
|
||||
mergeGroupUp: 'command+up',
|
||||
mergeGroupDown: 'command+down',
|
||||
mergeGroup: 'command+m',
|
||||
};
|
||||
|
||||
/** hot key maps for windows */
|
||||
export const WinHotkeyMap: HotkeyMap = {
|
||||
selectAll: 'ctrl+a',
|
||||
newPage: 'ctrl+n',
|
||||
undo: 'ctrl+z',
|
||||
redo: 'ctrl+shift+z',
|
||||
remove: 'backspace',
|
||||
checkUncheck: 'esc',
|
||||
preExpendSelect: 'shift+up',
|
||||
nextExpendSelect: 'shift+down',
|
||||
mergeGroupUp: 'command+up',
|
||||
mergeGroupDown: 'command+down',
|
||||
mergeGroup: 'command+m',
|
||||
up: 'up',
|
||||
down: 'down',
|
||||
left: 'left',
|
||||
right: 'right',
|
||||
enter: 'enter',
|
||||
};
|
||||
|
||||
type GlobalHotKeyTypes = 'search';
|
||||
export type GlobalHotkeyMap = Record<GlobalHotKeyTypes, string>;
|
||||
|
||||
export const GlobalMacHotkeyMap: GlobalHotkeyMap = {
|
||||
search: 'alt+space',
|
||||
};
|
||||
|
||||
export const GlobalWinHotkeyMap: GlobalHotkeyMap = {
|
||||
search: 'ctrl+space',
|
||||
};
|
||||
1
libs/components/editor-core/src/editor/keyboard/index.ts
Normal file
1
libs/components/editor-core/src/editor/keyboard/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { KeyboardManager } from './keyboard';
|
||||
297
libs/components/editor-core/src/editor/keyboard/keyboard.ts
Normal file
297
libs/components/editor-core/src/editor/keyboard/keyboard.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import HotKeys from 'hotkeys-js';
|
||||
|
||||
import { uaHelper } from '@toeverything/utils';
|
||||
|
||||
import { AsyncBlock, BlockEditor } from '../..';
|
||||
import { SelectionManager } from '../selection';
|
||||
import {
|
||||
HotKeyTypes,
|
||||
HotkeyMap,
|
||||
MacHotkeyMap,
|
||||
WinHotkeyMap,
|
||||
GlobalHotkeyMap,
|
||||
GlobalMacHotkeyMap,
|
||||
GlobalWinHotkeyMap,
|
||||
} 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;
|
||||
private selection_manager: SelectionManager;
|
||||
private hotkeys: HotkeyMap;
|
||||
private global_hotkeys: GlobalHotkeyMap;
|
||||
private handler_map: { [k: string]: Array<KeyboardEventHandler> };
|
||||
|
||||
constructor(editor: BlockEditor) {
|
||||
this._editor = editor;
|
||||
this.selection_manager = this._editor.selectionManager;
|
||||
if (uaHelper.isMacOs) {
|
||||
this.hotkeys = MacHotkeyMap;
|
||||
this.global_hotkeys = GlobalMacHotkeyMap;
|
||||
} else {
|
||||
this.hotkeys = WinHotkeyMap;
|
||||
this.global_hotkeys = GlobalWinHotkeyMap;
|
||||
}
|
||||
this.handler_map = {};
|
||||
|
||||
// WARNING: Remove the filter of hotkeys, the input event of input/select/textarea will be filtered out by default
|
||||
// When there is a problem with the input of the text component, you need to pay attention to this
|
||||
const old_filter = HotKeys.filter;
|
||||
HotKeys.filter = event => {
|
||||
let parent = (event.target as Element).parentElement;
|
||||
while (parent) {
|
||||
if (parent === editor.container) {
|
||||
return old_filter(event);
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
HotKeys.setScope('editor');
|
||||
|
||||
// this.init_common_shortcut_cb();
|
||||
this.bind_hot_key_handlers();
|
||||
}
|
||||
|
||||
private bind_hot_key_handlers() {
|
||||
this.bind_hotkey(
|
||||
this.hotkeys.selectAll,
|
||||
'editor',
|
||||
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.checkUncheck,
|
||||
'editor',
|
||||
this.handle_check_uncheck
|
||||
);
|
||||
this.bind_hotkey(
|
||||
this.hotkeys.preExpendSelect,
|
||||
'editor',
|
||||
this.handle_pre_expend_select
|
||||
);
|
||||
this.bind_hotkey(
|
||||
this.hotkeys.nextExpendSelect,
|
||||
'editor',
|
||||
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.enter, 'all', this.handleEnter);
|
||||
this.bind_hotkey(this.global_hotkeys.search, 'all', this.handle_search);
|
||||
this.bind_hotkey(
|
||||
this.hotkeys.mergeGroupDown,
|
||||
'editor',
|
||||
this.mergeGroupDown
|
||||
);
|
||||
this.bind_hotkey(
|
||||
this.hotkeys.mergeGroupUp,
|
||||
'editor',
|
||||
this.mergeGroupUp
|
||||
);
|
||||
}
|
||||
|
||||
private bind_hotkey(
|
||||
key: string,
|
||||
scope: string,
|
||||
...handler: Array<KeyboardEventHandler>
|
||||
) {
|
||||
handler.forEach(h => {
|
||||
HotKeys(key, scope, h);
|
||||
if (!this.handler_map[key]) {
|
||||
this.handler_map[key] = [h];
|
||||
} else {
|
||||
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,
|
||||
* need pass a new keyboard event
|
||||
* @param {HotkeyMapKeys} type
|
||||
* @param {KeyboardEventHandler} handler
|
||||
* @memberof KeyboardManager
|
||||
*/
|
||||
public emit(type: HotKeyTypes, event: KeyboardEvent) {
|
||||
// this.common_handler(type, event);
|
||||
const handlers = this.handler_map[this.hotkeys[type]];
|
||||
if (handlers) {
|
||||
handlers.forEach(h => h(event));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* 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'));
|
||||
|
||||
this.handler_map = {};
|
||||
}
|
||||
|
||||
private handle_select_all = (event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
this.selection_manager.selectAllBlocks();
|
||||
};
|
||||
|
||||
private handle_undo = (event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
this._editor.undo();
|
||||
};
|
||||
|
||||
private handle_redo = (event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
this._editor.redo();
|
||||
};
|
||||
|
||||
private handle_search = (event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
this._editor.getHooks().onSearch();
|
||||
};
|
||||
|
||||
private handle_remove = (event: KeyboardEvent) => {
|
||||
const selectNode =
|
||||
this._editor.selectionManager.selectedNodesList || [];
|
||||
selectNode.forEach(node => node.remove());
|
||||
};
|
||||
|
||||
private handle_check_uncheck = (event: KeyboardEvent) => {
|
||||
if (this._editor.selectionManager.getSelectedNodesIds().length !== 0) {
|
||||
this._editor.selectionManager.setSelectedNodesIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
private handle_pre_expend_select = (event: KeyboardEvent) => {
|
||||
this.handle_expend_select(event, true);
|
||||
};
|
||||
|
||||
private handle_next_expend_select = (event: KeyboardEvent) => {
|
||||
this.handle_expend_select(event, false);
|
||||
};
|
||||
|
||||
private handle_expend_select = async (
|
||||
event: KeyboardEvent,
|
||||
is_previous: boolean
|
||||
) => {
|
||||
this._editor.selectionManager.expandBlockSelect(is_previous);
|
||||
};
|
||||
|
||||
private handle_click_up = (event: Event) => {
|
||||
const selectedIds = this._editor.selectionManager.getSelectedNodesIds();
|
||||
if (selectedIds.length) {
|
||||
event.preventDefault();
|
||||
this._editor.selectionManager.activePreviousNode(
|
||||
selectedIds[0],
|
||||
'end'
|
||||
);
|
||||
}
|
||||
};
|
||||
private handleClickDown = async (event: Event) => {
|
||||
const selectedIds = this._editor.selectionManager.getSelectedNodesIds();
|
||||
if (selectedIds.length) {
|
||||
event.preventDefault();
|
||||
this._editor.selectionManager.activeNextNode(selectedIds[0], 'end');
|
||||
}
|
||||
};
|
||||
private handleEnter = async (event: Event) => {
|
||||
const selectedIds = this._editor.selectionManager.getSelectedNodesIds();
|
||||
if (selectedIds.length) {
|
||||
event.preventDefault();
|
||||
const selectedNode = await this._editor.getBlockById(
|
||||
selectedIds[0]
|
||||
);
|
||||
if (selectedNode.type === Protocol.Block.Type.group) {
|
||||
const children = await selectedNode.children();
|
||||
if (!supportChildren(children[0])) {
|
||||
await this._editor.selectionManager.setSelectedNodesIds([
|
||||
children[0].id,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
await this._editor.selectionManager.activeNodeByNodeId(
|
||||
children[0].id
|
||||
);
|
||||
} else {
|
||||
// suspend(true)
|
||||
let 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();
|
||||
this._editor.commands.blockCommands.mergeGroup(...selectedGroup);
|
||||
};
|
||||
private mergeGroupDown = async (event: Event) => {
|
||||
let selectedGroup = await this.getSelectedGroups();
|
||||
if (selectedGroup.length) {
|
||||
let nextGroup = await selectedGroup[
|
||||
selectedGroup.length - 1
|
||||
].nextSibling();
|
||||
if (nextGroup?.type === Protocol.Block.Type.group) {
|
||||
this._editor.commands.blockCommands.mergeGroup(
|
||||
...selectedGroup,
|
||||
nextGroup
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
private mergeGroupUp = async (event: Event) => {
|
||||
let selectedGroup = await this.getSelectedGroups();
|
||||
if (selectedGroup.length) {
|
||||
let preGroup = await selectedGroup[0].previousSibling();
|
||||
if (preGroup?.type === Protocol.Block.Type.group) {
|
||||
this._editor.commands.blockCommands.mergeGroup(
|
||||
preGroup,
|
||||
...selectedGroup
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
private getSelectedGroups = async () => {
|
||||
const selectedIds = this._editor.selectionManager.getSelectedNodesIds();
|
||||
const selectedNodes = (
|
||||
await Promise.all(
|
||||
selectedIds.map(id => this._editor.getBlockById(id))
|
||||
)
|
||||
).filter(Boolean) as AsyncBlock[];
|
||||
if (
|
||||
!selectedNodes.every(
|
||||
node => node.type === Protocol.Block.Type.group
|
||||
)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return selectedNodes;
|
||||
};
|
||||
}
|
||||
1
libs/components/editor-core/src/editor/mouse/index.ts
Normal file
1
libs/components/editor-core/src/editor/mouse/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './mouse';
|
||||
146
libs/components/editor-core/src/editor/mouse/mouse.ts
Normal file
146
libs/components/editor-core/src/editor/mouse/mouse.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { debounce } from '@toeverything/utils';
|
||||
import EventEmitter from 'eventemitter3';
|
||||
|
||||
import { BlockEditor } from '../..';
|
||||
|
||||
const mouseupEventName = 'mouseup';
|
||||
const mousemoveEventName = 'mousemove';
|
||||
|
||||
export class MouseManager {
|
||||
private _editor: BlockEditor;
|
||||
private is_mouse_down: boolean;
|
||||
private mousedown_timer: number;
|
||||
private is_dragging: boolean;
|
||||
private _events = new EventEmitter();
|
||||
|
||||
constructor(editor: BlockEditor) {
|
||||
this._editor = editor;
|
||||
this.is_mouse_down = false;
|
||||
this.is_dragging = false;
|
||||
this.init_editor_mouse_event_handler();
|
||||
}
|
||||
|
||||
get isMouseDown() {
|
||||
return this.is_mouse_down;
|
||||
}
|
||||
|
||||
get isDragging() {
|
||||
return this.is_dragging;
|
||||
}
|
||||
|
||||
private init_editor_mouse_event_handler() {
|
||||
this.handle_mouse_down = this.handle_mouse_down.bind(this);
|
||||
this.handle_mouse_up = this.handle_mouse_up.bind(this);
|
||||
this.handle_mouse_down_capture =
|
||||
this.handle_mouse_down_capture.bind(this);
|
||||
// TODO: IMP: Check later to see if there is any need to add drag
|
||||
this.handle_mouse_drag = debounce(
|
||||
this.handle_mouse_drag.bind(this),
|
||||
15
|
||||
);
|
||||
this.handle_mouse_move = this.handle_mouse_move.bind(this);
|
||||
window.addEventListener('mousedown', this.handle_mouse_down_capture, {
|
||||
capture: true,
|
||||
});
|
||||
window.addEventListener('mousedown', this.handle_mouse_down);
|
||||
window.addEventListener('mouseup', this.handle_mouse_up);
|
||||
window.addEventListener('mousemove', this.handle_mouse_move);
|
||||
}
|
||||
|
||||
private handle_mouse_down_capture(e: MouseEvent) {
|
||||
this.is_mouse_down = true;
|
||||
}
|
||||
|
||||
private handle_mouse_down(e: MouseEvent) {
|
||||
this.mousedown_timer = window.setTimeout(() => {
|
||||
window.addEventListener('mousemove', this.handle_mouse_drag);
|
||||
this.mousedown_timer = undefined;
|
||||
}, 30);
|
||||
}
|
||||
|
||||
private handle_mouse_up(e: MouseEvent) {
|
||||
this.is_mouse_down = false;
|
||||
this.is_dragging = false;
|
||||
if (this.mousedown_timer) {
|
||||
window.clearTimeout(this.mousedown_timer);
|
||||
}
|
||||
this._events.emit(mouseupEventName, e);
|
||||
window.removeEventListener('mousemove', this.handle_mouse_drag);
|
||||
}
|
||||
|
||||
private get_select_start_event_name(blockId: string) {
|
||||
return `${blockId}-select_start`;
|
||||
}
|
||||
|
||||
private handle_mouse_drag(e: MouseEvent) {
|
||||
const selectionInfo = this._editor.selectionManager.currentSelectInfo;
|
||||
if (selectionInfo.type === 'Range') {
|
||||
if (selectionInfo.anchorNode) {
|
||||
this.emit_select_start_with(selectionInfo.anchorNode.id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handle_mouse_move(e: MouseEvent) {
|
||||
this._events.emit(mousemoveEventName, e);
|
||||
}
|
||||
|
||||
public onSelectStartWith = (
|
||||
blockId: string,
|
||||
cb: (e: MouseEvent) => void
|
||||
) => {
|
||||
this._events.on(this.get_select_start_event_name(blockId), cb);
|
||||
};
|
||||
|
||||
public offSelectStartWith = (
|
||||
blockId: string,
|
||||
cb: (e: MouseEvent) => void
|
||||
) => {
|
||||
this._events.off(this.get_select_start_event_name(blockId), cb);
|
||||
};
|
||||
|
||||
public onMouseupEventOnce(cb: (e: MouseEvent) => void) {
|
||||
this._events.once(mouseupEventName, cb);
|
||||
}
|
||||
|
||||
public onMouseUp(cb: (e: MouseEvent) => void) {
|
||||
this._events.on(mouseupEventName, cb);
|
||||
}
|
||||
|
||||
public offMouseUp(cb: (e: MouseEvent) => void) {
|
||||
this._events.on(mouseupEventName, cb);
|
||||
}
|
||||
|
||||
public onMouseMove(cb: (e: MouseEvent) => void) {
|
||||
this._events.on(mousemoveEventName, cb);
|
||||
}
|
||||
|
||||
public offMouseMove(cb: (e: MouseEvent) => void) {
|
||||
this._events.off(mousemoveEventName, cb);
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this._events.removeAllListeners();
|
||||
window.removeEventListener(
|
||||
'mousedown',
|
||||
this.handle_mouse_down_capture,
|
||||
{
|
||||
capture: true,
|
||||
}
|
||||
);
|
||||
window.removeEventListener('mousedown', this.handle_mouse_down);
|
||||
window.removeEventListener('mouseup', this.handle_mouse_up);
|
||||
window.removeEventListener('mousemove', this.handle_mouse_drag);
|
||||
window.removeEventListener('mousemove', this.handle_mouse_move);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* emit browser select start event to the start block by start id
|
||||
* @private
|
||||
* @memberof MouseManager
|
||||
*/
|
||||
private emit_select_start_with(id: string, e: MouseEvent) {
|
||||
this._events.emit(this.get_select_start_event_name(id), e);
|
||||
}
|
||||
}
|
||||
200
libs/components/editor-core/src/editor/plugin/hooks.ts
Normal file
200
libs/components/editor-core/src/editor/plugin/hooks.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
HooksRunner,
|
||||
PluginHooks,
|
||||
HookType,
|
||||
HookBaseArgs,
|
||||
BlockDomInfo,
|
||||
AnyFunction,
|
||||
AnyThisType,
|
||||
} from '../types';
|
||||
|
||||
interface PluginHookInfo {
|
||||
thisObj?: AnyThisType;
|
||||
callback: AnyFunction;
|
||||
once: boolean;
|
||||
}
|
||||
|
||||
export class Hooks implements HooksRunner, PluginHooks {
|
||||
private hooks_map: Map<string, PluginHookInfo[]> = new Map();
|
||||
|
||||
dispose() {
|
||||
this.hooks_map.clear();
|
||||
}
|
||||
|
||||
private run_hook(key: HookType, ...params: unknown[]): void {
|
||||
const hook_infos: PluginHookInfo[] = this.hooks_map.get(key) || [];
|
||||
hook_infos.forEach(hook_info => {
|
||||
if (hook_info.once) {
|
||||
this.removeHook(key, hook_info.callback);
|
||||
}
|
||||
let is_stopped_propagation = false;
|
||||
const hookOption: HookBaseArgs = {
|
||||
stopImmediatePropagation: () => {
|
||||
is_stopped_propagation = true;
|
||||
},
|
||||
};
|
||||
hook_info.callback.call(
|
||||
hook_info.thisObj || this,
|
||||
...params,
|
||||
hookOption
|
||||
);
|
||||
return is_stopped_propagation;
|
||||
});
|
||||
}
|
||||
|
||||
private has_hook(key: HookType, callback: AnyFunction): boolean {
|
||||
const hook_infos: PluginHookInfo[] = this.hooks_map.get(key) || [];
|
||||
for (let i = hook_infos.length - 1; i >= 0; i--) {
|
||||
if (hook_infos[i].callback === callback) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 执行多次
|
||||
public addHook(
|
||||
key: HookType,
|
||||
callback: AnyFunction,
|
||||
thisObj?: AnyThisType,
|
||||
once?: boolean
|
||||
): void {
|
||||
if (this.has_hook(key, callback)) {
|
||||
throw new Error('Duplicate registration of the same class');
|
||||
}
|
||||
if (!this.hooks_map.has(key)) {
|
||||
this.hooks_map.set(key, []);
|
||||
}
|
||||
const hook_infos: PluginHookInfo[] = this.hooks_map.get(key);
|
||||
hook_infos.push({ callback, thisObj, once });
|
||||
}
|
||||
|
||||
// 执行一次
|
||||
public addOnceHook(
|
||||
key: HookType,
|
||||
callback: AnyFunction,
|
||||
thisObj?: AnyThisType
|
||||
): void {
|
||||
this.addHook(key, callback, thisObj, true);
|
||||
}
|
||||
|
||||
// 移除
|
||||
public removeHook(key: HookType, callback: AnyFunction): void {
|
||||
const hook_infos: PluginHookInfo[] = this.hooks_map.get(key) || [];
|
||||
for (let i = hook_infos.length - 1; i >= 0; i--) {
|
||||
if (hook_infos[i].callback === callback) {
|
||||
hook_infos.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public init(): void {
|
||||
this.run_hook(HookType.INIT);
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
this.run_hook(HookType.RENDER);
|
||||
}
|
||||
|
||||
public onRootNodeKeyDown(e: React.KeyboardEvent<HTMLDivElement>): void {
|
||||
this.run_hook(HookType.ON_ROOT_NODE_KEYDOWN, e);
|
||||
}
|
||||
|
||||
public onRootNodeKeyDownCapture(
|
||||
e: React.KeyboardEvent<HTMLDivElement>
|
||||
): void {
|
||||
this.run_hook(HookType.ON_ROOT_NODE_KEYDOWN_CAPTURE, e);
|
||||
}
|
||||
|
||||
public onRootNodeKeyUp(e: React.KeyboardEvent<HTMLDivElement>): void {
|
||||
this.run_hook(HookType.ON_ROOT_NODE_KEYUP, e);
|
||||
}
|
||||
|
||||
public onRootNodeMouseDown(
|
||||
e: React.MouseEvent<HTMLDivElement, MouseEvent>
|
||||
): void {
|
||||
this.run_hook(HookType.ON_ROOTNODE_MOUSE_DOWN, e);
|
||||
}
|
||||
|
||||
public onRootNodeMouseMove(
|
||||
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
root_rect: DOMRect
|
||||
): void {
|
||||
this.run_hook(HookType.ON_ROOTNODE_MOUSE_MOVE, e, root_rect);
|
||||
}
|
||||
|
||||
public onRootNodeMouseUp(
|
||||
e: React.MouseEvent<HTMLDivElement, MouseEvent>
|
||||
): void {
|
||||
this.run_hook(HookType.ON_ROOTNODE_MOUSE_UP, e);
|
||||
}
|
||||
|
||||
public onRootNodeMouseOut(
|
||||
e: React.MouseEvent<HTMLDivElement, MouseEvent>
|
||||
): void {
|
||||
this.run_hook(HookType.ON_ROOTNODE_MOUSE_OUT, e);
|
||||
}
|
||||
|
||||
public onRootNodeMouseLeave(
|
||||
e: React.MouseEvent<HTMLDivElement, MouseEvent>
|
||||
): void {
|
||||
this.run_hook(HookType.ON_ROOTNODE_MOUSE_LEAVE, e);
|
||||
}
|
||||
|
||||
public afterOnNodeMouseMove(
|
||||
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
node: BlockDomInfo
|
||||
): void {
|
||||
this.run_hook(HookType.AFTER_ON_NODE_MOUSE_MOVE, e, node);
|
||||
}
|
||||
|
||||
public afterOnResize(
|
||||
e: React.MouseEvent<HTMLDivElement, MouseEvent>
|
||||
): void {
|
||||
this.run_hook(HookType.AFTER_ON_RESIZE, e);
|
||||
}
|
||||
|
||||
public onRootNodeDragOver(
|
||||
e: React.DragEvent<Element>,
|
||||
root_rect: DOMRect
|
||||
): void {
|
||||
this.run_hook(HookType.ON_ROOTNODE_DRAG_OVER, e, root_rect);
|
||||
}
|
||||
|
||||
public onRootNodeDragEnd(
|
||||
e: React.DragEvent<Element>,
|
||||
root_rect: DOMRect
|
||||
): void {
|
||||
this.run_hook(HookType.ON_ROOTNODE_DRAG_END, e, root_rect);
|
||||
}
|
||||
|
||||
public onRootNodeDrop(e: React.DragEvent<Element>): void {
|
||||
this.run_hook(HookType.ON_ROOTNODE_DROP, e);
|
||||
}
|
||||
|
||||
public onRootNodeDragOverCapture(
|
||||
e: React.DragEvent<Element>,
|
||||
root_rect: DOMRect
|
||||
): void {
|
||||
this.run_hook(HookType.ON_ROOTNODE_DRAG_OVER_CAPTURE, e, root_rect);
|
||||
}
|
||||
|
||||
public afterOnNodeDragOver(
|
||||
e: React.DragEvent<Element>,
|
||||
node: BlockDomInfo
|
||||
): void {
|
||||
this.run_hook(HookType.AFTER_ON_NODE_DRAG_OVER, e, node);
|
||||
}
|
||||
|
||||
public onSearch(): void {
|
||||
this.run_hook(HookType.ON_SEARCH);
|
||||
}
|
||||
|
||||
public beforeCopy(e: ClipboardEvent): void {
|
||||
this.run_hook(HookType.BEFORE_COPY, e);
|
||||
}
|
||||
|
||||
public beforeCut(e: ClipboardEvent): void {
|
||||
this.run_hook(HookType.BEFORE_CUT, e);
|
||||
}
|
||||
}
|
||||
2
libs/components/editor-core/src/editor/plugin/index.ts
Normal file
2
libs/components/editor-core/src/editor/plugin/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { PluginManager } from './manager';
|
||||
export { Hooks } from './hooks';
|
||||
88
libs/components/editor-core/src/editor/plugin/manager.ts
Normal file
88
libs/components/editor-core/src/editor/plugin/manager.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import EventEmitter from 'eventemitter3';
|
||||
import { createNoopWithMessage } from '@toeverything/utils';
|
||||
import type {
|
||||
Virgo,
|
||||
Plugin,
|
||||
PluginCreator,
|
||||
PluginHooks,
|
||||
PluginManagerInterface,
|
||||
} from '../types';
|
||||
|
||||
export class PluginManager implements PluginManagerInterface {
|
||||
private editor: Virgo;
|
||||
private hooks: PluginHooks;
|
||||
private plugins: Record<string, Plugin> = {};
|
||||
private emitter = new EventEmitter();
|
||||
|
||||
constructor(editor: Virgo, hooks: PluginHooks) {
|
||||
this.editor = editor;
|
||||
this.hooks = hooks;
|
||||
}
|
||||
|
||||
register(createPlugin: PluginCreator): void {
|
||||
const plugin: Plugin = new createPlugin(this.editor, this.hooks);
|
||||
createNoopWithMessage({
|
||||
module: 'plugin/manager',
|
||||
message: 'Plugin registered: ' + createPlugin.pluginName,
|
||||
})();
|
||||
plugin.init();
|
||||
this.plugins[createPlugin.pluginName] = plugin;
|
||||
}
|
||||
|
||||
registerAll(createPlugins: PluginCreator[]): void {
|
||||
createPlugins.sort((a: PluginCreator, b: PluginCreator): number => {
|
||||
return a.priority - b.priority;
|
||||
});
|
||||
createPlugins.forEach((pluginCreator: PluginCreator): void => {
|
||||
this.register(pluginCreator);
|
||||
});
|
||||
}
|
||||
|
||||
deregister(pluginName: string): void {
|
||||
const plugin: Plugin | undefined = this.plugins[pluginName];
|
||||
try {
|
||||
plugin?.dispose();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
delete this.plugins[pluginName];
|
||||
}
|
||||
|
||||
dispose() {
|
||||
Object.entries(this.plugins).forEach(([pluginName, plugin]) => {
|
||||
try {
|
||||
plugin.dispose();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
delete this.plugins[pluginName];
|
||||
});
|
||||
}
|
||||
|
||||
observe(
|
||||
name: string,
|
||||
callback: (
|
||||
...args: Array<unknown>
|
||||
) => Promise<Record<string, unknown>> | void
|
||||
): void {
|
||||
this.emitter.on(name, callback);
|
||||
}
|
||||
unobserve(
|
||||
name: string,
|
||||
callback: (
|
||||
...args: Array<unknown>
|
||||
) => Promise<Record<string, unknown>> | void
|
||||
): void {
|
||||
this.emitter.off(name, callback);
|
||||
}
|
||||
emitAsync(name: string, ...params: Array<unknown>): Promise<any[]> {
|
||||
// return this.emitter.emitAsync(name, params);
|
||||
return {} as any;
|
||||
}
|
||||
emit(name: string, ...params: Array<unknown>): void {
|
||||
this.emitter.emit(name, params);
|
||||
}
|
||||
public getPlugin(pluginName: string): Plugin {
|
||||
return this.plugins[pluginName];
|
||||
}
|
||||
}
|
||||
6
libs/components/editor-core/src/editor/plugin/utils.ts
Normal file
6
libs/components/editor-core/src/editor/plugin/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum PluginsEventTypes {
|
||||
toggleTextBold = 'TOGGLE_TEXT_BOLD',
|
||||
toggleTextItalic = 'TOGGLE_TEXT_ITALIC',
|
||||
toggleTextStrikethrough = 'TOGGLE_TEXT_STRIKETHROUGH',
|
||||
checkTextBold = 'CHECK_TEXT_BOLD',
|
||||
}
|
||||
1
libs/components/editor-core/src/editor/scroll/index.ts
Normal file
1
libs/components/editor-core/src/editor/scroll/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './scroll';
|
||||
245
libs/components/editor-core/src/editor/scroll/scroll.ts
Normal file
245
libs/components/editor-core/src/editor/scroll/scroll.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import EventEmitter from 'eventemitter3';
|
||||
|
||||
import { domToRect, Rect } from '@toeverything/utils';
|
||||
import type { Editor as BlockEditor } from '../editor';
|
||||
|
||||
import { AsyncBlock } from '../block';
|
||||
|
||||
type VerticalTypes = 'up' | 'down' | null;
|
||||
type HorizontalTypes = 'left' | 'right' | null;
|
||||
|
||||
export class ScrollManager {
|
||||
private _editor: BlockEditor;
|
||||
private _animation_frame: null | number = null;
|
||||
private _event_name = 'scrolling';
|
||||
private _current_move_direction: [HorizontalTypes, VerticalTypes] = [
|
||||
null,
|
||||
null,
|
||||
];
|
||||
|
||||
public scrollMoveOffset = 8;
|
||||
public scrollingEvent = new EventEmitter();
|
||||
|
||||
constructor(editor: BlockEditor) {
|
||||
this._editor = editor;
|
||||
}
|
||||
|
||||
private update_scroll_info(left: number, top: number) {
|
||||
this.scrollTop = top;
|
||||
this.scrollLeft = left;
|
||||
}
|
||||
|
||||
public onScrolling(
|
||||
cb: (args: { direction: [HorizontalTypes, VerticalTypes] }) => void
|
||||
) {
|
||||
this.scrollingEvent.on(this._event_name, cb);
|
||||
}
|
||||
public removeScrolling(
|
||||
cb: (args: { direction: [HorizontalTypes, VerticalTypes] }) => void
|
||||
) {
|
||||
this.scrollingEvent.removeListener(this._event_name, cb);
|
||||
}
|
||||
|
||||
public get scrollContainer() {
|
||||
return this._editor.ui_container;
|
||||
}
|
||||
|
||||
public get verticalScrollTriggerDistance() {
|
||||
return 15;
|
||||
}
|
||||
public get horizontalScrollTriggerDistance() {
|
||||
// Set horizon distance when support horizontal scroll
|
||||
return -1;
|
||||
}
|
||||
|
||||
public get scrollTop() {
|
||||
return this._editor.ui_container.scrollTop;
|
||||
}
|
||||
public set scrollTop(top: number) {
|
||||
this._editor.ui_container.scrollTop = top;
|
||||
}
|
||||
public get scrollLeft() {
|
||||
return this._editor.ui_container.scrollLeft;
|
||||
}
|
||||
public set scrollLeft(left: number) {
|
||||
this._editor.ui_container.scrollLeft = left;
|
||||
}
|
||||
|
||||
public scrollTo({
|
||||
top,
|
||||
left,
|
||||
behavior = 'smooth',
|
||||
}: {
|
||||
top?: number;
|
||||
left?: number;
|
||||
behavior?: ScrollBehavior; // "auto" | "smooth";
|
||||
}) {
|
||||
top = top !== undefined ? top : this.scrollContainer.scrollTop;
|
||||
left = left !== undefined ? left : this.scrollContainer.scrollLeft;
|
||||
|
||||
if (behavior === 'smooth') {
|
||||
this._editor.ui_container.scrollBy({
|
||||
top,
|
||||
left,
|
||||
behavior,
|
||||
});
|
||||
} else {
|
||||
this._editor.ui_container.scrollTo(left, top);
|
||||
}
|
||||
}
|
||||
|
||||
public async scrollIntoViewByBlockId(
|
||||
blockId: string,
|
||||
behavior: ScrollBehavior = 'smooth'
|
||||
) {
|
||||
const block = await this._editor.getBlockById(blockId);
|
||||
|
||||
await this.scrollIntoViewByBlock(block, behavior);
|
||||
}
|
||||
|
||||
public async scrollIntoViewByBlock(
|
||||
block: AsyncBlock,
|
||||
behavior: ScrollBehavior = 'smooth'
|
||||
) {
|
||||
if (!block.dom) {
|
||||
return console.warn(`Block is not exist.`);
|
||||
}
|
||||
const containerRect = domToRect(this._editor.ui_container);
|
||||
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.update_scroll_info(
|
||||
blockRelativeLeftToEditor,
|
||||
blockRelativeTopToEditor
|
||||
);
|
||||
}
|
||||
|
||||
public async keepBlockInView(
|
||||
blockIdOrBlock: string | AsyncBlock,
|
||||
behavior: ScrollBehavior = 'auto'
|
||||
) {
|
||||
const block =
|
||||
typeof blockIdOrBlock === 'string'
|
||||
? await this._editor.getBlockById(blockIdOrBlock)
|
||||
: blockIdOrBlock;
|
||||
|
||||
if (!block.dom) {
|
||||
return console.warn(`Block is not exist.`);
|
||||
}
|
||||
const blockRect = domToRect(block.dom);
|
||||
|
||||
const value = this.get_keep_in_view_params(blockRect);
|
||||
|
||||
if (value !== 0) {
|
||||
this.scrollTo({
|
||||
top: this.scrollTop + blockRect.height * value,
|
||||
behavior,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private get_keep_in_view_params(blockRect: Rect) {
|
||||
const { top, bottom } = domToRect(this._editor.ui_container);
|
||||
if (blockRect.top <= top + blockRect.height * 3) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (blockRect.bottom >= bottom - blockRect.height * 3) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public scrollToBottom(behavior: ScrollBehavior = 'auto') {
|
||||
const containerRect = domToRect(this.scrollContainer);
|
||||
const scrollTop =
|
||||
this.scrollContainer.scrollHeight - containerRect.height;
|
||||
this.scrollTo({ top: scrollTop, behavior });
|
||||
}
|
||||
|
||||
public scrollToTop(behavior: ScrollBehavior = 'auto') {
|
||||
this.scrollTo({ top: 0, behavior });
|
||||
}
|
||||
|
||||
private auto_scroll() {
|
||||
const xValue =
|
||||
this._current_move_direction[0] === 'left'
|
||||
? -1
|
||||
: this._current_move_direction[0] === 'right'
|
||||
? 1
|
||||
: 0;
|
||||
const yValue =
|
||||
this._current_move_direction[1] === 'up'
|
||||
? -1
|
||||
: this._current_move_direction[1] === 'down'
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
const horizontalOffset = this.scrollMoveOffset * xValue;
|
||||
const verticalOffset = this.scrollMoveOffset * yValue;
|
||||
|
||||
const calcLeft = this.scrollLeft + horizontalOffset;
|
||||
const calcTop = this.scrollTop + verticalOffset;
|
||||
// If the scrollbar is out of range, the event is no longer fired
|
||||
if (
|
||||
(calcTop <= 0 ||
|
||||
calcTop >=
|
||||
this.scrollContainer.scrollHeight -
|
||||
this.scrollContainer.offsetHeight) &&
|
||||
(calcLeft <= 0 ||
|
||||
calcLeft >=
|
||||
this.scrollContainer.scrollWidth -
|
||||
this.scrollContainer.offsetWidth)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._animation_frame = requestAnimationFrame(() => {
|
||||
const left = this.scrollLeft + horizontalOffset;
|
||||
const top = this.scrollTop + verticalOffset;
|
||||
|
||||
this.scrollTo({
|
||||
left,
|
||||
top,
|
||||
behavior: 'auto',
|
||||
});
|
||||
this.update_scroll_info(left, top);
|
||||
this.scrollingEvent.emit(this._event_name, {
|
||||
direction: this._current_move_direction,
|
||||
});
|
||||
this.auto_scroll();
|
||||
});
|
||||
}
|
||||
|
||||
public startAutoScroll(direction: [HorizontalTypes, VerticalTypes]) {
|
||||
if (direction[0] === null && direction[1] === null) {
|
||||
this._current_move_direction = direction;
|
||||
this.stopAutoScroll();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
direction[0] !== this._current_move_direction[0] ||
|
||||
direction[1] !== this._current_move_direction[1]
|
||||
) {
|
||||
this._current_move_direction = direction;
|
||||
this.stopAutoScroll();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
this.auto_scroll();
|
||||
}
|
||||
public stopAutoScroll() {
|
||||
if (this._animation_frame) {
|
||||
cancelAnimationFrame(this._animation_frame);
|
||||
this._animation_frame = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './types';
|
||||
|
||||
export { SelectionManager } from './selection';
|
||||
export type { SelectionInfo } from './selection';
|
||||
1046
libs/components/editor-core/src/editor/selection/selection.ts
Normal file
1046
libs/components/editor-core/src/editor/selection/selection.ts
Normal file
File diff suppressed because it is too large
Load Diff
83
libs/components/editor-core/src/editor/selection/types.ts
Normal file
83
libs/components/editor-core/src/editor/selection/types.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Point } from '@toeverything/utils';
|
||||
import { Range } from 'slate';
|
||||
import { AsyncBlock } from '../block';
|
||||
|
||||
export const changeEventName = 'selection-change';
|
||||
export const selectEndEventName = 'select-end';
|
||||
export interface TextSelection {
|
||||
anchor: { offset: number; path: Array<number> };
|
||||
focus: { offset: number; path: Array<number> };
|
||||
}
|
||||
|
||||
export type ModelType = 'None' | 'Range' | 'Caret' | 'Block';
|
||||
|
||||
/** -*-*-*- enum import start -*-*-*- **/
|
||||
|
||||
export enum SelectEventTypes {
|
||||
active = 'active',
|
||||
setSelection = 'setSelection',
|
||||
onSelect = 'onSelect',
|
||||
}
|
||||
|
||||
export interface SelectEventCallbackTypes {
|
||||
[SelectEventTypes.active]: [CursorTypes];
|
||||
[SelectEventTypes.onSelect]: [boolean];
|
||||
[SelectEventTypes.setSelection]: [
|
||||
SelectionSettingsMap[keyof SelectionSettingsMap]
|
||||
];
|
||||
}
|
||||
|
||||
/** -*-*-*- enum import end -*-*-*- **/
|
||||
|
||||
/** -*-*-*- interface import start -*-*-*- **/
|
||||
|
||||
/**
|
||||
*
|
||||
* types for set selection
|
||||
* @export
|
||||
* @interface SelectionSettingsMap
|
||||
*/
|
||||
export interface SelectionSettingsMap {
|
||||
Range: Range | Point;
|
||||
None: null;
|
||||
Caret: Range | Point;
|
||||
Block: null;
|
||||
}
|
||||
|
||||
/** -*-*-*- interface import end -*-*-*- **/
|
||||
|
||||
/** -*-*-*- type import start -*-*-*- **/
|
||||
|
||||
export type IdList = Array<string>;
|
||||
|
||||
export type SelectionTypes = 'None' | 'Range' | 'Caret' | 'Block';
|
||||
|
||||
export type SelectionSettings = SelectionSettingsMap[SelectionTypes];
|
||||
|
||||
export type AsyncBlockList = Array<AsyncBlock>;
|
||||
|
||||
export type Path = Array<string>;
|
||||
|
||||
export type PathList = Array<Path>;
|
||||
|
||||
export type CursorTypes = Point | 'start' | 'end';
|
||||
|
||||
export type Form = 'up' | 'down';
|
||||
|
||||
export interface SelectPosition {
|
||||
arrayIndex: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface SelectBlock {
|
||||
blockId: string;
|
||||
startInfo?: SelectPosition;
|
||||
endInfo?: SelectPosition;
|
||||
children: SelectBlock[];
|
||||
}
|
||||
|
||||
export interface SelectInfo {
|
||||
type: 'Block' | 'Range' | 'None';
|
||||
blocks: SelectBlock[];
|
||||
}
|
||||
/** -*-*-*- type import end -*-*-*- **/
|
||||
42
libs/components/editor-core/src/editor/selection/utils.ts
Normal file
42
libs/components/editor-core/src/editor/selection/utils.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { AsyncBlockList, PathList } from './types';
|
||||
import { difference } from '@toeverything/utils';
|
||||
|
||||
interface TextSelection {
|
||||
anchor: { offset: number; path: Array<number> };
|
||||
focus: { offset: number; path: Array<number> };
|
||||
}
|
||||
|
||||
interface SelectText {
|
||||
// discard blockId discard
|
||||
blockId?: string;
|
||||
renderId?: string;
|
||||
selection?: TextSelection;
|
||||
parentRenderId?: string;
|
||||
}
|
||||
|
||||
export function isLikePathList(paths1: PathList, paths2: PathList) {
|
||||
if (paths1?.length !== paths2?.length) return false;
|
||||
return paths1.join() === paths2.join();
|
||||
}
|
||||
|
||||
export function isLikeBlockList(a: AsyncBlockList, b: AsyncBlockList) {
|
||||
if (a?.length !== b?.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isLikeBlockListIds(left: Array<string>, right: Array<string>) {
|
||||
if (left.length && right.length && left.length === right.length) {
|
||||
return !difference(left, right).length;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// export function isLikeSelectText(a: SelectText, b?: SelectText) {
|
||||
// if (a?.renderId === b?.renderId) {
|
||||
// if (a?.selection?.anchor.offset === b?.selection?.anchor.offset && a?.selection?.focus.offset === b?.selection?.focus.offset) return true;
|
||||
// }
|
||||
// return false;
|
||||
// }
|
||||
257
libs/components/editor-core/src/editor/types.ts
Normal file
257
libs/components/editor-core/src/editor/types.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Editor plug-in mechanism design
|
||||
* 1. Plug-ins are not aware of each other
|
||||
* 2. Disable dom operations in plugins, such as operating dom, monitoring mouse and keyboard events, etc.
|
||||
* 3. Interact with Editor through EditorApi, get value and set value
|
||||
* 4. Through the hook, complete the callback of the Editor, just make the callback,
|
||||
* Therefore, the parameters in the hook cannot directly transmit the reference data in the Editor to prevent the Plugin from modifying it.
|
||||
* 5. All Plugins should inherit from BasePlugin, in the form of objects
|
||||
* 6. Dependencies between plugins are not supported for the time being
|
||||
*/
|
||||
// import { CompleteInfoSelectOption } from '@authing/react-ui-components/components/CompleteInfo/interface';
|
||||
import type {
|
||||
BlockFlavors,
|
||||
BlockFlavorKeys,
|
||||
} from '@toeverything/datasource/db-service';
|
||||
import type { PatchNode } from '@toeverything/components/ui';
|
||||
import type { IdList, SelectionInfo, SelectionManager } from './selection';
|
||||
|
||||
import type { AsyncBlock } from './block';
|
||||
import type { BlockHelper } from './block/block-helper';
|
||||
import type { BlockCommands } from './commands/block-commands';
|
||||
import type { DragDropManager } from './drag-drop';
|
||||
|
||||
// import { BrowserClipboard } from './clipboard/browser-clipboard';
|
||||
|
||||
export interface StorageManager {
|
||||
// createStorage: () => void;
|
||||
// removeStorage: () => void;
|
||||
}
|
||||
|
||||
export interface Commands {
|
||||
blockCommands: Pick<
|
||||
BlockCommands,
|
||||
| 'createNextBlock'
|
||||
| 'convertBlock'
|
||||
| 'removeBlock'
|
||||
| 'splitGroupFromBlock'
|
||||
| 'mergeGroup'
|
||||
>;
|
||||
textCommands: {
|
||||
getBlockText: (blockId: string) => Promise<string>;
|
||||
setBlockText: (blockId: string, text: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface VirgoSelection {
|
||||
currentSelectInfo: SelectionInfo;
|
||||
getSelectedNodesIds: () => IdList;
|
||||
setSelectedNodesIds: (nodesIdsList: IdList) => void;
|
||||
onSelectionChange: (
|
||||
handler: (selectionInfo: SelectionInfo) => void
|
||||
) => void;
|
||||
unBindSelectionChange: (
|
||||
handler: (selectionInfo: SelectionInfo) => void
|
||||
) => void;
|
||||
onSelectEnd: (cb: (info: SelectionInfo) => void) => () => void;
|
||||
convertSelectedNodesToGroup: (nodes?: AsyncBlock[]) => Promise<void>;
|
||||
}
|
||||
|
||||
// Editor's external API
|
||||
export interface Virgo {
|
||||
selectionManager: SelectionManager;
|
||||
createBlock: (
|
||||
type: keyof BlockFlavors,
|
||||
parentId?: string
|
||||
) => Promise<AsyncBlock>;
|
||||
getRootBlockId: () => string;
|
||||
getBlockById(blockId: string): Promise<AsyncBlock | null>;
|
||||
setHotKeysScope(scope?: string): void;
|
||||
getBlockList: () => Promise<AsyncBlock[]>;
|
||||
// removeBlocks: () => void;
|
||||
storageManager: StorageManager | undefined;
|
||||
selection: VirgoSelection;
|
||||
plugins: PluginManagerInterface;
|
||||
/**
|
||||
* commands bind with editor , use for change model data
|
||||
* if want to get some block info use block helper
|
||||
*/
|
||||
commands: Commands;
|
||||
container: HTMLDivElement;
|
||||
/**
|
||||
* Block helper aim to get block`s self infos, is has some function for changing block use them carefully
|
||||
*/
|
||||
blockHelper: BlockHelper;
|
||||
dragDropManager: DragDropManager;
|
||||
// getRenderNodeById: () => void; // Post removal to transform the corresponding function through the hook mechanism
|
||||
// container is removed later using the attachElement interface
|
||||
readonly: boolean;
|
||||
// getRootRenderId is removed later and the corresponding function is modified through the hook mechanism
|
||||
// getRootRenderNode is later removed to transform the corresponding function through the hook mechanism
|
||||
// dispatchAction needs to be designed to transform the corresponding function through the hook mechanism
|
||||
// Plugin.actions needs to be designed to transform the corresponding functions through the hook mechanism
|
||||
// renderNodeMap is removed later and the corresponding function is transformed through the hook mechanism
|
||||
// getDomByBlockId is removed later and the corresponding function is modified through the hook mechanism
|
||||
// registerHotKey is later removed and the corresponding function is modified through the hook mechanism
|
||||
// unregisterHotKey is later removed to transform the corresponding function through the hook mechanism
|
||||
// eventEmitter needs to be designed to transform the corresponding function through the hook mechanism
|
||||
// getAllBlockTypes later removed
|
||||
reactRenderRoot: {
|
||||
render: PatchNode;
|
||||
has: (key: string) => boolean;
|
||||
};
|
||||
// clipboard: BrowserClipboard;
|
||||
workspace: string;
|
||||
getBlockDomById: (id: string) => Promise<HTMLElement>;
|
||||
isWhiteboard: boolean;
|
||||
}
|
||||
|
||||
export interface Plugin {
|
||||
init: () => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export interface PluginCreator {
|
||||
// Unique identifier to distinguish between different Plugins
|
||||
pluginName: string;
|
||||
// Priority, the higher the number, the higher the priority? ? Is it necessary to put it at the hook level?
|
||||
priority: number;
|
||||
// According to different capabilities, the api of editor/hooks will be different
|
||||
// If the capability to which the api belongs is enabled, the api will throw error
|
||||
// For example, a plug-in is a plug-in related to encrypted storage to localStorage, and needs to declare storage capability
|
||||
// Then operate localStorage through the encapsulated storage api
|
||||
// And when the plugin is uninstalled (such as editor destroyed), the ability to directly revoke the storage api
|
||||
// And when the user loads a plug-in, it can also remind the user what kind of capabilities the plug-in may use
|
||||
// getCapability: () => Capability[];
|
||||
new (affine: Virgo, hooks: PluginHooks): Plugin;
|
||||
}
|
||||
|
||||
// plugin management
|
||||
export interface PluginManagerInterface {
|
||||
/** register plugin to editor */
|
||||
register: (plugin: PluginCreator) => void;
|
||||
deregister: (pluginName: string) => void;
|
||||
getPlugin: (pluginName: string) => Plugin | undefined;
|
||||
|
||||
/** listen to event name, exec async listener callback */
|
||||
observe(
|
||||
name: string,
|
||||
callback: (
|
||||
...args: Array<any>
|
||||
) => Promise<Record<string, unknown>> | void
|
||||
): void;
|
||||
unobserve(
|
||||
name: string,
|
||||
callback: (
|
||||
...args: Array<any>
|
||||
) => Promise<Record<string, unknown>> | void
|
||||
): void;
|
||||
/** fire event name, and collect all results of listeners */
|
||||
emitAsync(name: string, ...params: Array<any>): Promise<any[]>;
|
||||
emit(name: string, ...params: Array<any>): void;
|
||||
}
|
||||
|
||||
export enum HookType {
|
||||
INIT = 'init',
|
||||
RENDER = 'render',
|
||||
ON_ROOT_NODE_KEYUP = 'onRootNodeKeyUp',
|
||||
ON_ROOTNODE_MOUSE_DOWN = 'onRootNodeMouseDown',
|
||||
ON_ROOT_NODE_KEYDOWN = 'onRootNodeKeyDown',
|
||||
ON_ROOT_NODE_KEYDOWN_CAPTURE = 'onRootNodeKeyDownCapture',
|
||||
ON_ROOTNODE_MOUSE_MOVE = 'onRootNodeMouseMove',
|
||||
ON_ROOTNODE_MOUSE_UP = 'onRootNodeMouseUp',
|
||||
ON_ROOTNODE_MOUSE_OUT = 'onRootNodeMouseOut',
|
||||
ON_ROOTNODE_MOUSE_LEAVE = 'onRootNodeMouseLeave',
|
||||
ON_SEARCH = 'onSearch',
|
||||
AFTER_ON_NODE_MOUSE_MOVE = 'afterOnNodeMouseMove',
|
||||
AFTER_ON_RESIZE = 'afterOnResize',
|
||||
ON_ROOTNODE_DRAG_OVER = 'onRootNodeDragOver',
|
||||
ON_ROOTNODE_DRAG_END = 'onRootNodeDragEnd',
|
||||
ON_ROOTNODE_DRAG_OVER_CAPTURE = 'onRootNodeDragOverCapture',
|
||||
ON_ROOTNODE_DROP = 'onRootNodeDrop',
|
||||
AFTER_ON_NODE_DRAG_OVER = 'afterOnNodeDragOver',
|
||||
BEFORE_COPY = 'beforeCopy',
|
||||
BEFORE_CUT = 'beforeCut',
|
||||
}
|
||||
|
||||
export interface HookBaseArgs {
|
||||
stopImmediatePropagation: () => void;
|
||||
}
|
||||
|
||||
export interface BlockDomInfo {
|
||||
blockId: string;
|
||||
dom: HTMLElement;
|
||||
type: BlockFlavorKeys;
|
||||
rect: DOMRect;
|
||||
rootRect: DOMRect;
|
||||
properties: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Editor's various callbacks, used in Editor
|
||||
export interface HooksRunner {
|
||||
init: () => void;
|
||||
render: () => void;
|
||||
onRootNodeKeyUp: (e: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
onRootNodeKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
onRootNodeKeyDownCapture: (e: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
onRootNodeMouseDown: (
|
||||
e: React.MouseEvent<HTMLDivElement, MouseEvent>
|
||||
) => void;
|
||||
onRootNodeMouseMove: (
|
||||
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
root_rect: DOMRect
|
||||
) => void;
|
||||
onRootNodeMouseUp: (
|
||||
e: React.MouseEvent<HTMLDivElement, MouseEvent>
|
||||
) => void;
|
||||
onRootNodeMouseOut: (
|
||||
e: React.MouseEvent<HTMLDivElement, MouseEvent>
|
||||
) => void;
|
||||
onRootNodeMouseLeave: (
|
||||
e: React.MouseEvent<HTMLDivElement, MouseEvent>
|
||||
) => void;
|
||||
onSearch: () => void;
|
||||
afterOnNodeMouseMove: (
|
||||
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
node: BlockDomInfo
|
||||
) => void;
|
||||
afterOnResize: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||
onRootNodeDragOver: (
|
||||
e: React.DragEvent<Element>,
|
||||
root_rect: DOMRect
|
||||
) => void;
|
||||
onRootNodeDragEnd: (
|
||||
e: React.DragEvent<Element>,
|
||||
root_rect: DOMRect
|
||||
) => void;
|
||||
onRootNodeDrop: (e: React.DragEvent<Element>) => void;
|
||||
afterOnNodeDragOver: (
|
||||
e: React.DragEvent<Element>,
|
||||
node: BlockDomInfo
|
||||
) => void;
|
||||
beforeCopy: (e: ClipboardEvent) => void;
|
||||
beforeCut: (e: ClipboardEvent) => void;
|
||||
}
|
||||
|
||||
export type AnyFunction = (...args: any[]) => any;
|
||||
export type AnyThisType = ThisParameterType<any>;
|
||||
|
||||
// hook管理,在editor、plugin中使用
|
||||
export interface PluginHooks {
|
||||
// 执行多次
|
||||
addHook: (
|
||||
key: HookType,
|
||||
callback: AnyFunction,
|
||||
thisObj?: AnyThisType,
|
||||
once?: boolean
|
||||
) => void;
|
||||
// 执行一次
|
||||
addOnceHook: (
|
||||
key: HookType,
|
||||
callback: AnyFunction,
|
||||
thisObj?: AnyThisType
|
||||
) => void;
|
||||
// 移除
|
||||
removeHook: (key: HookType, callback: AnyFunction) => void;
|
||||
}
|
||||
export * from './drag-drop/types';
|
||||
193
libs/components/editor-core/src/editor/views/base-view.ts
Normal file
193
libs/components/editor-core/src/editor/views/base-view.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { ComponentType, ReactElement } from 'react';
|
||||
|
||||
import type {
|
||||
Column,
|
||||
DefaultColumnsValue,
|
||||
} from '@toeverything/datasource/db-service';
|
||||
import {
|
||||
ArrayOperation,
|
||||
BlockDecoration,
|
||||
MapOperation,
|
||||
} from '@toeverything/datasource/jwt';
|
||||
import type { EventData } from '../block';
|
||||
import { AsyncBlock } from '../block';
|
||||
import type { Editor } from '../editor';
|
||||
import { cloneDeep } from '@toeverything/utils';
|
||||
import { SelectBlock } from '../selection';
|
||||
|
||||
export interface CreateView {
|
||||
block: AsyncBlock;
|
||||
editor: Editor;
|
||||
editorElement: () => JSX.Element;
|
||||
/**
|
||||
* @deprecated Use recast table instead
|
||||
*/
|
||||
columns: Column[];
|
||||
/**
|
||||
* @deprecated Use recast table instead
|
||||
*/
|
||||
columnsFromId: string;
|
||||
scene: 'page' | 'kanban' | 'table' | 'whiteboard';
|
||||
}
|
||||
|
||||
export interface ChildrenView extends CreateView {
|
||||
children: ReactElement;
|
||||
}
|
||||
|
||||
export abstract class BaseView {
|
||||
abstract type: string;
|
||||
|
||||
/**
|
||||
* activatable means can be focused
|
||||
* @memberof BaseView
|
||||
*/
|
||||
public activatable = true;
|
||||
|
||||
public selectable = true;
|
||||
|
||||
/**
|
||||
*
|
||||
* layout only means the block is only used as structured block
|
||||
* @memberof BaseView
|
||||
*/
|
||||
public layoutOnly = false;
|
||||
|
||||
/**
|
||||
* Whether to display the widget below the block
|
||||
* @deprecated pending further evaluation, use with caution
|
||||
*/
|
||||
public allowPendant = true;
|
||||
|
||||
abstract View: ComponentType<CreateView>;
|
||||
|
||||
ChildrenView: ComponentType<ChildrenView>;
|
||||
|
||||
/** Life Cycle */
|
||||
|
||||
// If it returns null, it means the creation failed
|
||||
async onCreate(block: AsyncBlock): Promise<AsyncBlock | null> {
|
||||
return block;
|
||||
}
|
||||
|
||||
// when the data is updated, call
|
||||
async onUpdate(event: EventData): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a child node is deleted
|
||||
*/
|
||||
async onDeleteChild(block: AsyncBlock): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
onExport(content: MapOperation<any>): string {
|
||||
try {
|
||||
return JSON.stringify((content as any)['toJSON']());
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
onMetadata(
|
||||
content: MapOperation<any>
|
||||
): Array<[string, number | string | string[]]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
onTagging(content: MapOperation<any>): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
protected get_decoration<T>(
|
||||
content: MapOperation<ArrayOperation<any>>,
|
||||
name: string
|
||||
): T | undefined {
|
||||
return content
|
||||
.get('decoration')
|
||||
?.asArray<BlockDecoration>()
|
||||
?.find<BlockDecoration>(obj => obj.key === name)?.value as T;
|
||||
}
|
||||
|
||||
/** Component utility function */
|
||||
|
||||
// Whether the component is empty
|
||||
isEmpty(block: AsyncBlock): boolean {
|
||||
const text = block.getProperty('text');
|
||||
return !text?.value?.[0]?.text;
|
||||
}
|
||||
|
||||
getSelProperties(block: AsyncBlock, selectInfo: any): DefaultColumnsValue {
|
||||
return cloneDeep(block.getProperties());
|
||||
}
|
||||
|
||||
html2block(el: Element, parseEl: (el: Element) => any[]): any[] | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async block2html(
|
||||
block: AsyncBlock,
|
||||
children: SelectBlock[],
|
||||
generateHtml: (el: any[]) => Promise<string>
|
||||
): Promise<string> {
|
||||
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);
|
||||
};
|
||||
204
libs/components/editor-core/src/hooks.ts
Normal file
204
libs/components/editor-core/src/hooks.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
AsyncBlock,
|
||||
SelectEventTypes,
|
||||
SelectionInfo,
|
||||
SelectionSettingsMap,
|
||||
} from './editor';
|
||||
import { noop, Point } from '@toeverything/utils';
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { RootContext } from './contexts';
|
||||
|
||||
function useRequestReRender() {
|
||||
const [, setUpdateCounter] = useState(0);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
|
||||
const requestReRender = useCallback((immediate = false) => {
|
||||
if (animationFrameRef.current && !immediate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!immediate) {
|
||||
animationFrameRef.current = requestAnimationFrame(() => {
|
||||
setUpdateCounter(state => state + 1);
|
||||
animationFrameRef.current = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
|
||||
setUpdateCounter(state => state + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return requestReRender;
|
||||
}
|
||||
|
||||
export const useBlock = (blockId: string) => {
|
||||
const [block, setBlock] = useState<AsyncBlock>();
|
||||
const requestReRender = useRequestReRender();
|
||||
const { editor } = useContext(RootContext);
|
||||
useEffect(() => {
|
||||
if (!blockId) {
|
||||
return undefined;
|
||||
}
|
||||
let valid = true;
|
||||
let offUpdate = noop;
|
||||
editor.getBlockById(blockId).then(node => {
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
if (!node) {
|
||||
console.warn('Failed to get block by id', blockId);
|
||||
return undefined;
|
||||
}
|
||||
setBlock(node);
|
||||
offUpdate = node.onUpdate(() => {
|
||||
requestReRender();
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
valid = false;
|
||||
offUpdate();
|
||||
};
|
||||
}, [blockId, editor, requestReRender]);
|
||||
|
||||
return { block };
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* hooks run when block node selected as a block
|
||||
* @export
|
||||
*/
|
||||
export const useOnSelect = (
|
||||
blockId: string,
|
||||
cb: (isSelect: boolean) => void
|
||||
) => {
|
||||
const { editor } = useContext(RootContext);
|
||||
useEffect(() => {
|
||||
editor.selectionManager.observe(blockId, SelectEventTypes.onSelect, cb);
|
||||
return () => {
|
||||
editor.selectionManager.unobserve(
|
||||
blockId,
|
||||
SelectEventTypes.onSelect,
|
||||
cb
|
||||
);
|
||||
};
|
||||
}, [cb, blockId, editor]);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* hooks run when block was set focused by selection manager
|
||||
* @export
|
||||
*/
|
||||
export const useOnSelectActive = (
|
||||
blockId: string,
|
||||
cb: (position: Point | undefined) => void
|
||||
) => {
|
||||
const { editor } = useContext(RootContext);
|
||||
useEffect(() => {
|
||||
editor.selectionManager.observe(blockId, SelectEventTypes.active, cb);
|
||||
return () => {
|
||||
editor.selectionManager.unobserve(
|
||||
blockId,
|
||||
SelectEventTypes.active,
|
||||
cb
|
||||
);
|
||||
};
|
||||
}, [cb, blockId, editor]);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* hooks run when block was set range by selection manager
|
||||
* @export
|
||||
*/
|
||||
export const useOnSelectSetSelection = <T extends keyof SelectionSettingsMap>(
|
||||
blockId: string,
|
||||
cb: (args: SelectionSettingsMap[T]) => void
|
||||
) => {
|
||||
const { editor } = useContext(RootContext);
|
||||
useEffect(() => {
|
||||
editor.selectionManager.observe(
|
||||
blockId,
|
||||
SelectEventTypes.setSelection,
|
||||
cb
|
||||
);
|
||||
return () => {
|
||||
editor.selectionManager.unobserve(
|
||||
blockId,
|
||||
SelectEventTypes.setSelection,
|
||||
cb
|
||||
);
|
||||
};
|
||||
}, [cb, blockId, editor]);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* hooks run when selection type or range is changed
|
||||
* @export
|
||||
*/
|
||||
export const useOnSelectChange = (cb: (info: SelectionInfo) => void) => {
|
||||
const { editor } = useContext(RootContext);
|
||||
useEffect(() => {
|
||||
editor.selectionManager.onSelectionChange(cb);
|
||||
return () => {
|
||||
editor.selectionManager.unBindSelectionChange(cb);
|
||||
};
|
||||
}, [editor, cb]);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* hooks run when select end (based on mouse up)
|
||||
* @export
|
||||
*/
|
||||
export const useOnSelectEnd = (cb: (info: SelectionInfo) => void) => {
|
||||
const { editor } = useContext(RootContext);
|
||||
useEffect(() => {
|
||||
editor.selectionManager.onSelectEnd(cb);
|
||||
return () => {
|
||||
editor.selectionManager.onSelectEnd(cb);
|
||||
};
|
||||
}, [editor, cb]);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* hooks run when select range start with the block node
|
||||
* @export
|
||||
*/
|
||||
export const useOnSelectStartWith = (
|
||||
blockId: string,
|
||||
cb: (args: MouseEvent) => void
|
||||
) => {
|
||||
const { editor } = useContext(RootContext);
|
||||
useEffect(() => {
|
||||
editor.mouseManager.onSelectStartWith(blockId, cb);
|
||||
return () => {
|
||||
editor.mouseManager.offSelectStartWith(blockId, cb);
|
||||
};
|
||||
}, [editor, cb, blockId]);
|
||||
};
|
||||
21
libs/components/editor-core/src/index.ts
Normal file
21
libs/components/editor-core/src/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export { ColumnsContext, RootContext } from './contexts';
|
||||
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 * from './kanban';
|
||||
export * from './kanban/types';
|
||||
|
||||
export * from './utils';
|
||||
|
||||
export * from './drag-drop-wrapper';
|
||||
export * from './block-content-wrapper';
|
||||
|
||||
export * from './editor';
|
||||
75
libs/components/editor-core/src/kanban/Context.tsx
Normal file
75
libs/components/editor-core/src/kanban/Context.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { genErrorObj } from '@toeverything/utils';
|
||||
import {
|
||||
ComponentType,
|
||||
createContext,
|
||||
ReactElement,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import {
|
||||
RecastBlock,
|
||||
RecastMetaProperty,
|
||||
RecastPropertyId,
|
||||
} from '../recast-block/types';
|
||||
import { useInitKanbanEffect, useRecastKanban } from './kanban';
|
||||
import { KanbanGroup } from './types';
|
||||
|
||||
type KanbanState = {
|
||||
groupBy: RecastMetaProperty;
|
||||
kanban: KanbanGroup[];
|
||||
recastBlock: RecastBlock;
|
||||
setGroupBy: (id: RecastPropertyId) => Promise<void>;
|
||||
};
|
||||
|
||||
export const KanbanContext = createContext<KanbanState>(
|
||||
genErrorObj(
|
||||
'Failed to get KanbanContext! Please use the hook under `KanbanProvider`.'
|
||||
// Just for type cast
|
||||
) as KanbanState
|
||||
);
|
||||
|
||||
/**
|
||||
* Provide the kanban context to the children.
|
||||
*
|
||||
* The Provider has effect to init the groupBy property.
|
||||
*/
|
||||
export const KanbanProvider = ({
|
||||
fallback,
|
||||
children,
|
||||
}: {
|
||||
fallback: ReactElement;
|
||||
children: ReactNode;
|
||||
}): JSX.Element => {
|
||||
const [loading, groupBy] = useInitKanbanEffect();
|
||||
const { kanban, setGroupBy, recastBlock } = useRecastKanban();
|
||||
|
||||
if (loading || !kanban.length) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const value = {
|
||||
groupBy,
|
||||
kanban,
|
||||
setGroupBy,
|
||||
recastBlock,
|
||||
};
|
||||
|
||||
return (
|
||||
<KanbanContext.Provider value={value}>
|
||||
{children}
|
||||
</KanbanContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap your component with {@link KanbanProvider} to get access to the recast block state
|
||||
* @public
|
||||
*/
|
||||
export const withKanban =
|
||||
<T,>(Component: ComponentType<T>): ComponentType<T> =>
|
||||
(props: T) => {
|
||||
return (
|
||||
<KanbanProvider fallback={<div>Loading</div>}>
|
||||
<Component {...props} />
|
||||
</KanbanProvider>
|
||||
);
|
||||
};
|
||||
223
libs/components/editor-core/src/kanban/atom.ts
Normal file
223
libs/components/editor-core/src/kanban/atom.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { Protocol } from '@toeverything/datasource/db-service';
|
||||
import { AsyncBlock } from '../editor';
|
||||
import { getRecastItemValue } from '../recast-block/property';
|
||||
import type { RecastBlock, RecastItem } from '../recast-block/types';
|
||||
import {
|
||||
PropertyType,
|
||||
RecastBlockValue,
|
||||
RecastMetaProperty,
|
||||
RecastPropertyId,
|
||||
} from '../recast-block/types';
|
||||
import type { DefaultGroup, KanbanGroup } from './types';
|
||||
import { DEFAULT_GROUP_ID } from './types';
|
||||
|
||||
/**
|
||||
* - If the `groupBy` is `SelectProperty` or `MultiSelectProperty`, return `(Multi)SelectProperty.options`.
|
||||
* - If the `groupBy` is `TextProperty` or `DateProperty`, return all values of the recastBlock's children
|
||||
*/
|
||||
export const getGroupOptions = async (
|
||||
groupBy: RecastMetaProperty,
|
||||
recastBlock: RecastBlock
|
||||
): Promise<KanbanGroup[]> => {
|
||||
if (!groupBy) {
|
||||
return [];
|
||||
}
|
||||
switch (groupBy.type) {
|
||||
case PropertyType.Select:
|
||||
case PropertyType.MultiSelect: {
|
||||
return groupBy.options.map(option => ({
|
||||
...option,
|
||||
type: groupBy.type,
|
||||
items: [],
|
||||
}));
|
||||
}
|
||||
case PropertyType.Text: {
|
||||
// const children = await recastBlock.children();
|
||||
// TODO: support group by text, need group children value
|
||||
return []; // WIP! Just a placeholder
|
||||
}
|
||||
default: {
|
||||
throw new Error(
|
||||
// Safe cast for future compatible
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
`Not support group by type "${(groupBy as any).type}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isValueBelongOption = (
|
||||
propertyValue: RecastBlockValue,
|
||||
option: KanbanGroup
|
||||
) => {
|
||||
switch (propertyValue.type) {
|
||||
case PropertyType.Select: {
|
||||
return propertyValue.value === option.id;
|
||||
}
|
||||
case PropertyType.MultiSelect: {
|
||||
return propertyValue.value.some(i => i === option.id);
|
||||
}
|
||||
// case PropertyType.Text: {
|
||||
// TOTODO:DO support this type
|
||||
// }
|
||||
default: {
|
||||
console.error(propertyValue, option);
|
||||
throw new Error('Not support group by type');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate the group that the card belongs to
|
||||
*/
|
||||
export const calcCardGroup = (
|
||||
card: RecastItem,
|
||||
groupBy: RecastMetaProperty,
|
||||
groupOptions: KanbanGroup[],
|
||||
defaultGroup: KanbanGroup
|
||||
) => {
|
||||
const { getValue } = getRecastItemValue(card);
|
||||
const propertyValue = getValue(groupBy.id);
|
||||
if (!propertyValue || propertyValue.type !== groupBy.type) {
|
||||
// No properties, maybe not have be initialized
|
||||
// Belong to default group
|
||||
return defaultGroup;
|
||||
}
|
||||
const target = groupOptions.find(option =>
|
||||
isValueBelongOption(propertyValue, option)
|
||||
);
|
||||
if (target) {
|
||||
return target;
|
||||
}
|
||||
// Belong to default group
|
||||
return defaultGroup;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set group value for the card block
|
||||
*/
|
||||
export const moveCardToGroup = async (
|
||||
groupById: RecastPropertyId,
|
||||
cardBlock: RecastItem,
|
||||
group: KanbanGroup
|
||||
) => {
|
||||
const { setValue, removeValue } = getRecastItemValue(cardBlock);
|
||||
let success = false;
|
||||
if (group.id === DEFAULT_GROUP_ID) {
|
||||
success = await removeValue(groupById);
|
||||
return false;
|
||||
}
|
||||
switch (group.type) {
|
||||
case PropertyType.Select: {
|
||||
success = await setValue({
|
||||
id: groupById,
|
||||
type: group.type,
|
||||
value: group.id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case PropertyType.MultiSelect: {
|
||||
success = await setValue({
|
||||
id: groupById,
|
||||
type: group.type,
|
||||
value: [group.id],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case PropertyType.Text: {
|
||||
success = await setValue({
|
||||
id: groupById,
|
||||
type: group.type,
|
||||
value: group.id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error('group', group, 'block', cardBlock);
|
||||
throw new Error('Not support move card to group');
|
||||
}
|
||||
return success;
|
||||
};
|
||||
|
||||
export const moveCardToBefore = async (
|
||||
targetBlock: RecastItem,
|
||||
nextBlock: RecastItem
|
||||
) => {
|
||||
if (targetBlock.id === nextBlock.id) return;
|
||||
await targetBlock.remove();
|
||||
await nextBlock.before(targetBlock as unknown as AsyncBlock);
|
||||
};
|
||||
|
||||
export const moveCardToAfter = async (
|
||||
targetBlock: RecastItem,
|
||||
previousBlock: RecastItem
|
||||
) => {
|
||||
if (targetBlock.id === previousBlock.id) return;
|
||||
await targetBlock.remove();
|
||||
await previousBlock.after(targetBlock as unknown as AsyncBlock);
|
||||
};
|
||||
|
||||
/**
|
||||
* Similar to {@link calcCardGroup}, but only find card from the existed group
|
||||
*/
|
||||
export const getCardGroup = (card: RecastItem, kanban: KanbanGroup[]) => {
|
||||
for (let i = 0; i < kanban.length; i++) {
|
||||
const kanbanGroup = kanban[i];
|
||||
for (let j = 0; j < kanbanGroup.items.length; j++) {
|
||||
const kanbanCard = kanbanGroup.items[j];
|
||||
if (kanbanCard.id === card.id) {
|
||||
return [kanbanGroup, j] as const;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error('kanban', kanban, 'card', card);
|
||||
throw new Error('Failed to find the group containing the card!');
|
||||
};
|
||||
|
||||
/**
|
||||
* Is the group is the default group.
|
||||
*
|
||||
* - the default group has unique id
|
||||
* - the default group can not be renamed/deleted
|
||||
*/
|
||||
export const checkIsDefaultGroup = (
|
||||
group: KanbanGroup
|
||||
): group is DefaultGroup => group.id === DEFAULT_GROUP_ID;
|
||||
|
||||
export const genDefaultGroup = (groupBy: RecastMetaProperty): DefaultGroup => ({
|
||||
id: DEFAULT_GROUP_ID,
|
||||
type: DEFAULT_GROUP_ID,
|
||||
name: `No ${groupBy.name}`,
|
||||
color: '#4324B9',
|
||||
background: '#E3DEFF',
|
||||
items: [],
|
||||
});
|
||||
|
||||
export const DEFAULT_GROUP_BY_PROPERTY = {
|
||||
name: 'Status',
|
||||
options: [
|
||||
{ name: 'No Started', color: '#E53535', background: '#FFCECE' },
|
||||
{ name: 'In Progress', color: '#A77F1A', background: '#FFF5AB' },
|
||||
{ name: 'Complete', color: '#3C8867', background: '#C5FBE0' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Unwrap blocks from the grid recursively.
|
||||
*
|
||||
* If the node is not a grid node, return as is and wrap with a array
|
||||
*/
|
||||
export const unwrapGrid = async (node: AsyncBlock): Promise<AsyncBlock[]> => {
|
||||
if (
|
||||
node.type === Protocol.Block.Type.grid ||
|
||||
node.type === Protocol.Block.Type.gridItem
|
||||
) {
|
||||
const children = await node.children();
|
||||
return (
|
||||
await Promise.all(children.map(child => unwrapGrid(child)))
|
||||
).flat();
|
||||
}
|
||||
|
||||
return [node];
|
||||
};
|
||||
103
libs/components/editor-core/src/kanban/group.ts
Normal file
103
libs/components/editor-core/src/kanban/group.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useSelectProperty } from '../recast-block/property';
|
||||
import { PropertyType, RecastMetaProperty } from '../recast-block/types';
|
||||
import { checkIsDefaultGroup } from './atom';
|
||||
import { KanbanGroup } from './types';
|
||||
|
||||
export const useKanbanGroup = (groupBy: RecastMetaProperty) => {
|
||||
const { updateSelect } = useSelectProperty();
|
||||
|
||||
switch (groupBy.type) {
|
||||
case PropertyType.MultiSelect:
|
||||
case PropertyType.Select: {
|
||||
const {
|
||||
addSelectOptions,
|
||||
renameSelectOptions,
|
||||
hasSelectOptions,
|
||||
removeSelectOptions,
|
||||
} = updateSelect(groupBy);
|
||||
|
||||
const addGroup = async (name: string) => {
|
||||
if (!name) {
|
||||
throw new Error(
|
||||
'Failed to add new group! Group Name can not be empty'
|
||||
);
|
||||
}
|
||||
if (hasSelectOptions(name)) {
|
||||
throw new Error(
|
||||
`Failed to add new group! Group name can not be repeated. name: ${name}`
|
||||
);
|
||||
}
|
||||
return await addSelectOptions({ name });
|
||||
};
|
||||
|
||||
const renameGroup = async (group: KanbanGroup, name: string) => {
|
||||
if (checkIsDefaultGroup(group)) {
|
||||
console.error('Cannot rename default group', group);
|
||||
return undefined;
|
||||
}
|
||||
if (groupBy.type !== group.type) {
|
||||
console.error('groupBy:', groupBy, 'group:', group);
|
||||
throw new Error(
|
||||
`Inconsistent group type, groupBy: ${groupBy.type} group: ${group.type}`
|
||||
);
|
||||
}
|
||||
if (!name) {
|
||||
throw new Error(
|
||||
'Failed to add rename group! Group Name can not be empty'
|
||||
);
|
||||
}
|
||||
if (hasSelectOptions(name)) {
|
||||
throw new Error(
|
||||
`Failed to rename group! Group name can not be repeated. name: ${name}`
|
||||
);
|
||||
}
|
||||
|
||||
return await renameSelectOptions({ ...group, name });
|
||||
};
|
||||
|
||||
const removeGroup = async (group: KanbanGroup) => {
|
||||
if (checkIsDefaultGroup(group)) {
|
||||
console.error('Cannot remove default group', group);
|
||||
return;
|
||||
}
|
||||
if (groupBy.type !== group.type) {
|
||||
console.error('groupBy:', groupBy, 'group:', group);
|
||||
throw new Error(
|
||||
`Inconsistent group type, groupBy: ${groupBy.type} group: ${group.type}`
|
||||
);
|
||||
}
|
||||
await removeSelectOptions(group.id);
|
||||
};
|
||||
|
||||
return {
|
||||
addGroup,
|
||||
renameGroup,
|
||||
removeGroup,
|
||||
checkIsDefaultGroup,
|
||||
};
|
||||
}
|
||||
|
||||
case PropertyType.Text: {
|
||||
const addGroup = async (name: string) => {
|
||||
throw new Error('TODO');
|
||||
};
|
||||
const renameGroup = async (group: KanbanGroup, name: string) => {
|
||||
throw new Error('TODO');
|
||||
};
|
||||
const removeGroup = async (group: KanbanGroup) => {
|
||||
throw new Error('TODO');
|
||||
};
|
||||
return {
|
||||
addGroup,
|
||||
renameGroup,
|
||||
removeGroup,
|
||||
checkIsDefaultGroup,
|
||||
};
|
||||
}
|
||||
// TODO: support other types
|
||||
|
||||
default: {
|
||||
throw new Error(`Unsupported group type: ${groupBy.type}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
2
libs/components/editor-core/src/kanban/index.ts
Normal file
2
libs/components/editor-core/src/kanban/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { useKanban, useRecastKanbanGroupBy } from './kanban';
|
||||
export { KanbanProvider, withKanban } from './Context';
|
||||
339
libs/components/editor-core/src/kanban/kanban.ts
Normal file
339
libs/components/editor-core/src/kanban/kanban.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import { Protocol } from '@toeverything/datasource/db-service';
|
||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { useEditor } from '../contexts';
|
||||
import { AsyncBlock } from '../editor';
|
||||
import { useRecastBlock } from '../recast-block/Context';
|
||||
import {
|
||||
useRecastBlockMeta,
|
||||
useSelectProperty,
|
||||
} from '../recast-block/property';
|
||||
import type { RecastItem } from '../recast-block/types';
|
||||
import {
|
||||
KANBAN_PROPERTIES_KEY,
|
||||
PropertyType,
|
||||
RecastMetaProperty,
|
||||
RecastPropertyId,
|
||||
} from '../recast-block/types';
|
||||
import { supportChildren } from '../utils';
|
||||
import {
|
||||
calcCardGroup,
|
||||
DEFAULT_GROUP_BY_PROPERTY,
|
||||
genDefaultGroup,
|
||||
getCardGroup,
|
||||
getGroupOptions,
|
||||
moveCardToAfter,
|
||||
moveCardToBefore,
|
||||
moveCardToGroup,
|
||||
unwrapGrid,
|
||||
} from './atom';
|
||||
import { KanbanContext } from './Context';
|
||||
import { useKanbanGroup } from './group';
|
||||
import { KanbanCard, KanbanGroup } from './types';
|
||||
|
||||
/**
|
||||
* Get the recast kanban groupBy property and set groupBy
|
||||
*
|
||||
* Only works with kanban view
|
||||
*
|
||||
* Get the multidimensional block kanban groupBy attribute
|
||||
* @public
|
||||
*/
|
||||
export const useRecastKanbanGroupBy = () => {
|
||||
const recastBlock = useRecastBlock();
|
||||
const { getProperty, getProperties } = useRecastBlockMeta();
|
||||
const kanbanProperties = recastBlock.getProperty(KANBAN_PROPERTIES_KEY);
|
||||
|
||||
// TODO: remove filter
|
||||
// Add other type groupBy support
|
||||
const supportedGroupBy = getProperties().filter(
|
||||
prop =>
|
||||
prop.type === PropertyType.Select ||
|
||||
prop.type === PropertyType.MultiSelect
|
||||
);
|
||||
|
||||
const setGroupBy = useCallback(
|
||||
async (id: RecastPropertyId) => {
|
||||
const ok = await recastBlock.setProperty(KANBAN_PROPERTIES_KEY, {
|
||||
...kanbanProperties,
|
||||
groupBy: id,
|
||||
});
|
||||
if (!ok) {
|
||||
throw new Error('Failed to set groupBy');
|
||||
}
|
||||
},
|
||||
[recastBlock, kanbanProperties]
|
||||
);
|
||||
|
||||
const groupById = kanbanProperties?.groupBy;
|
||||
// 1. groupBy is not set
|
||||
if (!groupById) {
|
||||
return {
|
||||
setGroupBy,
|
||||
supportedGroupBy,
|
||||
};
|
||||
}
|
||||
// 2. groupBy has been set, but not the detail found for the groupBy
|
||||
const groupByProperty = getProperty(groupById);
|
||||
if (!groupByProperty) {
|
||||
return {
|
||||
setGroupBy,
|
||||
supportedGroupBy,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. groupBy has been set and the detail found
|
||||
// but the type of the groupBy is not supported currently
|
||||
// TODO: support other property type
|
||||
if (
|
||||
groupByProperty.type !== PropertyType.Select &&
|
||||
groupByProperty.type !== PropertyType.MultiSelect
|
||||
) {
|
||||
console.warn('Not support groupBy type', groupByProperty);
|
||||
|
||||
return {
|
||||
setGroupBy,
|
||||
supportedGroupBy,
|
||||
};
|
||||
}
|
||||
// TODO: remove the type cast after support all property type
|
||||
const groupBy = groupByProperty as RecastMetaProperty;
|
||||
// TODO: end remove this
|
||||
|
||||
return {
|
||||
groupBy,
|
||||
supportedGroupBy,
|
||||
setGroupBy,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Init kanban groupBy property if not set
|
||||
* Effect to set groupBy property
|
||||
*/
|
||||
export const useInitKanbanEffect = ():
|
||||
| readonly [loading: true, groupBy: null]
|
||||
| readonly [loading: false, groupBy: RecastMetaProperty] => {
|
||||
const { groupBy, setGroupBy, supportedGroupBy } = useRecastKanbanGroupBy();
|
||||
const { getProperties } = useRecastBlockMeta();
|
||||
const { createSelect } = useSelectProperty();
|
||||
|
||||
useEffect(() => {
|
||||
const initKanban = async () => {
|
||||
// 1. has group by
|
||||
// do nothing
|
||||
if (groupBy) {
|
||||
return;
|
||||
}
|
||||
// 2. no group by, but has properties exist
|
||||
// set the first supported property as group by
|
||||
if (supportedGroupBy.length) {
|
||||
await setGroupBy(supportedGroupBy[0].id);
|
||||
return;
|
||||
}
|
||||
// 3. no group by, no properties
|
||||
// create a new property and set it as group by
|
||||
const prop = await createSelect(DEFAULT_GROUP_BY_PROPERTY);
|
||||
await setGroupBy(prop.id);
|
||||
};
|
||||
|
||||
initKanban();
|
||||
}, [createSelect, getProperties, groupBy, setGroupBy, supportedGroupBy]);
|
||||
|
||||
if (groupBy) {
|
||||
return [false, groupBy] as const;
|
||||
}
|
||||
return [true, null] as const;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the recast kanban group.
|
||||
*
|
||||
* If not need kanban cards, use {@link useRecastKanbanGroupBy} instead.
|
||||
* @private
|
||||
*/
|
||||
export const useRecastKanban = () => {
|
||||
const recastBlock = useRecastBlock();
|
||||
const { groupBy, setGroupBy } = useRecastKanbanGroupBy();
|
||||
const { editor } = useEditor();
|
||||
const [kanban, setKanban] = useState<KanbanGroup[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const getGroupCards = async () => {
|
||||
if (!groupBy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultGroup = genDefaultGroup(groupBy);
|
||||
const groupOptions = await getGroupOptions(groupBy, recastBlock);
|
||||
// Init group map
|
||||
const kanbanMap: Record<string, KanbanGroup> = Object.fromEntries([
|
||||
// Default group
|
||||
[defaultGroup.id, defaultGroup],
|
||||
// Groups from options
|
||||
...groupOptions.map(option => [option.id, option]),
|
||||
]);
|
||||
|
||||
const children = (await recastBlock.children()).filter(
|
||||
Boolean
|
||||
// Safe type cast because of the filter guarantee
|
||||
) as AsyncBlock[];
|
||||
|
||||
const unwrapGridChildren = (
|
||||
await Promise.all(children.map(child => unwrapGrid(child)))
|
||||
).flat();
|
||||
|
||||
// Just for type cast
|
||||
// It's safe because of the implementation of RecastItem is AsyncBlock
|
||||
const recastItems = unwrapGridChildren as unknown as RecastItem[];
|
||||
|
||||
recastItems.forEach(child => {
|
||||
const card: KanbanCard = {
|
||||
id: child.id,
|
||||
block: child,
|
||||
moveTo: async (
|
||||
id: KanbanGroup['id'],
|
||||
beforeBlock: string | null,
|
||||
afterBlock: string | null
|
||||
) => {
|
||||
await moveCardToGroup(groupBy.id, child, kanbanMap[id]);
|
||||
if (beforeBlock) {
|
||||
const block = await editor.getBlockById(
|
||||
beforeBlock
|
||||
);
|
||||
if (!block) {
|
||||
throw new Error(
|
||||
`Failed to move card! card id ${id} not found`
|
||||
);
|
||||
}
|
||||
await moveCardToAfter(
|
||||
child,
|
||||
block as unknown as RecastItem
|
||||
);
|
||||
} else if (afterBlock) {
|
||||
const block = await editor.getBlockById(afterBlock);
|
||||
if (!block) {
|
||||
throw new Error(
|
||||
`Failed to move card! card id ${id} not found`
|
||||
);
|
||||
}
|
||||
await moveCardToBefore(
|
||||
child,
|
||||
block as unknown as RecastItem
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
const group = calcCardGroup(
|
||||
child,
|
||||
groupBy,
|
||||
groupOptions,
|
||||
defaultGroup
|
||||
);
|
||||
kanbanMap[group.id].items.push(card);
|
||||
});
|
||||
setKanban(Object.values(kanbanMap));
|
||||
};
|
||||
getGroupCards();
|
||||
// Workaround: Add the extra `recastBlock.lastUpdated` as dependencies for the `recastBlock` can not update reference
|
||||
}, [editor, groupBy, recastBlock, recastBlock.lastUpdated]);
|
||||
|
||||
return {
|
||||
recastBlock,
|
||||
kanban,
|
||||
setGroupBy,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the kanban API.
|
||||
*
|
||||
* Please make sure the {@link KanbanProvider} is set before use.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { kanban, groupBy, setGroupBy, addGroup } = useKanban();
|
||||
*
|
||||
* await addGroup('new group');
|
||||
* await moveCard(card, newGroup, 0);
|
||||
* ```
|
||||
*/
|
||||
export const useKanban = () => {
|
||||
const { groupBy, kanban, recastBlock } = useContext(KanbanContext);
|
||||
const groupOp = useKanbanGroup(groupBy);
|
||||
const { editor } = useEditor();
|
||||
|
||||
/**
|
||||
* Move a card to a group.
|
||||
*/
|
||||
const moveCard = useCallback(
|
||||
async (
|
||||
targetCard: RecastItem,
|
||||
targetGroup: KanbanGroup | null,
|
||||
idx?: number
|
||||
) => {
|
||||
targetCard = targetCard as unknown as RecastItem;
|
||||
const [nowGroup] = getCardGroup(targetCard, kanban);
|
||||
// 1. Move to the target group
|
||||
if (!targetGroup) {
|
||||
// 1.1 Target group is not specified, just set the nowGroup as the targetGroup
|
||||
targetGroup = nowGroup;
|
||||
}
|
||||
if (nowGroup.id !== targetGroup.id) {
|
||||
// 1.2 Move to the target group
|
||||
await moveCardToGroup(groupBy.id, targetCard, targetGroup);
|
||||
}
|
||||
|
||||
// 2. Reorder the card
|
||||
if (!targetGroup.items.length) {
|
||||
// 2.1 If target group is empty, no need to reorder
|
||||
return;
|
||||
}
|
||||
if (typeof idx !== 'number') {
|
||||
// 2.2 idx is not specified, do nothing
|
||||
return;
|
||||
}
|
||||
|
||||
if (idx === 0) {
|
||||
await moveCardToBefore(targetCard, nowGroup.items[0].block);
|
||||
return;
|
||||
}
|
||||
if (idx > nowGroup.items.length) {
|
||||
idx = nowGroup.items.length;
|
||||
}
|
||||
const previousBlock = nowGroup.items[idx - 1].block;
|
||||
await moveCardToAfter(targetCard, previousBlock);
|
||||
},
|
||||
[groupBy, kanban]
|
||||
);
|
||||
|
||||
const addCard = useCallback(
|
||||
async (group: KanbanGroup) => {
|
||||
const newBlock = await editor.createBlock(Protocol.Block.Type.text);
|
||||
if (!newBlock) {
|
||||
throw new Error('Failed to create new block!');
|
||||
}
|
||||
recastBlock.append(newBlock);
|
||||
const newCard = newBlock as unknown as RecastItem;
|
||||
await moveCardToGroup(groupBy.id, newCard, group);
|
||||
},
|
||||
[editor, groupBy.id, recastBlock]
|
||||
);
|
||||
|
||||
const addSubItem = useCallback(
|
||||
async (card: RecastItem) => {
|
||||
if (!supportChildren(card)) {
|
||||
throw new Error('This card does not support children!');
|
||||
}
|
||||
|
||||
const newBlock = await editor.createBlock(Protocol.Block.Type.text);
|
||||
if (!newBlock) {
|
||||
throw new Error('Failed to create new block!');
|
||||
}
|
||||
card.append(newBlock);
|
||||
editor.selectionManager.activeNodeByNodeId(newBlock.id);
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
return { kanban, groupBy, moveCard, addCard, addSubItem, ...groupOp };
|
||||
};
|
||||
57
libs/components/editor-core/src/kanban/types.ts
Normal file
57
libs/components/editor-core/src/kanban/types.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { RecastItem } from '../recast-block/types';
|
||||
import { PropertyType, SelectOption } from '../recast-block/types';
|
||||
|
||||
export const DEFAULT_GROUP_ID = '__EMPTY_GROUP';
|
||||
type DefaultGroupId = typeof DEFAULT_GROUP_ID;
|
||||
|
||||
/**
|
||||
* Block id
|
||||
*/
|
||||
type CardId = string;
|
||||
|
||||
export type KanbanCard = {
|
||||
id: CardId;
|
||||
block: RecastItem;
|
||||
/**
|
||||
* Move the item to other group. (Set property to the block)
|
||||
* @deprecated Use {@link useKanban().moveCard} instead
|
||||
*/
|
||||
moveTo: (
|
||||
id: KanbanGroup['id'],
|
||||
beforeBlockId: string | null,
|
||||
afterBlockId: string | null
|
||||
) => Promise<void>;
|
||||
// moveToBefore: (id: CardId) => Promise<boolean>;
|
||||
// moveToAfter: (id: CardId) => Promise<boolean>;
|
||||
};
|
||||
|
||||
type KanbanGroupBase = {
|
||||
/**
|
||||
* Group name
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Block id list
|
||||
*/
|
||||
items: KanbanCard[];
|
||||
};
|
||||
|
||||
export type DefaultGroup = KanbanGroupBase & {
|
||||
type: DefaultGroupId;
|
||||
id: DefaultGroupId;
|
||||
name: `No ${string}`;
|
||||
color?: SelectOption['color'];
|
||||
background?: SelectOption['background'];
|
||||
};
|
||||
|
||||
type SelectGroup = KanbanGroupBase &
|
||||
SelectOption & {
|
||||
type: PropertyType.Select | PropertyType.MultiSelect;
|
||||
};
|
||||
|
||||
type TextGroup = KanbanGroupBase & {
|
||||
type: PropertyType.Text;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type KanbanGroup = DefaultGroup | SelectGroup | TextGroup;
|
||||
74
libs/components/editor-core/src/recast-block/Context.tsx
Normal file
74
libs/components/editor-core/src/recast-block/Context.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Protocol } from '@toeverything/datasource/db-service';
|
||||
import { AsyncBlock } from '../editor';
|
||||
import { ComponentType, createContext, ReactNode, useContext } from 'react';
|
||||
import { RecastBlock } from './types';
|
||||
|
||||
/**
|
||||
* Determine whether the block supports RecastBlock
|
||||
*/
|
||||
export const isRecastBlock = (block: unknown): block is RecastBlock => {
|
||||
if (!(block instanceof AsyncBlock)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
block.type === Protocol.Block.Type.page ||
|
||||
block.type === Protocol.Block.Type.group
|
||||
);
|
||||
};
|
||||
|
||||
export const RecastBlockContext = createContext<RecastBlock | null>(null);
|
||||
|
||||
export const RecastBlockProvider = ({
|
||||
block,
|
||||
children,
|
||||
}: {
|
||||
block: AsyncBlock;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
if (!isRecastBlock(block)) {
|
||||
throw new Error(
|
||||
'RecastBlockProvider only works for page and group block'
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RecastBlockContext.Provider value={block}>
|
||||
{children}
|
||||
</RecastBlockContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the root recast block
|
||||
* @private
|
||||
*/
|
||||
export const useRecastBlock = () => {
|
||||
const recastBlock = useContext(RecastBlockContext);
|
||||
if (!recastBlock) {
|
||||
throw new Error(
|
||||
'Failed to find recastBlock! Please use the hook under `RecastTableProvider`.'
|
||||
);
|
||||
}
|
||||
return recastBlock;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap your component with {@link RecastBlockProvider} to get access to the recast block state
|
||||
* @public
|
||||
*/
|
||||
export const withRecastBlock =
|
||||
<T extends { block: AsyncBlock }>(
|
||||
Component: ComponentType<T>
|
||||
): ComponentType<T> =>
|
||||
props => {
|
||||
return (
|
||||
<RecastBlockProvider block={props.block}>
|
||||
<Component {...props} />
|
||||
</RecastBlockProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link withRecastBlock} instead.
|
||||
*/
|
||||
export const withRecastTable = withRecastBlock;
|
||||
70
libs/components/editor-core/src/recast-block/README.md
Normal file
70
libs/components/editor-core/src/recast-block/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Recast Block
|
||||
|
||||
# What's Recast block?
|
||||
|
||||
A recast block is a block that can be recast into other forms, such as list, kanban, table, and so on.
|
||||
|
||||
But now, we only have List and Kanban implementation.
|
||||
|
||||
Every recast block has some Property also call it "Meta Data"
|
||||
|
||||
# Usage
|
||||
|
||||
**In most cases, you do not need to use recast block API directly.**
|
||||
|
||||
## Context
|
||||
|
||||
- `RecastBlockProvider` and `withRecastBlock`
|
||||
|
||||
```tsx
|
||||
const RecastComponent = () => {
|
||||
return (
|
||||
<RecastBlockProvider>
|
||||
<SomeBlock />
|
||||
</RecastBlockProvider>
|
||||
);
|
||||
|
||||
// or
|
||||
|
||||
const RecastComponent = withRecastBlock(SomeBlock);
|
||||
```
|
||||
|
||||
## Meta Data
|
||||
|
||||
```tsx
|
||||
const SomeBlock = () => {
|
||||
const {
|
||||
// Get meta data
|
||||
getProperty,
|
||||
// Get all meta data
|
||||
getProperties,
|
||||
// Set meta data
|
||||
addProperty,
|
||||
// Update meta data
|
||||
updateProperty,
|
||||
// Remove meta data
|
||||
removeProperty,
|
||||
} = useRecastBlockMeta();
|
||||
|
||||
return <div>...</div>;
|
||||
};
|
||||
```
|
||||
|
||||
## Scene
|
||||
|
||||
**Notice: The scene API will refactor at next version.**
|
||||
|
||||
```tsx
|
||||
const SomeBlock = () => {
|
||||
const { scene, setScene, setPage, setTable, setKanban } =
|
||||
useRecastBlockScene();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>Scene: {scene}</div>
|
||||
<button onClick={setPage}>list</button>
|
||||
<button onClick={setKanban}>kanban</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
```
|
||||
45
libs/components/editor-core/src/recast-block/Scene.tsx
Normal file
45
libs/components/editor-core/src/recast-block/Scene.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useRecastBlock } from './Context';
|
||||
import { RecastScene } from './types';
|
||||
|
||||
/**
|
||||
* Get the recast table state
|
||||
*
|
||||
* 获取/设置多维区块场景
|
||||
* @public
|
||||
*/
|
||||
export const useRecastBlockScene = () => {
|
||||
const groupBlock = useRecastBlock();
|
||||
const DEFAULT_SCENE = RecastScene.Page;
|
||||
let maybeScene = groupBlock.getProperty('scene');
|
||||
// TODO remove this
|
||||
// Backward compatible
|
||||
if (maybeScene && typeof maybeScene !== 'string') {
|
||||
groupBlock.setProperty('scene', DEFAULT_SCENE);
|
||||
maybeScene = DEFAULT_SCENE;
|
||||
}
|
||||
// End of backward compatible
|
||||
const scene = maybeScene ?? DEFAULT_SCENE;
|
||||
|
||||
const setScene = useCallback(
|
||||
(scene: RecastScene) => {
|
||||
return groupBlock.setProperty('scene', scene);
|
||||
},
|
||||
[groupBlock]
|
||||
);
|
||||
|
||||
const setPage = useCallback(() => setScene(RecastScene.Page), [setScene]);
|
||||
const setTable = useCallback(() => setScene(RecastScene.Table), [setScene]);
|
||||
const setKanban = useCallback(
|
||||
() => setScene(RecastScene.Kanban),
|
||||
[setScene]
|
||||
);
|
||||
|
||||
return {
|
||||
scene,
|
||||
setScene,
|
||||
setPage,
|
||||
setTable,
|
||||
setKanban,
|
||||
};
|
||||
};
|
||||
224
libs/components/editor-core/src/recast-block/group.ts
Normal file
224
libs/components/editor-core/src/recast-block/group.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { Protocol } from '@toeverything/datasource/db-service';
|
||||
import type { AsyncBlock, BlockEditor } from '../editor';
|
||||
import type { RecastBlock } from '.';
|
||||
import { cloneRecastMetaTo, mergeRecastMeta } from './property';
|
||||
|
||||
const mergeGroupProperties = async (...groups: RecastBlock[]) => {
|
||||
const [headGroup, ...restGroups] = groups;
|
||||
for (const group of restGroups) {
|
||||
await mergeRecastMeta(headGroup, group);
|
||||
}
|
||||
};
|
||||
|
||||
const splitGroupProperties = async (...groups: RecastBlock[]) => {
|
||||
const [headGroup, ...restGroups] = groups;
|
||||
await cloneRecastMetaTo(headGroup, ...restGroups);
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge multiple groups into one group.
|
||||
*/
|
||||
export const mergeGroup = async (...groups: AsyncBlock[]) => {
|
||||
if (!groups.length) {
|
||||
return undefined;
|
||||
}
|
||||
const allIsGroup = groups.every(
|
||||
group => group.type === Protocol.Block.Type.group
|
||||
);
|
||||
if (!allIsGroup) {
|
||||
console.error(groups);
|
||||
throw new Error(
|
||||
'Failed to merge groups! Only the the group block can merged!'
|
||||
);
|
||||
}
|
||||
|
||||
await mergeGroupProperties(...(groups as RecastBlock[]));
|
||||
|
||||
const [headGroup, ...restGroups] = groups;
|
||||
// Add all children to the head group
|
||||
const children = (
|
||||
await Promise.all(restGroups.map(group => group.children()))
|
||||
).flat();
|
||||
|
||||
// Remove other group
|
||||
for (const group of restGroups) {
|
||||
await group.remove();
|
||||
}
|
||||
|
||||
await headGroup.append(...children);
|
||||
return headGroup;
|
||||
};
|
||||
|
||||
export const mergeToPreviousGroup = async (group: AsyncBlock) => {
|
||||
const previousGroup = await group.previousSibling();
|
||||
if (!previousGroup) {
|
||||
throw new Error(
|
||||
'Failed to merge previous group! previous block not found!'
|
||||
);
|
||||
}
|
||||
if (previousGroup.type !== Protocol.Block.Type.group) {
|
||||
console.error('previous block:', previousGroup);
|
||||
throw new Error(
|
||||
`Failed to merge previous group! previous block not group! type: ${previousGroup.type}`
|
||||
);
|
||||
}
|
||||
return await mergeGroup(previousGroup, group);
|
||||
};
|
||||
|
||||
const findParentGroup = async (block: AsyncBlock) => {
|
||||
const group = await block.parent();
|
||||
if (!group) {
|
||||
throw new Error('Failed to split group! Parent group not found!');
|
||||
}
|
||||
if (group.type !== Protocol.Block.Type.group) {
|
||||
// TODO: find group recursively, need to handle splitIdx also
|
||||
console.error(
|
||||
`Expected type ${Protocol.Block.Type.group} but got type "${group.type}"`,
|
||||
'group:',
|
||||
group
|
||||
);
|
||||
throw new Error(
|
||||
'Failed to split group! Only the the group block can split!'
|
||||
);
|
||||
}
|
||||
return group;
|
||||
};
|
||||
|
||||
const createGroupWithEmptyText = async (editor: BlockEditor) => {
|
||||
const groupBlock = await editor.createBlock('group');
|
||||
if (!groupBlock) {
|
||||
throw new Error('Create new group block fail!');
|
||||
}
|
||||
const textBlock = await editor.createBlock('text');
|
||||
if (!textBlock) {
|
||||
throw new Error('Create new text block fail!');
|
||||
}
|
||||
await groupBlock.append(textBlock);
|
||||
return groupBlock;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* ```markdown
|
||||
* # Example
|
||||
*
|
||||
* - block1
|
||||
* - block2
|
||||
* - block3 <- Select block to split
|
||||
* - block4
|
||||
*
|
||||
* ↓
|
||||
*
|
||||
* - block1
|
||||
* - block2
|
||||
* --- <- Create a new group
|
||||
* - block3 <- Remove it if `removeSplitPoint` is true
|
||||
* - block4
|
||||
*
|
||||
* ```
|
||||
*/
|
||||
export const splitGroup = async (
|
||||
editor: BlockEditor,
|
||||
splitPoint: AsyncBlock,
|
||||
removeSplitPoint = false
|
||||
) => {
|
||||
const group = await findParentGroup(splitPoint);
|
||||
const groupChildrenIds = group.childrenIds;
|
||||
const splitIdx = group.findChildIndex(splitPoint.id);
|
||||
if (splitIdx === -1) {
|
||||
console.error('split block', splitPoint);
|
||||
throw new Error('Failed to split group! split point block not found!');
|
||||
}
|
||||
if (splitIdx === 0) {
|
||||
// Split from the first block
|
||||
const groupBlock = await createGroupWithEmptyText(editor);
|
||||
group.before(groupBlock);
|
||||
if (removeSplitPoint) {
|
||||
await splitPoint.remove();
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
const newGroupBlock = await editor.createBlock('group');
|
||||
if (!newGroupBlock) {
|
||||
throw new Error('Failed to split group! Create new group block fail!');
|
||||
}
|
||||
const newGroupChildId = groupChildrenIds.slice(splitIdx);
|
||||
const newGroupChild = (
|
||||
await Promise.all(newGroupChildId.map(id => editor.getBlockById(id)))
|
||||
).filter(Boolean) as AsyncBlock[];
|
||||
|
||||
// Remove from old group
|
||||
for (const block of newGroupChild) {
|
||||
await block.remove();
|
||||
}
|
||||
|
||||
await newGroupBlock.append(...newGroupChild);
|
||||
if (removeSplitPoint) {
|
||||
await splitPoint.remove();
|
||||
}
|
||||
// If only one divider block add empty text block in group
|
||||
// TODO: use this simple logic after the block sync bug is fixed
|
||||
// if (!newGroupBlock.children.length) {
|
||||
if (
|
||||
!newGroupChild.length ||
|
||||
(newGroupChild.length === 1 && removeSplitPoint)
|
||||
) {
|
||||
const textBlock = await editor.createBlock('text');
|
||||
if (!textBlock) {
|
||||
throw new Error(
|
||||
'Failed to split group! Create new text block fail!'
|
||||
);
|
||||
}
|
||||
await newGroupBlock.append(textBlock);
|
||||
}
|
||||
|
||||
splitGroupProperties(
|
||||
group as RecastBlock,
|
||||
newGroupBlock as unknown as RecastBlock
|
||||
);
|
||||
await group.after(newGroupBlock);
|
||||
const newGroupFirstlyBlock = await newGroupBlock.children();
|
||||
setTimeout(() => {
|
||||
editor.selectionManager.activeNodeByNodeId(newGroupFirstlyBlock[0].id);
|
||||
}, 100);
|
||||
return newGroupBlock;
|
||||
};
|
||||
|
||||
export const addNewGroup = async (
|
||||
editor: BlockEditor,
|
||||
previousBlock: AsyncBlock,
|
||||
active = false
|
||||
) => {
|
||||
const newGroupBlock = await createGroupWithEmptyText(editor);
|
||||
await previousBlock.after(newGroupBlock);
|
||||
if (active) {
|
||||
// Active text block
|
||||
await editor.selectionManager.activeNodeByNodeId(
|
||||
newGroupBlock.childrenIds[0]
|
||||
);
|
||||
}
|
||||
return newGroupBlock;
|
||||
};
|
||||
|
||||
export const unwrapGroup = async (group: AsyncBlock) => {
|
||||
const groupChild = await group.children();
|
||||
if (!groupChild.length) {
|
||||
await group.remove();
|
||||
return;
|
||||
}
|
||||
const prevBlock = await group.previousSibling();
|
||||
if (prevBlock) {
|
||||
prevBlock.after(...groupChild);
|
||||
await group.remove();
|
||||
return;
|
||||
}
|
||||
const parentBlock = await group.parent();
|
||||
if (parentBlock) {
|
||||
parentBlock.after(...groupChild);
|
||||
await group.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('Failed to unwrap group! Parent group not found!');
|
||||
};
|
||||
24
libs/components/editor-core/src/recast-block/index.ts
Normal file
24
libs/components/editor-core/src/recast-block/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
getRecastItemValue,
|
||||
useRecastBlockMeta,
|
||||
useSelectProperty,
|
||||
genSelectOptionId,
|
||||
} from './property';
|
||||
import { useRecastBlockScene } from './Scene';
|
||||
export * from './types';
|
||||
export {
|
||||
RecastBlockProvider,
|
||||
withRecastBlock,
|
||||
withRecastTable,
|
||||
useRecastBlock,
|
||||
} from './Context';
|
||||
|
||||
export {
|
||||
getRecastItemValue,
|
||||
useRecastBlockScene,
|
||||
useRecastBlockMeta,
|
||||
useSelectProperty,
|
||||
genSelectOptionId,
|
||||
};
|
||||
|
||||
export * from './group';
|
||||
393
libs/components/editor-core/src/recast-block/property.ts
Normal file
393
libs/components/editor-core/src/recast-block/property.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useCallback } from 'react';
|
||||
import { AsyncBlock } from '../editor';
|
||||
import { useRecastBlock } from './Context';
|
||||
import type { RecastBlock, RecastItem, StatusProperty } from './types';
|
||||
import {
|
||||
META_PROPERTIES_KEY,
|
||||
MultiSelectProperty,
|
||||
PropertyType,
|
||||
RecastBlockValue,
|
||||
RecastMetaProperty,
|
||||
RecastPropertyId,
|
||||
SelectOption,
|
||||
SelectOptionId,
|
||||
SelectProperty,
|
||||
TABLE_VALUES_KEY,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Generate a unique id for a property
|
||||
*/
|
||||
const genPropertyId = () => nanoid(16) as RecastPropertyId; // This is a safe type cast
|
||||
|
||||
/**
|
||||
* Generate a unique id for a select option
|
||||
*/
|
||||
export const genSelectOptionId = () => nanoid(16) as SelectOptionId; // This is a safe type cast
|
||||
|
||||
/**
|
||||
* Clone all **meta** properties to other block
|
||||
* The meta of the `toRecastBlock` will be changed in place!
|
||||
*/
|
||||
export const cloneRecastMetaTo = async (
|
||||
fromRecastBlock: RecastBlock,
|
||||
...toRecastBlock: RecastBlock[]
|
||||
) => {
|
||||
const blockProperties = fromRecastBlock.getProperty(META_PROPERTIES_KEY);
|
||||
for (const group of toRecastBlock) {
|
||||
await group.setProperty(META_PROPERTIES_KEY, blockProperties);
|
||||
}
|
||||
return blockProperties;
|
||||
};
|
||||
|
||||
const mergeSelectOptions = (
|
||||
select1: SelectOption[],
|
||||
select2: SelectOption[]
|
||||
): SelectOption[] => {
|
||||
// TODO: handle duplicate names
|
||||
return [...select1, ...select2].filter(
|
||||
(value, index, self) => index === self.findIndex(t => t.id === value.id)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The meta of the `toRecastBlock` will be changed in place!
|
||||
*/
|
||||
export const mergeRecastMeta = async (
|
||||
toRecastBlock: RecastBlock,
|
||||
fromRecastBlock: RecastBlock
|
||||
) => {
|
||||
const fromBlockProperties =
|
||||
fromRecastBlock.getProperty(META_PROPERTIES_KEY) ?? [];
|
||||
const toBlockProperties =
|
||||
toRecastBlock.getProperty(META_PROPERTIES_KEY) ?? [];
|
||||
const newProperty = [...toBlockProperties];
|
||||
for (const fromProp of fromBlockProperties) {
|
||||
const theSameProperty = toBlockProperties.find(
|
||||
toProp => toProp.id === fromProp.id
|
||||
);
|
||||
if (!theSameProperty) {
|
||||
newProperty.push(fromProp);
|
||||
continue;
|
||||
}
|
||||
if (theSameProperty.type !== fromProp.type) {
|
||||
console.error(
|
||||
'Can not merge properties',
|
||||
theSameProperty,
|
||||
fromProp
|
||||
);
|
||||
throw new Error(
|
||||
'Failed to merge properties! There are two properties with the same id that have different types!'
|
||||
);
|
||||
}
|
||||
switch (theSameProperty.type) {
|
||||
case PropertyType.Select:
|
||||
case PropertyType.MultiSelect: {
|
||||
// Caution! Here the value of the property is overwritten directly
|
||||
theSameProperty.options = mergeSelectOptions(
|
||||
theSameProperty.options,
|
||||
// The type cast is safe because the type of the `fromProp` is same as the `theSameProperty`
|
||||
(fromProp as typeof theSameProperty).options
|
||||
);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// TODO: fix property merge
|
||||
console.warn(
|
||||
'Can not merge properties',
|
||||
theSameProperty,
|
||||
'drop property',
|
||||
fromProp
|
||||
);
|
||||
// throw new Error(
|
||||
// 'Failed to merge properties! Unknown property type'
|
||||
// );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await toRecastBlock.setProperty(META_PROPERTIES_KEY, newProperty);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the recast block state
|
||||
*
|
||||
* Get/set multi-dimensional block meta properties
|
||||
* @public
|
||||
*/
|
||||
export const useRecastBlockMeta = () => {
|
||||
const recastBlock = useRecastBlock();
|
||||
const blockProperties = recastBlock.getProperty(META_PROPERTIES_KEY);
|
||||
|
||||
const getProperties = useCallback(
|
||||
() => blockProperties ?? [],
|
||||
[blockProperties]
|
||||
);
|
||||
|
||||
const getProperty = useCallback(
|
||||
(id: RecastPropertyId) =>
|
||||
blockProperties?.find(props => id === props.id),
|
||||
[blockProperties]
|
||||
);
|
||||
|
||||
const addProperty = useCallback(
|
||||
async (propertyWithoutId: Omit<RecastMetaProperty, 'id'>) => {
|
||||
const newProperty = {
|
||||
id: genPropertyId(),
|
||||
...propertyWithoutId,
|
||||
} as RecastMetaProperty;
|
||||
|
||||
const nameDuplicated = blockProperties?.find(
|
||||
prop => prop.name === newProperty.name
|
||||
);
|
||||
if (nameDuplicated) {
|
||||
throw new Error('Duplicated property name');
|
||||
}
|
||||
const newProperties = [...(blockProperties ?? []), newProperty];
|
||||
const success = await recastBlock.setProperty(
|
||||
META_PROPERTIES_KEY,
|
||||
newProperties
|
||||
);
|
||||
if (!success) {
|
||||
console.error(
|
||||
'Failed to set',
|
||||
propertyWithoutId,
|
||||
'newProperties',
|
||||
newProperties
|
||||
);
|
||||
throw new Error('Failed to set property');
|
||||
}
|
||||
return newProperty;
|
||||
},
|
||||
[recastBlock, blockProperties]
|
||||
);
|
||||
|
||||
const removeProperty = useCallback(
|
||||
(id: RecastPropertyId) => {
|
||||
const newProperties = blockProperties?.filter(
|
||||
property => property.id !== id
|
||||
);
|
||||
return recastBlock.setProperty(META_PROPERTIES_KEY, newProperties);
|
||||
},
|
||||
[recastBlock, blockProperties]
|
||||
);
|
||||
|
||||
const updateProperty = useCallback(
|
||||
async (property: RecastMetaProperty) => {
|
||||
if (!blockProperties) {
|
||||
throw new Error(
|
||||
'Cannot update property, because the block has no properties'
|
||||
);
|
||||
}
|
||||
const idx = blockProperties.findIndex(
|
||||
prop => prop.id === property.id
|
||||
);
|
||||
if (idx === -1) {
|
||||
console.error(blockProperties, property);
|
||||
throw new Error(
|
||||
`Failed to update Property. The id "${property.id}" not found.`
|
||||
);
|
||||
}
|
||||
const success = await recastBlock.setProperty(META_PROPERTIES_KEY, [
|
||||
...blockProperties.slice(0, idx),
|
||||
property,
|
||||
...blockProperties.slice(idx + 1),
|
||||
]);
|
||||
if (!success) {
|
||||
console.error(
|
||||
'update property',
|
||||
property,
|
||||
'blockProperties',
|
||||
blockProperties
|
||||
);
|
||||
throw new Error('Failed to update property');
|
||||
}
|
||||
return property;
|
||||
},
|
||||
[recastBlock, blockProperties]
|
||||
);
|
||||
|
||||
return {
|
||||
getProperty,
|
||||
getProperties,
|
||||
addProperty,
|
||||
updateProperty,
|
||||
removeProperty,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the recast item value
|
||||
*
|
||||
* Get the value of the entry inside the multidimensional table
|
||||
* @public
|
||||
* @example
|
||||
* ```ts
|
||||
* const { getAllValue, getValue, setValue } = getRecastItemValue(block);
|
||||
*
|
||||
* ```
|
||||
*/
|
||||
export const getRecastItemValue = (block: RecastItem | AsyncBlock) => {
|
||||
const recastItem = block as unknown as RecastItem;
|
||||
const props = recastItem.getProperty(TABLE_VALUES_KEY) ?? {};
|
||||
|
||||
const getAllValue = () => {
|
||||
return Object.values(props);
|
||||
};
|
||||
|
||||
const getValue = (id: RecastPropertyId) => {
|
||||
return props[id];
|
||||
};
|
||||
|
||||
const setValue = (newValue: RecastBlockValue) => {
|
||||
return recastItem.setProperty(TABLE_VALUES_KEY, {
|
||||
...props,
|
||||
[newValue.id]: newValue,
|
||||
});
|
||||
};
|
||||
|
||||
const removeValue = (propertyId: RecastPropertyId) => {
|
||||
return recastItem.setProperty(TABLE_VALUES_KEY, {
|
||||
...props,
|
||||
[propertyId]: null,
|
||||
});
|
||||
};
|
||||
return { getAllValue, getValue, setValue, removeValue };
|
||||
};
|
||||
|
||||
const isSelectLikeProperty = (
|
||||
metaProperty?: RecastMetaProperty
|
||||
): metaProperty is SelectProperty | MultiSelectProperty => {
|
||||
if (
|
||||
!metaProperty ||
|
||||
(metaProperty.type !== PropertyType.Select &&
|
||||
metaProperty.type !== PropertyType.MultiSelect)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* A helper to handle select property
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Init useSelectProperty
|
||||
* const { createSelect, updateSelect } = useSelectProperty();
|
||||
*
|
||||
* // Create a new select property
|
||||
* const selectProp = await createSelect({ name: 'Select Prop', options: [{ name: 'select 1' }] })
|
||||
* const selectProp2 = await createSelect({ name: 'Select Prop', type: PropertyType.Select })
|
||||
*
|
||||
* // Update select property
|
||||
* await updateSelect(selectProp).addSelectOptions({ name: 'select 2' })
|
||||
* ```
|
||||
*/
|
||||
export const useSelectProperty = () => {
|
||||
const { getProperty, addProperty, updateProperty } = useRecastBlockMeta();
|
||||
|
||||
/**
|
||||
* Notice: Before use, you need to check whether the name is repeated manually.
|
||||
*/
|
||||
const createSelect = async <
|
||||
T extends SelectProperty | MultiSelectProperty | StatusProperty
|
||||
>({
|
||||
name,
|
||||
options = [],
|
||||
type = PropertyType.Select,
|
||||
}: {
|
||||
name: string;
|
||||
options?: Omit<SelectOption, 'id'>[];
|
||||
type?: T['type'];
|
||||
}) => {
|
||||
const selectProperty = {
|
||||
name,
|
||||
type,
|
||||
options: options.map(option => ({
|
||||
...option,
|
||||
id: genSelectOptionId(),
|
||||
})),
|
||||
};
|
||||
return (await addProperty(selectProperty)) as T;
|
||||
};
|
||||
|
||||
const updateSelect = (
|
||||
selectProperty: SelectProperty | MultiSelectProperty
|
||||
) => {
|
||||
// if (typeof selectProperty === 'string') {
|
||||
// const maybeSelectProperty = getProperty(selectProperty);
|
||||
// if (maybeSelectProperty) {
|
||||
// selectProperty = maybeSelectProperty;
|
||||
// }
|
||||
// }
|
||||
if (!isSelectLikeProperty(selectProperty)) {
|
||||
console.error('selectProperty', selectProperty);
|
||||
throw new Error(
|
||||
`Incorrect usage of "selectPropertyHelper.updateSelect". The property is not a select property.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notice: Before use, you need to call {@link hasSelectOptions} to check whether the name is repeated
|
||||
*/
|
||||
const addSelectOptions = (...options: Omit<SelectOption, 'id'>[]) => {
|
||||
const newOptions = [
|
||||
...selectProperty.options,
|
||||
...options.map(option => ({
|
||||
...option,
|
||||
id: genSelectOptionId(),
|
||||
})),
|
||||
];
|
||||
return updateProperty({
|
||||
...selectProperty,
|
||||
options: newOptions,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Notice: Before use, you need to call {@link hasSelectOptions} to check whether the name is repeated
|
||||
*/
|
||||
const renameSelectOptions = (targetOption: SelectOption) => {
|
||||
const newOptions = selectProperty.options.map(option => {
|
||||
if (option.id === targetOption.id) {
|
||||
return {
|
||||
...option,
|
||||
name: targetOption.name,
|
||||
};
|
||||
}
|
||||
return option;
|
||||
});
|
||||
return updateProperty({
|
||||
...selectProperty,
|
||||
options: newOptions,
|
||||
});
|
||||
};
|
||||
|
||||
const removeSelectOptions = (...options: SelectOptionId[]) => {
|
||||
const newOptions = selectProperty.options.filter(
|
||||
i => !options.some(id => id === i.id)
|
||||
);
|
||||
return updateProperty({
|
||||
...selectProperty,
|
||||
options: newOptions,
|
||||
});
|
||||
};
|
||||
|
||||
const hasSelectOptions = (name: string) => {
|
||||
return selectProperty.options.some(option => option.name === name);
|
||||
};
|
||||
return {
|
||||
addSelectOptions,
|
||||
renameSelectOptions,
|
||||
removeSelectOptions,
|
||||
hasSelectOptions,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
createSelect,
|
||||
updateSelect,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
// Constants
|
||||
|
||||
export const META_PROPERTIES_KEY = 'metaProps' as const;
|
||||
export const TABLE_VALUES_KEY = 'recastValues' as const;
|
||||
/**
|
||||
* @deprecated Use {@link META_VIEWS_KEY} instead.
|
||||
*/
|
||||
export const KANBAN_PROPERTIES_KEY = 'kanbanProps' as const;
|
||||
export const META_VIEWS_KEY = 'recastViews' as const;
|
||||
32
libs/components/editor-core/src/recast-block/types/index.ts
Normal file
32
libs/components/editor-core/src/recast-block/types/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { AsyncBlock } from '../../editor';
|
||||
import { RecastBlockValue } from './recast-value';
|
||||
import { RecastDataProperties, RecastPropertyId } from './recast-property';
|
||||
import { TABLE_VALUES_KEY } from './constant';
|
||||
|
||||
// ---------------------------------------------------
|
||||
// Block
|
||||
|
||||
// TODO update AsyncBlock
|
||||
type VariantBlock<Props> = Omit<
|
||||
AsyncBlock,
|
||||
'getProperty' | 'getProperties' | 'setProperty' | 'setProperties'
|
||||
> & {
|
||||
getProperty<T extends keyof Props>(key: T): Props[T];
|
||||
getProperties(): Props;
|
||||
setProperty<T extends keyof Props>(
|
||||
key: T,
|
||||
value: Props[T]
|
||||
): Promise<boolean>;
|
||||
setProperties(value: Partial<Props>): Promise<boolean>;
|
||||
};
|
||||
|
||||
export type RecastBlock = VariantBlock<RecastDataProperties>;
|
||||
|
||||
export type RecastItem = VariantBlock<{
|
||||
[TABLE_VALUES_KEY]: Record<RecastPropertyId, RecastBlockValue>;
|
||||
}>;
|
||||
|
||||
export * from './constant';
|
||||
export * from './recast-property';
|
||||
export * from './recast-value';
|
||||
export * from './view';
|
||||
@@ -0,0 +1,121 @@
|
||||
import { CSSProperties, ReactNode } from 'react';
|
||||
import {
|
||||
KANBAN_PROPERTIES_KEY,
|
||||
META_PROPERTIES_KEY,
|
||||
META_VIEWS_KEY,
|
||||
} from './constant';
|
||||
import { RecastScene, RecastView } from './view';
|
||||
|
||||
// ---------------------------------------------------
|
||||
// Property
|
||||
|
||||
export enum PropertyType {
|
||||
Text = 'text',
|
||||
Status = 'status',
|
||||
Select = 'select',
|
||||
MultiSelect = 'multiSelect',
|
||||
Date = 'date',
|
||||
Mention = 'mention',
|
||||
Information = 'information',
|
||||
}
|
||||
|
||||
export type RecastPropertyId = string & {
|
||||
/**
|
||||
* Type differentiator only.
|
||||
*/
|
||||
readonly __isPropertyId: true;
|
||||
};
|
||||
|
||||
interface BaseProperty {
|
||||
readonly id: RecastPropertyId;
|
||||
name: string;
|
||||
background?: CSSProperties['background'];
|
||||
color?: CSSProperties['color'];
|
||||
iconName?: string;
|
||||
}
|
||||
|
||||
export interface TextProperty extends BaseProperty {
|
||||
type: PropertyType.Text;
|
||||
}
|
||||
|
||||
export interface DateProperty extends BaseProperty {
|
||||
type: PropertyType.Date;
|
||||
}
|
||||
|
||||
export interface MentionProperty extends BaseProperty {
|
||||
type: PropertyType.Mention;
|
||||
}
|
||||
|
||||
export type SelectOptionId = string & {
|
||||
/**
|
||||
* Type differentiator only.
|
||||
*/
|
||||
readonly __isSelectOptionId: true;
|
||||
};
|
||||
|
||||
export interface SelectOption {
|
||||
id: SelectOptionId;
|
||||
name: string;
|
||||
// value: string;
|
||||
color?: CSSProperties['color'];
|
||||
background?: CSSProperties['background'];
|
||||
iconName?: string;
|
||||
}
|
||||
|
||||
export interface StatusProperty extends BaseProperty {
|
||||
type: PropertyType.Status;
|
||||
options: SelectOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Select
|
||||
*/
|
||||
export interface SelectProperty extends BaseProperty {
|
||||
type: PropertyType.Select;
|
||||
options: SelectOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* MultiSelect
|
||||
*
|
||||
* TODO pending for further evaluation
|
||||
*/
|
||||
export interface MultiSelectProperty extends BaseProperty {
|
||||
type: PropertyType.MultiSelect;
|
||||
options: SelectOption[];
|
||||
/**
|
||||
* Limit the number of choices, if it is 1, it is a single choice
|
||||
* @deprecated pending for further evaluation
|
||||
*/
|
||||
multiple?: number;
|
||||
}
|
||||
|
||||
export interface InformationProperty extends BaseProperty {
|
||||
type: PropertyType.Information;
|
||||
phoneOptions: SelectOption[];
|
||||
locationOptions: SelectOption[];
|
||||
emailOptions: SelectOption[];
|
||||
}
|
||||
// TODO add more value
|
||||
export type RecastMetaProperty =
|
||||
| TextProperty
|
||||
| SelectProperty
|
||||
| MultiSelectProperty
|
||||
| DateProperty
|
||||
| MentionProperty
|
||||
| StatusProperty
|
||||
| InformationProperty;
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link RecastView}
|
||||
*/
|
||||
export type RecastKanbanProperty = {
|
||||
groupBy: RecastPropertyId;
|
||||
};
|
||||
|
||||
export type RecastDataProperties = Partial<{
|
||||
scene: RecastScene;
|
||||
[META_PROPERTIES_KEY]: RecastMetaProperty[];
|
||||
[META_VIEWS_KEY]: RecastView[];
|
||||
[KANBAN_PROPERTIES_KEY]: RecastKanbanProperty;
|
||||
}>;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import {
|
||||
RecastPropertyId,
|
||||
PropertyType,
|
||||
SelectOptionId,
|
||||
} from './recast-property';
|
||||
|
||||
// Property Value
|
||||
|
||||
type BaseValue = {
|
||||
readonly id: RecastPropertyId;
|
||||
};
|
||||
|
||||
export interface TextValue extends BaseValue {
|
||||
type: PropertyType.Text;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface DateValue extends BaseValue {
|
||||
type: PropertyType.Date;
|
||||
value: number | [number, number];
|
||||
}
|
||||
|
||||
export interface SelectValue extends BaseValue {
|
||||
type: PropertyType.Select;
|
||||
value: SelectOptionId;
|
||||
}
|
||||
|
||||
export interface MultiSelectValue extends BaseValue {
|
||||
type: PropertyType.MultiSelect;
|
||||
value: SelectOptionId[];
|
||||
}
|
||||
|
||||
export interface MentionValue extends BaseValue {
|
||||
type: PropertyType.Mention;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface StatusValue extends BaseValue {
|
||||
type: PropertyType.Status;
|
||||
value: SelectOptionId;
|
||||
}
|
||||
|
||||
export interface InformationValue extends BaseValue {
|
||||
type: PropertyType.Information;
|
||||
value: {
|
||||
phone: SelectOptionId[];
|
||||
location: SelectOptionId[];
|
||||
email: SelectOptionId[];
|
||||
};
|
||||
}
|
||||
|
||||
// TODO add more value
|
||||
export type RecastBlockValue =
|
||||
| TextValue
|
||||
| SelectValue
|
||||
| MultiSelectValue
|
||||
| StatusValue
|
||||
| DateValue
|
||||
| MentionValue
|
||||
| InformationValue;
|
||||
28
libs/components/editor-core/src/recast-block/types/view.ts
Normal file
28
libs/components/editor-core/src/recast-block/types/view.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { RecastPropertyId } from './recast-property';
|
||||
|
||||
export enum RecastScene {
|
||||
/**
|
||||
* normal view
|
||||
*/
|
||||
Page = 'page',
|
||||
Kanban = 'kanban',
|
||||
Table = 'table',
|
||||
Whiteboard = 'whiteboard',
|
||||
}
|
||||
|
||||
type BaseView = {
|
||||
name: string;
|
||||
// TODO: design this
|
||||
// order?: string[];
|
||||
};
|
||||
|
||||
export interface PageView extends BaseView {
|
||||
type: RecastScene.Page;
|
||||
}
|
||||
|
||||
export interface KanbanView extends BaseView {
|
||||
type: RecastScene.Kanban;
|
||||
groupBy: RecastPropertyId;
|
||||
}
|
||||
|
||||
export type RecastView = PageView | KanbanView;
|
||||
72
libs/components/editor-core/src/render-block/RenderBlock.tsx
Normal file
72
libs/components/editor-core/src/render-block/RenderBlock.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { styled, Theme } from '@toeverything/components/ui';
|
||||
import { FC, useContext, useLayoutEffect, useMemo, useRef } from 'react';
|
||||
|
||||
// import { RenderChildren } from './RenderChildren';
|
||||
import { RootContext } from '../contexts';
|
||||
import { useBlock } from '../hooks';
|
||||
|
||||
interface RenderBlockProps {
|
||||
blockId: string;
|
||||
hasContainer?: boolean;
|
||||
}
|
||||
|
||||
export const RenderBlock: FC<RenderBlockProps> = ({
|
||||
blockId,
|
||||
hasContainer = true,
|
||||
}) => {
|
||||
const { editor, editorElement } = useContext(RootContext);
|
||||
const { block } = useBlock(blockId);
|
||||
const blockRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (block && blockRef.current) {
|
||||
block.dom = blockRef.current;
|
||||
}
|
||||
});
|
||||
|
||||
const blockView = useMemo(() => {
|
||||
if (block?.type) {
|
||||
return editor.getView(block.type);
|
||||
}
|
||||
return null;
|
||||
}, [editor, block?.type]);
|
||||
|
||||
if (!block) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
const columns = {
|
||||
fromId: block.id ?? '',
|
||||
columns: block.columns ?? [],
|
||||
};
|
||||
|
||||
const view = blockView?.View ? (
|
||||
<blockView.View
|
||||
editor={editor}
|
||||
block={block}
|
||||
columns={columns.columns}
|
||||
columnsFromId={columns.fromId}
|
||||
scene="page"
|
||||
editorElement={editorElement}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return hasContainer ? (
|
||||
<BlockContainer
|
||||
block-id={blockId}
|
||||
ref={blockRef}
|
||||
data-block-id={blockId}
|
||||
>
|
||||
{view}
|
||||
</BlockContainer>
|
||||
) : (
|
||||
<> {view}</>
|
||||
);
|
||||
};
|
||||
|
||||
const BlockContainer = styled('div')(({ theme }) => ({
|
||||
fontSize: theme.typography.body1.fontSize,
|
||||
}));
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { FC } from 'react';
|
||||
import type { AsyncBlock } from '../editor';
|
||||
import { RenderBlock } from './RenderBlock';
|
||||
|
||||
interface RenderChildrenProps {
|
||||
block: AsyncBlock;
|
||||
}
|
||||
|
||||
export const RenderBlockChildren: FC<RenderChildrenProps> = ({ block }) => {
|
||||
return block.childrenIds.length ? (
|
||||
<>
|
||||
{block.childrenIds.map(childId => {
|
||||
return <RenderBlock key={childId} blockId={childId} />;
|
||||
})}
|
||||
</>
|
||||
) : null;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user