init: the first public commit for AFFiNE

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

View File

@@ -0,0 +1,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 : '',
})
);

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { Image } from './ImageView';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { formatUrl } from './format-url';

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
.v-basic-table-body {
overflow: hidden !important;
&:hover {
overflow: auto !important;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
export { Table } from './table';
export type { TableColumn, TableRow } from './basic-table';
export { CustomCell } from './custom-cell';

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

View File

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

View File

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

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