mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-16 17:46:18 +08:00
init: the first public commit for AFFiNE
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
import type { TextProps } from '@toeverything/components/common';
|
||||
import {
|
||||
ContentColumnValue,
|
||||
services,
|
||||
Protocol,
|
||||
} from '@toeverything/datasource/db-service';
|
||||
import { type CreateView } from '@toeverything/framework/virgo';
|
||||
import { useEffect, useRef, useState, type FC } from 'react';
|
||||
|
||||
import {
|
||||
TextManage,
|
||||
type ExtendedTextUtils,
|
||||
} from '../../components/text-manage';
|
||||
import { tabBlock } from '../../utils/indent';
|
||||
import { BulletBlock, BulletProperties } from './types';
|
||||
import {
|
||||
supportChildren,
|
||||
RenderBlockChildren,
|
||||
useOnSelect,
|
||||
WrapperWithPendantAndDragDrop,
|
||||
} from '@toeverything/components/editor-core';
|
||||
import { List } from '../../components/style-container';
|
||||
import { getChildrenType, BulletIcon, NumberType } from './data';
|
||||
import { IndentWrapper } from '../../components/IndentWrapper';
|
||||
import { BlockContainer } from '../../components/BlockContainer';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
export const defaultBulletProps: BulletProperties = {
|
||||
text: { value: [{ text: '' }] },
|
||||
numberType: NumberType.type1,
|
||||
};
|
||||
const reset_todo_state = async (block: BulletBlock) => {
|
||||
await block.setProperties({});
|
||||
};
|
||||
|
||||
const todoIsEmpty = (contentValue: ContentColumnValue): boolean => {
|
||||
const todoValue = contentValue.value;
|
||||
return (
|
||||
todoValue.length === 0 ||
|
||||
(todoValue.length === 1 && !todoValue[0]['text'])
|
||||
);
|
||||
};
|
||||
const BulletLeft = styled('div')(() => ({
|
||||
height: '22px',
|
||||
}));
|
||||
export const BulletView: FC<CreateView> = ({ block, editor }) => {
|
||||
// block.remove();
|
||||
const properties = { ...defaultBulletProps, ...block.getProperties() };
|
||||
const text_ref = useRef<ExtendedTextUtils>(null);
|
||||
// const [type, set_type] = useState('type-1');
|
||||
const [isSelect, setIsSelect] = useState<boolean>();
|
||||
|
||||
useOnSelect(block.id, (is_select: boolean) => {
|
||||
setIsSelect(is_select);
|
||||
});
|
||||
const turn_into_text_block = async () => {
|
||||
// Convert to text block
|
||||
await block.setType('text');
|
||||
await reset_todo_state(block);
|
||||
|
||||
if (!text_ref.current) {
|
||||
throw new Error(
|
||||
'Failed to set cursor position! text_ref is not exist!'
|
||||
);
|
||||
}
|
||||
const currentSelection = text_ref.current.getStartSelection();
|
||||
if (!currentSelection) {
|
||||
throw new Error(
|
||||
'Failed to get cursor selection! currentSelection is not exist!'
|
||||
);
|
||||
}
|
||||
editor.selectionManager.setNodeActiveSelection(block.id, {
|
||||
type: 'Range',
|
||||
info: currentSelection,
|
||||
});
|
||||
// Update cursor position
|
||||
// editor.selectionManager.setActivatedNodeId(block.id);
|
||||
// editor.selectionManager.setNodeActiveSelection(block.id, {
|
||||
// type: 'Range',
|
||||
// info: currentSelection
|
||||
// });
|
||||
};
|
||||
const i = 0;
|
||||
const listChange = async () => {
|
||||
const preBlock = await block.previousSiblings();
|
||||
const parent_block = await block.parent();
|
||||
let parent_number_type = parent_block.getProperty('numberType');
|
||||
if (
|
||||
!parent_number_type &&
|
||||
parent_block.type === Protocol.Block.Type.bullet
|
||||
) {
|
||||
parent_number_type = NumberType.type1;
|
||||
parent_block.setProperty('numberType', parent_number_type);
|
||||
}
|
||||
block.setProperty('numberType', getChildrenType(parent_number_type));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
listChange();
|
||||
let obj: any;
|
||||
const parentChange = async () => {
|
||||
const parent_block = await block.parent();
|
||||
obj = await services.api.editorBlock.observe(
|
||||
{
|
||||
workspace: editor.workspace,
|
||||
id: parent_block.id,
|
||||
},
|
||||
listChange
|
||||
);
|
||||
};
|
||||
parentChange();
|
||||
return () => {
|
||||
obj?.();
|
||||
};
|
||||
// console.log('_parent_block: ', _parent_block);
|
||||
}, []);
|
||||
|
||||
const on_text_enter: TextProps['handleEnter'] = async props => {
|
||||
const { splitContents, isShiftKey } = props;
|
||||
if (isShiftKey) {
|
||||
return false;
|
||||
}
|
||||
const { contentBeforeSelection, contentAfterSelection } = splitContents;
|
||||
const before = [...contentBeforeSelection.content];
|
||||
const after = [...contentAfterSelection.content];
|
||||
if (todoIsEmpty({ value: before } as ContentColumnValue)) {
|
||||
await turn_into_text_block();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Move children to new block
|
||||
const children = await block.children();
|
||||
await block.removeChildren();
|
||||
|
||||
const next_node = await editor.createBlock(Protocol.Block.Type.bullet);
|
||||
if (!next_node) {
|
||||
throw new Error('Failed to create todo block');
|
||||
}
|
||||
await next_node.append(...children);
|
||||
await next_node.setProperties({
|
||||
text: { value: after } as ContentColumnValue,
|
||||
});
|
||||
await block.setProperties({
|
||||
text: { value: before } as ContentColumnValue,
|
||||
});
|
||||
await block.after(next_node);
|
||||
|
||||
editor.selectionManager.activeNodeByNodeId(next_node.id);
|
||||
|
||||
return true;
|
||||
};
|
||||
const on_tab: TextProps['handleTab'] = async ({ isShiftKey }) => {
|
||||
if (!isShiftKey) {
|
||||
const preSiblingBlock = await block.previousSibling();
|
||||
if (preSiblingBlock && supportChildren(preSiblingBlock)) {
|
||||
const copy_block = block;
|
||||
block.remove();
|
||||
block.removeChildren();
|
||||
const block_children = await copy_block.children();
|
||||
preSiblingBlock.append(copy_block, ...block_children);
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
return tabBlock(block, isShiftKey);
|
||||
}
|
||||
};
|
||||
|
||||
const on_backspace: TextProps['handleBackSpace'] = async ({
|
||||
isCollAndStart,
|
||||
}) => {
|
||||
if (!isCollAndStart) {
|
||||
return false;
|
||||
}
|
||||
await turn_into_text_block();
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<BlockContainer editor={editor} block={block} selected={isSelect}>
|
||||
<WrapperWithPendantAndDragDrop editor={editor} block={block}>
|
||||
<List>
|
||||
<BulletLeft>
|
||||
<BulletIcon numberType={properties.numberType} />
|
||||
</BulletLeft>
|
||||
<div className={'textContainer'}>
|
||||
<TextManage
|
||||
ref={text_ref}
|
||||
editor={editor}
|
||||
block={block}
|
||||
supportMarkdown
|
||||
placeholder="/Bullet list"
|
||||
handleEnter={on_text_enter}
|
||||
handleBackSpace={on_backspace}
|
||||
handleTab={on_tab}
|
||||
/>
|
||||
</div>
|
||||
</List>
|
||||
</WrapperWithPendantAndDragDrop>
|
||||
<IndentWrapper>
|
||||
<RenderBlockChildren block={block} />
|
||||
</IndentWrapper>
|
||||
</BlockContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
BulletedList_1Icon,
|
||||
BulletedList_2Icon,
|
||||
BulletedList_3Icon,
|
||||
BulletedList_4Icon,
|
||||
} from '@toeverything/components/icons';
|
||||
export enum NumberType {
|
||||
type1 = 'type1',
|
||||
type2 = 'type2',
|
||||
type3 = 'type3',
|
||||
type4 = 'type4',
|
||||
}
|
||||
export function BulletIcon(props: any) {
|
||||
const { numberType } = props;
|
||||
if (numberType === NumberType.type2) {
|
||||
return <BulletedList_2Icon />;
|
||||
}
|
||||
if (numberType === NumberType.type3) {
|
||||
return <BulletedList_3Icon />;
|
||||
}
|
||||
if (numberType === NumberType.type4) {
|
||||
return <BulletedList_4Icon />;
|
||||
}
|
||||
return <BulletedList_1Icon />;
|
||||
}
|
||||
|
||||
export function getChildrenType(type: string) {
|
||||
const typeMap: Record<string, string> = {
|
||||
type1: 'type2',
|
||||
type2: 'type3',
|
||||
type3: 'type4',
|
||||
};
|
||||
return typeMap[type];
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
AsyncBlock,
|
||||
BaseView,
|
||||
CreateView,
|
||||
getTextProperties,
|
||||
SelectBlock,
|
||||
getTextHtml,
|
||||
} from '@toeverything/framework/virgo';
|
||||
import {
|
||||
Protocol,
|
||||
DefaultColumnsValue,
|
||||
} from '@toeverything/datasource/db-service';
|
||||
// import { withTreeViewChildren } from '../../utils/with-tree-view-children';
|
||||
import { defaultBulletProps, BulletView } from './BulletView';
|
||||
import { IndentWrapper } from '../../components/IndentWrapper';
|
||||
|
||||
export class BulletBlock extends BaseView {
|
||||
public type = Protocol.Block.Type.bullet;
|
||||
|
||||
View = BulletView;
|
||||
|
||||
// override ChildrenView = IndentWrapper;
|
||||
|
||||
override async onCreate(block: AsyncBlock) {
|
||||
if (!block.getProperty('text')) {
|
||||
await block.setProperties(defaultBulletProps);
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
override getSelProperties(
|
||||
block: AsyncBlock,
|
||||
selectInfo: any
|
||||
): DefaultColumnsValue {
|
||||
const properties = super.getSelProperties(block, selectInfo);
|
||||
return getTextProperties(properties, selectInfo);
|
||||
}
|
||||
|
||||
override html2block(
|
||||
el: Element,
|
||||
parseEl: (el: Element) => any[]
|
||||
): any[] | null {
|
||||
const tag_name = el.tagName;
|
||||
if (tag_name === 'UL') {
|
||||
const result = [];
|
||||
for (let i = 0; i < el.children.length; i++) {
|
||||
const blocks_info = parseEl(el.children[i]);
|
||||
result.push(...blocks_info);
|
||||
}
|
||||
return result.length > 0 ? result : null;
|
||||
}
|
||||
|
||||
if (tag_name == 'LI' && !el.textContent.startsWith('[ ] ')) {
|
||||
const childNodes = el.childNodes;
|
||||
const texts = [];
|
||||
const children = [];
|
||||
for (let i = 0; i < childNodes.length; i++) {
|
||||
const blocks_info = parseEl(childNodes[i] as Element);
|
||||
for (let j = 0; j < blocks_info.length; j++) {
|
||||
if (blocks_info[j].type === 'text') {
|
||||
const block_texts =
|
||||
blocks_info[j].properties.text.value;
|
||||
texts.push(...block_texts);
|
||||
} else {
|
||||
children.push(blocks_info[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [
|
||||
{
|
||||
type: this.type,
|
||||
properties: {
|
||||
text: { value: texts },
|
||||
},
|
||||
children: children,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
override async block2html(
|
||||
block: AsyncBlock,
|
||||
children: SelectBlock[],
|
||||
generateHtml: (el: any[]) => Promise<string>
|
||||
): Promise<string> {
|
||||
let content = getTextHtml(block);
|
||||
content += await generateHtml(children);
|
||||
return `<ul><li>${content}</li></ul>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { AsyncBlock } from '@toeverything/framework/virgo';
|
||||
import type {
|
||||
ContentColumnValue,
|
||||
BooleanColumnValue,
|
||||
} from '@toeverything/datasource/db-service';
|
||||
|
||||
export interface BulletProperties {
|
||||
text: ContentColumnValue;
|
||||
numberType: any;
|
||||
}
|
||||
|
||||
export class BulletBlock extends AsyncBlock {
|
||||
override setProperties(
|
||||
properties: Partial<BulletProperties>
|
||||
): Promise<boolean> {
|
||||
return super.setProperties(properties);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import React, { useEffect, useRef, useImperativeHandle } from 'react';
|
||||
import { EditorState, EditorStateConfig, Extension } from '@codemirror/state';
|
||||
import { EditorView, ViewUpdate } from '@codemirror/view';
|
||||
import { useCodeMirror } from './use-code-mirror';
|
||||
|
||||
export * from './use-code-mirror';
|
||||
|
||||
export interface ReactCodeMirrorProps
|
||||
extends Omit<EditorStateConfig, 'doc' | 'extensions'>,
|
||||
Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange' | 'placeholder'> {
|
||||
/** value of the auto created model in the editor. */
|
||||
value?: string;
|
||||
height?: string;
|
||||
minHeight?: string;
|
||||
maxHeight?: string;
|
||||
width?: string;
|
||||
minWidth?: string;
|
||||
maxWidth?: string;
|
||||
/** focus on the editor. */
|
||||
autoFocus?: boolean;
|
||||
/** Enables a placeholder—a piece of example content to show when the editor is empty. */
|
||||
placeholder?: string | HTMLElement;
|
||||
/**
|
||||
* `light` / `dark` / `Extension` Defaults to `light`.
|
||||
* @default light
|
||||
*/
|
||||
theme?: 'light' | 'dark' | Extension;
|
||||
/**
|
||||
* Whether to optional basicSetup by default
|
||||
* @default true
|
||||
*/
|
||||
basicSetup?: boolean;
|
||||
/**
|
||||
* This disables editing of the editor content by the user.
|
||||
* @default true
|
||||
*/
|
||||
editable?: boolean;
|
||||
readOnly?: boolean;
|
||||
/**
|
||||
* Whether to optional basicSetup by default
|
||||
* @default true
|
||||
*/
|
||||
indentWithTab?: boolean;
|
||||
/** Fired whenever a change occurs to the document. */
|
||||
onChange?(value: string, viewUpdate: ViewUpdate): void;
|
||||
/** Fired whenever a change occurs to the document. There is a certain difference with `onChange`. */
|
||||
onUpdate?(viewUpdate: ViewUpdate): void;
|
||||
handleKeyArrowUp?: () => void;
|
||||
handleKeyArrowDown?: () => void;
|
||||
/**
|
||||
* Extension values can be [provided](https://codemirror.net/6/docs/ref/#state.EditorStateConfig.extensions) when creating a state to attach various kinds of configuration and behavior information.
|
||||
* They can either be built-in extension-providing objects,
|
||||
* such as [state fields](https://codemirror.net/6/docs/ref/#state.StateField) or [facet providers](https://codemirror.net/6/docs/ref/#state.Facet.of),
|
||||
* or objects with an extension in its `extension` property. Extensions can be nested in arrays arbitrarily deep—they will be flattened when processed.
|
||||
*/
|
||||
extensions?: Extension[];
|
||||
/**
|
||||
* If the view is going to be mounted in a shadow root or document other than the one held by the global variable document (the default), you should pass it here.
|
||||
* Originally from the [config of EditorView](https://codemirror.net/6/docs/ref/#view.EditorView.constructor%5Econfig.root)
|
||||
*/
|
||||
root?: ShadowRoot | Document;
|
||||
}
|
||||
|
||||
export interface ReactCodeMirrorRef {
|
||||
editor?: HTMLDivElement | null;
|
||||
state?: EditorState;
|
||||
view?: EditorView;
|
||||
}
|
||||
|
||||
const ReactCodeMirror = React.forwardRef<
|
||||
ReactCodeMirrorRef,
|
||||
ReactCodeMirrorProps
|
||||
>((props, ref) => {
|
||||
const {
|
||||
className,
|
||||
value = '',
|
||||
selection,
|
||||
extensions = [],
|
||||
onChange,
|
||||
onUpdate,
|
||||
handleKeyArrowUp,
|
||||
handleKeyArrowDown,
|
||||
autoFocus,
|
||||
theme = 'light',
|
||||
height,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
width,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
basicSetup,
|
||||
placeholder,
|
||||
indentWithTab,
|
||||
editable,
|
||||
readOnly,
|
||||
root,
|
||||
...other
|
||||
} = props;
|
||||
const editor = useRef<HTMLDivElement>(null);
|
||||
const { state, view, container, setContainer } = useCodeMirror({
|
||||
container: editor.current,
|
||||
root,
|
||||
value,
|
||||
autoFocus,
|
||||
theme,
|
||||
height,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
width,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
basicSetup,
|
||||
placeholder,
|
||||
indentWithTab,
|
||||
editable,
|
||||
readOnly,
|
||||
selection,
|
||||
onChange,
|
||||
onUpdate,
|
||||
extensions,
|
||||
handleKeyArrowUp,
|
||||
handleKeyArrowDown,
|
||||
});
|
||||
useImperativeHandle(ref, () => ({ editor: container, state, view }), [
|
||||
container,
|
||||
state,
|
||||
view,
|
||||
]);
|
||||
useEffect(() => {
|
||||
setContainer(editor.current);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// check type of value
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`value must be typeof string but got ${typeof value}`);
|
||||
}
|
||||
|
||||
const defaultClassNames =
|
||||
typeof theme === 'string' ? `cm-theme-${theme}` : 'cm-theme';
|
||||
return (
|
||||
<div
|
||||
ref={editor}
|
||||
className={`${defaultClassNames}${
|
||||
className ? ` ${className}` : ''
|
||||
}`}
|
||||
{...other}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
ReactCodeMirror.displayName = 'CodeMirror';
|
||||
|
||||
export default ReactCodeMirror;
|
||||
@@ -0,0 +1,220 @@
|
||||
import { FC, useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { StyleWithAtRules } from 'style9';
|
||||
|
||||
import { CreateView } from '@toeverything/framework/virgo';
|
||||
import CodeMirror from './CodeMirror';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import DeleteSweepOutlinedIcon from '@mui/icons-material/DeleteSweepOutlined';
|
||||
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { css } from '@codemirror/lang-css';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { python } from '@codemirror/lang-python';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { xml } from '@codemirror/lang-xml';
|
||||
import { sql, MySQL, PostgreSQL } from '@codemirror/lang-sql';
|
||||
import { java } from '@codemirror/lang-java';
|
||||
import { rust } from '@codemirror/lang-rust';
|
||||
import { cpp } from '@codemirror/lang-cpp';
|
||||
import { lezer } from '@codemirror/lang-lezer';
|
||||
import { php } from '@codemirror/lang-php';
|
||||
import { StreamLanguage } from '@codemirror/language';
|
||||
import { go } from '@codemirror/legacy-modes/mode/go';
|
||||
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
|
||||
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||
import { lua } from '@codemirror/legacy-modes/mode/lua';
|
||||
import { swift } from '@codemirror/legacy-modes/mode/swift';
|
||||
import { tcl } from '@codemirror/legacy-modes/mode/tcl';
|
||||
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
|
||||
import { vb } from '@codemirror/legacy-modes/mode/vb';
|
||||
import { powerShell } from '@codemirror/legacy-modes/mode/powershell';
|
||||
import { brainfuck } from '@codemirror/legacy-modes/mode/brainfuck';
|
||||
import { stylus } from '@codemirror/legacy-modes/mode/stylus';
|
||||
import { erlang } from '@codemirror/legacy-modes/mode/erlang';
|
||||
import { nginx } from '@codemirror/legacy-modes/mode/nginx';
|
||||
import { perl } from '@codemirror/legacy-modes/mode/perl';
|
||||
import { pascal } from '@codemirror/legacy-modes/mode/pascal';
|
||||
import { liveScript } from '@codemirror/legacy-modes/mode/livescript';
|
||||
import { scheme } from '@codemirror/legacy-modes/mode/scheme';
|
||||
import { toml } from '@codemirror/legacy-modes/mode/toml';
|
||||
import { vbScript } from '@codemirror/legacy-modes/mode/vbscript';
|
||||
import { clojure } from '@codemirror/legacy-modes/mode/clojure';
|
||||
import { coffeeScript } from '@codemirror/legacy-modes/mode/coffeescript';
|
||||
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
|
||||
import { julia } from '@codemirror/legacy-modes/mode/julia';
|
||||
import { r } from '@codemirror/legacy-modes/mode/r';
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { Select } from '../../components/select';
|
||||
import {
|
||||
useOnSelect,
|
||||
WrapperWithPendantAndDragDrop,
|
||||
} from '@toeverything/components/editor-core';
|
||||
interface CreateCodeView extends CreateView {
|
||||
style9?: StyleWithAtRules;
|
||||
containerClassName?: string;
|
||||
}
|
||||
const langs: Record<string, any> = {
|
||||
javascript,
|
||||
jsx: () => javascript({ jsx: true }),
|
||||
typescript: () => javascript({ typescript: true }),
|
||||
tsx: () => javascript({ jsx: true, typescript: true }),
|
||||
json,
|
||||
html,
|
||||
css,
|
||||
python,
|
||||
markdown,
|
||||
xml,
|
||||
sql,
|
||||
mysql: () => sql({ dialect: MySQL }),
|
||||
pgsql: () => sql({ dialect: PostgreSQL }),
|
||||
java,
|
||||
rust,
|
||||
cpp,
|
||||
lezer,
|
||||
php,
|
||||
go: () => StreamLanguage.define(go),
|
||||
shell: () => StreamLanguage.define(shell),
|
||||
lua: () => StreamLanguage.define(lua),
|
||||
swift: () => StreamLanguage.define(swift),
|
||||
tcl: () => StreamLanguage.define(tcl),
|
||||
yaml: () => StreamLanguage.define(yaml),
|
||||
vb: () => StreamLanguage.define(vb),
|
||||
powershell: () => StreamLanguage.define(powerShell),
|
||||
brainfuck: () => StreamLanguage.define(brainfuck),
|
||||
stylus: () => StreamLanguage.define(stylus),
|
||||
erlang: () => StreamLanguage.define(erlang),
|
||||
nginx: () => StreamLanguage.define(nginx),
|
||||
perl: () => StreamLanguage.define(perl),
|
||||
ruby: () => StreamLanguage.define(ruby),
|
||||
pascal: () => StreamLanguage.define(pascal),
|
||||
livescript: () => StreamLanguage.define(liveScript),
|
||||
scheme: () => StreamLanguage.define(scheme),
|
||||
toml: () => StreamLanguage.define(toml),
|
||||
vbscript: () => StreamLanguage.define(vbScript),
|
||||
clojure: () => StreamLanguage.define(clojure),
|
||||
coffeescript: () => StreamLanguage.define(coffeeScript),
|
||||
julia: () => StreamLanguage.define(julia),
|
||||
dockerfile: () => StreamLanguage.define(dockerFile),
|
||||
r: () => StreamLanguage.define(r),
|
||||
// clike: () => StreamLanguage.define(clike),
|
||||
// clike: () => clike({ }),
|
||||
};
|
||||
|
||||
const CodeBlock = styled('div')(({ theme }) => ({
|
||||
backgroundColor: '#f8f9fa',
|
||||
border: '1px solid #e0e0e0',
|
||||
'&:hover': {
|
||||
'.operation': {
|
||||
display: 'flex',
|
||||
},
|
||||
},
|
||||
'.operation': {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
padding: '0 8px 0px',
|
||||
},
|
||||
'.delete-block': {
|
||||
padding: '10px 4px 10px 10px',
|
||||
},
|
||||
'.cm-focused': {
|
||||
outline: 'none !important',
|
||||
},
|
||||
'.select': {
|
||||
marginTop: '10px',
|
||||
select: {
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
height: '22px',
|
||||
lineHeight: theme.affine.typography.body1.lineHeight,
|
||||
paddingLeft: '10px',
|
||||
maxHeight: '400px',
|
||||
background: '#f1f1f1',
|
||||
borderRadius: '6px',
|
||||
},
|
||||
},
|
||||
}));
|
||||
export const CodeView: FC<CreateCodeView> = ({ block, editor }) => {
|
||||
const init_value: string = block.getProperty('text')?.value?.[0]?.text;
|
||||
const lang_type: string = block.getProperty('lang')?.value?.[0]?.text;
|
||||
const [mode, setMode] = useState('javascript');
|
||||
const [extensions, setExtensions] = useState<Extension[]>();
|
||||
const codeMirror = useRef();
|
||||
useOnSelect(block.id, (is_select: boolean) => {
|
||||
if (codeMirror.current) {
|
||||
//@ts-ignore
|
||||
codeMirror?.current?.view?.focus();
|
||||
}
|
||||
});
|
||||
const onChange = (value: any, codeEditor: any) => {
|
||||
console.log(value);
|
||||
block.setProperty('text', {
|
||||
value: [{ text: value }],
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
handleLangChange(lang_type ? lang_type : 'javascript');
|
||||
}, []);
|
||||
function handleLangChange(lang: string) {
|
||||
block.setProperty('lang', lang);
|
||||
setMode(lang);
|
||||
setExtensions([langs[lang]()]);
|
||||
}
|
||||
const handle_remove_block = () => {
|
||||
block.remove();
|
||||
};
|
||||
const handleKeyArrowDown = () => {
|
||||
editor.selectionManager.activeNextNode(block.id, 'start');
|
||||
};
|
||||
const handleKeyArrowUp = () => {
|
||||
editor.selectionManager.activePreviousNode(block.id, 'start');
|
||||
};
|
||||
return (
|
||||
<WrapperWithPendantAndDragDrop editor={editor} block={block}>
|
||||
<CodeBlock
|
||||
onKeyDown={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="operation">
|
||||
<div className="select">
|
||||
<Select
|
||||
label="Lang"
|
||||
options={Object.keys(langs)}
|
||||
value={mode}
|
||||
onChange={evn => handleLangChange(evn.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className="delete-block"
|
||||
onClick={handle_remove_block}
|
||||
>
|
||||
<DeleteSweepOutlinedIcon
|
||||
className="delete-icon"
|
||||
fontSize="small"
|
||||
sx={{
|
||||
color: 'rgba(0,0,0,.5)',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { color: 'rgba(0,0,0,.9)' },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CodeMirror
|
||||
ref={codeMirror}
|
||||
value={init_value}
|
||||
height={'auto'}
|
||||
extensions={extensions}
|
||||
onChange={onChange}
|
||||
handleKeyArrowDown={handleKeyArrowDown}
|
||||
handleKeyArrowUp={handleKeyArrowUp}
|
||||
/>
|
||||
</CodeBlock>
|
||||
</WrapperWithPendantAndDragDrop>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
BaseView,
|
||||
AsyncBlock,
|
||||
getTextProperties,
|
||||
CreateView,
|
||||
SelectBlock,
|
||||
getTextHtml,
|
||||
} from '@toeverything/framework/virgo';
|
||||
import {
|
||||
Protocol,
|
||||
DefaultColumnsValue,
|
||||
} from '@toeverything/datasource/db-service';
|
||||
import { CodeView } from './CodeView';
|
||||
import { ComponentType } from 'react';
|
||||
|
||||
export class CodeBlock extends BaseView {
|
||||
type = Protocol.Block.Type.code;
|
||||
public override selectable = true;
|
||||
public override activatable = false;
|
||||
// View = CodeView;
|
||||
View = CodeView;
|
||||
override async onCreate(block: AsyncBlock): Promise<AsyncBlock> {
|
||||
if (!block.getProperty('text')) {
|
||||
await block.setProperty('text', {
|
||||
value: [{ text: '' }],
|
||||
});
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
override getSelProperties(
|
||||
block: AsyncBlock,
|
||||
selectInfo: any
|
||||
): DefaultColumnsValue {
|
||||
const properties = super.getSelProperties(block, selectInfo);
|
||||
return getTextProperties(properties, selectInfo);
|
||||
}
|
||||
|
||||
// TODO: internal format not implemented yet
|
||||
override html2block(
|
||||
el: Element,
|
||||
parseEl: (el: Element) => any[]
|
||||
): any[] | null {
|
||||
const tag_name = el.tagName;
|
||||
if (tag_name === 'CODE') {
|
||||
const childNodes = el.childNodes;
|
||||
let text_value = '';
|
||||
for (let i = 0; i < childNodes.length; i++) {
|
||||
const blocks_info = parseEl(childNodes[i] as Element);
|
||||
for (let j = 0; j < blocks_info.length; j++) {
|
||||
if (blocks_info[j].type === 'text') {
|
||||
const block_texts =
|
||||
blocks_info[j].properties.text.value;
|
||||
if (block_texts.length > 0) {
|
||||
text_value += block_texts[0].text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return [
|
||||
{
|
||||
type: this.type,
|
||||
properties: {
|
||||
text: { value: [{ text: text_value }] },
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
override async block2html(
|
||||
block: AsyncBlock,
|
||||
children: SelectBlock[],
|
||||
generateHtml: (el: any[]) => Promise<string>
|
||||
): Promise<string> {
|
||||
let content = getTextHtml(block);
|
||||
content += await generateHtml(children);
|
||||
return `<code>${content}</code>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
// import { basicSetup, minimalSetup } from 'codemirror';
|
||||
import { EditorState, StateEffect, Extension } from '@codemirror/state';
|
||||
import {
|
||||
indentWithTab,
|
||||
defaultKeymap,
|
||||
history,
|
||||
historyKeymap,
|
||||
} from '@codemirror/commands';
|
||||
import {
|
||||
EditorView,
|
||||
keymap,
|
||||
ViewUpdate,
|
||||
placeholder,
|
||||
highlightSpecialChars,
|
||||
drawSelection,
|
||||
highlightActiveLine,
|
||||
dropCursor,
|
||||
rectangularSelection,
|
||||
crosshairCursor,
|
||||
lineNumbers,
|
||||
highlightActiveLineGutter,
|
||||
} from '@codemirror/view';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { ReactCodeMirrorProps } from './CodeMirror';
|
||||
|
||||
import {
|
||||
defaultHighlightStyle,
|
||||
syntaxHighlighting,
|
||||
indentOnInput,
|
||||
bracketMatching,
|
||||
foldGutter,
|
||||
foldKeymap,
|
||||
} from '@codemirror/language';
|
||||
// import {searchKeymap, highlightSelectionMatches} from "@codemirror/search"
|
||||
// import {autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap} from "@codemirror/autocomplete"
|
||||
// import {lintKeymap} from "@codemirror/lint"
|
||||
|
||||
export interface UseCodeMirror extends ReactCodeMirrorProps {
|
||||
container?: HTMLDivElement | null;
|
||||
}
|
||||
|
||||
const basicSetup: Extension = [
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
foldGutter(),
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
indentOnInput(),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
bracketMatching(),
|
||||
rectangularSelection(),
|
||||
crosshairCursor(),
|
||||
highlightActiveLine(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap, ...foldKeymap]),
|
||||
];
|
||||
|
||||
export function useCodeMirror(props: UseCodeMirror) {
|
||||
const {
|
||||
value,
|
||||
selection,
|
||||
onChange,
|
||||
onUpdate,
|
||||
handleKeyArrowUp,
|
||||
handleKeyArrowDown,
|
||||
extensions = [],
|
||||
autoFocus,
|
||||
theme = 'light',
|
||||
height = '',
|
||||
minHeight = '',
|
||||
maxHeight = '',
|
||||
placeholder: placeholderStr = '',
|
||||
width = '',
|
||||
minWidth = '',
|
||||
maxWidth = '',
|
||||
editable = true,
|
||||
readOnly = false,
|
||||
indentWithTab: defaultIndentWithTab = true,
|
||||
basicSetup: defaultBasicSetup = true,
|
||||
|
||||
root,
|
||||
} = props;
|
||||
const [container, setContainer] = useState(props.container);
|
||||
const [view, setView] = useState<EditorView>();
|
||||
const [state, setState] = useState<EditorState>();
|
||||
const defaultLightThemeOption = EditorView.theme(
|
||||
{
|
||||
'&': {
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
},
|
||||
{
|
||||
dark: false,
|
||||
}
|
||||
);
|
||||
const defaultThemeOption = EditorView.theme({
|
||||
'&': {
|
||||
height,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
width,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
},
|
||||
});
|
||||
const updateListener = EditorView.updateListener.of((vu: ViewUpdate) => {
|
||||
if (vu.docChanged && typeof onChange === 'function') {
|
||||
const doc = vu.state.doc;
|
||||
const value = doc.toString();
|
||||
onChange(value, vu);
|
||||
}
|
||||
});
|
||||
// keymap.of([{''}])
|
||||
// keymap.of()
|
||||
const upFunction = [
|
||||
{
|
||||
key: 'ArrowUp',
|
||||
run: () => {
|
||||
handleKeyArrowUp();
|
||||
return false;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ArrowDown',
|
||||
run: (e: any) => {
|
||||
handleKeyArrowDown();
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
];
|
||||
// completionKeymap
|
||||
|
||||
let getExtensions = [updateListener, keymap.of(upFunction)];
|
||||
|
||||
if (defaultIndentWithTab) {
|
||||
getExtensions.unshift(keymap.of([indentWithTab]));
|
||||
}
|
||||
if (defaultBasicSetup) {
|
||||
getExtensions.unshift(basicSetup);
|
||||
}
|
||||
|
||||
if (placeholderStr) {
|
||||
getExtensions.unshift(placeholder(placeholderStr));
|
||||
}
|
||||
|
||||
switch (theme) {
|
||||
case 'light':
|
||||
getExtensions.push(defaultLightThemeOption);
|
||||
break;
|
||||
case 'dark':
|
||||
getExtensions.push(oneDark);
|
||||
break;
|
||||
default:
|
||||
getExtensions.push(theme);
|
||||
break;
|
||||
}
|
||||
|
||||
if (editable === false) {
|
||||
getExtensions.push(EditorView.editable.of(false));
|
||||
}
|
||||
if (readOnly) {
|
||||
getExtensions.push(EditorState.readOnly.of(true));
|
||||
}
|
||||
|
||||
if (onUpdate && typeof onUpdate === 'function') {
|
||||
getExtensions.push(EditorView.updateListener.of(onUpdate));
|
||||
}
|
||||
getExtensions.unshift(keymap.of([indentWithTab]));
|
||||
getExtensions = getExtensions.concat(extensions);
|
||||
|
||||
useEffect(() => {
|
||||
if (container && !state) {
|
||||
const stateCurrent = EditorState.create({
|
||||
doc: value,
|
||||
selection,
|
||||
extensions: getExtensions,
|
||||
});
|
||||
setState(stateCurrent);
|
||||
if (!view) {
|
||||
const viewCurrent = new EditorView({
|
||||
state: stateCurrent,
|
||||
parent: container,
|
||||
root,
|
||||
});
|
||||
setView(viewCurrent);
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (view) {
|
||||
setView(undefined);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [container, state]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (view) {
|
||||
view.destroy();
|
||||
setView(undefined);
|
||||
}
|
||||
},
|
||||
[view]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && view) {
|
||||
view.focus();
|
||||
}
|
||||
}, [autoFocus, view]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentValue = view ? view.state.doc.toString() : '';
|
||||
if (view && value !== currentValue) {
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: currentValue.length,
|
||||
insert: value || '',
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [value, view]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view) {
|
||||
view.dispatch({
|
||||
effects: StateEffect.reconfigure.of(getExtensions),
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
theme,
|
||||
extensions,
|
||||
height,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
width,
|
||||
placeholderStr,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
editable,
|
||||
defaultIndentWithTab,
|
||||
defaultBasicSetup,
|
||||
]);
|
||||
|
||||
return { state, setState, view, setView, container, setContainer };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { FC, useState } from 'react';
|
||||
import { CreateView } from '@toeverything/framework/virgo';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { useOnSelect } from '@toeverything/components/editor-core';
|
||||
|
||||
const DividerBlock = styled('div')<{ isSelected: boolean }>(
|
||||
({ isSelected }) => ({
|
||||
margin: 0,
|
||||
padding: '10px 0',
|
||||
borderWidth: '2px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: isSelected ? 'blue' : 'transparent',
|
||||
})
|
||||
);
|
||||
|
||||
const Line = styled('div')({
|
||||
height: '1px',
|
||||
backgroundColor: '#e2e8f0',
|
||||
});
|
||||
|
||||
export const DividerView: FC<CreateView> = ({ block, editor }) => {
|
||||
const [isSelected, setIsSelected] = useState(false);
|
||||
|
||||
useOnSelect(block.id, (isSelect: boolean) => {
|
||||
setIsSelected(isSelect);
|
||||
});
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
editor.selectionManager.setSelectedNodesIds([block.id]);
|
||||
};
|
||||
|
||||
return (
|
||||
<DividerBlock isSelected={isSelected} onClick={handleClick}>
|
||||
<Line />
|
||||
</DividerBlock>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
AsyncBlock,
|
||||
BaseView,
|
||||
SelectBlock,
|
||||
} from '@toeverything/framework/virgo';
|
||||
import { Protocol } from '@toeverything/datasource/db-service';
|
||||
import { DividerView } from './divider-view';
|
||||
|
||||
export class DividerBlock extends BaseView {
|
||||
type = Protocol.Block.Type.divider;
|
||||
View = DividerView;
|
||||
override html2block(
|
||||
el: Element,
|
||||
parseEl: (el: Element) => any[]
|
||||
): any[] | null {
|
||||
const tag_name = el.tagName;
|
||||
if (tag_name === 'HR') {
|
||||
return [
|
||||
{
|
||||
type: this.type,
|
||||
properties: {
|
||||
text: {},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
override async block2html(
|
||||
block: AsyncBlock,
|
||||
children: SelectBlock[],
|
||||
generateHtml: (el: any[]) => Promise<string>
|
||||
): Promise<string> {
|
||||
return `<hr>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { FC, useState } from 'react';
|
||||
import { CreateView } from '@toeverything/framework/virgo';
|
||||
import {
|
||||
WrapperWithPendantAndDragDrop,
|
||||
useOnSelect,
|
||||
} from '@toeverything/components/editor-core';
|
||||
import { Upload } from '../../components/upload/upload';
|
||||
import { SourceView } from '../../components/source-view';
|
||||
import { LinkContainer } from '../../components/style-container';
|
||||
|
||||
const MESSAGES = {
|
||||
ADD_EMBED_LINK: 'Add embed link',
|
||||
};
|
||||
|
||||
type EmbedLinkView = CreateView;
|
||||
export const EmbedLinkView: FC<EmbedLinkView> = props => {
|
||||
const { block, editor } = props;
|
||||
const [isSelect, setIsSelect] = useState(false);
|
||||
|
||||
const [embedLinkUrl, setEmbedLinkUrl] = useState<string>(
|
||||
block.getProperty('embedLink')?.value
|
||||
);
|
||||
|
||||
useOnSelect(block.id, (isSelect: boolean) => {
|
||||
setIsSelect(isSelect);
|
||||
});
|
||||
|
||||
const onEmbedLinkUrlChange = async (link: string) => {
|
||||
const DEMO_URL = 'https://affine.pro/';
|
||||
const value = link ? link : DEMO_URL;
|
||||
setEmbedLinkUrl(value);
|
||||
block.setProperty('embedLink', { value: value, name: 'embedLink' });
|
||||
};
|
||||
|
||||
return (
|
||||
<WrapperWithPendantAndDragDrop editor={editor} block={block}>
|
||||
<LinkContainer>
|
||||
{embedLinkUrl ? (
|
||||
<SourceView
|
||||
block={block}
|
||||
editorElement={props.editorElement}
|
||||
isSelected={isSelect}
|
||||
viewType="embedLink"
|
||||
link={embedLinkUrl}
|
||||
/>
|
||||
) : (
|
||||
<Upload
|
||||
firstCreate={block.firstCreateFlag}
|
||||
uploadType={'embedLink'}
|
||||
savaLink={onEmbedLinkUrlChange}
|
||||
defaultAddBtnText={MESSAGES.ADD_EMBED_LINK}
|
||||
isSelected={isSelect}
|
||||
/>
|
||||
)}
|
||||
</LinkContainer>
|
||||
</WrapperWithPendantAndDragDrop>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
AsyncBlock,
|
||||
BaseView,
|
||||
SelectBlock,
|
||||
} from '@toeverything/framework/virgo';
|
||||
import { Protocol } from '@toeverything/datasource/db-service';
|
||||
import { EmbedLinkView } from './EmbedLinkView';
|
||||
|
||||
export class EmbedLinkBlock extends BaseView {
|
||||
public override selectable = true;
|
||||
public override activatable = false;
|
||||
type = Protocol.Block.Type.embedLink;
|
||||
View = EmbedLinkView;
|
||||
|
||||
override html2block(
|
||||
el: Element,
|
||||
parseEl: (el: Element) => any[]
|
||||
): any[] | null {
|
||||
const tag_name = el.tagName;
|
||||
if (tag_name === 'A' && el.parentElement?.childElementCount === 1) {
|
||||
return [
|
||||
{
|
||||
type: this.type,
|
||||
properties: {
|
||||
// TODO: Not sure what value to fill for name
|
||||
embedLink: {
|
||||
name: this.type,
|
||||
value: el.getAttribute('href'),
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
override async block2html(
|
||||
block: AsyncBlock,
|
||||
children: SelectBlock[],
|
||||
generateHtml: (el: any[]) => Promise<string>
|
||||
): Promise<string> {
|
||||
const figma_url = block.getProperty('embedLink')?.value;
|
||||
return `<p><a src=${figma_url}>${figma_url}</p>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { FC, useState } from 'react';
|
||||
import { CreateView } from '@toeverything/framework/virgo';
|
||||
import {
|
||||
useOnSelect,
|
||||
WrapperWithPendantAndDragDrop,
|
||||
} from '@toeverything/components/editor-core';
|
||||
import { Upload } from '../../components/upload/upload';
|
||||
import { SourceView } from '../../components/source-view';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { LinkContainer } from '../../components/style-container';
|
||||
|
||||
const MESSAGES = {
|
||||
ADD_FIGMA_LINK: 'Add figma link',
|
||||
};
|
||||
|
||||
interface FigmaView extends CreateView {
|
||||
figmaUrl?: string;
|
||||
}
|
||||
export const FigmaView: FC<FigmaView> = ({ block, editor }) => {
|
||||
const [figmaUrl, setFigmaUrl] = useState<string>(
|
||||
block.getProperty('embedLink')?.value
|
||||
);
|
||||
|
||||
const onFigmaUrlChange = async (link: string) => {
|
||||
setFigmaUrl(link);
|
||||
block.setProperty('embedLink', { value: link, name: 'figma' });
|
||||
};
|
||||
const [isSelect, setIsSelect] = useState<boolean>();
|
||||
useOnSelect(block.id, (isSelect: boolean) => {
|
||||
setIsSelect(isSelect);
|
||||
});
|
||||
return (
|
||||
<WrapperWithPendantAndDragDrop editor={editor} block={block}>
|
||||
<LinkContainer>
|
||||
{figmaUrl ? (
|
||||
<SourceView
|
||||
block={block}
|
||||
viewType="figma"
|
||||
link={figmaUrl}
|
||||
isSelected={isSelect}
|
||||
/>
|
||||
) : (
|
||||
<Upload
|
||||
firstCreate={block.firstCreateFlag}
|
||||
uploadType={'figma'}
|
||||
savaLink={onFigmaUrlChange}
|
||||
deleteFile={() => {
|
||||
block.remove();
|
||||
}}
|
||||
defaultAddBtnText={MESSAGES.ADD_FIGMA_LINK}
|
||||
isSelected={isSelect}
|
||||
/>
|
||||
)}
|
||||
</LinkContainer>
|
||||
</WrapperWithPendantAndDragDrop>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
AsyncBlock,
|
||||
BaseView,
|
||||
SelectBlock,
|
||||
} from '@toeverything/framework/virgo';
|
||||
import { Protocol, services } from '@toeverything/datasource/db-service';
|
||||
import { FigmaView } from './FigmaView';
|
||||
|
||||
export class FigmaBlock extends BaseView {
|
||||
public override selectable = true;
|
||||
public override activatable = false;
|
||||
type = Protocol.Block.Type.figma;
|
||||
View = FigmaView;
|
||||
|
||||
override html2block(
|
||||
el: Element,
|
||||
parseEl: (el: Element) => any[]
|
||||
): any[] | null {
|
||||
const tag_name = el.tagName;
|
||||
if (tag_name === 'A' && el.parentElement?.childElementCount === 1) {
|
||||
const href = el.getAttribute('href');
|
||||
if (href.indexOf('.figma.com') !== -1) {
|
||||
return [
|
||||
{
|
||||
type: this.type,
|
||||
properties: {
|
||||
// TODO: Not sure what value to fill for name
|
||||
embedLink: {
|
||||
name: this.type,
|
||||
value: el.getAttribute('href'),
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
override async block2html(
|
||||
block: AsyncBlock,
|
||||
children: SelectBlock[],
|
||||
generateHtml: (el: any[]) => Promise<string>
|
||||
): Promise<string> {
|
||||
const figma_url = block.getProperty('embedLink')?.value;
|
||||
return `<p><a src=${figma_url}>${figma_url}</p>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { FC, useState, useEffect, useRef } from 'react';
|
||||
import { CreateView } from '@toeverything/framework/virgo';
|
||||
import { Upload } from '../../components/upload/upload';
|
||||
import { services, FileColumnValue } from '@toeverything/datasource/db-service';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { useOnSelect } from '@toeverything/components/editor-core';
|
||||
type FileView = CreateView;
|
||||
const MESSAGES = {
|
||||
ADD_AN_FILE: 'Add an file',
|
||||
};
|
||||
|
||||
const FileViewContainer = styled('div')<{ isSelected: boolean }>(
|
||||
({ theme, isSelected }) => {
|
||||
return {
|
||||
borderRadius: theme.affine.shape.xsBorderRadius,
|
||||
border: `1px solid ${
|
||||
isSelected ? theme.affine.palette.primary : '#e0e0e0'
|
||||
}`,
|
||||
width: '100%',
|
||||
background: '#f7f7f7',
|
||||
padding: '15px 10px',
|
||||
display: 'flex',
|
||||
marginTop: '10px',
|
||||
marginBottom: '10px',
|
||||
'.file-name': {
|
||||
fontWeight: 600,
|
||||
maxWidth: '100px',
|
||||
display: 'inline-block',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'.file-size': {
|
||||
color: '#666',
|
||||
paddingLeft: '10px',
|
||||
},
|
||||
'.delete': {
|
||||
display: 'none',
|
||||
float: 'right',
|
||||
},
|
||||
'&:hover': {
|
||||
background: '#eee',
|
||||
'.delete': {
|
||||
display: 'inline-block',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
export const FileView: FC<FileView> = ({ block, editor }) => {
|
||||
const [fileUrl, setFileUrl] = useState<string>();
|
||||
const fileInfo = block.getProperty('file') || ({} as FileColumnValue);
|
||||
const file_id = fileInfo.value;
|
||||
|
||||
useEffect(() => {
|
||||
if (file_id) {
|
||||
services.api.file.get(file_id, editor.workspace).then(fileInfo => {
|
||||
setFileUrl(fileInfo.url);
|
||||
});
|
||||
}
|
||||
}, [editor.workspace, file_id]);
|
||||
const [isSelect, setIsSelect] = useState<boolean>();
|
||||
useOnSelect(block.id, (is_select: boolean) => {
|
||||
setIsSelect(is_select);
|
||||
});
|
||||
const down_ref = useRef(null);
|
||||
const onFileChange = async (file: File) => {
|
||||
const result = await services.api.file.create({
|
||||
workspace: editor.workspace,
|
||||
file: file,
|
||||
});
|
||||
setFileUrl(result.url);
|
||||
block.setProperty('file', {
|
||||
value: result.id,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
});
|
||||
};
|
||||
const deleteFile = () => {
|
||||
block.remove();
|
||||
};
|
||||
const downFile = () => {
|
||||
if (down_ref) {
|
||||
down_ref.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{fileUrl ? (
|
||||
// <div>
|
||||
// <DownloadIcon onClick={down_file} className="delete-icon" fontSize="small" sx={{ color: '#fff', cursor: 'pointer', marginRight: '10px' }}></DownloadIcon>
|
||||
// <DeleteSweepOutlinedIcon onClick={delete_file} className="delete-icon" fontSize="small" sx={{ color: '#fff', cursor: 'pointer' }}></DeleteSweepOutlinedIcon>
|
||||
// <a href={file_url} ref={down_ref} style={{ display: 'none' }} download="text.png">
|
||||
//
|
||||
// </a>
|
||||
// </div>
|
||||
<FileViewContainer isSelected={isSelect}>
|
||||
<span onClick={downFile} className="file-name">
|
||||
{fileInfo.name}
|
||||
</span>
|
||||
<span className="file-size"> {fileInfo.size}kb</span>
|
||||
<a
|
||||
href={fileUrl}
|
||||
ref={down_ref}
|
||||
style={{ display: 'none' }}
|
||||
download={fileInfo.name}
|
||||
/>
|
||||
</FileViewContainer>
|
||||
) : (
|
||||
<Upload
|
||||
uploadType={'file'}
|
||||
firstCreate={block.firstCreateFlag}
|
||||
fileChange={onFileChange}
|
||||
deleteFile={deleteFile}
|
||||
defaultAddBtnText={MESSAGES.ADD_AN_FILE}
|
||||
isSelected={isSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
AsyncBlock,
|
||||
BaseView,
|
||||
SelectBlock,
|
||||
} from '@toeverything/framework/virgo';
|
||||
import {
|
||||
FileColumnValue,
|
||||
Protocol,
|
||||
services,
|
||||
} from '@toeverything/datasource/db-service';
|
||||
import { FileView } from './FileView';
|
||||
|
||||
export class FileBlock extends BaseView {
|
||||
public override selectable = true;
|
||||
public override activatable = false;
|
||||
type = Protocol.Block.Type.file;
|
||||
View = FileView;
|
||||
|
||||
override async block2html(
|
||||
block: AsyncBlock,
|
||||
children: SelectBlock[],
|
||||
generateHtml: (el: any[]) => Promise<string>
|
||||
): Promise<string> {
|
||||
const file_property =
|
||||
block.getProperty('file') || ({} as FileColumnValue);
|
||||
const file_id = file_property.value;
|
||||
let file_info = null;
|
||||
if (file_id) {
|
||||
file_info = await services.api.file.get(file_id, block.workspace);
|
||||
}
|
||||
return `<p><a src=${file_info?.url}>${file_property?.name}</p>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { FC, useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { ChildrenView } from '@toeverything/framework/virgo';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { sleep } from '@toeverything/utils';
|
||||
import { GRID_ITEM_MIN_WIDTH, GRID_PROPERTY_KEY, removePercent } from '../grid';
|
||||
|
||||
export const GRID_ITEM_CLASS_NAME = 'grid-item';
|
||||
export const GRID_ITEM_CONTENT_CLASS_NAME = `${GRID_ITEM_CLASS_NAME}-content`;
|
||||
|
||||
export const GridItem: FC<ChildrenView> = function (props) {
|
||||
const { children, block } = props;
|
||||
const RENDER_DELAY_TIME = 100;
|
||||
const ref = useRef<HTMLDivElement>();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (block && ref.current) {
|
||||
block.dom = ref.current;
|
||||
}
|
||||
});
|
||||
|
||||
const setWidth = (width: string) => {
|
||||
const parent = ref.current?.parentElement as HTMLDivElement;
|
||||
parent.style.width = width;
|
||||
};
|
||||
|
||||
const checkAndRefreshWidth = async () => {
|
||||
const currentWidth = block.getProperty(GRID_PROPERTY_KEY);
|
||||
if (currentWidth) {
|
||||
setWidth(currentWidth);
|
||||
} else if (!block.dom?.style.width) {
|
||||
const parent = await block.parent();
|
||||
const children = await parent.children();
|
||||
const length = children.length;
|
||||
/* TODO fix db update time is not controllable */
|
||||
await sleep(RENDER_DELAY_TIME);
|
||||
/* if do not has gridItemWidth means it is a new block ,need set new width */
|
||||
if (!block.getProperty(GRID_PROPERTY_KEY)) {
|
||||
/* only new grid has two grid */
|
||||
if (length <= 2) {
|
||||
block.setProperty(GRID_PROPERTY_KEY, '50%');
|
||||
setWidth('50%');
|
||||
} else {
|
||||
const newBlockLength = Math.floor(100 / length);
|
||||
let totalWidth = newBlockLength;
|
||||
let index = 0;
|
||||
const minus = newBlockLength / (length - 1);
|
||||
setWidth(`${newBlockLength}%`);
|
||||
await block.setProperty(
|
||||
GRID_PROPERTY_KEY,
|
||||
`${newBlockLength}%`
|
||||
);
|
||||
let needFixWidth = 0;
|
||||
for (const child of children) {
|
||||
if (child.id !== block.id) {
|
||||
/* fix other block`s width */
|
||||
const originWidth = Number(
|
||||
removePercent(
|
||||
child.getProperty(GRID_PROPERTY_KEY)
|
||||
)
|
||||
);
|
||||
let newWidth;
|
||||
newWidth = Math.floor(originWidth - minus);
|
||||
/*
|
||||
if new width less then min width,
|
||||
set min width and next block will be fix width
|
||||
*/
|
||||
if (newWidth < GRID_ITEM_MIN_WIDTH) {
|
||||
needFixWidth += GRID_ITEM_MIN_WIDTH - newWidth;
|
||||
newWidth = GRID_ITEM_MIN_WIDTH;
|
||||
}
|
||||
// if can fix width, fix width
|
||||
if (
|
||||
newWidth > GRID_ITEM_MIN_WIDTH &&
|
||||
needFixWidth
|
||||
) {
|
||||
if (
|
||||
newWidth - needFixWidth >=
|
||||
GRID_ITEM_MIN_WIDTH
|
||||
) {
|
||||
newWidth = newWidth - needFixWidth;
|
||||
needFixWidth = 0;
|
||||
} else {
|
||||
needFixWidth =
|
||||
needFixWidth -
|
||||
(newWidth - GRID_ITEM_MIN_WIDTH);
|
||||
newWidth = GRID_ITEM_MIN_WIDTH;
|
||||
}
|
||||
}
|
||||
if (index === children.length - 2) {
|
||||
// the last other block width should be 100% - other totalWidth
|
||||
newWidth = Math.floor(100 - totalWidth);
|
||||
}
|
||||
totalWidth += newWidth;
|
||||
await child.setProperty(
|
||||
GRID_PROPERTY_KEY,
|
||||
`${newWidth}%`
|
||||
);
|
||||
if (child.dom.parentElement) {
|
||||
child.dom.parentElement.style.width = `${newWidth}%`;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkAndRefreshWidth();
|
||||
}, [block]);
|
||||
|
||||
return (
|
||||
<GridItemContainer
|
||||
data-block-id={props.block.id}
|
||||
className={GRID_ITEM_CONTENT_CLASS_NAME}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</GridItemContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const GridItemContainer = styled('div')({
|
||||
transition: 'background-color 0.3s ease-in-out',
|
||||
maxWidth: 'calc(100% - 10px)',
|
||||
padding: '4px',
|
||||
flexGrow: 1,
|
||||
border: '1px solid transparent',
|
||||
borderRadius: '4px',
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { FC } from 'react';
|
||||
import { RenderBlock } from '@toeverything/components/editor-core';
|
||||
import { ChildrenView, CreateView } from '@toeverything/framework/virgo';
|
||||
|
||||
export const GridItemRender = function (creator: FC<ChildrenView>) {
|
||||
const GridItem: FC<CreateView> = function (props) {
|
||||
const { block } = props;
|
||||
const children = (
|
||||
<>
|
||||
{block.childrenIds.map(id => {
|
||||
return <RenderBlock key={id} blockId={id} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
return <>{creator({ ...props, children })}</>;
|
||||
};
|
||||
return GridItem;
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { GridItem, GRID_ITEM_CLASS_NAME } from './GridItem';
|
||||
|
||||
import { Protocol } from '@toeverything/datasource/db-service';
|
||||
import { AsyncBlock, BaseView } from '@toeverything/framework/virgo';
|
||||
import { GridItemRender } from './GridItemRender';
|
||||
|
||||
export class GridItemBlock extends BaseView {
|
||||
public override selectable = false;
|
||||
public override activatable = false;
|
||||
public override allowPendant = false;
|
||||
|
||||
type = Protocol.Block.Type.grid;
|
||||
View = GridItemRender(GridItem);
|
||||
|
||||
override async onCreate(block: AsyncBlock): Promise<AsyncBlock> {
|
||||
return block;
|
||||
}
|
||||
|
||||
override async onDeleteChild(block: AsyncBlock): Promise<boolean> {
|
||||
if (block.childrenIds.length === 0) {
|
||||
await block.remove();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { FC } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { BlockEditor } from '@toeverything/framework/virgo';
|
||||
|
||||
type GridHandleProps = {
|
||||
editor: BlockEditor;
|
||||
onDrag?: (e: MouseEvent) => void;
|
||||
onMouseDown?: React.MouseEventHandler<HTMLDivElement>;
|
||||
blockId: string;
|
||||
enabledAddItem: boolean;
|
||||
draggable: boolean;
|
||||
};
|
||||
|
||||
export const GridHandle: FC<GridHandleProps> = function ({
|
||||
blockId,
|
||||
editor,
|
||||
enabledAddItem,
|
||||
onDrag,
|
||||
onMouseDown,
|
||||
draggable,
|
||||
}) {
|
||||
const [isMouseDown, setIsMouseDown] = useState<boolean>(false);
|
||||
const handleMouseDown: React.MouseEventHandler<HTMLDivElement> = e => {
|
||||
if (draggable) {
|
||||
const cb = (e: MouseEvent) => {
|
||||
onDrag && onDrag(e);
|
||||
};
|
||||
onMouseDown && onMouseDown(e);
|
||||
setIsMouseDown(true);
|
||||
editor.mouseManager.onMouseMove(cb);
|
||||
editor.mouseManager.onMouseupEventOnce(() => {
|
||||
setIsMouseDown(false);
|
||||
editor.mouseManager.offMouseMove(cb);
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleCreateGridItem = async () => {
|
||||
const [, textBlock] =
|
||||
await editor.commands.blockCommands.createGridItem(blockId);
|
||||
if (textBlock) {
|
||||
editor.selectionManager.setActivatedNodeId(textBlock.id);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<GridHandleContainer
|
||||
style={
|
||||
isMouseDown
|
||||
? {
|
||||
backgroundColor: '#3E6FDB',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{enabledAddItem ? (
|
||||
<AddGridHandle
|
||||
onClick={handleCreateGridItem}
|
||||
className="grid-add-handle"
|
||||
>
|
||||
+
|
||||
</AddGridHandle>
|
||||
) : null}
|
||||
</GridHandleContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const GridHandleContainer = styled('div')(({ theme }) => ({
|
||||
position: 'relative',
|
||||
width: '10px',
|
||||
flexGrow: '0',
|
||||
flexShrink: '0',
|
||||
padding: '5px 4px',
|
||||
minHeight: '15px',
|
||||
cursor: 'col-resize',
|
||||
borderRadius: '1px',
|
||||
backgroundClip: 'content-box',
|
||||
' &:hover': {
|
||||
backgroundColor: theme.affine.palette.primary,
|
||||
'.grid-add-handle': {
|
||||
display: 'block',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const AddGridHandle = styled('div')(({ theme }) => ({
|
||||
display: 'none',
|
||||
position: 'absolute',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
height: '6px',
|
||||
width: '6px',
|
||||
borderRadius: '6px',
|
||||
top: '-6px',
|
||||
left: '2px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: theme.affine.palette.menuSeparator,
|
||||
color: theme.affine.palette.menuSeparator,
|
||||
overflow: 'hidden',
|
||||
fontWeight: 'bold',
|
||||
lineHeight: '18px',
|
||||
textAlign: 'center',
|
||||
' &:hover': {
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '10px',
|
||||
backgroundColor: theme.affine.palette.primary,
|
||||
color: theme.affine.palette.white,
|
||||
top: '-20px',
|
||||
left: '-5px',
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,242 @@
|
||||
import { RenderBlock } from '@toeverything/components/editor-core';
|
||||
import { CreateView } from '@toeverything/framework/virgo';
|
||||
import React, { FC, useEffect, useRef, useState } from 'react';
|
||||
import { GridHandle } from './GirdHandle';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {
|
||||
GRID_ITEM_CLASS_NAME,
|
||||
GRID_ITEM_CONTENT_CLASS_NAME,
|
||||
} from '../grid-item/GridItem';
|
||||
import { debounce, domToRect, Point } from '@toeverything/utils';
|
||||
import clsx from 'clsx';
|
||||
import { Protocol } from '@toeverything/datasource/db-service';
|
||||
|
||||
const MAX_ITEM_COUNT = 6;
|
||||
const DB_UPDATE_DELAY = 50;
|
||||
const GRID_ON_DRAG_CLASS = 'grid-layout-on-drag';
|
||||
export const GRID_ITEM_MIN_WIDTH = 100 / MAX_ITEM_COUNT;
|
||||
export const GRID_PROPERTY_KEY = 'gridItemWidth';
|
||||
|
||||
export function removePercent(str: string) {
|
||||
return str.replace('%', '');
|
||||
}
|
||||
|
||||
export const Grid: FC<CreateView> = function (props) {
|
||||
const { block, editor } = props;
|
||||
const [isOnDrag, setIsOnDrag] = useState<boolean>(false);
|
||||
const isSetMouseUp = useRef<boolean>(false);
|
||||
const GridContainerRef = useRef<HTMLDivElement>();
|
||||
const mouseStartPoint = useRef<Point>();
|
||||
const gridItemCountRef = useRef<number>();
|
||||
const originalLeftWidth = useRef<number>(GRID_ITEM_MIN_WIDTH);
|
||||
const originalRightWidth = useRef<number>(GRID_ITEM_MIN_WIDTH);
|
||||
|
||||
const getLeftRightGridItemDomByIndex = (index: number) => {
|
||||
const gridItems = Array.from(GridContainerRef.current?.children).filter(
|
||||
child => {
|
||||
return child.classList.contains(GRID_ITEM_CLASS_NAME);
|
||||
}
|
||||
) as Array<HTMLDivElement>;
|
||||
const leftGrid = gridItems[index];
|
||||
const rightGrid = gridItems[index + 1];
|
||||
return { leftGrid, rightGrid };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
mayBeRefreshGridItemWidth();
|
||||
}, [block.childrenIds]);
|
||||
|
||||
const mayBeRefreshGridItemWidth = async () => {
|
||||
const gridItems = (await block.children()).filter(
|
||||
child => child.type === Protocol.Block.Type.gridItem
|
||||
);
|
||||
if (gridItems.length < gridItemCountRef.current) {
|
||||
let totalWidth = 0;
|
||||
const widthList = [];
|
||||
for (const gridItem of gridItems) {
|
||||
const itemWidth = Number(
|
||||
removePercent(gridItem.getProperty(GRID_PROPERTY_KEY))
|
||||
);
|
||||
totalWidth += itemWidth;
|
||||
widthList.push(itemWidth);
|
||||
}
|
||||
if (totalWidth < 100) {
|
||||
const plus = (100 - totalWidth) / gridItems.length;
|
||||
let totalNewWidth = 0;
|
||||
let index = 0;
|
||||
for (const gridItem of gridItems) {
|
||||
const newWidth = widthList.pop() + plus;
|
||||
let newWidthStr = `${newWidth}%`;
|
||||
if (index === gridItems.length - 1) {
|
||||
newWidthStr = `${100 - totalNewWidth}%`;
|
||||
}
|
||||
if (gridItem.dom) {
|
||||
setItemWidth(
|
||||
gridItem.dom.parentElement as HTMLDivElement,
|
||||
newWidthStr
|
||||
);
|
||||
}
|
||||
await gridItem.setProperty(GRID_PROPERTY_KEY, newWidthStr);
|
||||
totalNewWidth += newWidth;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
gridItemCountRef.current = gridItems.length;
|
||||
};
|
||||
|
||||
const handleMouseDown = (
|
||||
e: React.MouseEvent<HTMLDivElement>,
|
||||
index: number
|
||||
) => {
|
||||
mouseStartPoint.current = new Point(e.clientX, e.clientY);
|
||||
const { leftGrid, rightGrid } = getLeftRightGridItemDomByIndex(index);
|
||||
originalLeftWidth.current = domToRect(leftGrid).width;
|
||||
originalRightWidth.current = domToRect(rightGrid).width;
|
||||
// disable the default behavior of the drag event (selection about)
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const updateDbWidth = debounce(
|
||||
async (
|
||||
leftBlockId: string,
|
||||
leftWidth: string,
|
||||
rightBlockId: string,
|
||||
rightWidth: string
|
||||
) => {
|
||||
const leftBlock = await editor.getBlockById(leftBlockId);
|
||||
const rightBlock = await editor.getBlockById(rightBlockId);
|
||||
leftBlock?.setProperty(GRID_PROPERTY_KEY, leftWidth);
|
||||
rightBlock?.setProperty(GRID_PROPERTY_KEY, rightWidth);
|
||||
},
|
||||
DB_UPDATE_DELAY
|
||||
);
|
||||
|
||||
const setItemWidth = (itemDom: HTMLDivElement, width: string) => {
|
||||
itemDom.style.width = width;
|
||||
};
|
||||
|
||||
const handleDragGrid = (e: MouseEvent, index: number) => {
|
||||
setIsOnDrag(true);
|
||||
window.getSelection().removeAllRanges();
|
||||
if (!isSetMouseUp.current) {
|
||||
isSetMouseUp.current = true;
|
||||
editor.mouseManager.onMouseupEventOnce(() => {
|
||||
setIsOnDrag(false);
|
||||
isSetMouseUp.current = false;
|
||||
originalLeftWidth.current = GRID_ITEM_MIN_WIDTH;
|
||||
originalRightWidth.current = GRID_ITEM_MIN_WIDTH;
|
||||
mouseStartPoint.current = null;
|
||||
});
|
||||
} else {
|
||||
const { leftGrid, rightGrid } =
|
||||
getLeftRightGridItemDomByIndex(index);
|
||||
const leftBlockId = block.childrenIds[index];
|
||||
const rightBlockId = block.childrenIds[index + 1];
|
||||
if (
|
||||
leftGrid &&
|
||||
rightGrid &&
|
||||
mouseStartPoint.current &&
|
||||
GridContainerRef.current
|
||||
) {
|
||||
const currentMousePoint = new Point(e.clientX, e.clientY);
|
||||
const totalWidth =
|
||||
Number(removePercent(leftGrid.style.width)) +
|
||||
Number(removePercent(rightGrid.style.width));
|
||||
const containerWidth = domToRect(
|
||||
GridContainerRef.current
|
||||
).width;
|
||||
const xDistance =
|
||||
mouseStartPoint.current.xDistance(currentMousePoint);
|
||||
const newLeftWidth = originalLeftWidth.current - xDistance;
|
||||
let newLeftPercent = (newLeftWidth / containerWidth) * 100;
|
||||
let newRightPercent = Number(totalWidth) - newLeftPercent;
|
||||
if (newLeftPercent < GRID_ITEM_MIN_WIDTH) {
|
||||
newLeftPercent = GRID_ITEM_MIN_WIDTH;
|
||||
newRightPercent = totalWidth - GRID_ITEM_MIN_WIDTH;
|
||||
} else if (newRightPercent < GRID_ITEM_MIN_WIDTH) {
|
||||
newRightPercent = GRID_ITEM_MIN_WIDTH;
|
||||
newLeftPercent = totalWidth - GRID_ITEM_MIN_WIDTH;
|
||||
}
|
||||
//XXX first change dom style is for animation speed, maybe not a good idea
|
||||
const newLeft = `${newLeftPercent}%`;
|
||||
const newRight = `${newRightPercent}%`;
|
||||
setItemWidth(leftGrid, newLeft);
|
||||
setItemWidth(rightGrid, newRight);
|
||||
updateDbWidth(leftBlockId, newLeft, rightBlockId, newRight);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const children = (
|
||||
<>
|
||||
{block.childrenIds.map((id, i) => {
|
||||
return (
|
||||
<GridItem
|
||||
style={{
|
||||
transition: isOnDrag
|
||||
? 'none'
|
||||
: 'width 0.2s ease-in-out',
|
||||
}}
|
||||
key={id}
|
||||
className={GRID_ITEM_CLASS_NAME}
|
||||
>
|
||||
<RenderBlock hasContainer={false} blockId={id} />
|
||||
<GridHandle
|
||||
onDrag={event => handleDragGrid(event, i)}
|
||||
editor={editor}
|
||||
onMouseDown={event => handleMouseDown(event, i)}
|
||||
blockId={id}
|
||||
enabledAddItem={
|
||||
block.childrenIds.length < MAX_ITEM_COUNT
|
||||
}
|
||||
draggable={i !== block.childrenIds.length - 1}
|
||||
/>
|
||||
</GridItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GridContainer
|
||||
className={clsx({ [GRID_ON_DRAG_CLASS]: isOnDrag })}
|
||||
ref={GridContainerRef}
|
||||
>
|
||||
{children}
|
||||
</GridContainer>
|
||||
{isOnDrag
|
||||
? ReactDOM.createPortal(<GridMask />, window.document.body)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const GridContainer = styled('div')({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
minWidth: `${GRID_ITEM_MIN_WIDTH}%`,
|
||||
[`&:hover .${GRID_ITEM_CONTENT_CLASS_NAME}`]: {
|
||||
backgroundColor: 'rgba(100, 106, 115, 0.05)',
|
||||
},
|
||||
});
|
||||
|
||||
const GridMask = styled('div')({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 9999,
|
||||
cursor: 'col-resize',
|
||||
pointerEvents: 'all',
|
||||
});
|
||||
|
||||
const GridItem = styled('div')({
|
||||
display: 'flex',
|
||||
flexShrink: 0,
|
||||
flexGrow: 0,
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { FC } from 'react';
|
||||
import { CreateView } from '@toeverything/framework/virgo';
|
||||
import { BlockContainer } from '../../components/BlockContainer';
|
||||
|
||||
export function GridRender(creator: FC<CreateView>) {
|
||||
return function GridWithItem(props: CreateView) {
|
||||
const { editor, block } = props;
|
||||
return (
|
||||
<BlockContainer editor={editor} block={block} selected={false}>
|
||||
{creator({ ...props })}
|
||||
</BlockContainer>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Grid } from './Grid';
|
||||
import { Protocol } from '@toeverything/datasource/db-service';
|
||||
import { AsyncBlock, BaseView } from '@toeverything/framework/virgo';
|
||||
import { GridItem } from '../grid-item/GridItem';
|
||||
import { GridRender } from './GridRender';
|
||||
export { GRID_ITEM_MIN_WIDTH, GRID_PROPERTY_KEY, removePercent } from './Grid';
|
||||
|
||||
export class GridBlock extends BaseView {
|
||||
public override selectable = false;
|
||||
public override activatable = false;
|
||||
public override allowPendant = false;
|
||||
|
||||
type = Protocol.Block.Type.grid;
|
||||
View = GridRender(Grid);
|
||||
|
||||
override ChildrenView = GridItem;
|
||||
|
||||
override async onCreate(block: AsyncBlock): Promise<AsyncBlock> {
|
||||
return block;
|
||||
}
|
||||
|
||||
override async onDeleteChild(block: AsyncBlock): Promise<boolean> {
|
||||
if (block.childrenIds.length === 1) {
|
||||
// the children of grid will always be a grid item
|
||||
const firstChildren = await block.childAt(0);
|
||||
const itemChildren = await firstChildren.children();
|
||||
const beforeBlock = await block.previousSibling();
|
||||
if (beforeBlock) {
|
||||
await beforeBlock.after(...itemChildren);
|
||||
} else {
|
||||
const parent = await block.parent();
|
||||
if (parent) {
|
||||
await parent.prepend(...itemChildren);
|
||||
}
|
||||
}
|
||||
return block.remove();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { withRecastTable } from '@toeverything/components/editor-core';
|
||||
import { Protocol } from '@toeverything/datasource/db-service';
|
||||
import {
|
||||
AsyncBlock,
|
||||
BaseView,
|
||||
SelectBlock,
|
||||
} from '@toeverything/framework/virgo';
|
||||
import { GroupView } from './GroupView';
|
||||
|
||||
export class Group extends BaseView {
|
||||
public override selectable = true;
|
||||
public override activatable = false;
|
||||
public override allowPendant = false;
|
||||
|
||||
type = Protocol.Block.Type.group;
|
||||
View = withRecastTable(GroupView);
|
||||
|
||||
override async onDeleteChild(block: AsyncBlock): Promise<boolean> {
|
||||
if (block.childrenIds.length === 0) {
|
||||
await block.remove();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
override async onCreate(block: AsyncBlock): Promise<AsyncBlock> {
|
||||
return block;
|
||||
}
|
||||
|
||||
override async block2html(
|
||||
block: AsyncBlock,
|
||||
children: SelectBlock[],
|
||||
generateHtml: (el: any[]) => Promise<string>
|
||||
): Promise<string> {
|
||||
const content = await generateHtml(children);
|
||||
return `<div>${content}<div>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
mergeToPreviousGroup,
|
||||
RecastScene,
|
||||
useRecastBlockScene,
|
||||
useRecastKanbanGroupBy,
|
||||
} from '@toeverything/components/editor-core';
|
||||
import { Popover, useTheme, Tooltip } from '@toeverything/components/ui';
|
||||
import type { AsyncBlock, BlockEditor } from '@toeverything/framework/virgo';
|
||||
import { Filter } from './components/filter';
|
||||
import { Sorter } from './components/sorter';
|
||||
import { GroupPanel } from './components/group-panel/GroupPanel';
|
||||
import { IconButton } from './components/IconButton';
|
||||
import { Line } from './components/Line';
|
||||
import {
|
||||
TodoListIcon,
|
||||
KanBanIcon,
|
||||
TableIcon,
|
||||
FilterIcon,
|
||||
SortIcon,
|
||||
FullScreenIcon,
|
||||
GroupIcon,
|
||||
GroupByIcon,
|
||||
} from '@toeverything/components/icons';
|
||||
import { PANEL_CONFIG, SCENE_CONFIG } from './config';
|
||||
|
||||
import type { ActivePanel } from './types';
|
||||
import { useFlag } from '@toeverything/datasource/feature-flags';
|
||||
import { GroupBy } from './components/group-by/GroupBy';
|
||||
|
||||
const GroupMenuWrapper = ({
|
||||
block,
|
||||
editor,
|
||||
children,
|
||||
}: {
|
||||
block: AsyncBlock;
|
||||
editor: BlockEditor;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
/* feature flag: sprint14 close Filter、Sort feature */
|
||||
const filterSorterFlag = useFlag('FilterSorter', false);
|
||||
const { scene, setPage, setKanban, setTable } = useRecastBlockScene();
|
||||
const { groupBy } = useRecastKanbanGroupBy();
|
||||
const theme = useTheme();
|
||||
|
||||
/* state: add active-style */
|
||||
const [activePanel, setActivePanel] = useState<ActivePanel>(
|
||||
PANEL_CONFIG.GROUP_BY
|
||||
);
|
||||
/* state: open panel */
|
||||
const [openPanel, setOpenPanel] = useState<ActivePanel>(null);
|
||||
|
||||
/**
|
||||
* close panel state/active state
|
||||
*/
|
||||
const closePanel = () => {
|
||||
if (!groupBy?.id) {
|
||||
setActivePanel(null);
|
||||
}
|
||||
|
||||
setOpenPanel(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* change open panel: if target is current, close panel; else change
|
||||
* update active panel
|
||||
* @param panel
|
||||
*/
|
||||
const onPanelChange = (panel: ActivePanel) => {
|
||||
if (openPanel === panel) {
|
||||
return closePanel();
|
||||
}
|
||||
|
||||
setActivePanel(panel);
|
||||
setOpenPanel(panel);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
placement="top-end"
|
||||
zIndex={theme.affine.zIndex.header - 1}
|
||||
content={
|
||||
<GroupPanel>
|
||||
<IconButton
|
||||
active={scene === SCENE_CONFIG.PAGE}
|
||||
onClick={setPage}
|
||||
>
|
||||
<TodoListIcon fontSize="small" />
|
||||
<span>Text View</span>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
active={scene === SCENE_CONFIG.KANBAN}
|
||||
onClick={setKanban}
|
||||
>
|
||||
<KanBanIcon fontSize="small" />
|
||||
Kanban
|
||||
</IconButton>
|
||||
{
|
||||
// // Closed beta period temporarily
|
||||
// filterSorterFlag && (
|
||||
// <IconButton
|
||||
// active={scene === SCENE_CONFIG.TABLE}
|
||||
// onClick={setTable}
|
||||
// >
|
||||
// <TableIcon fontSize="small" />
|
||||
// Table View
|
||||
// </IconButton>
|
||||
// )
|
||||
<Tooltip content={'Comming Soon'} placement="top">
|
||||
<IconButton
|
||||
active={scene === SCENE_CONFIG.TABLE}
|
||||
style={{ cursor: 'not-allowed' }}
|
||||
>
|
||||
<TableIcon fontSize="small" />
|
||||
Table
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
<Tooltip content={'Comming Soon'} placement="top">
|
||||
<IconButton
|
||||
active={scene === SCENE_CONFIG.TABLE}
|
||||
style={{ cursor: 'not-allowed' }}
|
||||
>
|
||||
<KanBanIcon fontSize="small" />
|
||||
Calendar
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip content={'Comming Soon'} placement="top">
|
||||
<IconButton
|
||||
active={scene === SCENE_CONFIG.TABLE}
|
||||
style={{ cursor: 'not-allowed' }}
|
||||
>
|
||||
<TableIcon fontSize="small" />
|
||||
Timeline
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip content={'Comming Soon'} placement="top">
|
||||
<IconButton
|
||||
active={scene === SCENE_CONFIG.TABLE}
|
||||
style={{ cursor: 'not-allowed' }}
|
||||
>
|
||||
<KanBanIcon fontSize="small" />
|
||||
BI
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{
|
||||
// Closed beta period temporarily
|
||||
filterSorterFlag && (
|
||||
<IconButton
|
||||
active={activePanel === PANEL_CONFIG.FILTER}
|
||||
extraStyle={{ position: 'relative' }}
|
||||
onClick={() =>
|
||||
onPanelChange(PANEL_CONFIG.FILTER)
|
||||
}
|
||||
>
|
||||
<FilterIcon fontSize="small" />
|
||||
Filter
|
||||
{openPanel === PANEL_CONFIG.FILTER && (
|
||||
<Filter
|
||||
block={block}
|
||||
closePanel={closePanel}
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
// Closed beta period temporarily
|
||||
filterSorterFlag &&
|
||||
currentView.type === RecastScene.Kanban && (
|
||||
<IconButton
|
||||
active={activePanel === PANEL_CONFIG.SORTER}
|
||||
onClick={() =>
|
||||
onPanelChange(PANEL_CONFIG.SORTER)
|
||||
}
|
||||
>
|
||||
<SortIcon fontSize="small" />
|
||||
Sort
|
||||
{openPanel === PANEL_CONFIG.SORTER && (
|
||||
<Sorter
|
||||
block={block}
|
||||
closePanel={closePanel}
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
|
||||
{scene === RecastScene.Kanban && (
|
||||
<IconButton
|
||||
active={activePanel === PANEL_CONFIG.GROUP_BY}
|
||||
onClick={() => onPanelChange(PANEL_CONFIG.GROUP_BY)}
|
||||
>
|
||||
<GroupByIcon fontSize="small" />
|
||||
GroupBy
|
||||
{openPanel === PANEL_CONFIG.GROUP_BY && (
|
||||
<GroupBy closePanel={closePanel} />
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{filterSorterFlag && <Line />}
|
||||
|
||||
{
|
||||
// Closed beta period temporarily
|
||||
filterSorterFlag && (
|
||||
<IconButton extraStyle={{ width: 32 }}>
|
||||
<FullScreenIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
// Closed beta period temporarily
|
||||
filterSorterFlag && (
|
||||
<IconButton
|
||||
extraStyle={{ width: 32 }}
|
||||
onClick={async () => {
|
||||
await mergeToPreviousGroup(block);
|
||||
}}
|
||||
>
|
||||
<GroupIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
</GroupPanel>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export { GroupMenuWrapper };
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
addNewGroup,
|
||||
RecastScene,
|
||||
useOnSelect,
|
||||
useRecastBlockScene,
|
||||
} from '@toeverything/components/editor-core';
|
||||
import { SceneKanban } from './scene-kanban';
|
||||
import { ScenePage } from './ScenePage';
|
||||
import { SceneTable } from './SceneTable';
|
||||
import { GroupMenuWrapper } from './GroupMenu';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { useState } from 'react';
|
||||
import type { FC, ComponentType } from 'react';
|
||||
import type { CreateView } from '@toeverything/framework/virgo';
|
||||
|
||||
const SceneMap: Record<RecastScene, ComponentType<CreateView>> = {
|
||||
page: ScenePage,
|
||||
table: SceneTable,
|
||||
kanban: SceneKanban,
|
||||
whiteboard: ScenePage,
|
||||
} as const;
|
||||
|
||||
const GroupBox = styled('div')(({ theme }) => {
|
||||
return {
|
||||
'&:hover': {
|
||||
// Workaround referring to other components
|
||||
// See https://emotion.sh/docs/styled#targeting-another-emotion-component
|
||||
// [GroupActionWrapper.toString()]: {},
|
||||
'& > *': {
|
||||
visibility: 'visible',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const GroupActionWrapper = styled('div')(({ theme }) => ({
|
||||
height: '30px',
|
||||
display: 'flex',
|
||||
visibility: 'hidden',
|
||||
fontSize: theme.affine.typography.xs.fontSize,
|
||||
color: theme.affine.palette.icons,
|
||||
'.line': {
|
||||
flex: 1,
|
||||
height: '15px',
|
||||
borderBottom: `1px solid ${theme.affine.palette.icons}`,
|
||||
},
|
||||
'.add-button': {
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
// width: '130px',
|
||||
padding: '0 20px',
|
||||
height: '30px',
|
||||
lineHeight: '30px',
|
||||
span: {
|
||||
paddingRight: '10px',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const GroupContainer = styled('div')<{ isSelect?: boolean }>(
|
||||
({ isSelect, theme }) => ({
|
||||
background: theme.affine.palette.white,
|
||||
border: '2px solid #ECF1FB',
|
||||
boxShadow: isSelect
|
||||
? '0px 0px 5px 5px rgba(98, 137, 255, 0.25), 0px 0px 5px 5px #E3EAFF;'
|
||||
: '#none',
|
||||
padding: '15px 12px',
|
||||
borderRadius: '10px',
|
||||
'&:hover': {
|
||||
// borderColor: 'none',
|
||||
boxShadow: '0px 1px 10px rgb(152 172 189 / 60%)',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export const GroupView: FC<CreateView> = props => {
|
||||
const { block, editor } = props;
|
||||
const { scene } = useRecastBlockScene();
|
||||
const [groupIsSelect, setGroupIsSelect] = useState(false);
|
||||
|
||||
useOnSelect(block.id, (groupIsSelect: boolean) => {
|
||||
setGroupIsSelect(groupIsSelect);
|
||||
});
|
||||
|
||||
const addGroup = async () => {
|
||||
addNewGroup(editor, block, true);
|
||||
};
|
||||
|
||||
const View = SceneMap[scene];
|
||||
if (!View) {
|
||||
return <>Group scene not found: {scene}!</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupMenuWrapper block={block} editor={editor}>
|
||||
<GroupBox>
|
||||
<GroupContainer isSelect={groupIsSelect}>
|
||||
<View {...props} />
|
||||
</GroupContainer>
|
||||
|
||||
{editor.isWhiteboard ? null : (
|
||||
<GroupAction onAddGroup={addGroup} />
|
||||
)}
|
||||
</GroupBox>
|
||||
</GroupMenuWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const GroupAction = ({ onAddGroup }: { onAddGroup: () => void }) => {
|
||||
return (
|
||||
<GroupActionWrapper>
|
||||
<div className="line" />
|
||||
<div className="add-button" onClick={onAddGroup}>
|
||||
<span>+</span>
|
||||
<span>Add New Group Here</span>
|
||||
</div>
|
||||
<div className="line" />
|
||||
</GroupActionWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { RenderBlockChildren } from '@toeverything/components/editor-core';
|
||||
import type { CreateView } from '@toeverything/framework/virgo';
|
||||
import { FC } from 'react';
|
||||
|
||||
export const ScenePage: FC<CreateView> = ({ block }) => {
|
||||
return <RenderBlockChildren block={block} />;
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { FC } from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import type { DefaultColumnsValue } from '@toeverything/datasource/db-service';
|
||||
import type { CreateView } from '@toeverything/framework/virgo';
|
||||
import type { TableColumn, TableRow } from '../../components/table';
|
||||
import { Table, CustomCell } from '../../components/table';
|
||||
|
||||
export const SceneTable: FC<CreateView> = ({ block, columns, editor }) => {
|
||||
const [rows, set_rows] = useState<TableRow[]>([]);
|
||||
const data_columns = useMemo<TableColumn[]>(() => {
|
||||
return (columns || [])
|
||||
.map(column => {
|
||||
if (column.innerColumn) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
dataKey: column.key,
|
||||
label: column.name,
|
||||
type: column.type,
|
||||
width: 150,
|
||||
columnConfig: column,
|
||||
};
|
||||
})
|
||||
.filter(c => Boolean(c));
|
||||
}, [columns]);
|
||||
|
||||
useEffect(() => {
|
||||
const get_rows = async () => {
|
||||
const children = await block.children();
|
||||
const children_rows = children.map(child => {
|
||||
const properties = child.getProperties();
|
||||
return {
|
||||
id: child.id,
|
||||
...properties,
|
||||
text: properties?.text?.value?.[0]?.text,
|
||||
};
|
||||
});
|
||||
set_rows(children_rows);
|
||||
};
|
||||
get_rows();
|
||||
}, [block]);
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', maxHeight: '500px' }}>
|
||||
<Table
|
||||
rows={rows}
|
||||
columns={data_columns}
|
||||
rowKey="id"
|
||||
renderCell={props => {
|
||||
return (
|
||||
<CustomCell
|
||||
{...props}
|
||||
onChange={async value => {
|
||||
const block = await editor.getBlockById(
|
||||
value.row['id'] as string
|
||||
);
|
||||
if (block) {
|
||||
await block.setProperty(
|
||||
props.valueKey as keyof DefaultColumnsValue,
|
||||
value.value as DefaultColumnsValue[keyof DefaultColumnsValue]
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export const IconButton = styled('div')<{
|
||||
extraStyle?: CSSProperties;
|
||||
active?: boolean;
|
||||
}>(({ extraStyle, active }) => ({
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '6px 12px',
|
||||
cursor: 'pointer',
|
||||
color: '#98ACBD',
|
||||
fontSize: 14,
|
||||
...extraStyle,
|
||||
|
||||
'& > svg': {
|
||||
marginRight: 4,
|
||||
},
|
||||
'&:hover': {
|
||||
background: '#F5F7F8',
|
||||
borderRadius: 5,
|
||||
},
|
||||
...(active && {
|
||||
background: '#F5F7F8',
|
||||
borderRadius: 5,
|
||||
color: '#3E6FDB',
|
||||
}),
|
||||
}));
|
||||
@@ -0,0 +1,8 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
export const Line = styled('div')({
|
||||
width: 0,
|
||||
height: 32,
|
||||
border: '1px solid #E0E6EB',
|
||||
margin: '0px 6px',
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import type { ReactNode, MouseEvent, CSSProperties } from 'react';
|
||||
|
||||
const StyledPanel = styled('div')<{ extraStyle?: CSSProperties }>(
|
||||
({ extraStyle }) => ({
|
||||
position: 'absolute',
|
||||
top: 50,
|
||||
background: '#FFFFFF',
|
||||
boxShadow: '0px 1px 10px rgba(152, 172, 189, 0.6)',
|
||||
borderRadius: 10,
|
||||
padding: '12px 24px',
|
||||
...extraStyle,
|
||||
})
|
||||
);
|
||||
|
||||
const Panel = ({
|
||||
children,
|
||||
extraStyle,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
extraStyle?: CSSProperties;
|
||||
}) => {
|
||||
const stopPropagation = (e: MouseEvent<HTMLDivElement>) =>
|
||||
e.stopPropagation();
|
||||
|
||||
return (
|
||||
<StyledPanel style={extraStyle} onClick={stopPropagation}>
|
||||
{children}
|
||||
</StyledPanel>
|
||||
);
|
||||
};
|
||||
|
||||
export { Panel };
|
||||
@@ -0,0 +1,16 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
const StyledTitle = styled('div')({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '4px 8px',
|
||||
fontSize: 16,
|
||||
lineHeight: '22px',
|
||||
'& > div': {
|
||||
marginRight: 4,
|
||||
},
|
||||
color: '#3A4C5C',
|
||||
fontWeight: 400,
|
||||
});
|
||||
|
||||
export { StyledTitle as Title };
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
CheckBoxCheckIcon,
|
||||
CheckBoxUncheckIcon,
|
||||
} from '@toeverything/components/icons';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import type { CheckboxProps } from './types';
|
||||
|
||||
const StyledCheckbox = styled('div')({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
const Checkbox = ({ value, onChange }: CheckboxProps) => {
|
||||
return (
|
||||
<StyledCheckbox onClick={onChange}>
|
||||
{value ? (
|
||||
<CheckBoxCheckIcon />
|
||||
) : (
|
||||
<CheckBoxUncheckIcon style={{ color: '#4C6275' }} />
|
||||
)}
|
||||
</StyledCheckbox>
|
||||
);
|
||||
};
|
||||
|
||||
export { Checkbox };
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState } from 'react';
|
||||
import { TextareaGroup } from './TextareaGroup';
|
||||
import { Panel } from '../Panel';
|
||||
import { Title } from '../Title';
|
||||
import { HelpCenterIcon } from '@toeverything/components/icons';
|
||||
import { MODE_CONFIG } from './config/filter-mode-config';
|
||||
import { FilterGroup } from './FilterGroup';
|
||||
import { FilterContext } from './context/filter-context';
|
||||
import type { ClosePanel } from '../../types';
|
||||
import type { PanelMode } from './types';
|
||||
import type { AsyncBlock } from '@toeverything/framework/virgo';
|
||||
|
||||
export const Filter = ({
|
||||
closePanel,
|
||||
block,
|
||||
}: {
|
||||
closePanel: ClosePanel;
|
||||
block: AsyncBlock;
|
||||
}) => {
|
||||
const [mode, setMode] = useState<PanelMode>(MODE_CONFIG.NORMAL);
|
||||
|
||||
/**
|
||||
* switch mode:weak-sql or filter-group
|
||||
*/
|
||||
const switchMode = () => {
|
||||
setMode(
|
||||
mode === MODE_CONFIG.NORMAL
|
||||
? MODE_CONFIG.WEAK_SQL
|
||||
: MODE_CONFIG.NORMAL
|
||||
);
|
||||
};
|
||||
|
||||
/* remain:probably won't use */
|
||||
const makeView = () => {
|
||||
/* create a view */
|
||||
closePanel();
|
||||
};
|
||||
|
||||
const context = {
|
||||
mode,
|
||||
switchMode,
|
||||
makeView,
|
||||
block,
|
||||
};
|
||||
|
||||
return (
|
||||
<FilterContext.Provider value={context}>
|
||||
<Panel>
|
||||
<Title>
|
||||
<div>Filter</div>
|
||||
<HelpCenterIcon fontSize="small" />
|
||||
</Title>
|
||||
{mode === MODE_CONFIG.NORMAL ? (
|
||||
<FilterGroup />
|
||||
) : (
|
||||
<TextareaGroup />
|
||||
)}
|
||||
</Panel>
|
||||
</FilterContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useContext } from 'react';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { SorterItem } from './SelectItem';
|
||||
import { HandleGroup } from './HandleGroup';
|
||||
import { Introduce } from '../sorter/Introduce';
|
||||
import {
|
||||
FILTER_GROUP_CHANGE_TYPES,
|
||||
ruleMap,
|
||||
} from './config/filter-group-config';
|
||||
import { FilterContext } from './context/filter-context';
|
||||
import { makeFilterConstraint2Map } from './helper';
|
||||
import type {
|
||||
Constraint,
|
||||
ConstraintKey,
|
||||
Context,
|
||||
OnConstraintChange,
|
||||
} from './types';
|
||||
import type { Column } from '@toeverything/datasource/db-service';
|
||||
|
||||
const StyledFilterGroup = styled('div')({
|
||||
marginTop: 16,
|
||||
width: 684,
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
const FilterGroup = () => {
|
||||
const { block } = useContext<Context>(FilterContext);
|
||||
const fieldOption = (block.columns as Column[])?.filter(
|
||||
({ innerColumn }) => !innerColumn
|
||||
);
|
||||
const filterConstraintMap = makeFilterConstraint2Map(
|
||||
block.getProperties()?.filterConstraint || []
|
||||
);
|
||||
|
||||
/**
|
||||
* update filter constraints
|
||||
* @param type
|
||||
* @param deleteKey
|
||||
* @param newData
|
||||
*/
|
||||
const onConstraintChange: OnConstraintChange = async ({
|
||||
type,
|
||||
deleteKey,
|
||||
newData = [],
|
||||
}) => {
|
||||
const newFilterConstraint = new Map([...filterConstraintMap]);
|
||||
const [key, value] = newData;
|
||||
|
||||
switch (type) {
|
||||
case FILTER_GROUP_CHANGE_TYPES.RULE_ADD:
|
||||
case FILTER_GROUP_CHANGE_TYPES.CHECK_CHANGE:
|
||||
case FILTER_GROUP_CHANGE_TYPES.OP_CHANGE:
|
||||
case FILTER_GROUP_CHANGE_TYPES.VALUE_CHANGE: {
|
||||
newFilterConstraint.set(key, value);
|
||||
break;
|
||||
}
|
||||
case FILTER_GROUP_CHANGE_TYPES.RULE_DELETE: {
|
||||
newFilterConstraint.delete(deleteKey);
|
||||
break;
|
||||
}
|
||||
case FILTER_GROUP_CHANGE_TYPES.KEY_CHANGE: {
|
||||
newFilterConstraint.delete(deleteKey);
|
||||
newFilterConstraint.set(key, value);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`group_change_action.type error: ${type}`);
|
||||
}
|
||||
|
||||
await block.setProperties({
|
||||
filterConstraint: Array.from(newFilterConstraint.values()),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* add filter item
|
||||
*/
|
||||
const addRule = async () => {
|
||||
for (const option of fieldOption) {
|
||||
const { key, type } = option;
|
||||
if (!filterConstraintMap.has(key)) {
|
||||
await onConstraintChange({
|
||||
type: FILTER_GROUP_CHANGE_TYPES.RULE_ADD,
|
||||
newData: [
|
||||
key,
|
||||
{
|
||||
key,
|
||||
type,
|
||||
fieldValue: key,
|
||||
checked: true,
|
||||
opSelectValue: Object.values(ruleMap.get(type))?.[0]
|
||||
?.value,
|
||||
valueSelectValue: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* translate Map to Array for render */
|
||||
const filterConstraints: Array<[ConstraintKey, Constraint]> = Array.from(
|
||||
filterConstraintMap.entries()
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledFilterGroup>
|
||||
{filterConstraints.length ? (
|
||||
filterConstraints.map(constraint => {
|
||||
const key = constraint[0];
|
||||
return (
|
||||
<SorterItem
|
||||
key={key}
|
||||
constraint={constraint}
|
||||
constraints={filterConstraintMap}
|
||||
fieldOptions={fieldOption}
|
||||
onConstraintChange={onConstraintChange}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Introduce />
|
||||
)}
|
||||
</StyledFilterGroup>
|
||||
<HandleGroup addRule={addRule} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { FilterGroup };
|
||||
@@ -0,0 +1,62 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { useContext } from 'react';
|
||||
import { FilterContext } from './context/filter-context';
|
||||
import { MODE_CONFIG } from './config/filter-mode-config';
|
||||
import { AddViewIcon } from '@toeverything/components/icons';
|
||||
import { IconBtn } from './IconBtn';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
const StyledBtn = styled('div')<{ extraStyle?: CSSProperties }>(
|
||||
({ extraStyle }) => {
|
||||
return {
|
||||
padding: '6px 12px',
|
||||
border: '1px solid #E0E6EB',
|
||||
borderRadius: 5,
|
||||
fontSize: 12,
|
||||
lineHeight: '18px',
|
||||
...extraStyle,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const StyledBtnPanel = styled('div')({
|
||||
display: 'flex',
|
||||
});
|
||||
|
||||
const StyledButtonGroup = styled('div')({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: 12,
|
||||
});
|
||||
|
||||
interface Props {
|
||||
addRule?: () => void;
|
||||
confirm?: () => void;
|
||||
}
|
||||
|
||||
const HandleGroup = (props: Props) => {
|
||||
const { mode, switchMode, makeView } = useContext(FilterContext);
|
||||
const { addRule, confirm } = props;
|
||||
|
||||
return (
|
||||
<StyledButtonGroup>
|
||||
{mode === MODE_CONFIG.NORMAL ? (
|
||||
<IconBtn Icon={AddViewIcon} text="Add Rule" onClick={addRule} />
|
||||
) : (
|
||||
<StyledBtn onClick={confirm}>Confirm</StyledBtn>
|
||||
)}
|
||||
<StyledBtnPanel>
|
||||
<StyledBtn
|
||||
extraStyle={{ marginRight: 10 }}
|
||||
onClick={switchMode}
|
||||
>
|
||||
Switch to Filter Panel
|
||||
</StyledBtn>
|
||||
<StyledBtn onClick={makeView}>Make Panel</StyledBtn>
|
||||
</StyledBtnPanel>
|
||||
</StyledButtonGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export { HandleGroup };
|
||||
@@ -0,0 +1,33 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import type { FC } from 'react';
|
||||
import type { SvgIconProps } from '@toeverything/components/ui';
|
||||
|
||||
interface Props {
|
||||
Icon: FC<SvgIconProps>;
|
||||
text?: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const StyledAddSort = styled('div')({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '6px 8px',
|
||||
'& svg': {
|
||||
marginRight: 4,
|
||||
},
|
||||
fontSize: 12,
|
||||
color: '#3A4C5C',
|
||||
});
|
||||
|
||||
const IconBtn = (props: Props) => {
|
||||
const { Icon, text, onClick } = props;
|
||||
|
||||
return (
|
||||
<StyledAddSort onClick={onClick}>
|
||||
<Icon fontSize="small" />
|
||||
<span>{text}</span>
|
||||
</StyledAddSort>
|
||||
);
|
||||
};
|
||||
|
||||
export { IconBtn };
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import {
|
||||
styled,
|
||||
autocompleteClasses,
|
||||
useAutocomplete,
|
||||
} from '@toeverything/components/ui';
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { ValueOption } from './types';
|
||||
import * as React from 'react';
|
||||
|
||||
const ListBox = styled('ul')`
|
||||
width: 300px;
|
||||
margin: 2px 0 0;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
list-style: none;
|
||||
background-color: #fff;
|
||||
overflow: auto;
|
||||
max-height: 250px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1;
|
||||
|
||||
& li {
|
||||
padding: 5px 12px;
|
||||
display: flex;
|
||||
|
||||
& span {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
& svg {
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
& li[aria-selected='true'] {
|
||||
background-color: #fafafa;
|
||||
font-weight: 600;
|
||||
|
||||
& svg {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
& li.${autocompleteClasses.focused} {
|
||||
background-color: #e6f7ff;
|
||||
cursor: pointer;
|
||||
|
||||
& svg {
|
||||
color: currentColor;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const InputWrapper = styled('div')`
|
||||
min-width: 300px;
|
||||
min-height: 32px;
|
||||
border: 1px solid #d9d9d9;
|
||||
background-color: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 1px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 12px 0 6px;
|
||||
|
||||
&:hover {
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
&.focused {
|
||||
border-color: #40a9ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
|
||||
& input {
|
||||
background-color: #fff;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
box-sizing: border-box;
|
||||
padding: 0 6px;
|
||||
width: 0;
|
||||
min-width: 30px;
|
||||
flex-grow: 1;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const Tag = (props: {
|
||||
label: string;
|
||||
onDelete: (event: MouseEvent<SVGSVGElement>) => void;
|
||||
}) => {
|
||||
const { label, onDelete, ...other } = props;
|
||||
return (
|
||||
<div {...other}>
|
||||
<span>{label}</span>
|
||||
<CloseIcon onClick={onDelete} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledTag = styled(Tag)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
margin: 2px;
|
||||
line-height: 22px;
|
||||
background-color: #fafafa;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 2px;
|
||||
box-sizing: content-box;
|
||||
padding: 0 4px 0 10px;
|
||||
outline: 0;
|
||||
overflow: hidden;
|
||||
|
||||
&:focus {
|
||||
border-color: #40a9ff;
|
||||
background-color: #e6f7ff;
|
||||
}
|
||||
|
||||
& span {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
& svg {
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
initValue: string[];
|
||||
options: ValueOption[];
|
||||
onChange: (value: ValueOption[]) => void;
|
||||
}
|
||||
|
||||
const MultipleChipInput = (props: Props) => {
|
||||
const { initValue, options, onChange } = props;
|
||||
const {
|
||||
getInputProps,
|
||||
getTagProps,
|
||||
getListboxProps,
|
||||
getOptionProps,
|
||||
groupedOptions,
|
||||
value,
|
||||
setAnchorEl,
|
||||
} = useAutocomplete({
|
||||
id: 'multiple-chip-select',
|
||||
multiple: true,
|
||||
options,
|
||||
value: initValue,
|
||||
getOptionLabel: option => (option as ValueOption).title,
|
||||
onChange: (event: React.SyntheticEvent, data) => {
|
||||
onChange(data as ValueOption[]);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<InputWrapper ref={setAnchorEl}>
|
||||
{(value as ValueOption[]).map(
|
||||
({ title, value }, index: number) => (
|
||||
<StyledTag
|
||||
key={value}
|
||||
label={title}
|
||||
{...getTagProps({ index })}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<input {...getInputProps()} />
|
||||
</InputWrapper>
|
||||
|
||||
{!!groupedOptions.length && (
|
||||
<ListBox {...getListboxProps()}>
|
||||
{(groupedOptions as ValueOption[]).map((option, index) => (
|
||||
<li
|
||||
key={option.value as string}
|
||||
{...getOptionProps({ option, index })}
|
||||
>
|
||||
<span>{option.title}</span>
|
||||
<CheckIcon fontSize="small" />
|
||||
</li>
|
||||
))}
|
||||
</ListBox>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { MultipleChipInput };
|
||||
@@ -0,0 +1,168 @@
|
||||
import { OldSelect, styled } from '@toeverything/components/ui';
|
||||
import { DeleteCashBinIcon } from '@toeverything/components/icons';
|
||||
import {
|
||||
FILTER_GROUP_CHANGE_TYPES,
|
||||
ruleMap,
|
||||
} from './config/filter-group-config';
|
||||
import { getOptionsFromNativeData } from '../helper';
|
||||
import { MultipleChipInput } from './MultipleChipInput';
|
||||
import { TextInput } from './TextInput';
|
||||
import { Checkbox } from './Checkbox';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { SorterItemProps } from './types';
|
||||
import { ValueOption } from './types';
|
||||
|
||||
export const StyledSorterItem = styled('div')({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
'& select': {
|
||||
margin: '6px',
|
||||
},
|
||||
});
|
||||
|
||||
const selectStyle: CSSProperties = {
|
||||
height: 32,
|
||||
border: '1px solid #E0E6EB',
|
||||
borderRadius: 5,
|
||||
padding: '0 12px',
|
||||
color: '#4C6275',
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
const fieldSelectStyle: CSSProperties = {
|
||||
...selectStyle,
|
||||
minWidth: 168,
|
||||
};
|
||||
|
||||
const relationSelectStyle: CSSProperties = {
|
||||
...selectStyle,
|
||||
minWidth: 108,
|
||||
};
|
||||
|
||||
const SorterItem = (props: SorterItemProps) => {
|
||||
const { onConstraintChange, ...rest } = props;
|
||||
const { fieldOptions, valueOptions } = getOptionsFromNativeData(rest);
|
||||
const {
|
||||
constraint: [
|
||||
key,
|
||||
{ checked, type, fieldValue, opSelectValue, valueSelectValue },
|
||||
],
|
||||
} = rest;
|
||||
const ruleOptions = Object.values(ruleMap.get(type));
|
||||
|
||||
/* key change */
|
||||
const onFieldChange = (newKey: string) => {
|
||||
const { type } = fieldOptions.filter(item => item.key === newKey)[0];
|
||||
onConstraintChange({
|
||||
type: FILTER_GROUP_CHANGE_TYPES.KEY_CHANGE,
|
||||
deleteKey: key,
|
||||
newData: [
|
||||
newKey,
|
||||
{
|
||||
key: newKey,
|
||||
type,
|
||||
fieldValue: newKey,
|
||||
checked: true,
|
||||
opSelectValue: Object.values(ruleMap.get(type))?.[0]?.value,
|
||||
valueSelectValue: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
/* op change */
|
||||
const onOpChange = (newOp: string) => {
|
||||
onConstraintChange({
|
||||
type: FILTER_GROUP_CHANGE_TYPES.OP_CHANGE,
|
||||
newData: [
|
||||
key,
|
||||
{
|
||||
key,
|
||||
type,
|
||||
checked,
|
||||
fieldValue,
|
||||
opSelectValue: newOp,
|
||||
valueSelectValue,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
/* value change */
|
||||
const onValueChange = (newValue: string | ValueOption[]) => {
|
||||
onConstraintChange({
|
||||
type: FILTER_GROUP_CHANGE_TYPES.VALUE_CHANGE,
|
||||
newData: [
|
||||
key,
|
||||
{
|
||||
key,
|
||||
type,
|
||||
checked,
|
||||
fieldValue,
|
||||
opSelectValue,
|
||||
valueSelectValue: newValue,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
/* CheckBox change */
|
||||
const onCheckChange = () => {
|
||||
onConstraintChange({
|
||||
type: FILTER_GROUP_CHANGE_TYPES.CHECK_CHANGE,
|
||||
newData: [
|
||||
key,
|
||||
{
|
||||
key,
|
||||
type,
|
||||
fieldValue,
|
||||
checked: !checked,
|
||||
opSelectValue,
|
||||
valueSelectValue,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
/* delete constraint */
|
||||
const deleteConstraint = () => {
|
||||
onConstraintChange({
|
||||
type: FILTER_GROUP_CHANGE_TYPES.RULE_DELETE,
|
||||
deleteKey: key,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledSorterItem>
|
||||
<Checkbox value={checked} onChange={onCheckChange} />
|
||||
|
||||
<OldSelect
|
||||
extraStyle={fieldSelectStyle}
|
||||
value={fieldValue}
|
||||
options={fieldOptions}
|
||||
onChange={onFieldChange}
|
||||
/>
|
||||
<OldSelect
|
||||
extraStyle={relationSelectStyle}
|
||||
value={opSelectValue}
|
||||
options={ruleOptions}
|
||||
onChange={onOpChange}
|
||||
/>
|
||||
{valueOptions?.length ? (
|
||||
<MultipleChipInput
|
||||
initValue={(valueSelectValue || []) as string[]}
|
||||
options={valueOptions}
|
||||
onChange={onValueChange}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
value={(valueSelectValue || '') as string}
|
||||
onChange={onValueChange}
|
||||
/>
|
||||
)}
|
||||
<DeleteCashBinIcon fontSize="small" onClick={deleteConstraint} />
|
||||
</StyledSorterItem>
|
||||
);
|
||||
};
|
||||
|
||||
export { SorterItem };
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback } from 'react';
|
||||
import { styled, MuiInputBase } from '@toeverything/components/ui';
|
||||
import type { ChangeEvent } from 'react';
|
||||
|
||||
const StyledTextInput = styled(MuiInputBase)(({ theme }) => ({
|
||||
'& .MuiInputBase-input': {
|
||||
borderRadius: 4,
|
||||
position: 'relative',
|
||||
border: '1px solid #ced4da',
|
||||
fontSize: 14,
|
||||
width: 'auto',
|
||||
height: 30,
|
||||
padding: '0 8px',
|
||||
margin: '0 12px 0 6px',
|
||||
'&:focus': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export const TextInput = ({ value, onChange }: Props) => {
|
||||
const onInputChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledTextInput
|
||||
value={value}
|
||||
id="text-input"
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useContext, useState } from 'react';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { HandleGroup } from './HandleGroup';
|
||||
import { toAsync, weakSqlCreator } from '../../utils';
|
||||
import { FilterContext } from './context/filter-context';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import type { Context } from './types';
|
||||
|
||||
const StyledTextarea = styled('textarea')({
|
||||
marginTop: 16,
|
||||
width: 684,
|
||||
height: 170,
|
||||
border: '1px solid #E0E6EB',
|
||||
borderRadius: 5,
|
||||
padding: 12,
|
||||
fontWeight: 400,
|
||||
fontSize: 12,
|
||||
lineHeight: '16px',
|
||||
color: '#98ACBD',
|
||||
});
|
||||
|
||||
const TextareaGroup = () => {
|
||||
const { block } = useContext<Context>(FilterContext);
|
||||
const filterWeakSqlConstraint =
|
||||
block.getProperties()?.filterWeakSqlConstraint || '';
|
||||
const [textareaValue, setTextareaValue] = useState<string>(
|
||||
filterWeakSqlConstraint
|
||||
);
|
||||
const [errStatus, setErrStatus] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* textarea change: input weak-sql string
|
||||
* @param e
|
||||
*/
|
||||
const onTextareaChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setTextareaValue(e.target.value);
|
||||
};
|
||||
|
||||
/**
|
||||
* confirm filter: if string is valid,update filter-rule to store, and redistribute data
|
||||
*/
|
||||
const confirm = async () => {
|
||||
const [err, filterWeakSqlConstraint] = await toAsync(
|
||||
weakSqlCreator(textareaValue)
|
||||
);
|
||||
|
||||
/* need optimization */
|
||||
setErrStatus(err ? true : false);
|
||||
|
||||
/* when weak-sql analysis success, update textarea */
|
||||
if (!err) {
|
||||
await block.setProperties({
|
||||
filterWeakSqlConstraint: textareaValue,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledTextarea value={textareaValue} onChange={onTextareaChange} />
|
||||
{errStatus &&
|
||||
"Syntax error, reference: status = 'complete' & price >= 1000"}
|
||||
<HandleGroup confirm={confirm} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { TextareaGroup };
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/* filter item onChange types */
|
||||
const FILTER_GROUP_CHANGE_TYPES = {
|
||||
RULE_ADD: 'rule_add',
|
||||
RULE_DELETE: 'rule_delete',
|
||||
CHECK_CHANGE: 'check_change',
|
||||
KEY_CHANGE: 'key_change',
|
||||
OP_CHANGE: 'op_change',
|
||||
VALUE_CHANGE: 'value_change',
|
||||
} as const;
|
||||
|
||||
/* filter rule config */
|
||||
const FILTER_RULE_CONFIG = {
|
||||
include: {
|
||||
label: 'includes',
|
||||
value: 'includes1',
|
||||
},
|
||||
exclude: {
|
||||
label: 'excludes',
|
||||
value: 'excludes2',
|
||||
},
|
||||
gt: {
|
||||
label: '>',
|
||||
value: '>1',
|
||||
},
|
||||
lt: {
|
||||
label: '<',
|
||||
value: '<1',
|
||||
},
|
||||
eq: {
|
||||
label: '=',
|
||||
value: '=1',
|
||||
},
|
||||
gte: {
|
||||
label: '>=',
|
||||
value: '>=1',
|
||||
},
|
||||
lte: {
|
||||
label: '<=',
|
||||
value: '<=1',
|
||||
},
|
||||
null: {
|
||||
label: 'is null',
|
||||
value: 'is null1',
|
||||
},
|
||||
notNull: {
|
||||
label: 'is not null',
|
||||
value: 'is not null1',
|
||||
},
|
||||
between: {
|
||||
label: 'between',
|
||||
value: 'between',
|
||||
},
|
||||
};
|
||||
|
||||
/* rule Map for defaultValue or compare */
|
||||
const ruleMap = new Map([
|
||||
[
|
||||
'content',
|
||||
{
|
||||
include: FILTER_RULE_CONFIG.include,
|
||||
exclude: FILTER_RULE_CONFIG.exclude,
|
||||
},
|
||||
],
|
||||
['boolean', { eq: FILTER_RULE_CONFIG.eq }],
|
||||
]);
|
||||
|
||||
export { FILTER_RULE_CONFIG, FILTER_GROUP_CHANGE_TYPES, ruleMap };
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export const MODE_CONFIG = {
|
||||
NORMAL: 'normal' /* filter group */,
|
||||
WEAK_SQL: 'weak_sql' /* weak sql */,
|
||||
} as const;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { createContext } from 'react';
|
||||
import type { Context } from '../types';
|
||||
|
||||
const FilterContext = createContext<Context>(null);
|
||||
|
||||
export { FilterContext };
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { FilterConstraint, FilterConstraintMap } from './types';
|
||||
|
||||
const makeFilterConstraint2Map = (filterConstraint: FilterConstraint = []) =>
|
||||
filterConstraint.reduce<FilterConstraintMap>(
|
||||
(m, current) => m.set(current.key, current),
|
||||
new Map()
|
||||
);
|
||||
|
||||
export { makeFilterConstraint2Map };
|
||||
@@ -0,0 +1 @@
|
||||
export { Filter } from './Filter';
|
||||
@@ -0,0 +1,55 @@
|
||||
import { MODE_CONFIG } from './config/filter-mode-config';
|
||||
import type { ValueOf } from '../../types';
|
||||
import type { Column } from '@toeverything/datasource/db-service';
|
||||
import type { AsyncBlock } from '@toeverything/framework/virgo';
|
||||
|
||||
export type ConstraintKey = string;
|
||||
|
||||
export type MultipleValue = {
|
||||
title?: string;
|
||||
label?: string;
|
||||
value?: string | boolean;
|
||||
};
|
||||
|
||||
export type Constraint = {
|
||||
key: ConstraintKey;
|
||||
checked: boolean;
|
||||
type: string;
|
||||
fieldValue: string;
|
||||
opSelectValue: string;
|
||||
valueSelectValue: string | string[] | MultipleValue[];
|
||||
};
|
||||
|
||||
export type FilterConstraint = Constraint[];
|
||||
|
||||
export type FilterConstraintMap = Map<ConstraintKey, Constraint>;
|
||||
|
||||
export type OnConstraintChange = (params: {
|
||||
type: string;
|
||||
deleteKey?: string;
|
||||
newData?: [ConstraintKey, Constraint];
|
||||
}) => void;
|
||||
|
||||
export type ValueOption = MultipleValue;
|
||||
|
||||
export interface SorterItemProps {
|
||||
constraint: [ConstraintKey, Constraint];
|
||||
constraints: FilterConstraintMap;
|
||||
fieldOptions: Column[];
|
||||
valueOptions?: ValueOption[];
|
||||
onConstraintChange: OnConstraintChange;
|
||||
}
|
||||
|
||||
export interface CheckboxProps {
|
||||
value: boolean;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
export type PanelMode = ValueOf<typeof MODE_CONFIG>;
|
||||
|
||||
export interface Context {
|
||||
mode: PanelMode;
|
||||
switchMode: () => void;
|
||||
makeView: () => void;
|
||||
block: AsyncBlock;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { useRecastKanbanGroupBy } from '@toeverything/components/editor-core';
|
||||
import { StatusIcon } from '@toeverything/components/icons';
|
||||
import { Panel } from '../Panel';
|
||||
import type { RecastPropertyId } from '@toeverything/components/editor-core';
|
||||
|
||||
const panelStyle = {
|
||||
padding: '8px 4px',
|
||||
};
|
||||
|
||||
const disabledStyle = {
|
||||
fontSize: 12,
|
||||
lineHeight: '18px',
|
||||
color: '#98ACBD',
|
||||
};
|
||||
|
||||
const activeStyle = {
|
||||
color: '#3E6FDB',
|
||||
};
|
||||
|
||||
const hoverStyle = {
|
||||
borderRadius: 5,
|
||||
backgroundColor: '#F5F7F8',
|
||||
};
|
||||
|
||||
const Item = styled('div')<{ active?: boolean; disabled?: unknown }>(props => {
|
||||
const { active } = props;
|
||||
|
||||
return {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
padding: '6px 12px',
|
||||
minWidth: 120,
|
||||
fontSize: 14,
|
||||
lineHeight: '20px',
|
||||
color: '#4C6275',
|
||||
'&:hover': {
|
||||
...(!('disabled' in props) && hoverStyle),
|
||||
},
|
||||
...('disabled' in props && disabledStyle),
|
||||
...(active && activeStyle),
|
||||
};
|
||||
});
|
||||
|
||||
const Content = styled('div')({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
'& > div': {
|
||||
marginLeft: 12,
|
||||
},
|
||||
});
|
||||
|
||||
const GroupBy = ({ closePanel }: { closePanel: () => void }) => {
|
||||
const { supportedGroupBy, setGroupBy, groupBy } = useRecastKanbanGroupBy();
|
||||
|
||||
const onChange = (value: string) => {
|
||||
(async () => {
|
||||
await setGroupBy(value as RecastPropertyId);
|
||||
})();
|
||||
closePanel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel extraStyle={panelStyle}>
|
||||
<Item disabled>
|
||||
<div>COLUMN</div>
|
||||
</Item>
|
||||
{supportedGroupBy.map(({ name, id }) => (
|
||||
<Item
|
||||
active={groupBy.id === id}
|
||||
key={id}
|
||||
onClick={() => onChange(id)}
|
||||
>
|
||||
<Content>
|
||||
<StatusIcon fontSize="small" />
|
||||
<div>
|
||||
{name.slice(0, 1).toUpperCase()}
|
||||
{name.slice(1)}
|
||||
</div>
|
||||
</Content>
|
||||
</Item>
|
||||
))}
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
|
||||
export { GroupBy };
|
||||
@@ -0,0 +1,10 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
const GroupPanel = styled('div')({
|
||||
height: 32,
|
||||
background: '#FFFFFF',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export { GroupPanel };
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { SorterItemProps } from './filter/types';
|
||||
import type { FilterGroupConstraint } from '../types';
|
||||
import { BooleanColumn } from '@toeverything/datasource/db-service';
|
||||
|
||||
/**
|
||||
* filter module: adapt multiple selection scenarios
|
||||
* @param nativeData
|
||||
*/
|
||||
const getOptionsFromNativeData = (
|
||||
nativeData: Omit<SorterItemProps, 'onConstraintChange'>
|
||||
) => {
|
||||
const {
|
||||
constraint: [key, { fieldValue }],
|
||||
fieldOptions,
|
||||
constraints,
|
||||
} = nativeData;
|
||||
|
||||
const valueOptions = (
|
||||
fieldOptions.filter(item => item.key === key)[0] as BooleanColumn
|
||||
)?.options?.map(({ name, value }) => ({ title: name, value }));
|
||||
|
||||
/* options: contains current and unselected*/
|
||||
const effectiveOptions = fieldOptions
|
||||
.map(item => {
|
||||
const { key, name } = item;
|
||||
return {
|
||||
...item,
|
||||
label: name,
|
||||
value: key,
|
||||
};
|
||||
})
|
||||
.filter(item => {
|
||||
return !constraints.has(item.value) || item.value === fieldValue;
|
||||
});
|
||||
|
||||
return {
|
||||
fieldOptions: effectiveOptions,
|
||||
valueOptions,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* translate
|
||||
* @param constraints
|
||||
*/
|
||||
const constraints2FilterSchema = (constraints: FilterGroupConstraint) => {
|
||||
return [...constraints.entries()]
|
||||
.filter(constraint => constraint[1].checked)
|
||||
.map(([field, { op, value }]) => ({ field, op, value }));
|
||||
};
|
||||
|
||||
export { getOptionsFromNativeData, constraints2FilterSchema };
|
||||
@@ -0,0 +1,53 @@
|
||||
import { styled, OldSelect } from '@toeverything/components/ui';
|
||||
import { useRecastKanbanGroupBy } from '@toeverything/components/editor-core';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { RecastPropertyId } from '@toeverything/components/editor-core';
|
||||
|
||||
const extraStyle: CSSProperties = {
|
||||
height: 32,
|
||||
border: '1px solid #E0E6EB',
|
||||
borderRadius: 5,
|
||||
padding: '0 12px',
|
||||
};
|
||||
|
||||
const StyledGroupSelector = styled('div')({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 8px',
|
||||
fontWeight: 400,
|
||||
fontSize: 16,
|
||||
color: '#3A4C5C',
|
||||
'& select': {
|
||||
margin: '12px',
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
|
||||
const GroupBySelector = () => {
|
||||
const { supportedGroupBy, setGroupBy, groupBy } = useRecastKanbanGroupBy();
|
||||
/* groupBy config */
|
||||
const options = supportedGroupBy.map(({ name, id }) => ({
|
||||
label: name,
|
||||
value: id,
|
||||
}));
|
||||
|
||||
const onChange = (value: string) => {
|
||||
(async () => {
|
||||
await setGroupBy(value as RecastPropertyId);
|
||||
})();
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledGroupSelector>
|
||||
Group by
|
||||
<OldSelect
|
||||
extraStyle={{ ...extraStyle }}
|
||||
value={groupBy as unknown as string}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
/>
|
||||
</StyledGroupSelector>
|
||||
);
|
||||
};
|
||||
|
||||
export { GroupBySelector };
|
||||
@@ -0,0 +1,18 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
const StyledIntroduce = styled('div')({
|
||||
fontSize: 12,
|
||||
color: '#3A4C5C',
|
||||
});
|
||||
|
||||
const Introduce = () => {
|
||||
return (
|
||||
<StyledIntroduce>
|
||||
Sort your items by priority, creation date, price or
|
||||
<br />
|
||||
any column you have on your board.
|
||||
</StyledIntroduce>
|
||||
);
|
||||
};
|
||||
|
||||
export { Introduce };
|
||||
@@ -0,0 +1,41 @@
|
||||
import { HelpCenterIcon } from '@toeverything/components/icons';
|
||||
import { SorterSelector } from './SorterSelector';
|
||||
import { GroupBySelector } from './GroupBySelector';
|
||||
import { Panel } from '../Panel';
|
||||
import { Title } from '../Title';
|
||||
import type { ClosePanel } from '../../types';
|
||||
import type { AsyncBlock } from '@toeverything/framework/virgo';
|
||||
|
||||
const extraStyle = {
|
||||
width: 400,
|
||||
};
|
||||
|
||||
const Sorter = ({
|
||||
closePanel,
|
||||
block,
|
||||
}: {
|
||||
closePanel: ClosePanel;
|
||||
block: AsyncBlock;
|
||||
}) => {
|
||||
/* remain:probably won't use */
|
||||
const makeView = () => {
|
||||
/* create a view */
|
||||
closePanel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel extraStyle={extraStyle}>
|
||||
<GroupBySelector />
|
||||
<div>
|
||||
<Title>
|
||||
<div>Sort by</div>
|
||||
<HelpCenterIcon fontSize="small" />
|
||||
</Title>
|
||||
|
||||
<SorterSelector block={block} />
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
|
||||
export { Sorter };
|
||||
@@ -0,0 +1,91 @@
|
||||
import { OldSelect, styled } from '@toeverything/components/ui';
|
||||
import { DeleteCashBinIcon } from '@toeverything/components/icons';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { SorterItemProps } from './types';
|
||||
|
||||
export const StyledSorterItem = styled('div')({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
'& select': {
|
||||
margin: '6px',
|
||||
},
|
||||
});
|
||||
|
||||
const extraStyle: CSSProperties = {
|
||||
width: 180,
|
||||
height: 32,
|
||||
border: '1px solid #E0E6EB',
|
||||
borderRadius: 5,
|
||||
padding: '0 12px',
|
||||
};
|
||||
|
||||
const SorterItem = (props: SorterItemProps) => {
|
||||
const {
|
||||
constraint,
|
||||
constraints,
|
||||
fieldOptions,
|
||||
ruleOptions,
|
||||
onConstraintChange,
|
||||
} = props;
|
||||
/* current constraint */
|
||||
const [field, { rule }] = constraint;
|
||||
|
||||
/**
|
||||
* field change & update constraints
|
||||
* @param newField
|
||||
*/
|
||||
const onFieldChange = (newField: string) => {
|
||||
onConstraintChange({
|
||||
oldField: field,
|
||||
newField,
|
||||
newRule: rule,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* rule change & update constraints
|
||||
* @param newRule
|
||||
*/
|
||||
const onRuleChange = (newRule: string) => {
|
||||
onConstraintChange({
|
||||
newField: field,
|
||||
newRule,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* delete current constrain & update constraints
|
||||
* @param newField
|
||||
*/
|
||||
const deleteConstraint = () => {
|
||||
onConstraintChange({
|
||||
oldField: field,
|
||||
});
|
||||
};
|
||||
|
||||
/* recast options: contains current and unselected */
|
||||
const effectiveOptions = fieldOptions.filter(
|
||||
fieldOption =>
|
||||
!constraints.has(fieldOption.key) || fieldOption.key === field
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledSorterItem>
|
||||
<OldSelect
|
||||
extraStyle={extraStyle}
|
||||
value={field}
|
||||
options={effectiveOptions}
|
||||
onChange={onFieldChange}
|
||||
/>
|
||||
<OldSelect
|
||||
extraStyle={{ ...extraStyle, width: 125 }}
|
||||
value={rule}
|
||||
options={ruleOptions}
|
||||
onChange={onRuleChange}
|
||||
/>
|
||||
<DeleteCashBinIcon fontSize="small" onClick={deleteConstraint} />
|
||||
</StyledSorterItem>
|
||||
);
|
||||
};
|
||||
|
||||
export { SorterItem };
|
||||
@@ -0,0 +1,128 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { AddViewIcon } from '@toeverything/components/icons';
|
||||
import { Introduce } from './Introduce';
|
||||
import { SorterItem } from './SorterItem';
|
||||
import { SORTER_CONFIG } from './config';
|
||||
import { makesSorterConstraint2Map } from './helper';
|
||||
import type { Column } from '@toeverything/datasource/db-service';
|
||||
import type {
|
||||
Constraint,
|
||||
ConstraintKey,
|
||||
Constraints,
|
||||
OnConstraintChange,
|
||||
} from './types';
|
||||
import type { AsyncBlock } from '@toeverything/framework/virgo';
|
||||
|
||||
const StyledSorterItems = styled('div')({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '32px 0',
|
||||
});
|
||||
|
||||
const StyledAddSort = styled('div')({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '6px 8px',
|
||||
'& svg': {
|
||||
marginRight: 4,
|
||||
},
|
||||
fontSize: 12,
|
||||
color: '#3A4C5C',
|
||||
});
|
||||
|
||||
const SorterSelector = ({ block }: { block: AsyncBlock }) => {
|
||||
const sorterConstraintMap = makesSorterConstraint2Map(
|
||||
block.getProperties()?.sorterConstraint || []
|
||||
);
|
||||
|
||||
const fieldOptions = (block.columns as Column[])
|
||||
.filter(item => {
|
||||
if ('sorter' in item) {
|
||||
return !!item['sorter'];
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
.map(item => {
|
||||
const { name, key } = item;
|
||||
|
||||
return { key, label: name };
|
||||
});
|
||||
|
||||
/**
|
||||
* sort constraint change
|
||||
* @param constraints
|
||||
*/
|
||||
const onConstraintChange: OnConstraintChange = async ({
|
||||
oldField,
|
||||
newField,
|
||||
newRule,
|
||||
}) => {
|
||||
const newSorterConstraintMap: Constraints = new Map([
|
||||
...sorterConstraintMap.entries(),
|
||||
]);
|
||||
/* delete */
|
||||
oldField && newSorterConstraintMap.delete(oldField);
|
||||
/* update or add */
|
||||
/* maybe bug: when updated, item's position will change */
|
||||
newField &&
|
||||
newSorterConstraintMap.set(newField, {
|
||||
field: newField,
|
||||
rule: newRule,
|
||||
});
|
||||
|
||||
await block.setProperties({
|
||||
sorterConstraint: Array.from(newSorterConstraintMap.values()),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* add new sort item
|
||||
*/
|
||||
const addSort = () => {
|
||||
for (const option of fieldOptions) {
|
||||
if (!sorterConstraintMap.has(option.key)) {
|
||||
onConstraintChange({
|
||||
newField: option.key,
|
||||
newRule: SORTER_CONFIG.ASC.value,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* translate Map to Array for render */
|
||||
const constraintList: Array<[ConstraintKey, Constraint]> = Array.from(
|
||||
sorterConstraintMap.entries()
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledSorterItems>
|
||||
{!constraintList.length ? (
|
||||
<Introduce />
|
||||
) : (
|
||||
constraintList.map(constraint => (
|
||||
<SorterItem
|
||||
key={constraint[0]}
|
||||
constraint={constraint}
|
||||
constraints={sorterConstraintMap}
|
||||
fieldOptions={fieldOptions}
|
||||
ruleOptions={Object.values(SORTER_CONFIG)}
|
||||
onConstraintChange={onConstraintChange}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</StyledSorterItems>
|
||||
|
||||
<StyledAddSort onClick={addSort}>
|
||||
<AddViewIcon fontSize="small" />
|
||||
<span>Add new sort</span>
|
||||
</StyledAddSort>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { SorterSelector };
|
||||
@@ -0,0 +1,10 @@
|
||||
export const SORTER_CONFIG = {
|
||||
ASC: {
|
||||
label: 'ASC',
|
||||
value: 'asc',
|
||||
},
|
||||
DESC: {
|
||||
label: 'DESC',
|
||||
value: 'desc',
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Constraint, Constraints } from './types';
|
||||
|
||||
const makesSorterConstraint2Map = (sorterConstraint: Constraint[] = []) =>
|
||||
sorterConstraint.reduce<Constraints>(
|
||||
(m, cur) => m.set(cur.field, cur),
|
||||
new Map()
|
||||
);
|
||||
|
||||
export { makesSorterConstraint2Map };
|
||||
@@ -0,0 +1 @@
|
||||
export { Sorter } from './Sorter';
|
||||
@@ -0,0 +1,30 @@
|
||||
export type ConstraintKey = string;
|
||||
|
||||
export type Constraint = {
|
||||
field: ConstraintKey;
|
||||
rule: string;
|
||||
};
|
||||
|
||||
export type SorterConstraint = Constraint[];
|
||||
|
||||
export type Constraints = Map<ConstraintKey, Constraint>;
|
||||
|
||||
export type Option = {
|
||||
key?: string;
|
||||
label?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
export type OnConstraintChange = (params: {
|
||||
oldField?: string;
|
||||
newField?: string;
|
||||
newRule?: string;
|
||||
}) => void;
|
||||
|
||||
export interface SorterItemProps {
|
||||
constraint: [ConstraintKey, Constraint];
|
||||
constraints: Constraints;
|
||||
fieldOptions: Option[];
|
||||
ruleOptions: Option[];
|
||||
onConstraintChange: OnConstraintChange;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export const PANEL_CONFIG = {
|
||||
FILTER: 'filter',
|
||||
SORTER: 'sorter',
|
||||
ADD_VIEW: 'add_view',
|
||||
GROUP_BY: 'group_by',
|
||||
} as const;
|
||||
|
||||
export const SCENE_CONFIG = {
|
||||
PAGE: 'page',
|
||||
KANBAN: 'kanban',
|
||||
TABLE: 'table',
|
||||
REFLINK: 'reflink',
|
||||
} as const;
|
||||
@@ -0,0 +1,41 @@
|
||||
/* op rule for filter group */
|
||||
import { OpRule } from '../types';
|
||||
|
||||
export const FILTER_OP_RULE_CONFIG: OpRule = [
|
||||
{
|
||||
key: 'includes',
|
||||
value: 'includes1',
|
||||
},
|
||||
{
|
||||
key: 'excludes',
|
||||
value: 'excludes2',
|
||||
},
|
||||
{
|
||||
key: '>',
|
||||
value: '>1',
|
||||
},
|
||||
{
|
||||
key: '<',
|
||||
value: '<1',
|
||||
},
|
||||
{
|
||||
key: '=',
|
||||
value: '=1',
|
||||
},
|
||||
{
|
||||
key: '>=',
|
||||
value: '>=1',
|
||||
},
|
||||
{
|
||||
key: '<=',
|
||||
value: '<=1',
|
||||
},
|
||||
{
|
||||
key: 'is null',
|
||||
value: 'is null1',
|
||||
},
|
||||
{
|
||||
key: 'is not null',
|
||||
value: 'is not null1',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,10 @@
|
||||
export const SORTER_CONFIG = [
|
||||
{
|
||||
label: 'ASC',
|
||||
value: '1',
|
||||
},
|
||||
{
|
||||
label: 'DESC',
|
||||
value: '2',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
export { Group } from './Group';
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useKanban } from '@toeverything/components/editor-core';
|
||||
import { DoneIcon } from '@toeverything/components/icons';
|
||||
import {
|
||||
IconButton,
|
||||
MuiClickAwayListener,
|
||||
MuiPopper,
|
||||
} from '@toeverything/components/ui';
|
||||
import type { ChangeEvent, KeyboardEvent, MouseEvent } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { AddGroupWrapper, PopperContainer } from './styles';
|
||||
|
||||
export const AddGroupButton = () => {
|
||||
const { addGroup } = useKanban();
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const open = Boolean(anchorEl);
|
||||
const disabledAddGroup = !groupName;
|
||||
|
||||
const handleClick = useCallback(
|
||||
async (event: MouseEvent<HTMLElement>) => {
|
||||
if (open) {
|
||||
setAnchorEl(null);
|
||||
return;
|
||||
}
|
||||
setGroupName('');
|
||||
setAnchorEl(event.currentTarget);
|
||||
},
|
||||
[open]
|
||||
);
|
||||
|
||||
const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
setGroupName(e.target.value.trim());
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (!groupName) {
|
||||
return;
|
||||
}
|
||||
const result = await addGroup(groupName);
|
||||
if (result) {
|
||||
setAnchorEl(null);
|
||||
}
|
||||
}, [addGroup, groupName]);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== 'Enter') {
|
||||
return;
|
||||
}
|
||||
handleConfirm();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AddGroupWrapper onClick={handleClick}>+</AddGroupWrapper>
|
||||
<MuiPopper open={open} anchorEl={anchorEl} placement="bottom-start">
|
||||
<MuiClickAwayListener onClickAway={() => setAnchorEl(null)}>
|
||||
<PopperContainer>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={groupName}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Add..."
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="done"
|
||||
disabled={disabledAddGroup}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
<DoneIcon />
|
||||
</IconButton>
|
||||
</PopperContainer>
|
||||
</MuiClickAwayListener>
|
||||
</MuiPopper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { KanbanGroup } from '@toeverything/components/editor-core';
|
||||
import {
|
||||
DEFAULT_GROUP_ID,
|
||||
PropertyType,
|
||||
useKanban,
|
||||
} from '@toeverything/components/editor-core';
|
||||
import { DeleteCashBinIcon, DoneIcon } from '@toeverything/components/icons';
|
||||
import {
|
||||
IconButton,
|
||||
MuiClickAwayListener,
|
||||
MuiPopper,
|
||||
} from '@toeverything/components/ui';
|
||||
import type {
|
||||
ChangeEvent,
|
||||
CSSProperties,
|
||||
KeyboardEvent,
|
||||
MouseEvent,
|
||||
} from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { AddGroupButton } from './AddGroupButton';
|
||||
import { CardContext } from './CardContext';
|
||||
import { CardContextWrapper } from './dndable/wrapper/CardContextWrapper';
|
||||
import { DroppableContainer } from './dndable/wrapper/DroppableContainer';
|
||||
import {
|
||||
KanbanBoard,
|
||||
KanbanContainer,
|
||||
KanbanHeader,
|
||||
PopperContainer,
|
||||
Tag,
|
||||
} from './styles';
|
||||
import type { CardContainerProps } from './types';
|
||||
|
||||
const getKanbanColor = (
|
||||
group: KanbanGroup
|
||||
): [color: CSSProperties['color'], background: CSSProperties['background']] => {
|
||||
const DEFAULT_COLOR: [
|
||||
color: CSSProperties['color'],
|
||||
background: CSSProperties['background']
|
||||
] = ['#3A4C5C', 'transparent'];
|
||||
if (!group) {
|
||||
return DEFAULT_COLOR;
|
||||
}
|
||||
if (
|
||||
group.type === PropertyType.Select ||
|
||||
group.type === PropertyType.MultiSelect ||
|
||||
group.type === DEFAULT_GROUP_ID
|
||||
) {
|
||||
return [group.color, group.background];
|
||||
}
|
||||
// TODO other type color
|
||||
return DEFAULT_COLOR;
|
||||
};
|
||||
|
||||
const KanbanTag = (group: KanbanGroup) => {
|
||||
const { removeGroup, renameGroup, checkIsDefaultGroup } = useKanban();
|
||||
const [renamingGroupName, setRenamingGroupName] = useState(group.name);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
// Default group should not be renamed
|
||||
const isDefaultGroup = checkIsDefaultGroup(group);
|
||||
// Cannot be named empty string
|
||||
const disabledRenameBtn = !renamingGroupName;
|
||||
|
||||
const handleClickTag = useCallback(
|
||||
async (event: MouseEvent<HTMLElement>) => {
|
||||
if (isDefaultGroup) {
|
||||
return;
|
||||
}
|
||||
if (anchorEl) {
|
||||
setAnchorEl(null);
|
||||
return;
|
||||
}
|
||||
setAnchorEl(event.currentTarget);
|
||||
// Reset to current groupName
|
||||
setRenamingGroupName(group.name);
|
||||
},
|
||||
[anchorEl, group.name, isDefaultGroup]
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
setRenamingGroupName(e.target.value.trim());
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleRename = useCallback(async () => {
|
||||
if (!renamingGroupName) {
|
||||
return;
|
||||
}
|
||||
const result = await renameGroup(group, renamingGroupName);
|
||||
if (result) {
|
||||
setAnchorEl(null);
|
||||
}
|
||||
}, [group, renamingGroupName, renameGroup]);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== 'Enter') {
|
||||
return;
|
||||
}
|
||||
if (!renamingGroupName) {
|
||||
return;
|
||||
}
|
||||
handleRename();
|
||||
};
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
removeGroup(group);
|
||||
setAnchorEl(null);
|
||||
}, [group, removeGroup]);
|
||||
|
||||
const [color, bg] = getKanbanColor(group);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tag
|
||||
interactive={!isDefaultGroup}
|
||||
color={color}
|
||||
background={bg}
|
||||
onClick={handleClickTag}
|
||||
>
|
||||
{group.name}
|
||||
</Tag>
|
||||
|
||||
<MuiClickAwayListener onClickAway={() => setAnchorEl(null)}>
|
||||
<MuiPopper
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
placement="bottom-start"
|
||||
>
|
||||
<PopperContainer>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={renamingGroupName}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<IconButton aria-label="delete" onClick={handleDelete}>
|
||||
<DeleteCashBinIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label="done"
|
||||
disabled={disabledRenameBtn}
|
||||
onClick={handleRename}
|
||||
>
|
||||
<DoneIcon />
|
||||
</IconButton>
|
||||
</PopperContainer>
|
||||
</MuiPopper>
|
||||
</MuiClickAwayListener>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardContainer = (props: CardContainerProps) => {
|
||||
const { kanban } = useKanban();
|
||||
const { containerIds, items: dataSource, activeId } = props;
|
||||
return (
|
||||
<KanbanContainer>
|
||||
{containerIds.map((containerId, idx) => {
|
||||
const items = dataSource[containerId];
|
||||
|
||||
return (
|
||||
<KanbanBoard key={containerId}>
|
||||
<KanbanHeader>
|
||||
<KanbanTag {...kanban[idx]} />
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '10px',
|
||||
color: getKanbanColor(kanban[idx])[0],
|
||||
}}
|
||||
>
|
||||
{items.length}
|
||||
</span>
|
||||
</KanbanHeader>
|
||||
<DroppableContainer
|
||||
key={containerId}
|
||||
id={containerId}
|
||||
items={items}
|
||||
>
|
||||
<CardContextWrapper
|
||||
containerId={containerId}
|
||||
items={items}
|
||||
render={({ items }) => (
|
||||
<CardContext
|
||||
group={kanban[idx]}
|
||||
items={items}
|
||||
activeId={activeId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</DroppableContainer>
|
||||
</KanbanBoard>
|
||||
);
|
||||
})}
|
||||
<KanbanBoard>
|
||||
<AddGroupButton />
|
||||
</KanbanBoard>
|
||||
</KanbanContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useCallback } from 'react';
|
||||
import { CardItem } from './CardItem';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { useKanban } from '@toeverything/components/editor-core';
|
||||
import { CardItemPanelWrapper } from './dndable/wrapper/CardItemPanelWrapper';
|
||||
import type {
|
||||
KanbanCard,
|
||||
KanbanGroup,
|
||||
} from '@toeverything/components/editor-core';
|
||||
|
||||
const AddCardWrapper = styled('div')({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: '5px',
|
||||
width: '100%',
|
||||
color: '#B7C5D1',
|
||||
border: '1px solid #F5F7F8',
|
||||
fontSize: '12px',
|
||||
padding: '3px 15px',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
const AddCard = ({ group }: { group: KanbanGroup }) => {
|
||||
const { addCard } = useKanban();
|
||||
const handleClick = useCallback(async () => {
|
||||
await addCard(group);
|
||||
}, [addCard]);
|
||||
return <AddCardWrapper onClick={handleClick}>+</AddCardWrapper>;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
group: KanbanGroup;
|
||||
items: KanbanCard[];
|
||||
activeId?: string;
|
||||
}
|
||||
|
||||
export const CardContext = (props: Props) => {
|
||||
const { items, group, activeId } = props;
|
||||
return (
|
||||
<>
|
||||
{items.map(item => {
|
||||
const { id, block } = item;
|
||||
|
||||
return (
|
||||
<div key={id} style={{ cursor: 'pointer' }}>
|
||||
<CardItemPanelWrapper
|
||||
item={item}
|
||||
active={activeId === id}
|
||||
>
|
||||
<CardItem id={id} block={block} />
|
||||
</CardItemPanelWrapper>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<AddCard group={group} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { RenderBlock, useKanban } from '@toeverything/components/editor-core';
|
||||
import type { KanbanCard } from '@toeverything/components/editor-core';
|
||||
|
||||
const CardContainer = styled('div')({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #E2E7ED',
|
||||
borderRadius: '5px',
|
||||
});
|
||||
|
||||
const CardContent = styled('div')({
|
||||
margin: '20px',
|
||||
});
|
||||
|
||||
const CardActions = styled('div')({
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '29px',
|
||||
background: 'rgba(152, 172, 189, 0.1)',
|
||||
borderRadius: '0px 0px 5px 5px',
|
||||
padding: '6px 0 6px 19px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '300',
|
||||
color: '#98ACBD',
|
||||
});
|
||||
|
||||
const PlusIcon = styled('div')({
|
||||
marginRight: '9px',
|
||||
fontWeight: '500',
|
||||
lineHeight: 0,
|
||||
'::before': {
|
||||
content: '"+"',
|
||||
},
|
||||
});
|
||||
|
||||
export const CardItem = ({
|
||||
id,
|
||||
block,
|
||||
}: {
|
||||
id: KanbanCard['id'];
|
||||
block: KanbanCard['block'];
|
||||
}) => {
|
||||
const { addSubItem } = useKanban();
|
||||
const onAddItem = async () => {
|
||||
await addSubItem(block);
|
||||
};
|
||||
|
||||
return (
|
||||
<CardContainer>
|
||||
<CardContent>
|
||||
<RenderBlock blockId={id} />
|
||||
</CardContent>
|
||||
<CardActions onClick={onAddItem}>
|
||||
<PlusIcon />
|
||||
Add item
|
||||
</CardActions>
|
||||
</CardContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useKanban, withKanban } from '@toeverything/components/editor-core';
|
||||
import { CardContainer } from './CardContainer';
|
||||
import { SceneKanbanContext } from './context';
|
||||
import { CardContainerWrapper } from './dndable/wrapper/CardContainerWrapper';
|
||||
import type { ComponentType } from 'react';
|
||||
import type { CreateView } from '@toeverything/framework/virgo';
|
||||
|
||||
export const SceneKanban: ComponentType<CreateView> = withKanban<CreateView>(
|
||||
({ editor, block }) => {
|
||||
const { kanban } = useKanban();
|
||||
|
||||
return (
|
||||
<SceneKanbanContext.Provider value={{ editor, block }}>
|
||||
<CardContainerWrapper
|
||||
dataSource={kanban}
|
||||
render={({
|
||||
activeId,
|
||||
items,
|
||||
containerIds,
|
||||
isSortingContainer,
|
||||
}) => (
|
||||
<CardContainer
|
||||
activeId={activeId}
|
||||
items={items}
|
||||
isSortingContainer={isSortingContainer}
|
||||
containerIds={containerIds}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</SceneKanbanContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
import { AsyncBlock, CreateView } from '@toeverything/framework/virgo';
|
||||
import { createContext } from 'react';
|
||||
|
||||
const SceneKanbanContext = createContext<{
|
||||
editor: CreateView['editor'];
|
||||
block: AsyncBlock;
|
||||
}>({} as any);
|
||||
|
||||
export { SceneKanbanContext };
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { forwardRef } from 'react';
|
||||
import type { CSSProperties, ForwardedRef, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
label?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const Container = forwardRef(
|
||||
({ children, label, style }: Props, ref: ForwardedRef<HTMLDivElement>) => {
|
||||
return (
|
||||
<div ref={ref} style={style}>
|
||||
{label && <div>{label}</div>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import type { Transform } from '../type';
|
||||
|
||||
interface ItemStyleProps {
|
||||
dragOverlay?: boolean;
|
||||
transition?: string;
|
||||
fadeIn?: boolean;
|
||||
transform?: Transform;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
/* dnd-kit has built-in animation function, rewrite the form of classname to css-in-js here, please do not use it in the business layer */ const ItemStyle =
|
||||
styled('div')((props: ItemStyleProps) => {
|
||||
const { dragOverlay, transition, fadeIn, transform, index } = props;
|
||||
|
||||
const translateX = transform
|
||||
? `${Math.round(transform.x)}px`
|
||||
: undefined;
|
||||
const translateY = transform
|
||||
? `${Math.round(transform.y)}px`
|
||||
: undefined;
|
||||
const scaleAroundX = transform?.scaleX
|
||||
? `${transform.scaleX}`
|
||||
: undefined;
|
||||
const scaleAroundY = transform?.scaleY
|
||||
? `${transform.scaleY}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
transform: `translate3d(${translateX || 0}, ${
|
||||
translateY || 0
|
||||
}, 0) scaleX(${scaleAroundX || 1}) scaleY(${scaleAroundY || 1})`,
|
||||
transformOrigin: '0 0',
|
||||
touchAction: 'manipulation',
|
||||
transition: `${[transition].filter(Boolean).join(', ')}`,
|
||||
'--translate-x': translateX,
|
||||
'--translate-y': translateY,
|
||||
'--scale-x': scaleAroundX,
|
||||
'--scale-y': scaleAroundY,
|
||||
'--index': index,
|
||||
...(dragOverlay && {
|
||||
'--scale': 1.05,
|
||||
'z-index': 999,
|
||||
}),
|
||||
...(fadeIn && {
|
||||
animation: 'fadeIn 500ms ease',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
export { ItemStyle };
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { defaultDropAnimationSideEffects, DragOverlay } from '@dnd-kit/core';
|
||||
import { renderContainerDragOverlay } from './renderContainerDragOverlay';
|
||||
import { renderSortableItemDragOverlay } from './renderSortableItemDragOverlay';
|
||||
import type { DndableItems } from '../type';
|
||||
|
||||
interface DragOverlayPortal {
|
||||
activeId: string;
|
||||
containerIds: string[];
|
||||
items: DndableItems;
|
||||
}
|
||||
|
||||
const dropAnimation = {
|
||||
sideEffects: defaultDropAnimationSideEffects({
|
||||
styles: {
|
||||
active: {
|
||||
opacity: '0.5',
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const DragOverlayPortal = ({
|
||||
activeId,
|
||||
containerIds,
|
||||
items,
|
||||
}: DragOverlayPortal) => {
|
||||
return createPortal(
|
||||
<DragOverlay adjustScale={false} dropAnimation={dropAnimation}>
|
||||
{activeId
|
||||
? containerIds.includes(activeId)
|
||||
? renderContainerDragOverlay({
|
||||
containerId: activeId,
|
||||
items,
|
||||
})
|
||||
: renderSortableItemDragOverlay({ activeId, items })
|
||||
: null}
|
||||
</DragOverlay>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export { DragOverlayPortal };
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { CardItemWrapper } from '../wrapper/CardItemWrapper';
|
||||
import { CardItem } from '../../CardItem';
|
||||
import type { KanbanCard } from '@toeverything/components/editor-core';
|
||||
import type { DndableItems } from '../type';
|
||||
|
||||
export function renderContainerDragOverlay({
|
||||
containerId,
|
||||
items,
|
||||
}: {
|
||||
containerId: string;
|
||||
items: DndableItems;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ height: '100%' }}>
|
||||
{items[containerId].map((item, index) => {
|
||||
const { id, block } = item;
|
||||
|
||||
return (
|
||||
<CardItemWrapper
|
||||
key={id}
|
||||
card={<CardItem key={id} id={id} block={block} />}
|
||||
index={index}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { CardItem } from '../../CardItem';
|
||||
import { CardItemWrapper } from '../wrapper/CardItemWrapper';
|
||||
import { findContainer } from '../helper';
|
||||
import type { DndableItems } from '../type';
|
||||
|
||||
export function renderSortableItemDragOverlay({
|
||||
activeId,
|
||||
items,
|
||||
}: {
|
||||
activeId: string;
|
||||
items: DndableItems;
|
||||
}) {
|
||||
const activeContainer = findContainer(activeId, items);
|
||||
const item = items[activeContainer].find(item => item.id === activeId);
|
||||
|
||||
return <CardItemWrapper card={<CardItem {...item} />} dragOverlay />;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Data format conversion
|
||||
* @param data
|
||||
*/
|
||||
import type { DndableItems } from './type';
|
||||
import type {
|
||||
KanbanCard,
|
||||
KanbanGroup,
|
||||
} from '@toeverything/components/editor-core';
|
||||
import { isEqual } from '@toeverything/utils';
|
||||
|
||||
const transformKanban2DndFormat = (dataSource: KanbanGroup[]) => {
|
||||
return dataSource.reduce<[DndableItems, string[]]>(
|
||||
(tuple, current) => {
|
||||
const { id, items } = current;
|
||||
const [dndableItems, dndableContainerIds] = tuple;
|
||||
return [
|
||||
{ ...dndableItems, [id]: items },
|
||||
[...dndableContainerIds, id],
|
||||
];
|
||||
},
|
||||
[{}, []]
|
||||
);
|
||||
};
|
||||
|
||||
const findContainer = (id: string, items: DndableItems) => {
|
||||
if (id in items) {
|
||||
return id;
|
||||
}
|
||||
|
||||
return Object.keys(items).find(key =>
|
||||
items[key].some(item => item.id === id)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the sibling node after the dragging of the moved node ends
|
||||
* @param cards
|
||||
* @param currentCardId
|
||||
*/
|
||||
const findSibling = (cards: KanbanCard[], currentCardId: string) => {
|
||||
const index = cards.findIndex(card => card.id === currentCardId);
|
||||
|
||||
return [cards[index - 1]?.id ?? null, cards[index + 1]?.id ?? null];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get card ids
|
||||
* @param data
|
||||
*/
|
||||
const pickIdFromCards = (data: KanbanCard[][]) => {
|
||||
return data.reduce((arr: string[], current) => {
|
||||
const ids = current.map(item => item.id);
|
||||
|
||||
return [...arr, ...ids];
|
||||
}, []);
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine whether the current operation belongs to the addition, deletion, modification and inspection at the card/container level (distinguished from actions such as dragging, inputting card/container content, etc.)
|
||||
* @param prevContainerIds
|
||||
* @param prevCardIds
|
||||
* @param nextContainerIds
|
||||
* @param nextCardIds
|
||||
*/
|
||||
const shouldUpdate = (
|
||||
[prevContainerIds, prevCardIds]: [string[], string[]],
|
||||
[nextContainerIds, nextCardIds]: [string[], string[]]
|
||||
) => {
|
||||
return !(
|
||||
/* Since the rendering of the container is based on the order, the order of the container changes and needs to be updated */ (
|
||||
isEqual(
|
||||
prevContainerIds,
|
||||
nextContainerIds
|
||||
) /* add/remove container */ &&
|
||||
isEqual(prevCardIds.sort(), nextCardIds.sort())
|
||||
) /* add/delete cards */
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
transformKanban2DndFormat,
|
||||
findContainer,
|
||||
findSibling,
|
||||
pickIdFromCards,
|
||||
shouldUpdate,
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { KanbanCard } from '@toeverything/components/editor-core';
|
||||
import type {
|
||||
CollisionDetection,
|
||||
DragStartEvent,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
Active,
|
||||
} from '@dnd-kit/core';
|
||||
|
||||
export type DndableItems = Record<string, KanbanCard[]>;
|
||||
|
||||
export type UseDndableRes = [
|
||||
{
|
||||
active: Active;
|
||||
containerIds: string[];
|
||||
items: DndableItems;
|
||||
collisionDetectionStrategy: CollisionDetection;
|
||||
},
|
||||
{
|
||||
onDragStart: ({ active }: DragStartEvent) => void;
|
||||
onDragOver: ({ active, over }: DragOverEvent) => void;
|
||||
onDragEnd: ({ active, over }: DragEndEvent) => void;
|
||||
}
|
||||
];
|
||||
|
||||
export type Transform = {
|
||||
x: number;
|
||||
y: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
};
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
DndContext,
|
||||
MeasuringStrategy,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { useDndable } from './use-dndable';
|
||||
import {
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { DragOverlayPortal } from '../drag-overlay/DragOverlayPortal';
|
||||
import { transformKanban2DndFormat } from '../helper';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { KanbanGroup } from '@toeverything/components/editor-core';
|
||||
import type { DndableItems } from '../type';
|
||||
|
||||
interface RenderParams {
|
||||
containerIds: string[];
|
||||
items: DndableItems;
|
||||
isSortingContainer?: boolean;
|
||||
activeId?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
dataSource: KanbanGroup[];
|
||||
render: (params: RenderParams) => ReactNode;
|
||||
}
|
||||
|
||||
export const CardContainerWrapper = (props: Props) => {
|
||||
const { dataSource, render } = props;
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 15,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const [
|
||||
{ active, containerIds, items, collisionDetectionStrategy },
|
||||
{ onDragStart, onDragOver, onDragEnd },
|
||||
] = useDndable(...transformKanban2DndFormat(dataSource));
|
||||
const activeId = active?.id as string;
|
||||
const isSortingContainer = activeId
|
||||
? containerIds.includes(activeId)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetectionStrategy}
|
||||
measuring={{
|
||||
droppable: {
|
||||
strategy: MeasuringStrategy.Always,
|
||||
},
|
||||
}}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={containerIds}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{render({ activeId, containerIds, items, isSortingContainer })}
|
||||
</SortableContext>
|
||||
|
||||
<DragOverlayPortal
|
||||
items={items}
|
||||
activeId={activeId}
|
||||
containerIds={containerIds}
|
||||
/>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
verticalListSortingStrategy,
|
||||
SortableContext,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { KanbanGroup } from '../../styles';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { KanbanCard } from '@toeverything/components/editor-core';
|
||||
|
||||
interface Props {
|
||||
containerId: string;
|
||||
items: KanbanCard[];
|
||||
render: ({ items }: { items: KanbanCard[] }) => ReactNode;
|
||||
}
|
||||
|
||||
export const CardContextWrapper = (props: Props) => {
|
||||
const { items, render } = props;
|
||||
|
||||
return (
|
||||
<SortableContext items={items} strategy={verticalListSortingStrategy}>
|
||||
<KanbanGroup>{render({ items })}</KanbanGroup>
|
||||
</SortableContext>
|
||||
);
|
||||
};
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CardItemWrapper } from './CardItemWrapper';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Active } from '@dnd-kit/core';
|
||||
import type { KanbanCard } from '@toeverything/components/editor-core';
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
index?: number;
|
||||
item: KanbanCard;
|
||||
children: ReactNode;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
function useMountStatus() {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setIsMounted(true), 500);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
return isMounted;
|
||||
}
|
||||
|
||||
const CardItemPanelWrapper = ({
|
||||
disabled,
|
||||
index,
|
||||
item,
|
||||
active,
|
||||
children,
|
||||
}: Props) => {
|
||||
const { setNodeRef, listeners, isDragging, transform, transition } =
|
||||
useSortable({
|
||||
id: item.id,
|
||||
});
|
||||
|
||||
const mounted = useMountStatus();
|
||||
const mountedWhileDragging = isDragging && !mounted;
|
||||
|
||||
return (
|
||||
<CardItemWrapper
|
||||
ref={disabled ? undefined : setNodeRef}
|
||||
dragging={isDragging}
|
||||
index={index}
|
||||
style={{ opacity: active ? 0.5 : undefined }}
|
||||
transition={transition}
|
||||
transform={transform}
|
||||
fadeIn={mountedWhileDragging}
|
||||
listeners={listeners}
|
||||
card={children}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { CardItemPanelWrapper };
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { memo, forwardRef } from 'react';
|
||||
import { ItemStyle } from '../component/ItemStyle';
|
||||
import type { ReactNode, ForwardedRef, CSSProperties } from 'react';
|
||||
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
|
||||
import type { Transform } from '../type';
|
||||
|
||||
interface Props {
|
||||
index?: number;
|
||||
dragOverlay?: boolean;
|
||||
dragging?: boolean;
|
||||
disabled?: boolean;
|
||||
fadeIn?: boolean;
|
||||
listeners?: SyntheticListenerMap;
|
||||
transition?: string;
|
||||
transform?: Transform;
|
||||
style?: CSSProperties;
|
||||
card: ReactNode;
|
||||
}
|
||||
|
||||
const CardItemWrapper = memo(
|
||||
forwardRef(
|
||||
(
|
||||
{
|
||||
dragOverlay,
|
||||
dragging,
|
||||
disabled,
|
||||
fadeIn,
|
||||
index,
|
||||
listeners,
|
||||
transition,
|
||||
transform,
|
||||
card,
|
||||
style,
|
||||
...props
|
||||
}: Props,
|
||||
ref: ForwardedRef<HTMLDivElement>
|
||||
) => {
|
||||
const ItemStyleProps = {
|
||||
dragOverlay,
|
||||
transition,
|
||||
fadeIn,
|
||||
transform,
|
||||
index,
|
||||
};
|
||||
return (
|
||||
<ItemStyle ref={ref} {...ItemStyleProps} style={style}>
|
||||
<div {...listeners} {...props} tabIndex={0}>
|
||||
{card}
|
||||
</div>
|
||||
</ItemStyle>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export { CardItemWrapper };
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { useSortable, defaultAnimateLayoutChanges } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Container } from '../component/Container';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { AnimateLayoutChanges } from '@dnd-kit/sortable';
|
||||
import type { KanbanCard } from '@toeverything/components/editor-core';
|
||||
|
||||
const animateLayoutChanges: AnimateLayoutChanges = args =>
|
||||
defaultAnimateLayoutChanges({ ...args, wasDragging: true });
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
id: string;
|
||||
items: KanbanCard[];
|
||||
}
|
||||
|
||||
export function DroppableContainer({
|
||||
children,
|
||||
disabled,
|
||||
id,
|
||||
items,
|
||||
...props
|
||||
}: Props) {
|
||||
const { isDragging, setNodeRef, transition, transform } = useSortable({
|
||||
id,
|
||||
data: {
|
||||
type: 'container',
|
||||
children: items,
|
||||
},
|
||||
animateLayoutChanges,
|
||||
});
|
||||
|
||||
return (
|
||||
<Container
|
||||
ref={disabled ? undefined : setNodeRef}
|
||||
style={{
|
||||
transition,
|
||||
transform: CSS.Translate.toString(transform),
|
||||
...(isDragging && { opacity: 0.5 }),
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
closestCenter,
|
||||
getFirstCollision,
|
||||
pointerWithin,
|
||||
rectIntersection,
|
||||
} from '@dnd-kit/core';
|
||||
import { arrayMove } from '@dnd-kit/sortable';
|
||||
import {
|
||||
findContainer,
|
||||
findSibling,
|
||||
pickIdFromCards,
|
||||
shouldUpdate,
|
||||
} from '../helper';
|
||||
import type {
|
||||
CollisionDetection,
|
||||
DragStartEvent,
|
||||
DragOverEvent,
|
||||
DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import type { DndableItems, UseDndableRes } from '../type';
|
||||
|
||||
export const useDndable = (
|
||||
dndableItems: DndableItems,
|
||||
dndableContainerIds: string[]
|
||||
): UseDndableRes => {
|
||||
const [items, setItems] = useState(dndableItems);
|
||||
const [containerIds, setContainerIds] = useState(dndableContainerIds);
|
||||
const [active, setActive] = useState(null);
|
||||
const lastOverId = useRef(null);
|
||||
const recentlyMovedToNewContainer = useRef(false);
|
||||
const activeId = active?.id;
|
||||
|
||||
useEffect(() => {
|
||||
/*
|
||||
* Kanban operations are divided into: dragging, deleting, adding containers/cards, etc.;
|
||||
* 1. When any action is generated, the data source needs to be updated.
|
||||
* However, since the child components are rendered asynchronously, during this process, the payload will be continuously patched to the data source, causing useEffect to render endlessly.
|
||||
* (The main reason for the deformation of the card during the dragging process)
|
||||
* 2. In order to achieve better visual effects, use the hack scheme here to selectively update the data source:
|
||||
* 2.1 Maintain the state of the card in the state for drag and drop operations;
|
||||
* When a drag and drop behavior occurs: use moveTo to map the payload to the data source, but do not re-fetch the new data source
|
||||
* (If you use moveTo directly, the dragged cards and the exchanged cards will be re-rendered. This is because the current database read and write operations do not have a batch mechanism, and the vision is very strange)
|
||||
* 2.2 When deletion or addition occurs, the data source needs to be updated to display a new view;
|
||||
*/
|
||||
const nextContainerIds = Object.keys(dndableItems);
|
||||
const prevCardIds = pickIdFromCards(Object.values(items));
|
||||
const nextCardIds = pickIdFromCards(Object.values(dndableItems));
|
||||
|
||||
if (
|
||||
shouldUpdate(
|
||||
[containerIds, prevCardIds],
|
||||
[nextContainerIds, nextCardIds]
|
||||
)
|
||||
) {
|
||||
setItems(dndableItems);
|
||||
setContainerIds(nextContainerIds);
|
||||
}
|
||||
}, [containerIds, dndableItems, items]);
|
||||
|
||||
const collisionDetectionStrategy: CollisionDetection = useCallback(
|
||||
args => {
|
||||
if (activeId && activeId in items) {
|
||||
return closestCenter({
|
||||
...args,
|
||||
droppableContainers: args.droppableContainers.filter(
|
||||
container => container.id in items
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Start by finding any intersecting droppable
|
||||
const pointerIntersections = pointerWithin(args);
|
||||
const intersections =
|
||||
pointerIntersections.length > 0
|
||||
? // If there are droppables intersecting with the pointer, return those
|
||||
pointerIntersections
|
||||
: rectIntersection(args);
|
||||
let overId = getFirstCollision(intersections, 'id');
|
||||
|
||||
if (overId != null) {
|
||||
if (overId in items) {
|
||||
const containerItems = items[overId].filter(Boolean);
|
||||
|
||||
// If a container is matched and it contains items (columns 'A', 'B', 'C')
|
||||
if (containerItems.length > 0) {
|
||||
// Return the closest droppable within that container
|
||||
overId = closestCenter({
|
||||
...args,
|
||||
droppableContainers:
|
||||
args.droppableContainers.filter(
|
||||
container =>
|
||||
container.id !== overId &&
|
||||
containerItems.some(
|
||||
item => item.id === container.id
|
||||
)
|
||||
),
|
||||
})[0]?.id;
|
||||
}
|
||||
}
|
||||
|
||||
lastOverId.current = overId;
|
||||
|
||||
return [{ id: overId }];
|
||||
}
|
||||
|
||||
if (recentlyMovedToNewContainer.current) {
|
||||
lastOverId.current = activeId;
|
||||
}
|
||||
|
||||
// If no droppable is matched, return the last match
|
||||
return lastOverId.current ? [{ id: lastOverId.current }] : [];
|
||||
},
|
||||
[activeId, items]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
recentlyMovedToNewContainer.current = false;
|
||||
});
|
||||
}, [items]);
|
||||
|
||||
const onDragStart = ({ active }: DragStartEvent) => {
|
||||
setActive(active);
|
||||
};
|
||||
|
||||
const onDragOver = ({ active, over }: DragOverEvent) => {
|
||||
const overId = over?.id as string;
|
||||
const activeId = active?.id as string;
|
||||
if (overId == null || activeId in items) {
|
||||
return;
|
||||
}
|
||||
|
||||
const overContainer = findContainer(overId, items);
|
||||
const activeContainer = findContainer(activeId, items);
|
||||
|
||||
if (!overContainer || !activeContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeContainer !== overContainer) {
|
||||
setItems(items => {
|
||||
const activeItems = items[activeContainer];
|
||||
const overItems = items[overContainer];
|
||||
const overIndex = overItems.findIndex(
|
||||
item => item.id === overId
|
||||
);
|
||||
const activeIndex = activeItems.findIndex(
|
||||
item => item.id === activeId
|
||||
);
|
||||
const activeItem = activeItems.find(
|
||||
item => item.id === activeId
|
||||
);
|
||||
|
||||
let newIndex;
|
||||
|
||||
if (overId in items) {
|
||||
newIndex = overItems.length + 1;
|
||||
} else {
|
||||
const isBelowOverItem =
|
||||
over &&
|
||||
active.rect.current.translated &&
|
||||
active.rect.current.translated.top >
|
||||
over.rect.top + over.rect.height;
|
||||
|
||||
const modifier = isBelowOverItem ? 1 : 0;
|
||||
|
||||
newIndex =
|
||||
overIndex >= 0
|
||||
? overIndex + modifier
|
||||
: overItems.length + 1;
|
||||
}
|
||||
|
||||
recentlyMovedToNewContainer.current = true;
|
||||
|
||||
const data = {
|
||||
...items,
|
||||
[activeContainer]: items[activeContainer]
|
||||
.filter(item => item.id !== active.id)
|
||||
.filter(Boolean),
|
||||
[overContainer]: [
|
||||
...items[overContainer].slice(0, newIndex),
|
||||
items[activeContainer][activeIndex],
|
||||
...items[overContainer].slice(
|
||||
newIndex,
|
||||
items[overContainer].length
|
||||
),
|
||||
].filter(Boolean),
|
||||
};
|
||||
|
||||
/*
|
||||
* Linked with the following, if the cards in different Containers move horizontally (same level), onDragEnd.moveTo will not be triggered
|
||||
* used here to handle the scene
|
||||
* */
|
||||
const [beforeId, afterId] = findSibling(
|
||||
data[overContainer],
|
||||
activeId
|
||||
);
|
||||
|
||||
activeItem?.moveTo(overContainer, beforeId, afterId);
|
||||
|
||||
return data;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
const overId = over?.id as string;
|
||||
const activeId = active?.id as string;
|
||||
|
||||
if (activeId in items && overId) {
|
||||
setContainerIds(containerIds => {
|
||||
const activeIndex = containerIds.indexOf(activeId);
|
||||
const overIndex = containerIds.indexOf(overId);
|
||||
|
||||
if (activeIndex === -1 || overIndex === -1) {
|
||||
return containerIds;
|
||||
}
|
||||
|
||||
return arrayMove(containerIds, activeIndex, overIndex);
|
||||
});
|
||||
}
|
||||
|
||||
const activeContainer = findContainer(activeId, items);
|
||||
|
||||
if (!activeContainer) {
|
||||
setActive(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (overId == null) {
|
||||
setActive(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const overContainer = findContainer(overId, items);
|
||||
|
||||
if (overContainer) {
|
||||
const activeIndex = items[activeContainer].findIndex(
|
||||
item => item.id === activeId
|
||||
);
|
||||
const overIndex = items[overContainer].findIndex(
|
||||
item => item.id === overId
|
||||
);
|
||||
|
||||
if (activeIndex === -1 || overIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Between different containers, dragging at the same level will not go through this logic */
|
||||
if (activeIndex !== overIndex) {
|
||||
setItems(items => {
|
||||
const data = {
|
||||
...items,
|
||||
[overContainer]: arrayMove(
|
||||
items[overContainer],
|
||||
activeIndex,
|
||||
overIndex
|
||||
).filter(Boolean),
|
||||
};
|
||||
|
||||
const activeItems = items[activeContainer];
|
||||
const activeItem = activeItems.find(
|
||||
item => item.id === activeId
|
||||
);
|
||||
const [beforeId, afterId] = findSibling(
|
||||
items[overContainer],
|
||||
activeId
|
||||
);
|
||||
|
||||
activeItem?.moveTo(overContainer, beforeId, afterId);
|
||||
|
||||
return data;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setActive(null);
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
active,
|
||||
containerIds,
|
||||
items,
|
||||
collisionDetectionStrategy,
|
||||
},
|
||||
{
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragEnd,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import { DEFAULT_COLUMN_KEYS } from '@toeverything/datasource/db-service';
|
||||
import { SORTER_CONFIG } from '../components/sorter/config';
|
||||
import { FILTER_RULE_CONFIG } from '../components/filter/config/filter-group-config';
|
||||
import type { KanbanGroup } from '@toeverything/components/editor-core';
|
||||
import type {
|
||||
FilterConstraint,
|
||||
MultipleValue,
|
||||
} from '../components/filter/types';
|
||||
import type { SorterConstraint } from '../components/sorter/types';
|
||||
import type { DefaultColumnsValue } from '@toeverything/datasource/db-service';
|
||||
|
||||
/**
|
||||
* TODO: Grayscale configuration
|
||||
*/
|
||||
|
||||
const filter = (
|
||||
kanban: KanbanGroup[],
|
||||
filterConstraint: FilterConstraint = []
|
||||
) => {
|
||||
return kanban.map(panel => {
|
||||
const cards = panel.items;
|
||||
|
||||
const effectiveCards = cards.filter(card => {
|
||||
const properties =
|
||||
card.block.getProperties() as unknown as DefaultColumnsValue;
|
||||
|
||||
const validRes = filterConstraint.map(constraint => {
|
||||
const { key, opSelectValue, valueSelectValue } = constraint;
|
||||
|
||||
/* filter constraint has no value, no filter */ if (
|
||||
!valueSelectValue ||
|
||||
!valueSelectValue.length
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/* For the time being, only content and checked need to be filtered, and you can continue to add policies in the future */
|
||||
if (key === DEFAULT_COLUMN_KEYS.Text) {
|
||||
if (key in properties) {
|
||||
const textValue = properties?.[key]?.value
|
||||
.reduce((res, current) => res + current.text, '')
|
||||
.toLowerCase();
|
||||
const isContainer = textValue.includes(
|
||||
(valueSelectValue as string).trim()
|
||||
);
|
||||
if (
|
||||
opSelectValue === FILTER_RULE_CONFIG.include.value
|
||||
) {
|
||||
return isContainer;
|
||||
}
|
||||
|
||||
return !isContainer;
|
||||
}
|
||||
}
|
||||
|
||||
if (key === DEFAULT_COLUMN_KEYS.Checked) {
|
||||
/* selected all */
|
||||
if (valueSelectValue.length === 2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (opSelectValue === FILTER_RULE_CONFIG.eq.value) {
|
||||
return (
|
||||
(properties[key]?.value || false) ===
|
||||
(valueSelectValue[0] as MultipleValue).value
|
||||
);
|
||||
}
|
||||
|
||||
return !(
|
||||
(properties[key]?.value || false) ===
|
||||
(valueSelectValue[0] as MultipleValue).value
|
||||
);
|
||||
}
|
||||
|
||||
/* If the card has neither content nor checked, then filter it out directly */
|
||||
return false;
|
||||
});
|
||||
|
||||
const isValid = !validRes.some(item => item === false);
|
||||
|
||||
return isValid;
|
||||
});
|
||||
|
||||
return {
|
||||
...panel,
|
||||
items: effectiveCards,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const sorter = (kanban: KanbanGroup[], sorterConstraint: SorterConstraint) => {
|
||||
return kanban.map(cardPanel => {
|
||||
let cards = cardPanel.items;
|
||||
|
||||
/* The data is divided into three categories: checked is true; checked is false; no checked */
|
||||
for (const constraint of sorterConstraint) {
|
||||
const { field, rule } = constraint;
|
||||
if (field === 'checked') {
|
||||
const checkedData = [];
|
||||
const unCheckedData = [];
|
||||
const noCheckedData = [];
|
||||
|
||||
for (const card of cards) {
|
||||
const properties =
|
||||
card.block.getProperties() as unknown as DefaultColumnsValue;
|
||||
const checked = properties[field]?.value;
|
||||
|
||||
if (checked === true) {
|
||||
checkedData.push(card);
|
||||
} else if (checked === false) {
|
||||
unCheckedData.push(card);
|
||||
} else {
|
||||
noCheckedData.push(card);
|
||||
}
|
||||
}
|
||||
|
||||
if (rule === SORTER_CONFIG.ASC.value) {
|
||||
cards = [].concat(
|
||||
checkedData,
|
||||
unCheckedData,
|
||||
noCheckedData
|
||||
);
|
||||
} else {
|
||||
cards = [].concat(
|
||||
unCheckedData,
|
||||
checkedData,
|
||||
noCheckedData
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...cardPanel,
|
||||
items: cards,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export { filter, sorter };
|
||||
@@ -0,0 +1 @@
|
||||
export { SceneKanban } from './SceneKanban';
|
||||
@@ -0,0 +1,105 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export const KanbanContainer = styled('div')({
|
||||
display: 'flex',
|
||||
padding: '15px 0',
|
||||
|
||||
// Scrollbars
|
||||
// Always show scrollbars when hover
|
||||
// See https://stackoverflow.com/questions/7492062/css-overflow-scroll-always-show-vertical-scroll-bar
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/CSS/::-webkit-scrollbar
|
||||
overflowX: 'scroll',
|
||||
// Set overflowY to 'hidden' to workaround the scrollbars flash when drag kanban card
|
||||
overflowY: 'hidden',
|
||||
webkitOverflowScrolling: 'auto',
|
||||
'::-webkit-scrollbar': {
|
||||
webkitAppearance: 'none',
|
||||
height: '7px',
|
||||
},
|
||||
'&:hover': {
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
borderRadius: '4px',
|
||||
backgroundColor: '#98ACBD33',
|
||||
},
|
||||
'&::-webkit-scrollbar': {
|
||||
borderRadius: '4px',
|
||||
backgroundColor: '#98ACBD1A',
|
||||
},
|
||||
},
|
||||
// scrollbarGutter: 'stable',
|
||||
// overscrollBehavior: 'contain',
|
||||
|
||||
'& > * + *': {
|
||||
marginLeft: '10px',
|
||||
},
|
||||
});
|
||||
|
||||
export const KanbanBoard = styled('div')({
|
||||
display: 'flex',
|
||||
width: '296px',
|
||||
flex: '0 0 296px',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
export const KanbanGroup = styled('div')({
|
||||
marginTop: '18px',
|
||||
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'& > * + *': {
|
||||
marginTop: '10px',
|
||||
},
|
||||
});
|
||||
|
||||
export const KanbanHeader = styled('div')({
|
||||
whiteSpace: 'nowrap',
|
||||
color: '#3A4C5C',
|
||||
fontSize: '12px',
|
||||
});
|
||||
|
||||
export const Tag = styled('div')<{
|
||||
interactive?: boolean;
|
||||
color?: CSSProperties['color'];
|
||||
background?: CSSProperties['background'];
|
||||
}>(({ interactive, color, background }) => ({
|
||||
display: 'inline-flex',
|
||||
justifyContent: 'center',
|
||||
height: '20px',
|
||||
alignItems: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
borderRadius: '10px',
|
||||
background: background ?? '#ECEFF2',
|
||||
color: color ?? '#3A4C5C',
|
||||
fontSize: '12px',
|
||||
padding: '0 8px',
|
||||
cursor: interactive ? 'pointer' : 'default',
|
||||
userSelect: 'none',
|
||||
}));
|
||||
|
||||
export const AddGroupWrapper = styled('div')({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: '5px',
|
||||
width: '100%',
|
||||
color: '#B7C5D1',
|
||||
background: '#F5F7F8',
|
||||
fontSize: '12px',
|
||||
padding: '3px 15px',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
export const PopperContainer = styled('div')`
|
||||
color: #4c6275;
|
||||
background: #fff;
|
||||
box-shadow: 0px 1px 10px rgba(152, 172, 189, 0.6);
|
||||
border-radius: 0px 10px 10px 10px;
|
||||
padding: 6px 12px;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
& > input::placeholder {
|
||||
color: #98acbd;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { DndableItems } from './dndable/type';
|
||||
|
||||
export interface CardContainerProps {
|
||||
containerIds: string[];
|
||||
items: DndableItems;
|
||||
isSortingContainer?: boolean;
|
||||
activeId?: string;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { PANEL_CONFIG, SCENE_CONFIG } from './config';
|
||||
|
||||
export type ActivePanel = ValueOf<typeof PANEL_CONFIG | null>;
|
||||
export type ActiveScene = ValueOf<typeof SCENE_CONFIG | null>;
|
||||
|
||||
export type Option = {
|
||||
key?: string;
|
||||
label?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
/* closePanel */
|
||||
export type ClosePanel = () => void;
|
||||
|
||||
export interface ViewItem {
|
||||
[key: string]: {
|
||||
id: number | string;
|
||||
value: boolean | number | string | string[];
|
||||
type: string;
|
||||
options?: Option[];
|
||||
};
|
||||
}
|
||||
|
||||
export type ViewData = ViewItem[];
|
||||
|
||||
export interface OpRuleItem {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type OpRule = OpRuleItem[];
|
||||
|
||||
export interface FilterGroupConstraintItemValue {
|
||||
checked: boolean;
|
||||
op: OpRuleItem['value'];
|
||||
value: string | string[] | unknown;
|
||||
}
|
||||
|
||||
export type FilterGroupConstraint = Map<string, FilterGroupConstraintItemValue>;
|
||||
|
||||
export type SorterConstraint = Map<string, string>;
|
||||
|
||||
export type GroupData = {
|
||||
viewData: ViewData;
|
||||
filterGroupConstraint: FilterGroupConstraint;
|
||||
filterWeakSqlConstraint: string;
|
||||
sorterConstraint: SorterConstraint;
|
||||
};
|
||||
|
||||
export type ValueOf<T> = T extends { [key: string]: infer V } ? V : never;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { weakSql2Sql, weakSqlCreator } from './weak-sql';
|
||||
export { toAsync } from './toAsync';
|
||||
@@ -0,0 +1,4 @@
|
||||
const toAsync = <T>(promise: Promise<T>) =>
|
||||
promise.then(data => [null, data]).catch(err => [err]);
|
||||
|
||||
export { toAsync };
|
||||
@@ -0,0 +1 @@
|
||||
export { weakSql2Sql, weakSqlCreator } from './weakSql2Sql';
|
||||
@@ -0,0 +1,16 @@
|
||||
export const compareRules = new Map([
|
||||
['>', '$gt'] /* example: { field: {$gt: null} } */,
|
||||
['<', '$lt'],
|
||||
['=', '$eq'],
|
||||
['>=', '$gte'],
|
||||
['<=', '$lte'],
|
||||
|
||||
['includes', '$in'] /* example: { field: {$in: [1, 2, 3]} } */,
|
||||
['excludes', '$nin'],
|
||||
]);
|
||||
|
||||
/* example: { field: {}} */
|
||||
export const nullRules = new Map([
|
||||
['is null', { $ne: null }],
|
||||
['is not null', { $exists: true }],
|
||||
]);
|
||||
@@ -0,0 +1,18 @@
|
||||
/* sql relation type */
|
||||
export type Relation =
|
||||
| '>'
|
||||
| '<'
|
||||
| '='
|
||||
| '>='
|
||||
| '<='
|
||||
| 'is null'
|
||||
| 'is not null'
|
||||
| 'includes'
|
||||
| 'excludes';
|
||||
|
||||
/* single weak sql type */
|
||||
export interface Constraint {
|
||||
field: string;
|
||||
relation: Relation;
|
||||
value: unknown;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { compareRules, nullRules } from './rule-config';
|
||||
import { weakSqlCreator } from './weakSqlCreator';
|
||||
|
||||
/**
|
||||
* weakSql to sql
|
||||
* @param weakSqlExpress
|
||||
*/
|
||||
const weakSql2Sql = async (weakSqlExpress = '') => {
|
||||
const weakConstraints = await weakSqlCreator(weakSqlExpress);
|
||||
|
||||
const constraints = weakConstraints.map(weakConstraint => {
|
||||
const { field, relation, value } = weakConstraint;
|
||||
|
||||
if (!compareRules.has(relation) && !nullRules.has(relation)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const rule = (compareRules.get(relation) ||
|
||||
nullRules.get(relation)) as string;
|
||||
|
||||
return {
|
||||
[field]: {
|
||||
[rule]: compareRules.has(relation)
|
||||
? value
|
||||
: nullRules.get(relation),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
$and: constraints,
|
||||
};
|
||||
};
|
||||
|
||||
export { weakSql2Sql, weakSqlCreator };
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Constraint, Relation } from './types';
|
||||
|
||||
/**
|
||||
* pick valid information in v
|
||||
* @param v
|
||||
* @returns {number|string}
|
||||
*/
|
||||
const pickValue = (v: string) => {
|
||||
/* pick legal value: number */
|
||||
if (!isNaN(Number(v))) {
|
||||
return Number(v);
|
||||
}
|
||||
|
||||
/* pick legal value: string | string[] | number[] */
|
||||
try {
|
||||
const set = v.match(/["|'](.*)["|']/)[1].split(',');
|
||||
|
||||
if (set.length === 1) {
|
||||
/* string */
|
||||
return set[0];
|
||||
}
|
||||
|
||||
/* string[] */
|
||||
return set;
|
||||
} catch (e) {
|
||||
/* number[] */
|
||||
return v.split(',').map(Number);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* make weak-sql str to weak-constraint
|
||||
* @param weak_sql_express
|
||||
* @return Promise<Constraint[]>
|
||||
*/
|
||||
const weakSqlCreator = (weak_sql_express = ''): Promise<Constraint[]> => {
|
||||
const weak_sql_pattern =
|
||||
/[^;&&;&]+(>|<|=|>=|<=|includes|excludes|is null)[^;&&;&]*(&&|&|;)?/gim;
|
||||
const relation_pattern = /(>=|<=|>|<|=|includes|excludes|is null)/gim;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const constraints: Constraint[] = [];
|
||||
|
||||
try {
|
||||
weak_sql_express.replace(weak_sql_pattern, weak_sql_str => {
|
||||
const [relation] = weak_sql_str.match(relation_pattern);
|
||||
const [field, value] = weak_sql_str.split(relation);
|
||||
|
||||
constraints.push({
|
||||
field: field.trim(),
|
||||
relation: relation.trim() as Relation,
|
||||
value: pickValue(value.replace(/&&|&|;/, '').trim()),
|
||||
});
|
||||
|
||||
/* meaningless return value */
|
||||
return '';
|
||||
});
|
||||
|
||||
resolve(constraints);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export { weakSqlCreator };
|
||||
@@ -0,0 +1,6 @@
|
||||
import { FC } from 'react';
|
||||
import { CreateView } from '@toeverything/framework/virgo';
|
||||
|
||||
export const GroupDividerView: FC<CreateView> = ({ block, editor }) => {
|
||||
return <></>;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
AsyncBlock,
|
||||
BaseView,
|
||||
SelectBlock,
|
||||
} from '@toeverything/framework/virgo';
|
||||
import { Protocol } from '@toeverything/datasource/db-service';
|
||||
import { GroupDividerView } from './groupDividerView';
|
||||
|
||||
export class GroupDividerBlock extends BaseView {
|
||||
type = Protocol.Block.Type.groupDivider;
|
||||
View = GroupDividerView;
|
||||
override html2block(
|
||||
el: Element,
|
||||
parseEl: (el: Element) => any[]
|
||||
): any[] | null {
|
||||
const tag_name = el.tagName;
|
||||
if (tag_name === 'HR') {
|
||||
return [
|
||||
{
|
||||
type: this.type,
|
||||
properties: {
|
||||
text: {},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
override async block2html(
|
||||
block: AsyncBlock,
|
||||
children: SelectBlock[],
|
||||
generateHtml: (el: any[]) => Promise<string>
|
||||
): Promise<string> {
|
||||
return `<hr>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { FC, useState, useEffect, useRef } from 'react';
|
||||
import { CreateView } from '@toeverything/framework/virgo';
|
||||
import { Upload } from '../../components/upload/upload';
|
||||
import { services } from '@toeverything/datasource/db-service';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import DeleteSweepOutlinedIcon from '@mui/icons-material/DeleteSweepOutlined';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import { Image as SourceView } from '../../components/ImageView';
|
||||
import {
|
||||
useOnSelect,
|
||||
useRecastBlockScene,
|
||||
WrapperWithPendantAndDragDrop,
|
||||
} from '@toeverything/components/editor-core';
|
||||
import './styles.css';
|
||||
import { SCENE_CONFIG } from '../group/config';
|
||||
const MESSAGES = {
|
||||
ADD_AN_IMAGE: 'Add an image',
|
||||
};
|
||||
interface ImageView extends CreateView {
|
||||
imageFile?: File;
|
||||
}
|
||||
const ImageBlock = styled('div')({
|
||||
position: 'relative',
|
||||
marginTop: '10px',
|
||||
'.option': {
|
||||
position: 'absolute',
|
||||
background: 'rgba(0,0,0,.4)',
|
||||
height: '30px',
|
||||
width: '100%',
|
||||
textAlign: 'right',
|
||||
top: '-30px',
|
||||
color: '#fff',
|
||||
padding: '4px',
|
||||
transition: 'top .5s',
|
||||
},
|
||||
'&:hover': {
|
||||
'.option': {
|
||||
top: '0px',
|
||||
},
|
||||
},
|
||||
'.img': {
|
||||
width: '100%',
|
||||
},
|
||||
'.progress': {},
|
||||
});
|
||||
const KanbanImageContainer = styled('div')<{ isSelected: boolean }>(
|
||||
({ theme, isSelected }) => {
|
||||
return {
|
||||
display: 'flex',
|
||||
borderRadius: theme.affine.shape.xsBorderRadius,
|
||||
background: isSelected ? 'rgba(152, 172, 189, 0.1)' : 'transparent',
|
||||
padding: '8px',
|
||||
// border: `2px solid ${
|
||||
// isSelected ? theme.affine.palette.primary : '#e0e0e0'
|
||||
// }`,
|
||||
};
|
||||
}
|
||||
);
|
||||
export const ImageView: FC<ImageView> = ({ block, editor }) => {
|
||||
const workspace = editor.workspace;
|
||||
const [imgUrl, set_image_url] = useState<string>();
|
||||
const [imgWidth, setImgWidth] = useState<number>(0);
|
||||
const [ratio, set_ratio] = useState<number>(0);
|
||||
const resize_box = useRef(null);
|
||||
const { scene } = useRecastBlockScene();
|
||||
const [isSelect, setIsSelect] = useState<boolean>();
|
||||
useOnSelect(block.id, (isSelect: boolean) => {
|
||||
setIsSelect(isSelect);
|
||||
});
|
||||
|
||||
const img_load = (url: string) => {
|
||||
let boxWidth = resize_box.current.offsetWidth;
|
||||
|
||||
const imageStyle = block.getProperty('image_style');
|
||||
if (imageStyle?.width) {
|
||||
set_ratio(imageStyle.width / imageStyle.height);
|
||||
setImgWidth(imageStyle.width);
|
||||
set_image_url(url);
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
// load complete execution
|
||||
img.onload = async () => {
|
||||
const img_radio = img.width / img.height;
|
||||
|
||||
if (img.width >= boxWidth - 20) {
|
||||
img.width = boxWidth - 20;
|
||||
}
|
||||
img.height = img.width / img_radio;
|
||||
|
||||
block.setProperty('image_style', {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
});
|
||||
setImgWidth(img.width);
|
||||
set_image_url(url);
|
||||
set_ratio(img_radio);
|
||||
};
|
||||
img.onerror = e => {
|
||||
console.log(e);
|
||||
};
|
||||
};
|
||||
useEffect(() => {
|
||||
const style = window.getComputedStyle(block?.dom);
|
||||
const image_info = block.getProperty('image');
|
||||
const image_block_id = image_info?.value;
|
||||
const image_info_url = image_info?.url;
|
||||
|
||||
if (image_info_url) {
|
||||
img_load(image_info_url);
|
||||
return;
|
||||
}
|
||||
if (image_block_id) {
|
||||
services.api.file.get(image_block_id, workspace).then(file_info => {
|
||||
img_load(file_info.url);
|
||||
});
|
||||
}
|
||||
}, [workspace]);
|
||||
|
||||
const down_ref = useRef(null);
|
||||
const on_file_change = async (file: File) => {
|
||||
const result = await services.api.file.create({
|
||||
workspace: editor.workspace,
|
||||
file: file,
|
||||
});
|
||||
img_load(result.url);
|
||||
block.setProperty('image', {
|
||||
value: result.id,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
});
|
||||
};
|
||||
const delete_file = () => {
|
||||
block.remove();
|
||||
};
|
||||
const sava_link = (link: string) => {
|
||||
img_load(link);
|
||||
block.setProperty('image', {
|
||||
value: '',
|
||||
url: link,
|
||||
name: link,
|
||||
size: 0,
|
||||
type: 'link',
|
||||
});
|
||||
};
|
||||
const handle_click = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
//TODO clear active selection
|
||||
// document.getElementsByTagName('body')[0].click();
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopPropagation();
|
||||
editor.selectionManager.setSelectedNodesIds([block.id]);
|
||||
editor.selectionManager.activeNodeByNodeId(block.id);
|
||||
};
|
||||
const down_file = () => {
|
||||
if (down_ref) {
|
||||
down_ref.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<WrapperWithPendantAndDragDrop editor={editor} block={block}>
|
||||
<ImageBlock>
|
||||
<div ref={resize_box}>
|
||||
{imgUrl ? (
|
||||
<div
|
||||
onClick={handle_click}
|
||||
onMouseDown={e => {
|
||||
// e.nativeEvent.stopPropagation();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{scene === SCENE_CONFIG.PAGE ? (
|
||||
<SourceView
|
||||
block={block}
|
||||
viewStyle={{
|
||||
width: imgWidth,
|
||||
maxWidth:
|
||||
resize_box.current.offsetWidth - 20,
|
||||
minWidth: 32,
|
||||
ratio: ratio,
|
||||
}}
|
||||
isSelected={isSelect}
|
||||
link={imgUrl}
|
||||
/>
|
||||
) : (
|
||||
<KanbanImageContainer isSelected={isSelect}>
|
||||
<img width={'100%'} src={imgUrl} alt="" />
|
||||
</KanbanImageContainer>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// </ResizableBox>
|
||||
<Upload
|
||||
firstCreate={block.firstCreateFlag}
|
||||
uploadType={'image'}
|
||||
fileChange={on_file_change}
|
||||
deleteFile={delete_file}
|
||||
savaLink={sava_link}
|
||||
isSelected={isSelect}
|
||||
defaultAddBtnText={MESSAGES.ADD_AN_IMAGE}
|
||||
accept={
|
||||
'image/gif,image/jpeg,image/jpg,image/png,image/svg'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/* <div>
|
||||
<DownloadIcon
|
||||
onClick={down_file}
|
||||
className="delete-icon"
|
||||
fontSize="small"
|
||||
sx={{
|
||||
color: '#000',
|
||||
cursor: 'pointer',
|
||||
marginRight: '10px'
|
||||
}}
|
||||
></DownloadIcon>
|
||||
<DeleteSweepOutlinedIcon
|
||||
onClick={delete_file}
|
||||
className="delete-icon"
|
||||
fontSize="small"
|
||||
sx={{ color: '#000', cursor: 'pointer' }}
|
||||
></DeleteSweepOutlinedIcon>
|
||||
<a
|
||||
href={imgUrl}
|
||||
ref={down_ref}
|
||||
style={{ display: 'none' }}
|
||||
download="text.png"
|
||||
></a>
|
||||
</div> */}
|
||||
</div>
|
||||
</ImageBlock>
|
||||
</WrapperWithPendantAndDragDrop>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
AsyncBlock,
|
||||
BaseView,
|
||||
SelectBlock,
|
||||
} from '@toeverything/framework/virgo';
|
||||
import { Protocol } from '@toeverything/datasource/db-service';
|
||||
import { ImageView } from './ImageView';
|
||||
|
||||
export class ImageBlock extends BaseView {
|
||||
public override selectable = true;
|
||||
public override activatable = false;
|
||||
type = Protocol.Block.Type.image;
|
||||
View = ImageView;
|
||||
|
||||
// TODO: needs to download the image and then upload it to get a new link and then assign it
|
||||
override html2block(
|
||||
el: Element,
|
||||
parseEl: (el: Element) => any[]
|
||||
): any[] | null {
|
||||
const tag_name = el.tagName;
|
||||
if (tag_name === 'IMG') {
|
||||
return [
|
||||
{
|
||||
type: this.type,
|
||||
properties: {
|
||||
value: '',
|
||||
url: el.getAttribute('src'),
|
||||
name: el.getAttribute('src'),
|
||||
size: 0,
|
||||
type: 'link',
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO:
|
||||
override async block2html(
|
||||
block: AsyncBlock,
|
||||
children: SelectBlock[],
|
||||
generateHtml: (el: any[]) => Promise<string>
|
||||
): Promise<string> {
|
||||
const text = block.getProperty('text');
|
||||
const content = '';
|
||||
if (text) {
|
||||
text.value.map(text => `<span>${text}</span>`).join('');
|
||||
}
|
||||
// TODO: child
|
||||
return `<p><img src=${content}></p>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
.react-resizable {
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
height: auto !important;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.react-resizable-handle {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #3e6fdb;
|
||||
background-repeat: no-repeat;
|
||||
background-origin: content-box;
|
||||
box-sizing: border-box;
|
||||
background-position: bottom right;
|
||||
border-radius: 10px;
|
||||
padding: 0 3px 3px 0;
|
||||
}
|
||||
.react-resizable-handle-sw {
|
||||
bottom: -6px;
|
||||
left: -6px;
|
||||
cursor: sw-resize;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.react-resizable-handle-se {
|
||||
bottom: -6px;
|
||||
right: -6px;
|
||||
cursor: se-resize;
|
||||
}
|
||||
.react-resizable-handle-nw {
|
||||
top: -6px;
|
||||
left: -6px;
|
||||
cursor: nw-resize;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.react-resizable-handle-ne {
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
cursor: ne-resize;
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
.react-resizable-handle-w,
|
||||
.react-resizable-handle-e {
|
||||
top: 50%;
|
||||
margin-top: -10px;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
.react-resizable-handle-w {
|
||||
left: 0;
|
||||
transform: rotate(135deg);
|
||||
}
|
||||
.react-resizable-handle-e {
|
||||
right: 0;
|
||||
transform: rotate(315deg);
|
||||
}
|
||||
.react-resizable-handle-n,
|
||||
.react-resizable-handle-s {
|
||||
left: 50%;
|
||||
margin-left: -10px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
.react-resizable-handle-n {
|
||||
top: 0;
|
||||
transform: rotate(225deg);
|
||||
}
|
||||
.react-resizable-handle-s {
|
||||
bottom: 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { TextProps } from '@toeverything/components/common';
|
||||
import {
|
||||
ContentColumnValue,
|
||||
services,
|
||||
Protocol,
|
||||
} from '@toeverything/datasource/db-service';
|
||||
import { type CreateView } from '@toeverything/framework/virgo';
|
||||
import { useEffect, useRef, useState, type FC } from 'react';
|
||||
import {
|
||||
TextManage,
|
||||
type ExtendedTextUtils,
|
||||
} from '../../components/text-manage';
|
||||
import { tabBlock } from '../../utils/indent';
|
||||
import { IndentWrapper } from '../../components/IndentWrapper';
|
||||
import type { Numbered, NumberedAsyncBlock } from './types';
|
||||
|
||||
import { getChildrenType, getNumber } from './data';
|
||||
import {
|
||||
supportChildren,
|
||||
RenderBlockChildren,
|
||||
useOnSelect,
|
||||
WrapperWithPendantAndDragDrop,
|
||||
} from '@toeverything/components/editor-core';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { List } from '../../components/style-container';
|
||||
import { BlockContainer } from '../../components/BlockContainer';
|
||||
|
||||
export const defaultTodoProps: Numbered = {
|
||||
text: { value: [{ text: '' }] },
|
||||
numberType: 'type1',
|
||||
};
|
||||
const reset_todo_state = async (block: NumberedAsyncBlock) => {
|
||||
await block.setProperties({});
|
||||
};
|
||||
|
||||
const todoIsEmpty = (contentValue: ContentColumnValue): boolean => {
|
||||
const todoValue = contentValue.value;
|
||||
return (
|
||||
todoValue.length === 0 ||
|
||||
(todoValue.length === 1 && !todoValue[0]['text'])
|
||||
);
|
||||
};
|
||||
|
||||
export const NumberedView: FC<CreateView> = ({ block, editor }) => {
|
||||
// block.remove();
|
||||
const properties = { ...defaultTodoProps, ...block.getProperties() };
|
||||
const [number, set_number] = useState<number>(1);
|
||||
const [isSelect, setIsSelect] = useState<boolean>();
|
||||
|
||||
// const [type, set_type] = useState('type-1');
|
||||
const text_ref = useRef<ExtendedTextUtils>(null);
|
||||
|
||||
useOnSelect(block.id, (is_select: boolean) => {
|
||||
setIsSelect(is_select);
|
||||
});
|
||||
const turn_into_text_block = async () => {
|
||||
// Convert to text block
|
||||
await block.setType('text');
|
||||
await reset_todo_state(block);
|
||||
|
||||
if (!text_ref.current) {
|
||||
throw new Error(
|
||||
'Failed to set cursor position! text_ref is not exist!'
|
||||
);
|
||||
}
|
||||
const currentSelection = text_ref.current.getCurrentSelection();
|
||||
if (!currentSelection) {
|
||||
throw new Error(
|
||||
'Failed to get cursor selection! currentSelection is not exist!'
|
||||
);
|
||||
}
|
||||
// Update cursor position
|
||||
editor.selectionManager.setNodeActiveSelection(block.id, {
|
||||
type: 'Range',
|
||||
info: currentSelection,
|
||||
});
|
||||
};
|
||||
const i = 0;
|
||||
const listChange = async () => {
|
||||
let number = 1;
|
||||
const preBlock = await block.previousSiblings();
|
||||
const parent_block = await block.parent();
|
||||
let parent_number_type = 'type3';
|
||||
if (parent_block.type === 'numbered') {
|
||||
parent_number_type = await parent_block.getProperty('numberType');
|
||||
}
|
||||
block.setProperty('numberType', getChildrenType(parent_number_type));
|
||||
|
||||
if (preBlock.length) {
|
||||
preBlock.reverse();
|
||||
for (let i = 0; i <= preBlock.length; i++) {
|
||||
if (preBlock[i]?.type === 'numbered') {
|
||||
number++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
set_number(number);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
listChange();
|
||||
let obj: any;
|
||||
const parentChange = async () => {
|
||||
const parent_block = await block.parent();
|
||||
obj = await services.api.editorBlock.observe(
|
||||
{
|
||||
workspace: editor.workspace,
|
||||
id: parent_block.id,
|
||||
},
|
||||
listChange
|
||||
);
|
||||
};
|
||||
parentChange();
|
||||
return () => {
|
||||
obj?.();
|
||||
};
|
||||
// console.log('_parent_block: ', _parent_block);
|
||||
}, []);
|
||||
|
||||
const on_text_enter: TextProps['handleEnter'] = async props => {
|
||||
const { splitContents, isShiftKey } = props;
|
||||
if (isShiftKey) {
|
||||
return false;
|
||||
}
|
||||
const { contentBeforeSelection, contentAfterSelection } = splitContents;
|
||||
const before = [...contentBeforeSelection.content];
|
||||
const after = [...contentAfterSelection.content];
|
||||
if (todoIsEmpty({ value: before } as ContentColumnValue)) {
|
||||
await turn_into_text_block();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Move children to new block
|
||||
const children = await block.children();
|
||||
await block.removeChildren();
|
||||
|
||||
const next_node = await editor.createBlock(
|
||||
Protocol.Block.Type.numbered
|
||||
);
|
||||
if (!next_node) {
|
||||
throw new Error('Failed to create todo block');
|
||||
}
|
||||
await next_node.append(...children);
|
||||
await next_node.setProperties({
|
||||
text: { value: after } as ContentColumnValue,
|
||||
});
|
||||
await block.setProperties({
|
||||
text: { value: before } as ContentColumnValue,
|
||||
});
|
||||
await block.after(next_node);
|
||||
|
||||
editor.selectionManager.activeNodeByNodeId(next_node.id);
|
||||
|
||||
return true;
|
||||
};
|
||||
const on_tab: TextProps['handleTab'] = async ({ isShiftKey }) => {
|
||||
if (!isShiftKey) {
|
||||
const preSiblingBlock = await block.previousSibling();
|
||||
|
||||
if (preSiblingBlock && supportChildren(preSiblingBlock)) {
|
||||
const copy_block = block;
|
||||
block.remove();
|
||||
block.removeChildren();
|
||||
const block_children = await copy_block.children();
|
||||
preSiblingBlock.append(copy_block, ...block_children);
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
tabBlock(block, isShiftKey);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const on_backspace: TextProps['handleBackSpace'] = async ({
|
||||
isCollAndStart,
|
||||
}) => {
|
||||
if (!isCollAndStart) {
|
||||
return false;
|
||||
}
|
||||
await turn_into_text_block();
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<BlockContainer editor={editor} block={block} selected={isSelect}>
|
||||
<WrapperWithPendantAndDragDrop editor={editor} block={block}>
|
||||
<List>
|
||||
<div className={'checkBoxContainer'}>
|
||||
{getNumber(properties.numberType, number)}.
|
||||
</div>
|
||||
<div className="textContainer">
|
||||
<TextManage
|
||||
ref={text_ref}
|
||||
editor={editor}
|
||||
block={block}
|
||||
supportMarkdown
|
||||
placeholder="Numbered list"
|
||||
handleEnter={on_text_enter}
|
||||
handleBackSpace={on_backspace}
|
||||
handleTab={on_tab}
|
||||
/>
|
||||
</div>
|
||||
</List>
|
||||
</WrapperWithPendantAndDragDrop>
|
||||
|
||||
<IndentWrapper>
|
||||
<RenderBlockChildren block={block} />
|
||||
</IndentWrapper>
|
||||
</BlockContainer>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user