mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
init: the first public commit for AFFiNE
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import type { FC } from 'react';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { AsyncBlock, BlockEditor } from '@toeverything/framework/virgo';
|
||||
|
||||
type BlockContainerProps = Parameters<typeof Container>[0] & {
|
||||
block: AsyncBlock;
|
||||
editor: BlockEditor;
|
||||
};
|
||||
|
||||
export const BlockContainer: FC<BlockContainerProps> = function ({
|
||||
block,
|
||||
children,
|
||||
className,
|
||||
editor,
|
||||
...restProps
|
||||
}) {
|
||||
return (
|
||||
<Container
|
||||
className={`${className || ''} block_container`}
|
||||
{...restProps}
|
||||
>
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export const Container = styled('div')<{ selected: boolean }>(
|
||||
({ selected, theme }) => ({
|
||||
backgroundColor: selected ? theme.affine.palette.textSelected : '',
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export { BlockContainer } from './BlockContainer';
|
||||
@@ -0,0 +1,65 @@
|
||||
import { AsyncBlock } from '@toeverything/framework/virgo';
|
||||
import { FC } from 'react';
|
||||
import { ResizableBox } from 'react-resizable';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
export interface Props {
|
||||
block: AsyncBlock;
|
||||
link: string;
|
||||
viewStyle: {
|
||||
width: number;
|
||||
maxWidth: number;
|
||||
minWidth: number;
|
||||
ratio: number;
|
||||
};
|
||||
isSelected: boolean;
|
||||
resize?: boolean;
|
||||
}
|
||||
const ImageContainer = styled('div')<{ isSelected: boolean }>(
|
||||
({ theme, isSelected }) => {
|
||||
return {
|
||||
position: 'relative',
|
||||
fontSize: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: `2px solid ${
|
||||
isSelected ? theme.affine.palette.primary : '#fff'
|
||||
}`,
|
||||
img: {
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
// height: '100%',
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
export const Image: FC<Props> = props => {
|
||||
const { link, viewStyle, isSelected, block } = props;
|
||||
const on_resize_end = (e: any, data: any) => {
|
||||
block.setProperty('image_style', data.size);
|
||||
};
|
||||
return (
|
||||
<div
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<ResizableBox
|
||||
className="box"
|
||||
width={viewStyle.width}
|
||||
height={viewStyle.width / viewStyle.ratio}
|
||||
minConstraints={[
|
||||
viewStyle.minWidth,
|
||||
viewStyle.minWidth / viewStyle.ratio,
|
||||
]}
|
||||
lockAspectRatio={true}
|
||||
resizeHandles={isSelected ? ['sw', 'nw', 'se', 'ne'] : []}
|
||||
onResizeStop={on_resize_end}
|
||||
>
|
||||
<ImageContainer isSelected={isSelected}>
|
||||
<img src={link} alt={link} />
|
||||
</ImageContainer>
|
||||
</ResizableBox>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { Image } from './ImageView';
|
||||
@@ -0,0 +1,17 @@
|
||||
import { FC, PropsWithChildren } from 'react';
|
||||
import { ChildrenView } from '@toeverything/framework/virgo';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
/**
|
||||
* Indent rendering child nodes
|
||||
*/
|
||||
export const IndentWrapper: FC<PropsWithChildren> = props => {
|
||||
return <StyledIdentWrapper>{props.children}</StyledIdentWrapper>;
|
||||
};
|
||||
|
||||
const StyledIdentWrapper = styled('div')({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
// TODO: marginLeft should use theme provided by styled
|
||||
marginLeft: '30px',
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './IndentWrapper';
|
||||
@@ -0,0 +1,25 @@
|
||||
import { FC, useMemo } from 'react';
|
||||
import { createEditor } from 'slate';
|
||||
import { Slate, Editable as SlateEditable, withReact } from 'slate-react';
|
||||
import { ErrorBoundary } from '@toeverything/utils';
|
||||
// import { EditableText, EditableElement } from './types';
|
||||
|
||||
// interface EditableProps {
|
||||
// value: EditableText[];
|
||||
// onChange: () => void;
|
||||
// }
|
||||
|
||||
export const Editable: FC = () => {
|
||||
const editor = useMemo(() => withReact(createEditor()), []);
|
||||
return (
|
||||
<ErrorBoundary
|
||||
FallbackComponent={props => (
|
||||
<div>Render Error. {props.error?.message}</div>
|
||||
)}
|
||||
>
|
||||
<Slate editor={editor} value={[]} onChange={() => 1}>
|
||||
<SlateEditable />
|
||||
</Slate>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
// import { BaseEditor } from 'slate';
|
||||
// import { ReactEditor } from 'slate-react';
|
||||
|
||||
// export type EditableText = { text: string };
|
||||
// export type EditableElement = { type: 'paragraph'; children: EditableElement[] };
|
||||
|
||||
// declare module 'slate' {
|
||||
// interface CustomTypes {
|
||||
// Editor: BaseEditor & ReactEditor;
|
||||
// Element: EditableElement;
|
||||
// Text: EditableText;
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,35 @@
|
||||
interface SelectProps {
|
||||
label?: string;
|
||||
value?: string;
|
||||
options?: string[];
|
||||
onChange?(evn: React.ChangeEvent<HTMLSelectElement>): void;
|
||||
}
|
||||
|
||||
export const Select = ({
|
||||
label = '',
|
||||
value,
|
||||
options = [],
|
||||
onChange,
|
||||
}: SelectProps) => {
|
||||
return (
|
||||
<label>
|
||||
{label && <span>{label}:</span>}
|
||||
<span>
|
||||
<select value={value} onChange={onChange}>
|
||||
{options.map((item, key) => {
|
||||
const optionProps: React.OptionHTMLAttributes<HTMLOptionElement> =
|
||||
{};
|
||||
if (value === item) {
|
||||
optionProps.value = item;
|
||||
}
|
||||
return (
|
||||
<option key={key} {...optionProps}>
|
||||
{item}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
import { FC, memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { StyledBlockPreview } from '@toeverything/components/common';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { AsyncBlock, useRecastBlockScene } from '@toeverything/framework/virgo';
|
||||
|
||||
import { formatUrl } from './format-url';
|
||||
import { SCENE_CONFIG } from '../../blocks/group/config';
|
||||
import { services } from '@toeverything/datasource/db-service';
|
||||
import { debounce } from '@toeverything/utils';
|
||||
|
||||
export interface Props {
|
||||
block: AsyncBlock;
|
||||
editorElement?: () => JSX.Element;
|
||||
viewType?: string;
|
||||
link: string;
|
||||
// onResizeEnd: (data: any) => void;
|
||||
isSelected: boolean;
|
||||
resize?: boolean;
|
||||
}
|
||||
|
||||
const _getLinkStyle = (scene: string) => {
|
||||
switch (scene) {
|
||||
case SCENE_CONFIG.PAGE:
|
||||
return {
|
||||
width: '420px',
|
||||
height: '198px',
|
||||
};
|
||||
case SCENE_CONFIG.REFLINK:
|
||||
return {};
|
||||
default:
|
||||
return {
|
||||
width: '252px',
|
||||
height: '126px',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
type BlockPreviewProps = {
|
||||
block: AsyncBlock;
|
||||
blockId: string;
|
||||
editorElement?: () => JSX.Element;
|
||||
};
|
||||
|
||||
const BlockPreview = (props: BlockPreviewProps) => {
|
||||
const container = useRef<HTMLDivElement>();
|
||||
const [preview, setPreview] = useState(true);
|
||||
const [title, setTitle] = useState('Loading...');
|
||||
useEffect(() => {
|
||||
let callback: any = undefined;
|
||||
services.api.editorBlock
|
||||
.getBlock(props.block.workspace, props.blockId)
|
||||
.then(block => {
|
||||
if (block.id === props.blockId) {
|
||||
const updateTitle = debounce(
|
||||
async () => {
|
||||
const [page] =
|
||||
await services.api.editorBlock.search(
|
||||
props.block.workspace,
|
||||
{ tag: 'id:affine67Uz4DstDk6PKUbz' }
|
||||
);
|
||||
|
||||
console.log(page);
|
||||
setTitle(page?.content || 'Untitled');
|
||||
},
|
||||
100,
|
||||
{ maxWait: 500 }
|
||||
);
|
||||
block.on('content', props.block.id, updateTitle);
|
||||
callback = () => block.off('content', props.block.id);
|
||||
updateTitle();
|
||||
} else {
|
||||
setTitle('Untitled');
|
||||
}
|
||||
});
|
||||
return () => callback?.();
|
||||
}, [props.block.id, props.block.workspace, props.blockId]);
|
||||
|
||||
const AffineEditor = props.editorElement as any;
|
||||
|
||||
useEffect(() => {
|
||||
if (container?.current) {
|
||||
const element = container?.current;
|
||||
const resizeObserver = new IntersectionObserver(entries => {
|
||||
const height = entries?.[0]?.intersectionRect.height;
|
||||
setPreview(height < 174);
|
||||
});
|
||||
|
||||
resizeObserver.observe(element);
|
||||
return () => resizeObserver.unobserve(element);
|
||||
}
|
||||
return undefined;
|
||||
}, [container]);
|
||||
|
||||
return (
|
||||
<div ref={container}>
|
||||
<StyledBlockPreview title={title}>
|
||||
{preview ? (
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
fontSize: '128px',
|
||||
height: '480px',
|
||||
alignItems: 'center',
|
||||
color: '#5591ff',
|
||||
}}
|
||||
>
|
||||
Preview
|
||||
</span>
|
||||
) : AffineEditor ? (
|
||||
<AffineEditor
|
||||
workspace={props.block.workspace}
|
||||
rootBlockId={props.blockId}
|
||||
/>
|
||||
) : null}
|
||||
</StyledBlockPreview>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MemoBlockPreview = memo(BlockPreview, (prev, next) => {
|
||||
return (
|
||||
prev.block.workspace === next.block.workspace &&
|
||||
prev.blockId === next.blockId
|
||||
);
|
||||
});
|
||||
|
||||
const SourceViewContainer = styled('div')<{
|
||||
isSelected: boolean;
|
||||
scene: string;
|
||||
}>(({ theme, isSelected, scene }) => {
|
||||
return {
|
||||
..._getLinkStyle(scene),
|
||||
overflow: 'hidden',
|
||||
borderRadius: theme.affine.shape.borderRadius,
|
||||
background: isSelected ? 'rgba(152, 172, 189, 0.1)' : 'transparent',
|
||||
padding: '8px',
|
||||
// border:
|
||||
iframe: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: '1px solid #EAEEF2',
|
||||
borderRadius: theme.affine.shape.borderRadius,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const SourceView: FC<Props> = props => {
|
||||
const { link, isSelected, block, editorElement } = props;
|
||||
const { scene } = useRecastBlockScene();
|
||||
const src = formatUrl(link);
|
||||
if (src?.startsWith('http')) {
|
||||
return (
|
||||
<div
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
style={{ display: 'flex' }}
|
||||
>
|
||||
<SourceViewContainer isSelected={isSelected} scene={scene}>
|
||||
<iframe
|
||||
title={link}
|
||||
src={src}
|
||||
frameBorder="0"
|
||||
allowFullScreen
|
||||
/>
|
||||
</SourceViewContainer>
|
||||
</div>
|
||||
);
|
||||
} else if (src?.startsWith('affine')) {
|
||||
return (
|
||||
<SourceViewContainer
|
||||
isSelected={isSelected}
|
||||
scene={SCENE_CONFIG.REFLINK}
|
||||
>
|
||||
<MemoBlockPreview
|
||||
block={block}
|
||||
editorElement={editorElement}
|
||||
blockId={src}
|
||||
/>
|
||||
</SourceViewContainer>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
const _regex =
|
||||
/^(https?:\/\/(localhost:4200|(nightly|app)\.affine\.pro|.*?\.ligo-virgo\.pages\.dev)\/\w{28}\/)?(affine\w{16})$/;
|
||||
|
||||
export const isAffineUrl = (url?: string) => {
|
||||
if (!url) return false;
|
||||
return _regex.test(url);
|
||||
};
|
||||
|
||||
export const toAffineEmbedUrl = (url: string) => {
|
||||
return _regex.exec(url)?.[4];
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
export const isFigmaUrl = (url?: string) => {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
return /https:\/\/([\w.-]+\.)?figma.com\/(file|proto)\/([0-9a-zA-Z]{22,128})(?:\/.*)?$/.test(
|
||||
url
|
||||
);
|
||||
};
|
||||
|
||||
export const toFigmaEmbedUrl = (url: string) => {
|
||||
return `https://www.figma.com/embed?embed_host=affine&url=${url}`;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { isYoutubeUrl, parseYoutubeId } from './youtube';
|
||||
import { isFigmaUrl, toFigmaEmbedUrl } from './figma';
|
||||
import { isAffineUrl, toAffineEmbedUrl } from './affine';
|
||||
|
||||
export const formatUrl = (url?: string): undefined | string => {
|
||||
if (isYoutubeUrl(url)) {
|
||||
const youtubeId = parseYoutubeId(url);
|
||||
return youtubeId ? `https://www.youtube.com/embed/${youtubeId}` : url;
|
||||
}
|
||||
if (isFigmaUrl(url)) {
|
||||
return toFigmaEmbedUrl(url);
|
||||
}
|
||||
if (isAffineUrl(url)) {
|
||||
return toAffineEmbedUrl(url);
|
||||
}
|
||||
return url;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { formatUrl } from './format-url';
|
||||
@@ -0,0 +1,13 @@
|
||||
export const isYoutubeUrl = (url?: string): boolean => {
|
||||
return url.includes('youtu.be') || url.includes('youtube.com');
|
||||
};
|
||||
|
||||
const _regexp = /.*(?:youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=)([^#&?]*).*/;
|
||||
|
||||
export const parseYoutubeId = (url?: string): undefined | string => {
|
||||
if (!url) {
|
||||
return undefined;
|
||||
}
|
||||
const matched = url.match(_regexp);
|
||||
return matched && matched[1].length === 11 ? matched[1] : undefined;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { SourceView } from './SourceView';
|
||||
@@ -0,0 +1,19 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
export const List = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
'.checkBoxContainer': {
|
||||
marginRight: '4px',
|
||||
lineHeight: theme.affine.typography.body1.lineHeight,
|
||||
},
|
||||
'.textContainer': {
|
||||
flex: 1,
|
||||
maxWidth: '100%',
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'hidden',
|
||||
},
|
||||
}));
|
||||
export const LinkContainer = styled('div')(() => ({
|
||||
position: 'relative',
|
||||
overflowBlock: 'hidden',
|
||||
}));
|
||||
@@ -0,0 +1,6 @@
|
||||
.v-basic-table-body {
|
||||
overflow: hidden !important;
|
||||
&:hover {
|
||||
overflow: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
useMemo,
|
||||
memo,
|
||||
useRef,
|
||||
useState,
|
||||
useLayoutEffect,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { VariableSizeGrid, areEqual } from 'react-window';
|
||||
import type {
|
||||
GridChildComponentProps,
|
||||
GridItemKeySelector,
|
||||
} from 'react-window';
|
||||
import style9 from 'style9';
|
||||
import './basic-table.scss';
|
||||
export interface TableColumn {
|
||||
dataKey: string;
|
||||
label: string;
|
||||
width?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TableRow {
|
||||
height?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CustomCellProps<T = unknown> {
|
||||
columnIndex: number;
|
||||
rowIndex: number;
|
||||
column: TableColumn;
|
||||
row: TableRow;
|
||||
value: T;
|
||||
valueKey: string;
|
||||
}
|
||||
export type CustomCell<T = unknown> = FC<CustomCellProps<T>>;
|
||||
|
||||
interface TableData {
|
||||
columns: readonly TableColumn[];
|
||||
rows: readonly TableRow[];
|
||||
CustomCell: CustomCell;
|
||||
}
|
||||
|
||||
export interface BasicTableProps {
|
||||
columns: readonly TableColumn[];
|
||||
rows: readonly TableRow[];
|
||||
headerHeight?: number;
|
||||
/**
|
||||
* row unique identifier
|
||||
*/
|
||||
rowKey: string;
|
||||
/**
|
||||
* Whether to display border, default true
|
||||
*/
|
||||
border?: boolean;
|
||||
renderCell?: CustomCell;
|
||||
}
|
||||
|
||||
const DEFAULT_COLUMN_WIDTH = 150;
|
||||
const DEFAULT_ROW_HEIGHT = 42;
|
||||
const MAX_TABLE_HEIGHT = 400;
|
||||
export const DEFAULT_RENDER_CELL: CustomCell = ({ value }) => {
|
||||
return <span>{value ? String(value) : '--'}</span>;
|
||||
};
|
||||
|
||||
const Cell: FC<GridChildComponentProps<TableData>> = memo(
|
||||
({ data, rowIndex, columnIndex, style }) => {
|
||||
const column = data.columns[columnIndex];
|
||||
const row = data.rows[rowIndex];
|
||||
const is_first_column = columnIndex === 0;
|
||||
const is_first_row = rowIndex === 0;
|
||||
const class_name = styles({
|
||||
cell: true,
|
||||
cellLeftBorder: !is_first_column,
|
||||
cellTopBorder: !is_first_row,
|
||||
});
|
||||
|
||||
const CustomCell = data.CustomCell;
|
||||
return (
|
||||
<div style={style} className={class_name}>
|
||||
<CustomCell
|
||||
column={column}
|
||||
row={row}
|
||||
columnIndex={columnIndex}
|
||||
rowIndex={rowIndex}
|
||||
value={row[column.dataKey]}
|
||||
valueKey={column.dataKey}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
areEqual
|
||||
);
|
||||
|
||||
export const BasicTable: FC<BasicTableProps> = ({
|
||||
columns,
|
||||
rows,
|
||||
headerHeight = DEFAULT_ROW_HEIGHT,
|
||||
rowKey,
|
||||
border = true,
|
||||
renderCell = DEFAULT_RENDER_CELL,
|
||||
}) => {
|
||||
const container_ref = useRef<HTMLDivElement>();
|
||||
const [table_width, set_table_width] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container_rect =
|
||||
container_ref.current?.getBoundingClientRect() || { width: 0 };
|
||||
const container_width = border
|
||||
? container_rect.width - 2
|
||||
: container_rect.width;
|
||||
if (container_width !== table_width) {
|
||||
set_table_width(container_width);
|
||||
}
|
||||
});
|
||||
|
||||
const rows_with_header = useMemo(() => {
|
||||
const header_row = columns.reduce((acc, cur) => {
|
||||
acc[cur.dataKey] = cur.label;
|
||||
return acc;
|
||||
}, {} as TableRow);
|
||||
header_row['height'] = headerHeight;
|
||||
return [header_row, ...rows];
|
||||
}, [rows, columns, headerHeight]);
|
||||
|
||||
const table_height = useMemo(() => {
|
||||
let height = 0;
|
||||
let index = 0;
|
||||
while (height < MAX_TABLE_HEIGHT) {
|
||||
if (index >= rows_with_header.length) {
|
||||
return height;
|
||||
}
|
||||
const row = rows_with_header[index];
|
||||
height = height + (row.height || DEFAULT_ROW_HEIGHT);
|
||||
index = index + 1;
|
||||
}
|
||||
return MAX_TABLE_HEIGHT;
|
||||
}, [rows_with_header]);
|
||||
|
||||
const item_data = useMemo<TableData>(
|
||||
() => ({ columns, rows: rows_with_header, CustomCell: renderCell }),
|
||||
[columns, rows_with_header]
|
||||
);
|
||||
|
||||
const get_item_key = useCallback<GridItemKeySelector<TableData>>(
|
||||
({ data, columnIndex, rowIndex }) => {
|
||||
const column = data.columns[columnIndex];
|
||||
const row = data.rows[rowIndex];
|
||||
return `${column.dataKey}_${row[rowKey]}`;
|
||||
},
|
||||
[rowKey]
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={container_ref} className={styles('containerBorder')}>
|
||||
{table_width ? (
|
||||
<VariableSizeGrid
|
||||
className="v-basic-table-body"
|
||||
columnCount={columns.length}
|
||||
columnWidth={index =>
|
||||
columns[index].width || DEFAULT_COLUMN_WIDTH
|
||||
}
|
||||
height={table_height}
|
||||
rowCount={rows_with_header.length}
|
||||
rowHeight={index =>
|
||||
rows_with_header[index].height || DEFAULT_ROW_HEIGHT
|
||||
}
|
||||
width={table_width}
|
||||
itemData={item_data}
|
||||
itemKey={get_item_key}
|
||||
>
|
||||
{Cell}
|
||||
</VariableSizeGrid>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = style9.create({
|
||||
containerBorder: {
|
||||
borderTop: '1px solid #ECEFF3',
|
||||
borderBottom: '1px solid #ECEFF3',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
cell: {
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'hidden',
|
||||
padding: '10px 4px',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
cellLeftBorder: {
|
||||
// borderLeft: '1px solid #98ACBD'
|
||||
},
|
||||
cellTopBorder: {
|
||||
borderTop: '1px solid #ECEFF3',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { FC } from 'react';
|
||||
import { Checkbox } from '@toeverything/components/ui';
|
||||
import type { BooleanColumnValue } from '@toeverything/datasource/db-service';
|
||||
import type { CellProps } from '../types';
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const CheckBoxCell: FC<CellProps<BooleanColumnValue>> = ({
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={value?.value}
|
||||
onChange={event => onChange({ value: event.target.checked })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { FC } from 'react';
|
||||
import { ColumnType } from '@toeverything/datasource/db-service';
|
||||
import type { CustomCellProps as TableCustomCellProps } from '../basic-table';
|
||||
import { DEFAULT_RENDER_CELL } from '../basic-table';
|
||||
import { CheckBoxCell } from './check-box';
|
||||
import { SelectCell } from './select';
|
||||
import type { CellProps } from './types';
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
const DefaultCell: FC<CellProps> = ({ onChange, ...props }) => {
|
||||
return <DEFAULT_RENDER_CELL {...props} />;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
const cellMap: Record<ColumnType, FC<CellProps<any>>> = {
|
||||
[ColumnType.content]: DefaultCell,
|
||||
[ColumnType.number]: DefaultCell,
|
||||
[ColumnType.enum]: SelectCell,
|
||||
[ColumnType.date]: DefaultCell,
|
||||
[ColumnType.boolean]: CheckBoxCell,
|
||||
[ColumnType.file]: DefaultCell,
|
||||
[ColumnType.string]: DefaultCell,
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
interface CustomCellProps extends TableCustomCellProps<unknown> {
|
||||
onChange: (data: TableCustomCellProps<unknown>) => void;
|
||||
}
|
||||
|
||||
export const CustomCell: FC<CustomCellProps> = props => {
|
||||
const View =
|
||||
props.rowIndex === 0
|
||||
? DefaultCell
|
||||
: cellMap[props.column['type'] as ColumnType] || DefaultCell;
|
||||
return (
|
||||
<View
|
||||
{...(props as CellProps)}
|
||||
onChange={value => {
|
||||
props.onChange({
|
||||
...props,
|
||||
value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { FC } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { OldSelect } from '@toeverything/components/ui';
|
||||
import type { EnumColumnValue } from '@toeverything/datasource/db-service';
|
||||
import { isEnumColumn } from '@toeverything/datasource/db-service';
|
||||
import type { CellProps } from '../types';
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const SelectCell: FC<CellProps<EnumColumnValue>> = ({
|
||||
value,
|
||||
column,
|
||||
onChange,
|
||||
}) => {
|
||||
const options = useMemo(() => {
|
||||
if (isEnumColumn(column.columnConfig)) {
|
||||
return column.columnConfig.options.map(option => {
|
||||
return {
|
||||
label: option.name,
|
||||
value: String(option.value),
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}, [column]);
|
||||
return (
|
||||
<OldSelect
|
||||
value={value?.value?.[0]}
|
||||
options={options}
|
||||
onChange={eventValue => onChange({ value: [eventValue] })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Column } from '@toeverything/datasource/db-service';
|
||||
import type { CustomCellProps, TableColumn } from '../basic-table';
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export interface BusinessTableColumn extends TableColumn {
|
||||
columnConfig: Column;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export interface CellProps<T = unknown> extends CustomCellProps<T> {
|
||||
onChange: (value: T) => void;
|
||||
column: BusinessTableColumn;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { Table } from './table';
|
||||
export type { TableColumn, TableRow } from './basic-table';
|
||||
|
||||
export { CustomCell } from './custom-cell';
|
||||
16
libs/components/editor-blocks/src/components/table/table.tsx
Normal file
16
libs/components/editor-blocks/src/components/table/table.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { BasicTable } from './basic-table';
|
||||
import type { BasicTableProps } from './basic-table';
|
||||
|
||||
interface TableProps extends BasicTableProps {
|
||||
addon?: ReactNode;
|
||||
}
|
||||
|
||||
export const Table: FC<TableProps> = ({ addon, ...props }) => {
|
||||
return (
|
||||
<div>
|
||||
{addon}
|
||||
<BasicTable {...props} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,515 @@
|
||||
/* eslint-disable max-lines */
|
||||
import {
|
||||
Text,
|
||||
type SlateUtils,
|
||||
type TextProps,
|
||||
} from '@toeverything/components/common';
|
||||
import {
|
||||
useOnSelectActive,
|
||||
useOnSelectSetSelection,
|
||||
} from '@toeverything/components/editor-core';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { ContentColumnValue } from '@toeverything/datasource/db-service';
|
||||
import {
|
||||
AsyncBlock,
|
||||
BlockEditor,
|
||||
CursorTypes,
|
||||
} from '@toeverything/framework/virgo';
|
||||
import { isEqual, Point } from '@toeverything/utils';
|
||||
import {
|
||||
forwardRef,
|
||||
MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
type MutableRefObject,
|
||||
} from 'react';
|
||||
import { Range } from 'slate';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
|
||||
interface CreateTextView extends TextProps {
|
||||
// TODO: need to optimize
|
||||
block: AsyncBlock;
|
||||
editor: BlockEditor;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export type ExtendedTextUtils = SlateUtils & {
|
||||
setLinkModalVisible: (visible: boolean) => void;
|
||||
};
|
||||
const TextBlockContainer = styled(Text)(({ theme }) => ({
|
||||
lineHeight: theme.affine.typography.body1.lineHeight,
|
||||
}));
|
||||
|
||||
const findSlice = (arr: string[], p: string, q: string) => {
|
||||
let should_include = false;
|
||||
return arr.filter(block => {
|
||||
if (block === p || block === q) {
|
||||
should_include = !should_include;
|
||||
return true;
|
||||
} else {
|
||||
return should_include;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const findLowestCommonAncestor = async (
|
||||
editor: BlockEditor,
|
||||
p: string,
|
||||
q: string
|
||||
) => {
|
||||
const root = editor.getRootBlockId();
|
||||
|
||||
const ancestor: { id: string; children: string[] }[] = [
|
||||
{ id: p, children: [] },
|
||||
];
|
||||
|
||||
let current = p;
|
||||
|
||||
while (current !== root) {
|
||||
const parent = await (await editor.getBlockById(current)).parent();
|
||||
ancestor.push({ id: parent.id, children: parent.childrenIds });
|
||||
current = parent.id;
|
||||
}
|
||||
current = q;
|
||||
let prev = q;
|
||||
let commonAncestor = ancestor.length - 1;
|
||||
while (current !== root) {
|
||||
// eslint-disable-next-line no-loop-func
|
||||
const same = ancestor.findIndex(a => a.id === current);
|
||||
if (same !== -1) {
|
||||
commonAncestor = same;
|
||||
break;
|
||||
}
|
||||
const parent = await (await editor.getBlockById(current)).parent();
|
||||
prev = current;
|
||||
current = parent.id;
|
||||
}
|
||||
|
||||
// ancestor is p
|
||||
if (commonAncestor === 0) {
|
||||
return [p];
|
||||
}
|
||||
|
||||
// ancestor is q
|
||||
if (current === q) {
|
||||
return [q];
|
||||
}
|
||||
|
||||
return findSlice(
|
||||
ancestor[commonAncestor].children,
|
||||
prev,
|
||||
ancestor[commonAncestor - 1].id
|
||||
);
|
||||
};
|
||||
|
||||
export const TextManage = forwardRef<ExtendedTextUtils, CreateTextView>(
|
||||
(props, ref) => {
|
||||
const { block, editor, ...otherOptions } = props;
|
||||
const defaultRef = useRef<ExtendedTextUtils>(null);
|
||||
// Maybe there is a better way
|
||||
const textRef =
|
||||
(ref as MutableRefObject<ExtendedTextUtils>) || defaultRef;
|
||||
|
||||
const properties = block.getProperties();
|
||||
// const [is_select, set_is_select] = useState<boolean>();
|
||||
// useOnSelect(block.id, (is_select: boolean) => {
|
||||
// set_is_select(is_select);
|
||||
// });
|
||||
const on_text_view_set_selection = (selection: Range | Point) => {
|
||||
if (selection instanceof Point) {
|
||||
//do some thing
|
||||
} else {
|
||||
textRef.current.setSelection(selection);
|
||||
}
|
||||
};
|
||||
|
||||
// block = await editor.commands.blockCommands.createNextBlock(block.id,)
|
||||
const on_text_view_active = useCallback(
|
||||
(point: CursorTypes, rang_form?: 'up' | 'down') => {
|
||||
// TODO code to be optimized
|
||||
if (textRef.current) {
|
||||
const end_selection = textRef.current.getEndSelection();
|
||||
const start_selection = textRef.current.getStartSelection();
|
||||
if (point === 'start') {
|
||||
textRef.current.setSelection(start_selection);
|
||||
return;
|
||||
}
|
||||
if (point === 'end') {
|
||||
textRef.current.setSelection(end_selection);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (point instanceof Point) {
|
||||
let blockTop = point.y;
|
||||
const blockDomStyle = block?.dom
|
||||
.getElementsByClassName('text-paragraph')[0]
|
||||
.getBoundingClientRect();
|
||||
|
||||
if (rang_form === 'up') {
|
||||
blockTop = blockDomStyle.bottom - 5;
|
||||
} else {
|
||||
blockTop = blockDomStyle.top + 5;
|
||||
}
|
||||
const end_position = ReactEditor.toDOMRange(
|
||||
textRef.current.editor,
|
||||
end_selection
|
||||
)
|
||||
.getClientRects()
|
||||
.item(0);
|
||||
const start_position = ReactEditor.toDOMRange(
|
||||
textRef.current.editor,
|
||||
start_selection
|
||||
)
|
||||
.getClientRects()
|
||||
.item(0);
|
||||
if (end_position.left <= point.x) {
|
||||
textRef.current.setSelection(end_selection);
|
||||
return;
|
||||
}
|
||||
if (start_position.left >= point.x) {
|
||||
textRef.current.setSelection(start_selection);
|
||||
return;
|
||||
}
|
||||
let range: globalThis.Range;
|
||||
if (document.caretRangeFromPoint) {
|
||||
range = document.caretRangeFromPoint(
|
||||
point.x,
|
||||
blockTop
|
||||
);
|
||||
} else if (document.caretPositionFromPoint) {
|
||||
const caret = document.caretPositionFromPoint(
|
||||
point.x,
|
||||
blockTop
|
||||
);
|
||||
|
||||
range = document.createRange();
|
||||
range.setStart(caret.offsetNode, caret.offset);
|
||||
}
|
||||
const slate_rang = ReactEditor.toSlateRange(
|
||||
textRef.current.editor,
|
||||
range,
|
||||
{
|
||||
exactMatch: true,
|
||||
suppressThrow: true,
|
||||
}
|
||||
);
|
||||
textRef.current.setSelection(slate_rang);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('e: ', e);
|
||||
textRef.current.setSelection(end_selection);
|
||||
}
|
||||
}
|
||||
},
|
||||
[textRef]
|
||||
);
|
||||
|
||||
useOnSelectActive(block.id, on_text_view_active);
|
||||
useOnSelectSetSelection<'Range'>(block.id, on_text_view_set_selection);
|
||||
|
||||
useEffect(() => {
|
||||
if (textRef.current) {
|
||||
editor.blockHelper.registerTextUtils(block.id, textRef.current);
|
||||
|
||||
return () => editor.blockHelper.unRegisterTextUtils(block.id);
|
||||
}
|
||||
return undefined;
|
||||
}, [textRef, block, editor.blockHelper]);
|
||||
|
||||
// set active in initialization
|
||||
useEffect(() => {
|
||||
try {
|
||||
const activatedNodeId =
|
||||
editor.selectionManager.getActivatedNodeId();
|
||||
const {
|
||||
nodeId: lastSelectNodeId,
|
||||
type,
|
||||
info,
|
||||
} = editor.selectionManager.getLastActiveSelectionSetting<'Range'>();
|
||||
if (block.id === activatedNodeId) {
|
||||
if (
|
||||
(block.id === lastSelectNodeId && type === 'Range') ||
|
||||
(type === 'Range' && info)
|
||||
) {
|
||||
on_text_view_active('end');
|
||||
} else {
|
||||
on_text_view_active('start');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('error occured in set active in initialization');
|
||||
}
|
||||
}, [block.id, editor.selectionManager, on_text_view_active, textRef]);
|
||||
|
||||
const on_text_change: TextProps['handleChange'] = async (
|
||||
value,
|
||||
textStyle
|
||||
) => {
|
||||
if (
|
||||
!isEqual(value, properties.text.value) ||
|
||||
//@ts-ignore
|
||||
(properties.textStyle &&
|
||||
!isEqual(
|
||||
//@ts-ignore
|
||||
textStyle.textAlign,
|
||||
//@ts-ignore
|
||||
properties.textStyle.textAlign
|
||||
))
|
||||
) {
|
||||
await block.setProperties({
|
||||
text: { value } as ContentColumnValue,
|
||||
textStyle: textStyle as Record<'textAlign', string>,
|
||||
});
|
||||
}
|
||||
};
|
||||
const get_now_and_pre_rang_position = () => {
|
||||
window.getSelection().getRangeAt(0);
|
||||
// const now_range =
|
||||
// editor.selectionManager.currentSelectInfo?.browserSelection.getRangeAt(
|
||||
// 0
|
||||
// );
|
||||
const now_range = window.getSelection().getRangeAt(0);
|
||||
let pre_position = null;
|
||||
const now_position = now_range.getClientRects().item(0);
|
||||
try {
|
||||
if (now_range.startOffset !== 0) {
|
||||
const pre_rang = document.createRange();
|
||||
pre_rang.setStart(
|
||||
now_range.startContainer,
|
||||
now_range.startOffset + 1
|
||||
);
|
||||
pre_rang.setEnd(
|
||||
now_range.endContainer,
|
||||
now_range.endOffset + 1
|
||||
);
|
||||
pre_position = pre_rang.getClientRects().item(0);
|
||||
}
|
||||
} catch (e) {
|
||||
// console.log(e);
|
||||
}
|
||||
return { nowPosition: now_position, prePosition: pre_position };
|
||||
};
|
||||
|
||||
const onKeyboardUp = (event: React.KeyboardEvent<Element>) => {
|
||||
// if default event is prevented do noting
|
||||
// if U want to disable up/down/enter use capture event for preventing
|
||||
if (!event.isDefaultPrevented()) {
|
||||
const positions = get_now_and_pre_rang_position();
|
||||
const prePosition = positions.prePosition;
|
||||
const nowPosition = positions.nowPosition;
|
||||
if (prePosition) {
|
||||
if (prePosition.top !== nowPosition.top) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Create the first element range of slate_editor
|
||||
const startPoint = textRef.current.getStart();
|
||||
|
||||
const startSlateRange: Range = {
|
||||
anchor: startPoint,
|
||||
focus: startPoint,
|
||||
};
|
||||
const startPosition = ReactEditor.toDOMRange(
|
||||
textRef.current.editor,
|
||||
startSlateRange
|
||||
)
|
||||
.getClientRects()
|
||||
.item(0);
|
||||
if (nowPosition.top === startPosition.top) {
|
||||
editor.selectionManager.activePreviousNode(
|
||||
block.id,
|
||||
new Point(nowPosition.left, nowPosition.top - 20)
|
||||
);
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const onKeyboardDown = (event: React.KeyboardEvent<Element>) => {
|
||||
// if default event is prevented do noting
|
||||
// if U want to disable up/down/enter use capture event for preventing
|
||||
// editor.selectionManager.activeNextNode(block.id, 'start');
|
||||
// return;
|
||||
if (!event.isDefaultPrevented()) {
|
||||
const positions = get_now_and_pre_rang_position();
|
||||
const prePosition = positions.prePosition;
|
||||
const nowPosition = positions.nowPosition;
|
||||
// Create the last element range of slate_editor
|
||||
const endPoint = textRef.current.getEnd();
|
||||
|
||||
const endSlateRange: Range = {
|
||||
anchor: endPoint,
|
||||
focus: endPoint,
|
||||
};
|
||||
const endPosition = ReactEditor.toDOMRange(
|
||||
textRef.current.editor,
|
||||
endSlateRange
|
||||
)
|
||||
.getClientRects()
|
||||
.item(0);
|
||||
|
||||
if (nowPosition.bottom === endPosition.bottom) {
|
||||
// The specific amount of TODO needs to be determined after subsequent padding
|
||||
editor.selectionManager.activeNextNode(
|
||||
block.id,
|
||||
new Point(nowPosition.left, nowPosition.bottom + 20)
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
if (prePosition?.bottom === endPosition.bottom) {
|
||||
editor.selectionManager.activeNextNode(
|
||||
block.id,
|
||||
new Point(
|
||||
prePosition.left,
|
||||
prePosition?.bottom + 20
|
||||
)
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const onKeyboardLeft = () => {
|
||||
const isEndText = textRef.current.isStart();
|
||||
if (isEndText) {
|
||||
editor.selectionManager.activePreviousNode(block.id, 'end');
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const onKeyboardRight = () => {
|
||||
const isEndText = textRef.current.isEnd();
|
||||
if (isEndText) {
|
||||
editor.selectionManager.activeNextNode(block.id, 'start');
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const on_select_all = () => {
|
||||
const isSelectAll =
|
||||
textRef.current.isEmpty() || textRef.current.isSelectAll();
|
||||
if (isSelectAll) {
|
||||
editor.selectionManager.selectAllBlocks();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const on_undo = () => {
|
||||
editor.undo();
|
||||
};
|
||||
|
||||
const on_redo = () => {
|
||||
editor.redo();
|
||||
};
|
||||
|
||||
const on_keyboard_esc = () => {
|
||||
if (editor.selectionManager.getSelectedNodesIds().length === 0) {
|
||||
const active_node_id =
|
||||
editor.selectionManager.getActivatedNodeId();
|
||||
if (active_node_id) {
|
||||
editor.selectionManager.setSelectedNodesIds([
|
||||
active_node_id,
|
||||
]);
|
||||
ReactEditor.blur(textRef.current.editor);
|
||||
}
|
||||
} else {
|
||||
editor.selectionManager.setSelectedNodesIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const on_shift_click = async (e: MouseEvent) => {
|
||||
if (e.shiftKey) {
|
||||
const activeId = editor.selectionManager.getActivatedNodeId();
|
||||
if (activeId === block.id) {
|
||||
return;
|
||||
}
|
||||
const currentId = block.id;
|
||||
const parent = await block.parent();
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
const position = parent.findChildIndex(block.id);
|
||||
if (position === -1) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
const activeBlock = await editor.getBlockById(activeId);
|
||||
const activeParent = await activeBlock.parent();
|
||||
if (activeParent === parent) {
|
||||
const sibilings = findSlice(
|
||||
parent.childrenIds,
|
||||
currentId,
|
||||
activeId
|
||||
);
|
||||
editor.blockHelper.blur(activeId);
|
||||
editor.selectionManager.setSelectedNodesIds(sibilings);
|
||||
} else {
|
||||
const ids = await findLowestCommonAncestor(
|
||||
editor,
|
||||
currentId,
|
||||
activeId
|
||||
);
|
||||
|
||||
editor.blockHelper.blur(activeId);
|
||||
editor.selectionManager.setSelectedNodesIds(ids);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (!properties || !properties.text) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TextBlockContainer
|
||||
ref={textRef}
|
||||
supportMarkdown
|
||||
className={`${otherOptions.className}`}
|
||||
currentValue={properties.text.value}
|
||||
textStyle={properties.textStyle}
|
||||
handleChange={on_text_change}
|
||||
handleUp={onKeyboardUp}
|
||||
handleDown={onKeyboardDown}
|
||||
handleLeft={onKeyboardLeft}
|
||||
handleRight={onKeyboardRight}
|
||||
handleSelectAll={on_select_all}
|
||||
handleMouseDown={on_shift_click}
|
||||
handleUndo={on_undo}
|
||||
handleRedo={on_redo}
|
||||
handleEsc={on_keyboard_esc}
|
||||
{...otherOptions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
declare global {
|
||||
interface Document {
|
||||
/**
|
||||
*
|
||||
* The caretRangeFromPoint() method of the Document interface returns a Range object for the document fragment under the specified coordinates.
|
||||
*
|
||||
* Non-standard: This feature is non-standard and is not on a standards track. Do not use it on production sites facing the Web: it will not work for every user. There may also be large incompatibilities between implementations and the behavior may change in the future.
|
||||
* @memberof Document
|
||||
*/
|
||||
caretPositionFromPoint?: (
|
||||
clientX: number,
|
||||
clientY: number
|
||||
) => CaretPosition;
|
||||
}
|
||||
|
||||
interface CaretPosition {
|
||||
offsetNode: Node;
|
||||
offset: number;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './TextManage';
|
||||
257
libs/components/editor-blocks/src/components/upload/upload.tsx
Normal file
257
libs/components/editor-blocks/src/components/upload/upload.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
FC,
|
||||
useRef,
|
||||
ChangeEvent,
|
||||
ReactElement,
|
||||
useState,
|
||||
SyntheticEvent,
|
||||
} from 'react';
|
||||
import DeleteSweepOutlinedIcon from '@mui/icons-material/DeleteSweepOutlined';
|
||||
import {
|
||||
styled,
|
||||
SxProps,
|
||||
MuiTextField as TextField,
|
||||
MuiButton as Button,
|
||||
MuiClickAwayListener as ClickAwayListener,
|
||||
MuiTabs as Tabs,
|
||||
MuiTab as Tab,
|
||||
MuiBox as Box,
|
||||
} from '@toeverything/components/ui';
|
||||
|
||||
const MESSAGES = {
|
||||
ADD_AN_FILE: 'Add an file',
|
||||
SIZE_EXCEEDS_LIMIT: 'Size exceeds limit',
|
||||
};
|
||||
interface Props {
|
||||
uploadType?: string;
|
||||
title?: string;
|
||||
view?: ReactElement;
|
||||
size?: number;
|
||||
accept?: string;
|
||||
isSelected?: boolean;
|
||||
defaultAddBtnText?: string;
|
||||
firstCreate: boolean | undefined;
|
||||
fileChange?: (file: File) => void;
|
||||
deleteFile?: () => void;
|
||||
savaLink?: (link: string) => void;
|
||||
}
|
||||
const styles: SxProps = {
|
||||
position: 'absolute',
|
||||
width: '600px',
|
||||
zIndex: 99,
|
||||
marginLeft: '50px',
|
||||
p: 1,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: '4px',
|
||||
textAlign: 'center',
|
||||
boxShadow:
|
||||
'rgb(15 15 15 / 5%) 0px 0px 0px 1px, rgb(15 15 15 / 10%) 0px 3px 6px, rgb(15 15 15 / 20%) 0px 9px 24px',
|
||||
};
|
||||
const UploadBox = styled('div')<{ isSelected: boolean }>(
|
||||
({ theme, isSelected }) => {
|
||||
return {
|
||||
width: '100%',
|
||||
background: '#f7f7f7',
|
||||
padding: '15px 10px',
|
||||
fontSize: theme.affine.typography.body1.fontSize,
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
border: isSelected
|
||||
? `1px solid ${theme.affine.palette.primary}`
|
||||
: '1px solid #e0e0e0',
|
||||
'.delete': {
|
||||
display: 'none',
|
||||
float: 'right',
|
||||
},
|
||||
'&:hover': {
|
||||
background: '#eee',
|
||||
'.delete': {
|
||||
display: 'inline-block',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const button_styles: SxProps = { width: '60%', fontSize: '12px' };
|
||||
export const Upload: FC<Props> = props => {
|
||||
const {
|
||||
fileChange,
|
||||
size,
|
||||
accept,
|
||||
deleteFile,
|
||||
uploadType,
|
||||
defaultAddBtnText,
|
||||
savaLink,
|
||||
firstCreate,
|
||||
isSelected,
|
||||
} = props;
|
||||
const input_ref = useRef<HTMLInputElement>(null);
|
||||
const [upload_link, set_upload_link] = useState('');
|
||||
|
||||
const [open, setOpen] = useState<boolean>(firstCreate);
|
||||
const type = ['file', 'image'].includes(uploadType) ? 'file' : 'link';
|
||||
const [value, setValue] = useState(type);
|
||||
|
||||
const handleChange = (event: SyntheticEvent, newValue: string) => {
|
||||
setValue(newValue);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
setOpen(prev => !prev);
|
||||
};
|
||||
|
||||
const handleClickAway = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
const choose_file = () => {
|
||||
if (input_ref.current) {
|
||||
input_ref.current.click();
|
||||
}
|
||||
};
|
||||
const handle_input_change = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
const file = files[0];
|
||||
if (size && size < file.size) {
|
||||
console.log(MESSAGES.SIZE_EXCEEDS_LIMIT);
|
||||
}
|
||||
fileChange(file);
|
||||
if (input_ref.current) {
|
||||
input_ref.current.value = '';
|
||||
}
|
||||
};
|
||||
const on_link_input_change = (e: any) => {
|
||||
set_upload_link(e.target.value);
|
||||
};
|
||||
const handle_sava_link = () => {
|
||||
savaLink(upload_link);
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<UploadBox onClick={handleClick} isSelected={isSelected}>
|
||||
{defaultAddBtnText
|
||||
? defaultAddBtnText
|
||||
: MESSAGES.ADD_AN_FILE}
|
||||
<span
|
||||
className="delete"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
deleteFile();
|
||||
}}
|
||||
>
|
||||
<DeleteSweepOutlinedIcon
|
||||
className="delete-icon"
|
||||
fontSize="small"
|
||||
sx={{
|
||||
color: 'rgba(0,0,0,.5)',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { color: 'rgba(0,0,0,.9)' },
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</UploadBox>
|
||||
{open && (
|
||||
<ClickAwayListener onClickAway={handleClickAway}>
|
||||
<Box
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
sx={styles}
|
||||
>
|
||||
<Tabs
|
||||
sx={{
|
||||
borderBottom: '1px solid #ccc',
|
||||
minHeight: '36px',
|
||||
}}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{['file', 'image'].includes(uploadType) ? (
|
||||
<Tab
|
||||
sx={{
|
||||
fontSize: '12px',
|
||||
minHeight: '36px',
|
||||
}}
|
||||
value="file"
|
||||
label="Upload"
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
{!['file', 'image'].includes(uploadType) ? (
|
||||
<Tab
|
||||
sx={{
|
||||
fontSize: '12px',
|
||||
minHeight: '36px',
|
||||
}}
|
||||
value="link"
|
||||
label="Embed Link"
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
{value === 'file' ? (
|
||||
<Box sx={{ padding: '10px' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
sx={button_styles}
|
||||
onClick={choose_file}
|
||||
size="small"
|
||||
>
|
||||
{defaultAddBtnText
|
||||
? defaultAddBtnText
|
||||
: MESSAGES.ADD_AN_FILE}
|
||||
</Button>
|
||||
<input
|
||||
ref={input_ref}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handle_input_change}
|
||||
accept={accept}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
'.MuiTextField-root': {
|
||||
width: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
value={upload_link}
|
||||
hiddenLabel
|
||||
margin={'dense'}
|
||||
sx={{
|
||||
fontSize: '12px',
|
||||
padding: 0,
|
||||
}}
|
||||
onChange={on_link_input_change}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
sx={button_styles}
|
||||
onClick={handle_sava_link}
|
||||
size="small"
|
||||
>
|
||||
embed link
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</ClickAwayListener>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user