init: the first public commit for AFFiNE

This commit is contained in:
DarkSky
2022-07-22 15:49:21 +08:00
commit e3e3741393
1451 changed files with 108124 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
{
"presets": [
[
"@nrwl/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}

View 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": {}
}
]
}

View 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).

View 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',
};

View 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"
}
}

View 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
}
}
}
}

View 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' });

View File

@@ -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>
);
};

View File

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

View File

@@ -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>
);
};

View File

@@ -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;
}
`;

View 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>;
};

View File

@@ -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',
};
});

View 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: '',
// },
];

View File

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

View File

@@ -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;
`;

View File

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

View File

@@ -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;
`;

View File

@@ -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);
}}
/>
);
};

View File

@@ -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;
`;

View File

@@ -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')
)
}
/>
</>
);
};

View File

@@ -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);
}}
/>
);
};

View File

@@ -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>
);
};

View File

@@ -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',
});

View File

@@ -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;
`;

View File

@@ -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);
}}
/>
);
};

View File

@@ -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,
};
});

View File

@@ -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);
}}
/>
);
};

View File

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

View File

@@ -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;
};

View File

@@ -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(),
}));
};

View File

@@ -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>
);
};

View File

@@ -0,0 +1,2 @@
export { CreatePendantPanel } from './CreatePendantPanel';
export { UpdatePendantPanel } from './UpdatePendantPanel';

View File

@@ -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',
};

View File

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

View File

@@ -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;
`;

View File

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

View 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[];
};

View 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,
};
};

View 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 })];
};

View 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: [],
});

View File

@@ -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>
);
}

View File

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

View 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();
}
}

View 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;
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,2 @@
export { AsyncBlock } from './async-block';
export type { WorkspaceAndBlockId, EventData } from './async-block';

View File

@@ -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, '&nbsp;');
// data = data.replace(/\t/g, '&nbsp;&nbsp;&nbsp;&nbsp;');
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 };

View 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 };

View File

@@ -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;
}
}

View File

@@ -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 };

File diff suppressed because it is too large Load Diff

View 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[];
}

View File

@@ -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;
}
}

View 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);
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,4 @@
export enum GridDropType {
left = 'left',
right = 'right',
}

View 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;
}
}

View File

@@ -0,0 +1,2 @@
export { DragDropManager } from './drag-drop';
export * from './types';

View 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',
}

View 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();
}
}

View 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';

View File

@@ -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',
};

View File

@@ -0,0 +1 @@
export { KeyboardManager } from './keyboard';

View 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;
};
}

View File

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

View 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);
}
}

View 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);
}
}

View File

@@ -0,0 +1,2 @@
export { PluginManager } from './manager';
export { Hooks } from './hooks';

View 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];
}
}

View File

@@ -0,0 +1,6 @@
export enum PluginsEventTypes {
toggleTextBold = 'TOGGLE_TEXT_BOLD',
toggleTextItalic = 'TOGGLE_TEXT_ITALIC',
toggleTextStrikethrough = 'TOGGLE_TEXT_STRIKETHROUGH',
checkTextBold = 'CHECK_TEXT_BOLD',
}

View File

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

View 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;
}
}
}

View File

@@ -0,0 +1,4 @@
export * from './types';
export { SelectionManager } from './selection';
export type { SelectionInfo } from './selection';

File diff suppressed because it is too large Load Diff

View 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 -*-*-*- **/

View 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;
// }

View 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';

View 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);
};

View 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]);
};

View 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';

View 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>
);
};

View 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];
};

View 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}`);
}
}
};

View File

@@ -0,0 +1,2 @@
export { useKanban, useRecastKanbanGroupBy } from './kanban';
export { KanbanProvider, withKanban } from './Context';

View 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 };
};

View 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;

View 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;

View 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>
</>
);
};
```

View 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,
};
};

View 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!');
};

View 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';

View 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,
};
};

View File

@@ -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;

View 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';

View File

@@ -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;
}>;

View File

@@ -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;

View 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;

View 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,
}));

View File

@@ -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