init: the first public commit for AFFiNE

This commit is contained in:
DarkSky
2022-07-22 15:49:21 +08:00
commit e3e3741393
1451 changed files with 108124 additions and 0 deletions
@@ -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">
// &nbsp;
// </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 modeweak-sql or filter-group
*/
const switchMode = () => {
setMode(
mode === MODE_CONFIG.NORMAL
? MODE_CONFIG.WEAK_SQL
: MODE_CONFIG.NORMAL
);
};
/* remainprobably 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 };
@@ -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 validupdate 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 };
@@ -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 };
@@ -0,0 +1,4 @@
export const MODE_CONFIG = {
NORMAL: 'normal' /* filter group */,
WEAK_SQL: 'weak_sql' /* weak sql */,
} as const;
@@ -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;
}) => {
/* remainprobably 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 };
@@ -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>
);
}
);
@@ -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 };
@@ -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 };
@@ -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>
);
}
@@ -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;
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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 };
@@ -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 };
@@ -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>
);
}
@@ -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